M25: Einrichten-Wizard-Politur + SSH-Trust-Persistenz endlich verifiziert

- WAN-Schritt: fehlenden "Zurück"-Button ergänzt (beide Modi)
- "Abbrechen"/"Jetzt sichern" prominent gemacht (wie "Neu scannen")
- LAN-/VLAN-Schritt: Adressfelder starten leer, nur Format-Beispiel im
  Feld sichtbar statt vorbelegter Werte. VlanStepView bekam dafür eine
  Validierungssperre auf "Weiter" (fehlte bisher, war ok solange
  Defaults immer gültig waren). Zwei Tests entsprechend angepasst.
- SSH-Host-Key-Trust aus M24 hatte sich entgegen der Live-Bestätigung
  nie tatsächlich persistiert (defaults read zeigte leeren Schlüssel,
  Ursache ungeklärt) — betraf BackupServices dedizierte SSH-Verbindung
  beim ersten Experte-Tab-Schreibversuch pro Sitzung. Erneut über den
  bestehenden Trust-Dialog bestätigt, diesmal per defaults read
  verifiziert statt nur der UI-Bestätigung vertraut.

Alles live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-16 12:41:47 +02:00
co-authored by Claude Sonnet 5
parent 086393d379
commit 8a5df3338a
11 changed files with 146 additions and 28 deletions
+68 -9
View File
@@ -1481,6 +1481,42 @@ Zeitpunkt bereits verbunden ist, würden dieselben dedizierten Dienste
wieder wortlos nichts tun, ohne Hinweis auf die Ursache — siehe
"Nächste Schritte".
**M25: Einrichten-Wizard-Politur** (2026-09-16, mehrere kleine
Nutzerwünsche in einer Runde):
- **WAN-Schritt: "Zurück"-Button ergänzt** — fehlte seit jeher (nur
"Weiter"), in beiden Modi, fiel im Experte-Modus nur eher auf, da
dort mehr Schritte durchgeklickt werden. Live bestätigt.
- **"Abbrechen" (Einrichten) + "Jetzt sichern" (Sicherungen) prominent
gemacht** — dieselbe `.buttonStyle(.borderedProminent)`-Behandlung
wie zuvor schon "Neu scannen" (Bug: "leicht zu übersehen"), jetzt
konsequent auf alle **einzelnen, gleichrangigen** Toolbar-Aktions-
buttons der App angewendet. Übersicht-Tabs Toolbar-Gruppe (Zoom/
Zurücksetzen/Aktualisieren, mehrere gleichrangige Buttons
nebeneinander) bewusst ausgenommen — dort würde durchgängiges
Prominent-Styling überladen wirken, ein anderes UI-Muster als "eine
einzelne leicht übersehene Hauptaktion".
- **LAN- und VLAN-Schritt: Adressfelder starten leer** (Nutzerwunsch:
"die auszufüllenden Felder leer lassen, nur Beispiele zeigen ...
so sieht man das Schema dahinter") — `LanDhcpConfig`/`VlanEntry`
hatten bisher konkrete Default-Werte (z.B. "192.168.88.1/24" bzw.
eine aus der VLAN-ID abgeleitete Adresse); da jedes TextField-Label
("Router-Adresse (z.B. 192.168.88.1/24)") ohnehin als Platzhalter
dient, sobald das Feld leer ist, versteckte ein vorbelegter Wert
dieses Beispiel bisher komplett. Betrifft beide Modi (LAN-Schritt
wird von Einfach und Experte geteilt). `interfaceName` (Picker) und
`leaseTimeHours` (Stepper) bleiben unverändert vorbelegt — beides
Auswahl-UI ohne "Beispiel tippen"-Konzept. Notwendige Begleitänderung:
`VlanStepView`s "Weiter" hatte bisher **keine** Validierungssperre
(unnötig, solange die Defaults immer gültig waren) — jetzt analog zum
längst vorhandenen `LanStepView`-Muster ergänzt, sonst hätte ein leer
gelassenes VLAN-Feld einen Befehl mit leeren Argumenten zum Router
geschickt. Zwei Tests entsprechend angepasst
(`testDefaultAddressesStartEmptyForExamplePlaceholder` ersetzt die
alte Ableitungs-Prüfung; `testLanDhcpCommandsCoverAddressPoolServerAndNetwork`
setzt die Adressfelder jetzt explizit statt sich auf Modell-Defaults
zu verlassen). Live bestätigt ("gut soweit").
## Nächste Schritte
1. ~~M16: restliche `RouterOSSchemaCatalog.swift`-Sektionen übersetzen~~,
@@ -1645,15 +1681,38 @@ wieder wortlos nichts tun, ohne Hinweis auf die Ursache — siehe
`UpdateService`/`FactoryResetService` haben keinen eigenen
UI-Bestätigungspfad für einen neuen SSH-Host-Key, nur
`ConnectionService.connect`s SSH-*Fallback* zeigt den Dialog — und
der wird nie erreicht, solange REST erfolgreich verbindet. Ein
künftiger Host-Key-Wechsel (z.B. nach einem weiteren Werksreset)
würde also wieder zu wortlos leeren Ergebnissen führen (leere
Traffic-Anzeige, fehlschlagende Backups, etc.), ohne erkennbaren
Grund in der UI. Mögliche Fixes: diese Dienste bei
`untrustedSSHHostKey` einen eigenen Bestätigungsdialog zeigen
lassen, oder `ConnectionService` beim Verbinden zusätzlich (nicht
nur im Fallback-Fall) einmal den SSH-Host-Key prüfen/bestätigen
lassen, unabhängig davon, ob REST erfolgreich war.
der wird nie erreicht, solange REST erfolgreich verbindet. Mögliche
Fixes: diese Dienste bei `untrustedSSHHostKey` einen eigenen
Bestätigungsdialog zeigen lassen, oder `ConnectionService` beim
Verbinden zusätzlich (nicht nur im Fallback-Fall) einmal den
SSH-Host-Key prüfen/bestätigen lassen, unabhängig davon, ob REST
erfolgreich war. **Tatsächlich so eingetreten (2026-09-16):** beim
ersten Experte-Tab-Schreibversuch einer neuen Sitzung (`ensureSessionBackup`,
läuft einmal pro Verbindung vor der ersten Änderung) schlug
`BackupService`s eigene, dedizierte SSH-Verbindung mit
"Unbekannter SSH-Schlüssel" fehl — sichtbar nur als roter
`applyError`-Text im Experte-Formular, ohne erkennbaren
Zusammenhang zum eigentlichen SSH-Host-Key-Problem. Der erste
Trust-Fix-Versuch von vorhin (M24-Nebenbefund) hatte sich entgegen
der Live-Bestätigung des Nutzers ("vertraut, verbunden per SSH")
**nicht** tatsächlich persistiert — `defaults read
com.focus72.RouterOSAssistant
RouterOSAssistant.TrustedSSHHostKeyFingerprints` zeigte einen
komplett fehlenden Schlüssel, obwohl der strukturell identische,
nachweislich funktionierende REST-Zertifikat-Trust
(`TrustedCertificateFingerprints`) im selben Preferences-Bereich
korrekt vorhanden war. Ursache dafür bleibt ungeklärt — der Code-Pfad
(`ConnectViewModel.trustSSHHostKeyAndRetry` →
`ConnectionService.trustCurrentSSHHostKeyAndRetry` →
`SSHHostKeyTrustStore.trust`) ist exakt symmetrisch zum
funktionierenden Zertifikat-Pfad, kein Unterschied im Code
gefunden. Fix diesmal per `www-ssl` erneut kurz deaktiviert,
Trust-Dialog erneut bestätigt, **und diesmal per `defaults read`
direkt verifiziert, dass der Fingerprint wirklich persistiert wurde**
(war er) — DHCP-Netzwerk-Anlage danach live erfolgreich. Falls
dieses Symptom nochmal auftritt: zuerst per `defaults read` prüfen,
ob der Trust wirklich gespeichert wurde, nicht nur der UI-Bestätigung
vertrauen.
Gitea-Remote `origin` ist eingerichtet und wird laufend gepusht (siehe
oben) — dieser Hinweis war veraltet, korrigiert am 2026-09-15.
+12 -2
View File
@@ -89,7 +89,12 @@ Geführter Schritt-für-Schritt-Assistent für die Grundkonfiguration:
getrennte Netzwerke einrichten, jeweils mit eigenem Adressbereich und
optionaler **Netzwerk-Isolation** (Geräte in diesem Netz erreichen
keine anderen konfigurierten Netze, Internetzugriff bleibt erhalten).
Adressfelder starten leer — das Feld selbst zeigt ein Beispielformat
(z.B. "Router-Adresse (z.B. 192.168.88.1/24)"), das beim Tippen
verschwindet.
- **VLAN**: zusätzliche virtuelle Netzwerke auf demselben Kabel.
Adressfelder starten hier ebenfalls leer, mit Beispielformat im
jeweiligen Feld.
- **Firewall-Grundschutz**: Internetfreigabe (NAT) plus Schutz vor
unaufgeforderten Zugriffen aus dem Internet — mit einem Klick
aktivierbar, bestehende eigene Regeln bleiben erhalten.
@@ -263,8 +268,13 @@ Guided step-by-step assistant for base configuration:
Expert mode, multiple physical ports/interfaces can be set up as
separate networks, each with its own address range and optional
**network isolation** (devices on this network can't reach any other
configured network, while internet access still works).
- **VLAN**: additional virtual networks on the same cable.
configured network, while internet access still works). Address
fields start empty — the field itself shows a format example (e.g.
"Router Address (e.g. 192.168.88.1/24)"), which disappears as you
type.
- **VLAN**: additional virtual networks on the same cable. Address
fields here start empty too, with a format example shown in each
field.
- **Basic firewall**: internet sharing (NAT) plus protection against
unsolicited access from the internet — one click to enable, existing
custom rules are preserved.
+1
View File
@@ -157,6 +157,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| M22 | REST-Transport (M7) erstmals live gegen Hardware verifiziert, 4 Bugs gefunden+gefixt | ✅ live verifiziert |
| M23 | Experte-Tab: Sektionsüberschriften prominenter+eingefärbt, einklappbar (Standard: zugeklappt) | ✅ live verifiziert |
| M24 | LAN-Scanner: "Aktionen"-Button statt Rechtsklick, Traffic-Monitor+Sparkline pro Port, ARP-Bug gefixt | ✅ live verifiziert |
| M25 | Einrichten-Wizard-Politur: WAN-Zurück-Button, prominente Aktionsbuttons, leere Platzhalter-Felder (LAN/VLAN) | ✅ live verifiziert |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
@@ -3,12 +3,18 @@ import Foundation
struct LanDhcpConfig: Identifiable, Equatable {
var id = UUID()
var interfaceName: String = "bridge"
var networkAddress: String = "192.168.88.0/24"
var routerAddress: String = "192.168.88.1/24"
var poolRangeStart: String = "192.168.88.10"
var poolRangeEnd: String = "192.168.88.254"
/// Empty on purpose (Nutzerwunsch: "die auszufüllenden Felder leer lassen, nur Beispiele
/// zeigen ... so sieht man das Schema dahinter") each TextField's label (e.g.
/// "Router-Adresse (z.B. 192.168.88.1/24)") doubles as its placeholder while empty, so a
/// pre-filled value would hide the format example instead of showing it. `interfaceName`
/// above and `leaseTimeHours` below stay as real defaults a Picker selection and a
/// Stepper, neither has a "type an example" UI to hide.
var networkAddress: String = ""
var routerAddress: String = ""
var poolRangeStart: String = ""
var poolRangeEnd: String = ""
var leaseTimeHours: Int = 24
var dnsServers: String = "192.168.88.1"
var dnsServers: String = ""
/// Blocks forward traffic to/from every other configured LAN/VLAN network see
/// `FirewallConfig.NetworkSegment`. Internet access (WAN NAT) is unaffected.
var isolated: Bool = false
+11 -6
View File
@@ -21,16 +21,21 @@ struct VlanEntry: Identifiable, Equatable {
/// `FirewallConfig.NetworkSegment`. Internet access (WAN NAT) is unaffected.
var isolated: Bool = false
/// Fields start empty on purpose (Nutzerwunsch: "die auszufüllenden Felder leer lassen,
/// nur Beispiele zeigen ... so sieht man das Schema dahinter") each TextField's label
/// (e.g. "Router-Adresse (z.B. 192.168.20.1/24)") doubles as its placeholder while empty,
/// so leaving these blank shows the format example instead of hiding it behind an
/// auto-derived value the user might not even notice, let alone check. `VlanStepView`'s
/// "Weiter" button is disabled until every VLAN's fields are actually filled in.
init(name: String = "Gäste", vlanID: Int, parentInterface: String) {
self.name = name
self.vlanID = vlanID
self.parentInterface = parentInterface
let octet = vlanID % 256
self.networkAddress = "192.168.\(octet).0/24"
self.routerAddress = "192.168.\(octet).1/24"
self.poolRangeStart = "192.168.\(octet).10"
self.poolRangeEnd = "192.168.\(octet).254"
self.dnsServers = "192.168.\(octet).1"
self.networkAddress = ""
self.routerAddress = ""
self.poolRangeStart = ""
self.poolRangeEnd = ""
self.dnsServers = ""
}
var interfaceName: String { "vlan\(vlanID)" }
@@ -223,6 +223,9 @@ struct BackupListView: View {
Label(L10n.t("Jetzt sichern", appLanguage), systemImage: "square.and.arrow.down")
}
}
// Same precedent as LAN-Scanner's "Neu scannen"/Einrichten's "Abbrechen"
// a plain text toolbar button is easy to miss, applied app-wide now.
.buttonStyle(.borderedProminent)
.disabled(connectionService.credentials == nil || viewModel.isCreatingBackup)
.help(connectionService.credentials == nil ? L10n.t("Zuerst im Tab 'Verbinden' mit dem Router verbinden.", appLanguage) : L10n.t("Sicherung jetzt erstellen", appLanguage))
}
@@ -45,6 +45,13 @@ struct SetupView: View {
Button(L10n.t("Abbrechen", appLanguage)) {
showCancelConfirmation = true
}
// Same reasoning/precedent as LAN-Scanner's "Neu scannen" button
// (Bug/Nutzerwunsch: "wird übersehen") a plain text toolbar button
// blends into the window chrome; `.borderedProminent` gives it a
// filled, colored background so it's actually noticeable. Applied
// consistently to every standalone toolbar action button in the app
// per explicit request ("für die gesamte App fixen").
.buttonStyle(.borderedProminent)
.disabled(viewModel.isApplying)
}
}
@@ -62,10 +62,24 @@ struct VlanStepView: View {
Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() }
Spacer()
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
.disabled(!isStepValid)
}
}
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Zusätzliche Netzwerke (VLAN)", appLanguage)))
}
/// Only gates on filled-in fields while the VLAN section is actually enabled with it off,
/// `viewModel.vlans` is empty (see `setVlanSectionEnabled`) and there's nothing to validate.
/// Needed as of the fields-start-empty change: previously every VLAN's address fields had a
/// valid auto-derived default, so "Weiter" was always safe to press; now an incompletely
/// filled-in VLAN would otherwise reach `buildCommands()` with blank arguments.
private var isStepValid: Bool {
guard viewModel.vlanSectionEnabled else { return true }
return viewModel.vlans.allSatisfy { vlan in
!vlan.name.isEmpty && !vlan.networkAddress.isEmpty && !vlan.routerAddress.isEmpty
&& !vlan.poolRangeStart.isEmpty && !vlan.poolRangeEnd.isEmpty
}
}
}
@@ -51,6 +51,7 @@ struct WanStepView: View {
Section {
HStack {
Button(L10n.t("Zurück", appLanguage)) { viewModel.goBack() }
Spacer()
Button(L10n.t("Weiter", appLanguage)) { viewModel.goNext() }
.disabled(!isStepValid)
@@ -40,7 +40,15 @@ final class RouterOSCommandBuilderTests: XCTestCase {
}
func testLanDhcpCommandsCoverAddressPoolServerAndNetwork() {
let config = LanDhcpConfig()
// Address fields default to "" now (Nutzerwunsch, 2026-09-16: fields start empty,
// showing only the format example) set explicitly here so this test keeps verifying
// the command-building logic itself, not incidentally the model's old default values.
var config = LanDhcpConfig()
config.networkAddress = "192.168.88.0/24"
config.routerAddress = "192.168.88.1/24"
config.poolRangeStart = "192.168.88.10"
config.poolRangeEnd = "192.168.88.254"
config.dnsServers = "192.168.88.1"
let commands = config.buildCommands()
XCTAssertEqual(commands.count, 6)
+9 -5
View File
@@ -23,12 +23,16 @@ final class VlanEntryTests: XCTestCase {
XCTAssertEqual(commands[7].arguments["interface"], "vlan20")
}
func testDefaultAddressesAreDerivedFromVlanID() {
/// Address fields start empty by design (Nutzerwunsch, 2026-09-16: "die auszufüllenden
/// Felder leer lassen, nur Beispiele zeigen ... so sieht man das Schema dahinter") a
/// previous version auto-derived these from the VLAN ID; replaced with this test.
func testDefaultAddressesStartEmptyForExamplePlaceholder() {
let vlan = VlanEntry(vlanID: 30, parentInterface: "bridge")
XCTAssertEqual(vlan.routerAddress, "192.168.30.1/24")
XCTAssertEqual(vlan.networkAddress, "192.168.30.0/24")
XCTAssertEqual(vlan.poolRangeStart, "192.168.30.10")
XCTAssertEqual(vlan.poolRangeEnd, "192.168.30.254")
XCTAssertEqual(vlan.routerAddress, "")
XCTAssertEqual(vlan.networkAddress, "")
XCTAssertEqual(vlan.poolRangeStart, "")
XCTAssertEqual(vlan.poolRangeEnd, "")
XCTAssertEqual(vlan.dnsServers, "")
}
}