Files
RouterOS/bugs.md
T
KayandClaude Sonnet 5 dcb9d9e03c bugs.md #7: Isolation kappt jetzt auch bereits bestehende Verbindungen
Bisher wirkten die neuen Firewall-Isolationsregeln nur auf neue
Verbindungen - eine bereits offene Verbindung zwischen zwei gerade
isolierten Netzen lief unbeeinflusst weiter (Standard-Verhalten jeder
stateful Firewall). Neue SSHTransport.flushConnections/
ConnectionService.flushConnections entfernen per /ip firewall
connection remove [find where (src-address in A) and (dst-address in
B)] bereits getrackte Verbindungen zwischen isolierten Netzpaaren,
aufgerufen direkt nach den Firewall-Befehlen in SetupViewModel.apply().

FirewallConfig.NetworkSegment um networkAddress (CIDR) erweitert,
Paar-Logik in eine wiederverwendbare isolatedNetworkPairs-Property
extrahiert. networkA/networkB werden vor der SSH-Interpolation als
reine CIDR-Notation validiert (dieselbe Vorsicht wie bei der zuvor
gefixten CLI-Injection).

Ehrlicher Verifikationsstand dokumentiert statt Überclaiming: die
kombinierte remove-Bedingung ließ sich mangels zweier echter
Testnetze nicht end-to-end beweisen - ein erster scheinbarer Erfolg
stellte sich als Messfehler heraus (natürlicher ICMP-Conntrack-Timeout,
nicht der remove-Befehl selbst). Details in bugs.md #7.

Build + alle 103 Unit-Tests grün (1 neuer Regressionstest).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:30:51 +02:00

22 KiB

bugs.md

Ergebnis eines systematischen Tester-/Senior-Dev-Durchgangs (2026-09-17) gegen den echten hAP lite Testrouter (192.168.88.1, RouterOS 7.24.4). Hinweis zur Methode: die App-UI selbst konnte nicht geklickt werden (keine macOS-UI-Automatisierung verfügbar) — stattdessen Code-Audit jedes Feature-Bereichs, Router-Ground-Truth per SSH gegenprüft (/export terse, Live-Konfiguration), und wo möglich per Skript verifiziert statt geraten (siehe je Eintrag). Router-Zustand zum Testzeitpunkt: nahezu Werkszustand, nur die WLAN-Testkonfiguration aus einer früheren Session vorhanden.

Status: offen | postponed | fixed


2026-09-17

1. Race Condition bei Port-Konflikt-Prüfung im LAN-Schritt

Status: fixed (Build grün, noch nicht live gegenreproduziert) Gitea-Issue: #13 Confidence: hoch (Logikfehler direkt im Code nachvollzogen, nicht live reproduziert)

SetupViewModel.checkPortConflict(for:) (SetupViewModel.swift:146) liest config.interfaceName synchron beim Aufruf und startet dann einen Task, der das Ergebnis nach dem await unbedingt in lanPortConflicts[configID] schreibt — ohne Generation-Counter oder Abbruch des vorherigen Task.

Der Aufruf passiert an zwei Stellen für dieselbe configID: .onAppear (bei jedem Erscheinen des LAN-Schritts) und .onChange(of: config.interfaceName) (bei jeder Port-Auswahl). Wechselt der Nutzer den Port zügig zweimal hintereinander (oder wechselt ihn, bevor die onAppear-Prüfung des vorherigen Ports fertig ist), können zwei Tasks parallel laufen. Der zuerst gestartete, aber zuletzt fertige Task überschreibt das Ergebnis des neueren mit dem Stand des alten Ports — "last response wins" statt "last request wins".

Konkretes Fehlerbild: Nutzer wählt Port A (hat Konflikt, z.B. Bridge-Mitgliedschaft), App prüft noch, Nutzer wechselt schnell zu Port B (frei). Kommt Port As Prüfergebnis später zurück als Port Bs, zeigt die App fälschlich eine Konflikt-Warnung für den inzwischen ausgewählten, tatsächlich freien Port B — oder, im umgekehrten Fall, verschluckt eine echte Warnung für einen Port, der tatsächlich bereits belegt ist, sodass "Weiter" freigeschaltet wird, obwohl der gewählte Port beim Anwenden unbemerkt vorhandene Konfiguration überschreibt.

Fix: Generation-Zähler portConflictRequestGeneration: [LanDhcpConfig.ID: Int] ergänzt, bei jedem checkPortConflict(for:)-Aufruf hochgezählt; ein abgeschlossener Task schreibt sein Ergebnis nur, wenn seine Generation beim Abschluss noch die aktuellste ist — ein überholter Task verwirft sein Ergebnis stillschweigend statt es zu übernehmen.

2. "Firewall (optional)"-Titel widerspricht sich selbst im Einfach-Modus

Status: fixed (Build grün, noch nicht live gegenreproduziert) Gitea-Issue: #14 Confidence: hoch (direkt im Code sichtbar)

FirewallStepView.swift:63: .navigationTitle(... "Firewall (optional)" ...) ist fest, unabhängig vom Modus. Im Einfach-Modus zeigt derselbe Screen aber den Text "Firewall-Grundschutz ist im einfachen Modus immer aktiv." (Zeile 20) — der Titel behauptet "optional", der Inhalt sagt "immer an". Für Experte-Modus stimmt der Titel (dort gibt es den Toggle). Kleiner, aber sofort sichtbarer Text-Widerspruch für jeden, der im Einfach-Modus durch den Wizard geht.

Fix: Titel modusabhängig gemacht — viewModel.mode == .expert ? "Firewall (optional)" : "Firewall", neuer Key "Firewall" in L10n.swift ergänzt.

3. Fehlende Englisch-Übersetzungen (sichtbar im EN-UI)

Status: fixed (skriptgeprüft: beide Keys jetzt in L10n.swift, verbleibende zwei fehlende Gitea-Issue: #15 Keys "OK"/"Revision" bewusst nicht ergänzt — identisch in beiden Sprachen, kein sichtbarer Effekt) Confidence: hoch (skriptgeprüft: alle L10n.t(...)-Aufrufstellen gegen L10n.swifts Übersetzungs-Dictionary abgeglichen — L10n.t fällt bei fehlendem Key auf den deutschen Originaltext zurück, siehe L10n.swift:12-15)

Zwei Stellen zeigen im Sprachmodus Englisch weiterhin deutschen Text:

  • Features/Devices/DevicesView.swift:335"Netzwerk-Test fehlgeschlagen" (Fehlermeldungstitel)
  • Features/Overview/OverviewView.swift:350"Fokus-Ansicht schließen" (Tooltip auf dem Schließen-Button der Fokus-Ansicht)

(Ein dritter fehlender Key, "Revision", ist praktisch nicht sichtbar, da das Wort in beiden Sprachen identisch ist — nicht extra gelistet.)

Fix-Ansatz: Beide Strings in L10n.swifts translations-Dictionary ergänzen ("Netzwerk-Test fehlgeschlagen": "Network test failed", "Fokus-Ansicht schließen": "Close focus view").

4. Health-Check-Heartbeat ignoriert laufende Wizard-/Experte-Schreibvorgänge

Status: fixed (Build grün, noch nicht live gegenreproduziert) Gitea-Issue: #16

Fix: ConnectionService.beginWrite()/endWrite() (Zähler activeWriteCount) ergänzt, checkConnectionHealthAndReconnectIfNeeded() prüft jetzt zusätzlich activeWriteCount == 0. SetupViewModel.apply() und ExpertViewModel.saveEditingItem()/confirmRemoval() klammern ihren Task-Body jetzt mit beginWrite()/defer { endWrite() }. Confidence: mittel (Logiklücke im Code nachvollzogen; nicht live reproduziert — REST läuft über unabhängige HTTP-Requests und dürfte robust sein, SSH-Exec-Kanäle sind laut SSH-Protokoll grundsätzlich nebenläufig nutzbar, siehe SSHTransport.swift/RestTransport.swift — insofern kein Daten-Korruptionsrisiko, aber eine echte Lücke in der Ablaufsteuerung)

ConnectionService.checkConnectionHealthAndReconnectIfNeeded() (ConnectionService.swift:273) läuft alle 10 Sekunden (healthCheckInterval) und prüft nur !isReconnecting — nicht, ob gerade ein SetupViewModel.apply() oder ExpertViewModel.saveEditingItem() in Arbeit ist. Der hAP-lite- Testrouter ist sehr schwach (MIPS 24Kc, 1 Kern, 650MHz, im Test bereits 54% CPU-Last im Leerlauf gemessen). Ein mehrere Sekunden dauernder Apply (z.B. Bonding, mehrere Firewall-Regeln) kann zeitlich mit dem Heartbeat kollidieren; schlägt der Heartbeat unter Last mit Timeout fehl, während der eigentliche Apply eigentlich noch normal durchläuft, zeigt die App fälschlich den "Wiederverbinden…"-Banner an, obwohl gar keine echte Verbindungsunterbrechung vorliegt — und reconnectLoop könnte im ungünstigsten Fall sogar finishConnecting mit einer neuen Transport-Instanz auslösen, während der ursprüngliche Apply noch auf der alten weiterläuft.

Fix-Ansatz: Einfachster Schutz: SetupViewModel.isApplying / ExpertViewModel.isApplying (oder ein neuer gemeinsamer "isWriting"-Zähler auf ConnectionService) zusätzlich zur Guard-Bedingung in checkConnectionHealthAndReconnectIfNeeded() prüfen, damit der Heartbeat während eines aktiven Schreibvorgangs aussetzt.


2026-09-17 (Nachtrag: "erneuter Gegencheck mit echtem Deep Dive")

Zweite, tiefere Testrunde auf expliziten Nutzerwunsch. Diesmal inkl. echter Exploit-Verifikation gegen den Router (nicht nur Code-Lesen) — beide folgenden Funde live nachgewiesen und live gegenverifiziert, dass der Fix greift, mit anschließendem Aufräumen der Testartefakte.

5. RouterOS-CLI-Injection über beliebige Textfelder (SSH-Transportpfad)

Status: fixed (live exploitiert UND live als behoben bestätigt, siehe unten) Gitea-Issue: #17 Confidence: sehr hoch — kein Verdacht, sondern reproduzierter Exploit gegen den echten Router

RouterOSCommand.cliLine (RouterOSCommand.swift) baute CLI-Zeilen für den SSH-Transport per String-Interpolation. Die alte quoteIfNeeded(_:) quotete einen Wert nur, wenn er ein Leerzeichen enthielt, und escapte darin enthaltene "-Zeichen nie. RouterOS' Konsole behandelt ; als Befehlstrenner (dieselbe Gefahr, die SSHTransport.runDiagnosticCommands Doku-Kommentar für eigene, aufruferseitig sanitisierte Eingaben bereits benennt) — ein Wert wie test" ; :log warning "INJECTED (ein völlig plausibler freier Kommentar/SSID/Hostname) schloss das Anführungszeichen vorzeitig und ließ den Rest als zweiten, unabhängigen RouterOS-Befehl laufen.

Exploit live bestätigt (Testrouter, aufgeräumt danach): Befehl /ip firewall address-list add list=injection-test address=10.10.10.10 comment="test" ; :log warning "INJECTED-VIA-COMMENT-FIELD" über SSH ausgeführt → Log zeigt script,warning INJECTED-VIA-COMMENT-FIELD als eigenständig ausgeführten zweiten Befehl, ausgelöst rein durch ein Kommentarfeld.

Betroffen: jeder Text, der über den SSH-Transport in ein RouterOSCommand läuft — praktisch jedes Feld im Setup-Wizard (SSID, Kommentare, DNS-Server, …) und jedes Freitextfeld im Experte-Tab ("Weitere Parameter"). Der REST-Pfad ist NICHT betroffen (JSON-Encoding, kein String-Interpolieren — siehe RestTransport.apply). Auf dem aktuellen hAP-lite-Testrouter ist www-ssl deaktiviert (/ip service print bestätigt: Zeile 9, X = disabled) — REST ist also gar nicht erreichbar, jede Schreiboperation läuft aktuell über SSH. Der Bug war damit live aktiv, nicht nur theoretisch.

Fix: quoteIfNeeded quotet jetzt immer und escaped \\\, "\" innerhalb der Anführungszeichen — live verifiziert, dass das RouterOS' eigene Escape-Syntax ist (\"/\\ rundeten korrekt durch print detail). Unconditionelles Quoting live als unproblematisch bestätigt (auch für einwortige Werte und yes/no-Booleans anstandslos akzeptiert). Gleiche Lücke defensiv auch in SSHTransport.fetchFieldValues geschlossen (aktuell nur mit hartkodiertem "no" aufgerufen, aber generische RouterOSTransport-Methode). Exploit-PoC danach erneut gegen den Router gefahren — kein injizierter Log-Eintrag mehr, Kommentar korrekt als reiner Text gespeichert. Neuer Regressionstest testCliLineEscapesEmbeddedQuotesPreventingCommandInjection. Build + alle 101 Unit-Tests grün.

6. Genereller RouterOS-Antwort-Parser trunkiert mehrwortige Werte

Status: fixed (Ursache live nachgewiesen, Fix per Unit-Test abgesichert) Gitea-Issue: #18 Confidence: sehr hoch — live gegen zwei unabhängige Menüs nachgewiesen

RouterOSCliParser.keyValues(from:) nahm an, print terse quote mehrwortige Werte in Anführungszeichen (Regex ("[^"]*"|\S+)). Live-Test (zwei unabhängige Menüs, /ip firewall address-list und /interface ethernet, RouterOS 7.24.4) zeigt: RouterOS quotet dort gar nichts — ein Kommentar "multi word test value" kommt als literales, unquotiertes comment=multi word test value name=ether2 ... zurück. Der alte Regex-Parser matchte dafür nur \S+ und schnitt den Wert beim ersten Leerzeichen ab — "word test value" ging komplett verloren, ohne Fehler, ohne Warnung.

Betrifft praktisch jedes mehrwortige Freitextfeld, das über den generischen Parser gelesen wird — vor allem das Experte-Tab-"Weitere Parameter (frei)"-Grid, das laut eigenem Doku-Kommentar "fast alle" Menüs generisch parst. Der Bug war bereits indirekt im eigenen Testcode sichtbar: der existierende Live-Dump-Test testParseInterfacesMatchesLiveHexOutput enthält exakt dieses Muster (last-link-up-time=2026-09-15 20:19:19, ein echter Gerätedump) — nur fiel es nie auf, weil dieses Feld von keinem kuratierten Schema gelesen wird.

Fix: keyValues von Regex-Matching auf Token-Scanning umgestellt — ein Leerzeichen-getrenntes Wort startet nur dann ein neues Feld, wenn es selbst wie key=... aussieht; alles andere wird an den Wert des zuletzt begonnenen Feldes angehängt. Bekannter Restfall (inhärent durch RouterOS' mehrdeutiges unquotiertes Format, nicht clientseitig lösbar): ein Wert, der selbst ein wort=-Muster enthält (z.B. ein Kommentar "config=broken"), wird fälschlich als neues Feld gelesen — deutlich seltener als die vorherige 100%-Fehlerquote bei jedem mehrwortigen Wert. Neuer Regressionstest testParseGenericItemsPreservesUnquotedMultiWordValue, abgeleitet vom echten Live-Dump. Build + alle 101 Unit-Tests grün.

7. Isolation wirkte nicht rückwirkend auf bereits bestehende Verbindungen

Status: fixed (best-effort, Mechanismus teilweise live verifiziert — Details unten) Gitea-Issue: #19 Confidence: mittel — siehe ehrliche Verifikationslage unten

FirewallConfig.buildCommands() (FirewallConfig.swift) setzt die Regel "forward established,related → accept" auf Position 4, die Isolations-Drop-Regeln erst ab Position filterCommands.count (7+). Für eine neue Verbindung zwischen zwei isolierten Netzen ist das korrekt (erstes Paket hat connection-state=new, trifft also nicht Regel 4, sondern die Isolations-Regel weiter hinten). Für eine zum Zeitpunkt des Anwendens bereits bestehende (im Conntrack getrackte) Verbindung zwischen zwei gerade erst als isoliert markierten Netzen griff dagegen weiterhin Regel 4 zuerst — sie blieb offen, bis sie von selbst endete. Das ist Standardverhalten jeder stateful/conntrack-basierten Firewall (RouterOS, iptables, pf, …), kein App-spezifischer Fehler.

Fix: neue SSHTransport.flushConnections(between:and:) + ConnectionService.flushConnections (dedizierte SSH-Verbindung, kein REST-Äquivalent für RouterOS' CIDR-Mitgliedschafts-Abfragesprache vorhanden) — entfernt per /ip firewall connection remove [find where (src-address in A) and (dst-address in B)] (beide Richtungen) bereits getrackte Verbindungen zwischen zwei gerade isolierten Netzen, aufgerufen von SetupViewModel.apply() direkt nach den Firewall-Befehlen selbst (damit eine sofort neu aufgebaute Verbindung schon auf die neue Drop-Regel trifft). FirewallConfig.NetworkSegment um networkAddress (CIDR) erweitert, Paar-Logik in eine wiederverwendbare isolatedNetworkPairs-Property extrahiert. networkA/networkB werden vor der Interpolation ins Skript als reine CIDR-Notation validiert (dieselbe Vorsicht wie bei Fund #5 — sonst neue Injection-Fläche).

Ehrliche Verifikationslage: print count-only where src-address in <cidr> ist live bestätigt, korrekt nach Subnetz zu filtern (40 Treffer bei besetztem /24 vs. 0 bei leerem). Ein einfaches remove [find where dst-address=<exakte-IP>] (Gleichheit, kein in) ist live bestätigt, einen Eintrag wirklich zu löschen (Test-ICMP-Verbindung verschwand). Die kombinierte Form remove [find where (a in X) and (b in Y)], die der Fix tatsächlich nutzt, ließ sich NICHT sauber live beweisen: ein erster Testlauf sah erfolgreich aus, erwies sich aber als Messfehler (ICMP-Conntrack verfällt von selbst in wenigen Sekunden — der Mehrfach-SSH-Testablauf mit Verzögerung dazwischen täuschte ein Löschen nur vor). Ein sauberer Nachtest an einer echten, weiterhin aktiven TCP-Verbindung zeigte den Eintrag sofort wieder auftauchen — erwartbares Verhalten für Connection- Tracking allgemein (kein RST, nächstes Paket einer aktiven Verbindung wird einfach neu getrackt), kein Beweis, dass remove wirkungslos ist, aber eben auch kein Beweis, dass die neue Isolations-Regel das neu getrackte Paket tatsächlich abfängt. Dafür bräuchte es zwei echte, getrennte Testnetze mit echten Endgeräten, die hier nicht verfügbar sind. Die remove [find where ... in ...]-Technik selbst ist real und community-dokumentiert (MikroTik-Forum), nur nicht Ende-zu-Ende gegen dieses konkrete App-Szenario bewiesen. Best-effort per Design (Fehler bricht den Apply-Vorgang nicht ab) — ein echter Mehrnetz-Test ist der natürliche nächste Schritt.

Build + alle 103 Unit-Tests grün (1 neuer Regressionstest für die Paar-Logik).

Nicht automatisch gefixt: ein Fix würde bedeuten, beim Aktivieren der Isolation gezielt /ip firewall connection remove für die betroffenen Netzpaare auszulösen — ein zusätzlicher, potenziell überraschender Seiteneffekt (kappt aktive Verbindungen, die der Nutzer evtl. bewusst offen hat), der über die reine Bugfix-Aufgabe dieses Durchgangs hinausgeht und eine bewusste Produktentscheidung ist, keine reine Korrektur. Nur als Beobachtung vermerkt.


2026-09-17 (Nachtrag 2: "finale Test für maximale Sicherheit")

Dritter Durchgang, gezielt sicherheitsfokussiert: Zugangsdaten-Speicherung (Keychain, SavedRoutersStore), TOFU-Implementierung (Zertifikat/SSH-Hostkey), alle verbleibenden Stellen, die Text in Router-Befehle oder URLs interpolieren. Positiv: KeychainService speichert Passwörter korrekt im macOS-Keychain (nie Klartext/UserDefaults), SavedRoutersStore/SavedRouter enthalten kein Passwort-Feld, BackupService.escapeForRouterOSScript escapte bereits vor diesem Durchgang korrekt (unabhängig von Fund #5 — offenbar aus einem früheren Lockout-Vorfall gelernt), SSHHostKeyFingerprint hat keine Fallback-Schwäche. Zwei weitere Lücken gefunden und gehärtet (defensiv, nicht live exploitiert):

8. REST-Pendant zu Fund #5: ungeschützte Query-String-Interpolation

Status: fixed (Build grün, nicht live exploitiert — aktuell nur mit hartkodiertem Wert aufgerufen) Gitea-Issue: #20 Confidence: hoch (dieselbe Bugklasse wie #5, nur im REST- statt SSH-Pfad)

RestTransport.fetchFieldValues baute den Query-String "\(restPath)?\(whereField)=\(whereValue)" ohne jede Kodierung — beim SSH-Gegenstück wurde das in Fund #5 bereits gefixt, das REST-Pendant dabei übersehen. Ein whereValue mit & hätte einen zusätzlichen Query-Parameter einschleusen können. Aktuell nur mit hartkodiertem "no" aufgerufen (kein Live-Exploit möglich), aber generische RouterOSTransport-Methode — Härtung für zukünftige Aufrufer mit echtem Nutzer-/Gerätetext.

Fix: whereValue wird jetzt RFC-3986-"unreserved characters"-only percent-encoded (strenger als .urlQueryAllowed, das &/=/+/# weiterhin durchlässt).

9. TOFU-Zertifikatsprüfung hatte einen Fallback-Konstante-Blindfleck

Status: fixed (Build grün, Härtung — kein realistisch auslösbarer Live-Exploit gefunden) Gitea-Issue: #21 Confidence: mittel (theoretische Lücke im Code nachvollzogen, keine funktionierende PoC gebaut, da eine echte TLS-Handshake-Situation gebraucht würde, in der SecTrustCopyCertificateChain trotz abgeschlossenem Handshake leer zurückkommt — unüblich, aber laut Apple-API-Vertrag nicht ausgeschlossen)

CertificateFingerprint.sha256(of:) gab bei fehlgeschlagener Zertifikatsketten-Extraktion den festen String "unbekannt" statt eines echten Fingerabdrucks zurück. Da CertificateTrustStore.isTrusted Fingerabdrücke per exaktem String-Vergleich prüft, hätte das Vertrauen in EIN Zertifikat, dessen Extraktion fehlschlägt, automatisch JEDES andere ebenfalls extraktions-fehlschlagende Zertifikat für denselben Host als vertrauenswürdig durchgehen lassen — ein theoretisches TOFU-Pinning-Bypass-Fenster.

Fix: Rückgabetyp auf String? geändert; schlägt die Extraktion fehl, lehnt RestTransport.urlSession(_:didReceive:completionHandler:) die Challenge jetzt hart ab (.cancelAuthenticationChallenge, kein lastRejectedFingerprint gesetzt) — der Nutzer bekommt in diesem Fall nie einen "Zertifikat vertrauen?"-Dialog mit einer nicht verifizierbaren Kennung angeboten, sondern einen normalen Verbindungsfehler.

Build + alle 101 Unit-Tests grün nach beiden Fixes.


Noch nicht geprüft / außerhalb dieses Durchgangs

  • UI-Interaktion selbst (Klickpfade, Darstellung) — nicht automatisierbar, siehe Hinweis oben.
  • PPPoE-Client — laut Nutzer am Testrouter nicht testbar (hängt hinter einem weiteren konfigurierten Router, kein direkter ISP-Uplink). Kein neuer Fund, nur zur Vollständigkeit.
  • Restliche Tabs/Bereiche (Übersicht-Diagramm-Interaktion, Sicherungen, Mode-Taste, Settings) wurden im Code überflogen, ohne konkreten neuen Fund über die bereits in found.md/HANDOFF.md dokumentierten Punkte hinaus.

2026-09-17 (Nachtrag 3: README-Milestone-Nachcheck)

Nutzer wies auf den noch offenen Milestone "LAN-Port-Konflikt-Prüfung + 'Fertig'-Button" in README.md hin. Beim erneuten Code-Review dieses Bereichs (u.a. wegen des in Runde 1 bereits gefixten Race-Bugs #1 in genau dieser Feature) drei weitere, eigenständige Funde derselben Bugklasse ("Alert-Dismiss tut nichts") entdeckt und gefixt.

10. Drei nicht funktionierende Alert-"Abbrechen"/"OK"-Buttons

Status: fixed (Build + 102 Unit-Tests grün, 1 neuer Regressionstest) Gitea-Issue: #22 Confidence: hoch (Logikfehler direkt im Code nachvollzogen, nicht live reproduziert)

  1. ReviewApplyViews "Anwenden fehlgeschlagen"-Alert: OK-Button-Action leer, isPresented- Bindings-Setter ebenfalls No-Op — applyError wurde nie zurückgesetzt.
  2. ConnectViews "Unbekanntes Zertifikat"-Alert: "Abbrechen"-Button-Action komplett leer — connectionService.state blieb für immer auf .needsCertificateConfirmation hängen, kein Weg zurück außer dem Zertifikat zu vertrauen.
  3. ConnectViews "Unbekannter SSH-Schlüssel"-Alert: "Abbrechen" rief nur dismissPendingSSHTrust() (löschte nur pendingSSHTrustFingerprint) — funktionierte nur für einen von zwei möglichen Auslöse-Pfaden, beim anderen (state == .needsSSHHostKeyConfirmation) blieb der Dialog hängen.

Fix: neue ConnectionService.cancelPendingTrustConfirmation() (setzt state bei beiden "needs...Confirmation"-Fällen auf .idle zurück, löscht zusätzlich pendingSSHTrustFingerprint), neue SetupViewModel.dismissApplyError(). Alle drei Alerts verdrahtet (Button-Action UND Bindings-Setter, deckt auch Tap-Outside/Esc-Dismiss ab). Totes dismissPendingSSHTrust() entfernt.

Weiterhin offen: die Port-Konflikt-Prüfung selbst (Warndialoge, "Weiter"-Sperre, "Fertig"- Button) ist nach diesem zweiten Code-Review-Durchgang funktional plausibel, aber noch nie tatsächlich in der App-UI durchgeklickt worden — dafür bräuchte es einen Live-Test durch den Nutzer, da UI-Automatisierung hier nicht verfügbar ist.