6 Commits
Author SHA1 Message Date
KayandClaude Sonnet 5 bad6328c8f Release v1.1.0
SemVer Minor - seit v1.0.0: Firewall-Isolation-Connection-Flush
(neue Fähigkeit, bugs.md #7), 3 Alert-Dismiss-Bugfixes (M34),
wizard_flow-Diagramm-Korrektur, 20 Gitea-Issues für alle bisherigen
Funde. Build + alle 103 Unit-Tests grün.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:35:29 +02:00
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
KayandClaude Sonnet 5 06ddd837f1 Docs: found.md/bugs.md-Einträge mit Gitea-Issues verlinkt
Alle 20 granularen Funde aus found.md (#1-10) und bugs.md (#1-10) als
einzelne Gitea-Issues angelegt (#3-22): 19 direkt geschlossen (Status
laut Doku "fixed"), 1 offen gelassen (bugs.md #7, Firewall-Isolation-
Beobachtung, bewusst ohne automatischen Fix). Jeder Markdown-Eintrag
verweist jetzt per Link auf sein Gitea-Issue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:11:22 +02:00
KayandClaude Sonnet 5 4810b7df63 Fix 3 nicht funktionierende Alert-Dismiss-Buttons (M34)
README-Milestone-Nachcheck (Port-Konflikt-Prüfung/"Fertig"-Button)
deckte drei eigenständige Bugs derselben Klasse auf: Cancel/OK-Buttons
bei ReviewApplyViews Apply-Fehler-Alert, ConnectViews
Zertifikat-Alert und ConnectViews SSH-Hostkey-Alert taten nichts oder
zu wenig - der jeweilige Verbindungs-/Fehlerzustand blieb hängen,
der Dialog konnte nicht sauber verlassen werden.

Neue ConnectionService.cancelPendingTrustConfirmation() und
SetupViewModel.dismissApplyError(), alle drei Alerts korrekt
verdrahtet (Button-Action + Bindings-Setter fuer Tap-Outside/Esc).
Totes dismissPendingSSHTrust() entfernt. 1 neuer Regressionstest,
alle 102 Unit-Tests gruen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:04:01 +02:00
KayandClaude Sonnet 5 510e750b3c Manual: wizard_flow-Diagramm zeigt jetzt Einfach/Experte-Verzweigung
Diagramm-Gegencheck aller 5 Mermaid-Diagramme gegen aktuellen Code:
architecture/connect_trust/backup_restore/focus_mode stimmen exakt.
wizard_flow zeigte den Wizard als reine lineare Kette inkl. VLAN-Schritt
und verschwieg die M9-Verzweigung (VLAN-Schritt wird im Einfach-Modus
übersprungen, Firewall-Grundschutz dort fest an statt Toggle). Diagramm
korrigiert, PNG/PDF/App-HTML (DE+EN) über build-manual.py neu generiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:43:34 +02:00
KayandClaude Sonnet 5 bace9fe3b2 gitignore: Release-Artifacts/ ausschließen
Gebaute .app/.zip für Gitea-Release-Uploads gehört nicht ins Repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:36:57 +02:00
20 changed files with 400 additions and 50 deletions
+3
View File
@@ -13,6 +13,9 @@ xcuserdata/
*.npk
*.cpgz
# Built release artifacts (uploaded as Gitea release assets, not repo source)
/Release-Artifacts/
# Router config backups (real device data, not app source) — created here when
# testing the app's "choose backup folder" feature with this directory selected.
/Backups/
+7 -5
View File
@@ -1,8 +1,10 @@
flowchart LR
M["Modus wählen:\nEinfach oder Experte"] --> W["WAN\n(Internetanschluss)"]
W --> L["LAN\n(ein oder mehrere Netzwerke)"]
L --> V["VLAN\n(optional, zusätzliche Netzwerke)"]
V --> Wi["WLAN\n(nur falls erkannt)"]
Wi --> F["Firewall-Grundschutz"]
M{"Modus wählen"} -->|"Einfach"| W["WAN\n(Internetanschluss)"]
M -->|"Experte"| W
W --> L["LAN\n(Experte: ein oder mehrere Netzwerke)"]
L -->|"Experte"| V["VLAN\n(optional, zusätzliche Netzwerke)"]
L -->|"Einfach: übersprungen"| Wi["WLAN\n(nur falls erkannt)"]
V --> Wi
Wi --> F["Firewall-Grundschutz\n(Einfach: immer aktiv, Experte: optional)"]
F --> R["Review / Apply\n(jeder Befehl sichtbar, Auto-Backup vorher)"]
R -->|"Jetzt anwenden"| D["Fertig"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 29 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+3 -1
View File
@@ -170,13 +170,15 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| M25 | Einrichten-Wizard-Politur: WAN-Zurück-Button, prominente Aktionsbuttons, leere Platzhalter-Felder (LAN/VLAN) | ✅ live verifiziert |
| M26 | Bekannte Router: Seriennummer hinterlegt, trennt zwei Geräte mit identischem Host+Benutzername | ✅ live verifiziert |
| M27 | Passwort-Anzeige-Button, Schlüsselbund nach Seriennummer getrennt, `terse`-Fallback verallgemeinert | ✅ live verifiziert |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Code-Review bestätigt (dabei 3 echte Alert-Dismiss-Bugs gefunden+gefixt, siehe M34), Live-Klicktest durch Nutzer weiterhin offen |
| M28 | Übersicht-Tab: Fokus-Modus (Klick auf Knoten → Kette im schwebenden Popup, Rest abgedunkelt); Close-Button-Layout in allen vier Popups vereinheitlicht | ✅ live verifiziert |
| M29 | Einstellungen-Fenster (⌘,): Sprache, Update-Auto-Check, Farbschema, Textgröße, Bedienelement-Größe, LAN-Scanner-Refreshraten | ✅ live verifiziert |
| M30 | Experte-Tab: "Mode-Taste"-Menü (`/system routerboard mode-button`), inkl. SSH-Zwangsweg für REST-Deckungslücken | ✅ live verifiziert |
| M31 | Handbuch in der App (⌘? -Buttons, Textanker, Übersicht/Wizard/45 Experte-Menüs), DE+EN vollständig übersetzt | ✅ live verifiziert |
| M32 | Automatisches Wiederverbinden bei Verbindungsabbruch (Herzschlag+Retry-Schleife, Versuchszähler+Countdown im Verbinden-Tab) | ✅ live verifiziert |
| M33 | Dreifacher Tester-/Sicherheits-Deep-Dive (9 Funde: RouterOS-CLI-Injection live exploitiert+gefixt, Parser-Datenverlust, Race Condition, TOFU-Härtung u.a.) | ✅ Build+101 Tests grün, Details in [`bugs.md`](bugs.md) |
| M34 | README-Nachcheck: 3 Alert-Dismiss-Bugs gefunden+gefixt ("Abbrechen"/OK-Buttons bei Zertifikat-/SSH-Hostkey-/Apply-Fehler-Dialogen ohne Wirkung), wizard_flow-Diagramm korrigiert | ✅ Build+102 Tests grün |
| M35 | bugs.md #7: Firewall-Isolation trennt jetzt auch bereits bestehende Verbindungen (Connection-Flush, bugs.md #7) | ✅ Build+103 Tests grün, Mechanismus teilweise live verifiziert (Details bugs.md) |
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
Session-Verlauf: [`HANDOFF.md`](HANDOFF.md) / [`CHATLOG.md`](CHATLOG.md).
@@ -20,6 +20,14 @@ struct FirewallConfig: Equatable {
struct NetworkSegment: Equatable {
var interfaceName: String
var isolated: Bool
/// CIDR range (e.g. "192.168.88.0/24") not used for the filter rules themselves
/// (those match on interface, not address), only for `isolatedNetworkPairs`, which
/// `SetupViewModel.apply()` uses to flush already-open connections between two networks
/// that just became isolated (bugs.md #7 / Gitea #19). Empty for a network with no
/// address filled in yet (e.g. mid-wizard-editing) such a network is simply skipped by
/// the connection-flush, same as it already is by the isolation filter rules once
/// applied (an unreachable address isolates itself).
var networkAddress: String = ""
}
var wanInterface: String
@@ -91,37 +99,49 @@ struct FirewallConfig: Equatable {
return [natCommand] + filterCommands + isolationCommands
}
/// Forward-drop rules between every network marked `isolated` and every other configured
/// network (both directions). Pairs are deduplicated so two mutually isolated networks
/// still only get one pair of rules, not two.
private func buildIsolationCommands(startingPlaceBefore: Int) -> [RouterOSCommand] {
/// Every pair of networks where at least one side is marked `isolated`, deduplicated so two
/// mutually isolated networks still only produce one pair, not two. Shared by
/// `buildIsolationCommands` (drop rules, by interface) and `SetupViewModel.apply()`'s
/// post-apply connection-flush (by address range, bugs.md #7 / Gitea #19) kept as one
/// source of truth so the two can never disagree about which pairs count as isolated.
var isolatedNetworkPairs: [(NetworkSegment, NetworkSegment)] {
var seenPairs = Set<Set<String>>()
var commands: [RouterOSCommand] = []
var placeBefore = startingPlaceBefore
var pairs: [(NetworkSegment, NetworkSegment)] = []
for network in networks where network.isolated {
for other in networks where other.interfaceName != network.interfaceName {
let pair = Set([network.interfaceName, other.interfaceName])
guard !seenPairs.contains(pair) else { continue }
seenPairs.insert(pair)
pairs.append((network, other))
}
}
for (from, to) in [(network.interfaceName, other.interfaceName), (other.interfaceName, network.interfaceName)] {
commands.append(
RouterOSCommand.add(
menuPath: "/ip firewall filter",
restPath: "ip/firewall/filter",
arguments: [
"chain": "forward",
"in-interface": from,
"out-interface": to,
"action": "drop",
"place-before": "\(placeBefore)"
],
summary: "Netzwerk \"\(from)\" von \"\(to)\" isolieren"
)
return pairs
}
/// Forward-drop rules between every isolated network pair (both directions).
private func buildIsolationCommands(startingPlaceBefore: Int) -> [RouterOSCommand] {
var commands: [RouterOSCommand] = []
var placeBefore = startingPlaceBefore
for (network, other) in isolatedNetworkPairs {
for (from, to) in [(network.interfaceName, other.interfaceName), (other.interfaceName, network.interfaceName)] {
commands.append(
RouterOSCommand.add(
menuPath: "/ip firewall filter",
restPath: "ip/firewall/filter",
arguments: [
"chain": "forward",
"in-interface": from,
"out-interface": to,
"action": "drop",
"place-before": "\(placeBefore)"
],
summary: "Netzwerk \"\(from)\" von \"\(to)\" isolieren"
)
placeBefore += 1
}
)
placeBefore += 1
}
}
@@ -208,6 +208,58 @@ final class SSHTransport: RouterOSTransport {
}
}
/// Forcibly ends already-tracked connections between two networks (bugs.md #7 / Gitea #19)
/// RouterOS' firewall rules only affect *new* connections, so a connection already open
/// between two networks at the moment they're marked isolated would otherwise keep flowing
/// through the pre-existing "forward established/related -> accept" rule indefinitely.
/// `/ip firewall connection` has no interface field, only address fields, so this matches by
/// CIDR-range membership instead. No REST equivalent exists for this CIDR-membership query
/// language (REST mirrors CRUD over menu paths, not scripting), so same reasoning as
/// `BackupService`/`NetworkToolsService`/`UpdateService` this always runs over SSH.
///
/// **Verification status, honestly (2026-09-17):** `print count-only where src-address in
/// <cidr>` is live-confirmed to filter by real subnet containment (40 matches for a
/// populated /24 vs. 0 for an empty one, and `remove [find where dst-address=<exact-ip>]`
/// single equality condition, no `in` is live-confirmed to actually delete an entry (a
/// disposable ICMP ping's tracked connection genuinely disappeared). The compound
/// `remove [find where (src-address in A) and (dst-address in B)]` form this function
/// actually uses could NOT be cleanly proven live: a first attempt looked successful, but
/// that was later traced to ICMP conntrack's very short natural timeout (a few seconds)
/// coinciding with the multi-second gap between separate manual SSH round-trips, not the
/// `remove` itself. A cleaner retest against a still-*active* long-lived TCP connection (an
/// open SSH session) showed the tracked entry reappearing immediately after `remove` which
/// is expected/correct behavior for connection tracking in general (removing the tracking
/// state doesn't RST the socket; the very next packet on an actively-flowing connection just
/// gets re-tracked as "new"), not proof the `remove` itself is a no-op, but this app has no
/// two genuinely separate test networks available to observe the one behavior that actually
/// matters here: whether a fresh isolation drop rule catches that re-tracked "new" packet
/// instead of silently re-admitting it. The `remove [find where ... in ...]` compound-CIDR
/// technique itself is real and community-documented (MikroTik forum), just not end-to-end
/// live-verified against this app's specific isolation scenario. Best-effort by design either
/// way (see `ConnectionService.flushConnections`'s caller in `SetupViewModel`) a live
/// multi-network test is the natural next verification step.
///
/// `networkA`/`networkB` are interpolated directly into the script, so they're validated as
/// plain CIDR notation first (digits/dots/slash only) an unvalidated value here would
/// reopen the exact command-injection class already fixed once in
/// `RouterOSCommand.quoteIfNeeded` (bugs.md #5). Both directions are removed since either
/// isolated network could be the connection's source or destination.
func flushConnections(between networkA: String, and networkB: String) async throws {
guard Self.isPlainCIDR(networkA), Self.isPlainCIDR(networkB) else {
throw RouterOSError.invalidResponse("Ungültiges Netzwerkformat: \(networkA) / \(networkB)")
}
let script = """
/ip firewall connection remove [find where (src-address in \(networkA)) and (dst-address in \(networkB))]
/ip firewall connection remove [find where (src-address in \(networkB)) and (dst-address in \(networkA))]
"""
_ = try await run(script)
}
private static func isPlainCIDR(_ value: String) -> Bool {
let allowed = CharacterSet(charactersIn: "0123456789./")
return !value.isEmpty && value.unicodeScalars.allSatisfy(allowed.contains)
}
/// Restores RouterOS' own vendor-default configuration and reboots the device. See
/// FactoryResetService for why this bypasses the RouterOSCommand add/set model entirely.
func resetToFactoryDefaults() async throws {
@@ -123,6 +123,27 @@ final class ConnectionService: ObservableObject {
await connect(with: credentials)
}
/// Backs out of a blocked connect attempt (`.needsCertificateConfirmation`/
/// `.needsSSHHostKeyConfirmation`) without trusting anything back to `.idle` so the
/// Verbinden-tab form is usable again for a fresh attempt (e.g. different credentials, wrong
/// host typo). Without this, `ConnectView`'s "Abbrechen" buttons on both trust alerts had no
/// way to actually change `state`, so the alert's `isPresented` binding (derived from
/// `state`/`pendingSSHTrustFingerprint`) stayed true and the dialog could never really be
/// dismissed found during the README milestone re-check, 2026-09-17 (same class of bug as
/// `SetupViewModel.dismissApplyError()`, fixed moments earlier this session). Also clears
/// `pendingSSHTrustFingerprint`, covering the background-trust-probe trigger path too, so one
/// method correctly backs out of either of the two ways `ConnectView`'s SSH-host-key alert
/// can be showing (see `pendingSSHTrustFingerprint`'s own doc comment for that distinction).
func cancelPendingTrustConfirmation() {
switch state {
case .needsCertificateConfirmation, .needsSSHHostKeyConfirmation:
state = .idle
default:
break
}
pendingSSHTrustFingerprint = nil
}
func trustCurrentSSHHostKeyAndRetry(fingerprint: String) async {
guard let credentials else { return }
sshHostKeyTrust.trust(host: credentials.host, fingerprint: fingerprint)
@@ -138,10 +159,6 @@ final class ConnectionService: ObservableObject {
pendingSSHTrustFingerprint = nil
}
func dismissPendingSSHTrust() {
pendingSSHTrustFingerprint = nil
}
/// Safety net for when the proactive `verifySSHTrust` check (above) didn't already cover a
/// dedicated SSH service's host key confirmed live: a session's first Expert-tab write (its
/// pre-apply backup, over its own dedicated SSH connection) can still hit
@@ -199,6 +216,25 @@ final class ConnectionService: ObservableObject {
}
}
/// Forcibly ends already-tracked connections between two networks that were just marked
/// isolated see `SSHTransport.flushConnections(between:and:)`'s doc comment for the full
/// reasoning and live verification (bugs.md #7 / Gitea #19). Best-effort: called by
/// `SetupViewModel.apply()` right after the isolation filter rules themselves are in place,
/// so any connection that re-establishes immediately after being flushed hits the new drop
/// rule instead of reopening freely.
func flushConnections(between networkA: String, and networkB: String) async throws {
guard let credentials else { throw RouterOSError.notConnected }
let transport = SSHTransport(credentials: credentials)
try await transport.connect()
do {
try await transport.flushConnections(between: networkA, and: networkB)
await transport.disconnect()
} catch {
await transport.disconnect()
throw error
}
}
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts {
guard let activeTransport else { throw RouterOSError.notConnected }
return try await activeTransport.fetchFirewallRuleCounts()
@@ -134,7 +134,9 @@ struct ConnectView: View {
Button(L10n.t("Vertrauen und verbinden", appLanguage)) {
viewModel.trustAndRetry(fingerprint: fingerprint)
}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {
viewModel.cancelCertificatePrompt()
}
} message: { fingerprint in
Text(L10n.t("Der Router hat sich mit einem unbekannten Zertifikat gemeldet.", appLanguage)
+ "\n" + L10n.t("Fingerabdruck:", appLanguage) + " \(fingerprint)\n\n"
@@ -189,7 +191,7 @@ struct ConnectView: View {
private var certificateAlertBinding: Binding<Bool> {
Binding(
get: { certificateFingerprint != nil },
set: { _ in }
set: { isPresented in if !isPresented { viewModel.cancelCertificatePrompt() } }
)
}
@@ -203,7 +205,7 @@ struct ConnectView: View {
private var sshHostKeyAlertBinding: Binding<Bool> {
Binding(
get: { sshHostKeyFingerprint != nil },
set: { _ in }
set: { isPresented in if !isPresented { viewModel.dismissSSHHostKeyPrompt() } }
)
}
@@ -145,8 +145,21 @@ final class ConnectViewModel: ObservableObject {
}
}
/// Backs out of whichever of the two trust-confirmation states is currently showing the
/// SSH-host-key alert (a blocked initial connect, or the background trust probe after a
/// successful REST connect see `ConnectionService.pendingSSHTrustFingerprint`'s doc
/// comment). Previously only cleared `pendingSSHTrustFingerprint`, which left the alert stuck
/// showing whenever it was triggered by the *other* path (`state ==
/// .needsSSHHostKeyConfirmation`) found during the README milestone re-check, 2026-09-17.
func dismissSSHHostKeyPrompt() {
connectionService.dismissPendingSSHTrust()
connectionService.cancelPendingTrustConfirmation()
}
/// Same "Abbrechen" fix as `dismissSSHHostKeyPrompt()`, for the unknown-TLS-certificate alert
/// its "Abbrechen" button previously had an empty action and could never actually clear
/// `connectionService.state` back out of `.needsCertificateConfirmation`.
func cancelCertificatePrompt() {
connectionService.cancelPendingTrustConfirmation()
}
func checkForUpdates(for credentials: RouterOSCredentials) {
@@ -79,10 +79,10 @@ struct ReviewApplyView: View {
L10n.t("Anwenden fehlgeschlagen", appLanguage),
isPresented: Binding(
get: { viewModel.applyError != nil },
set: { _ in }
set: { isPresented in if !isPresented { viewModel.dismissApplyError() } }
)
) {
Button(L10n.t("OK", appLanguage)) {}
Button(L10n.t("OK", appLanguage)) { viewModel.dismissApplyError() }
} message: {
Text(viewModel.applyError ?? "")
}
@@ -73,8 +73,12 @@ final class SetupViewModel: ObservableObject {
/// All configured LAN + VLAN networks, for the firewall isolation rules.
private var networkSegments: [FirewallConfig.NetworkSegment] {
lanConfigs.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
+ vlans.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
lanConfigs.map {
FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated, networkAddress: $0.networkAddress)
}
+ vlans.map {
FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated, networkAddress: $0.networkAddress)
}
}
/// Interface names of networks marked isolated, for display on the Firewall step.
@@ -264,6 +268,17 @@ final class SetupViewModel: ObservableObject {
setFirewallSectionEnabled(true)
}
/// Dismisses the "Anwenden fehlgeschlagen" alert `ReviewApplyView`'s `.alert(isPresented:)`
/// binding derives its presented state from `applyError != nil`, so without this the alert's
/// own OK button (whose action previously did nothing) could never actually clear the error:
/// SwiftUI's next re-render would immediately re-evaluate the getter as still true and the
/// alert would reappear or fail to dismiss. Found during the README milestone re-check,
/// 2026-09-17 (bugs.md-style fix see `SetupViewModel`'s port-conflict entry in bugs.md for
/// the same-feature-area race-condition fix earlier this session).
func dismissApplyError() {
applyError = nil
}
/// Ends the wizard after a successful apply without this, the completed review screen just
/// sits there with a disabled "Jetzt anwenden" and no way forward except "Zurück" (which would
/// re-walk now-stale steps against the router state this apply just changed). Resets to a
@@ -361,6 +376,10 @@ final class SetupViewModel: ObservableObject {
try await applyIdempotently(command)
}
if firewallSectionEnabled {
await flushIsolatedNetworkConnections()
}
didApplySuccessfully = true
} catch {
applyError = error.localizedDescription
@@ -369,6 +388,28 @@ final class SetupViewModel: ObservableObject {
}
}
/// Ends already-open connections between networks that just became isolated (bugs.md #7 /
/// Gitea #19) without this, the new "forward drop" rules just added only block *new*
/// connections; anything already flowing between the two networks at apply-time would
/// otherwise keep going through the existing "forward established/related -> accept" rule
/// until it ends on its own. Runs after the firewall commands themselves so a connection that
/// re-establishes right after being flushed immediately hits the new drop rule.
///
/// Best-effort and silent on failure (`try?`), same as the rest of this best-effort area
/// (`checkPortConflict`): a flush that fails (e.g. a transient SSH hiccup) must not fail the
/// whole apply the isolation rules themselves are already in place either way, this only
/// affects whether a pre-existing connection gets cut immediately or lingers until it times
/// out on its own. A pair where either side has no network address yet (e.g. left blank) is
/// skipped there's no address range to match connections against.
private func flushIsolatedNetworkConnections() async {
let pairs = FirewallConfig(wanInterface: wanConfig.interfaceName, networks: networkSegments).isolatedNetworkPairs
for (network, other) in pairs {
guard !network.networkAddress.isEmpty, !other.networkAddress.isEmpty else { continue }
applyLog.append("Bestehende Verbindungen zwischen \"\(network.interfaceName)\" und \"\(other.interfaceName)\" trennen…")
_ = try? await connectionService.flushConnections(between: network.networkAddress, and: other.networkAddress)
}
}
/// Menus where a duplicate `.add` should instead reconfigure the existing entry in place
/// re-running any wizard step against an already-configured router hits this on every menu
/// that enforces uniqueness (dhcp-client/pppoe-client: one per interface, live-confirmed
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>1.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSAppTransportSecurity</key>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -70,6 +70,25 @@ final class ConnectionServiceTests: XCTestCase {
XCTAssertEqual(service.state, .needsCertificateConfirmation(fingerprint: "AA:BB"))
}
/// Regression test for the README milestone re-check, 2026-09-17: `ConnectView`'s
/// "Abbrechen" button on the certificate-trust alert previously had an empty action and could
/// never actually clear `state` back out of `.needsCertificateConfirmation`, so the alert
/// (whose `isPresented` binding is derived from `state`) could never really be dismissed.
func testCancelPendingTrustConfirmationResetsCertificateConfirmationToIdle() async {
let service = ConnectionService()
await service.connect(
with: credentials,
makeRestTransport: { MockTransport(kind: .rest, connectError: RouterOSError.untrustedCertificate(fingerprint: "AA:BB")) },
makeSSHTransport: { MockTransport(kind: .ssh) }
)
XCTAssertEqual(service.state, .needsCertificateConfirmation(fingerprint: "AA:BB"))
service.cancelPendingTrustConfirmation()
XCTAssertEqual(service.state, .idle)
}
func testFailsWhenBothTransportsFail() async {
let service = ConnectionService()
@@ -81,4 +81,25 @@ final class FirewallConfigTests: XCTestCase {
XCTAssertEqual(config.buildCommands().count, 8)
}
/// Regression test for bugs.md #7 / Gitea #19: `isolatedNetworkPairs` is the shared source of
/// truth `SetupViewModel.flushIsolatedNetworkConnections()` uses to know which network pairs
/// to flush already-open connections between must report the same pairs
/// `buildIsolationCommands` derives its drop rules from, including the network's address
/// range (not used by the drop rules themselves, only by the connection flush).
func testIsolatedNetworkPairsCarriesAddressesForConnectionFlush() {
let config = FirewallConfig(
wanInterface: "ether1",
networks: [
FirewallConfig.NetworkSegment(interfaceName: "bridge", isolated: true, networkAddress: "192.168.88.0/24"),
FirewallConfig.NetworkSegment(interfaceName: "vlan20", isolated: false, networkAddress: "192.168.20.0/24")
]
)
let pairs = config.isolatedNetworkPairs
XCTAssertEqual(pairs.count, 1)
XCTAssertEqual(Set([pairs[0].0.interfaceName, pairs[0].1.interfaceName]), ["bridge", "vlan20"])
XCTAssertEqual(Set([pairs[0].0.networkAddress, pairs[0].1.networkAddress]), ["192.168.88.0/24", "192.168.20.0/24"])
}
}
+79 -6
View File
@@ -16,6 +16,7 @@ einer früheren Session vorhanden.
### 1. Race Condition bei Port-Konflikt-Prüfung im LAN-Schritt
**Status:** fixed (Build grün, noch nicht live gegenreproduziert)
**Gitea-Issue:** [#13](http://192.168.178.222:3500/kay/RouterOS/issues/13)
**Confidence:** hoch (Logikfehler direkt im Code nachvollzogen, nicht live reproduziert)
`SetupViewModel.checkPortConflict(for:)` (`SetupViewModel.swift:146`) liest `config.interfaceName`
@@ -43,6 +44,7 @@ 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](http://192.168.178.222:3500/kay/RouterOS/issues/14)
**Confidence:** hoch (direkt im Code sichtbar)
`FirewallStepView.swift:63`: `.navigationTitle(... "Firewall (optional)" ...)` ist fest, unabhängig
@@ -55,6 +57,7 @@ Text-Widerspruch für jeden, der im Einfach-Modus durch den Wizard geht.
### 3. Fehlende Englisch-Übersetzungen (sichtbar im EN-UI)
**Status:** fixed (skriptgeprüft: beide Keys jetzt in `L10n.swift`, verbleibende zwei fehlende
**Gitea-Issue:** [#15](http://192.168.178.222:3500/kay/RouterOS/issues/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.swift`s
Übersetzungs-Dictionary abgeglichen — `L10n.t` fällt bei fehlendem Key auf den deutschen
@@ -73,6 +76,7 @@ fehlgeschlagen": "Network test failed"`, `"Fokus-Ansicht schließen": "Close foc
### 4. Health-Check-Heartbeat ignoriert laufende Wizard-/Experte-Schreibvorgänge
**Status:** fixed (Build grün, noch nicht live gegenreproduziert)
**Gitea-Issue:** [#16](http://192.168.178.222:3500/kay/RouterOS/issues/16)
Fix: `ConnectionService.beginWrite()`/`endWrite()` (Zähler `activeWriteCount`) ergänzt,
`checkConnectionHealthAndReconnectIfNeeded()` prüft jetzt zusätzlich `activeWriteCount == 0`.
@@ -109,6 +113,7 @@ gegenverifiziert, dass der Fix greift, mit anschließendem Aufräumen der Testar
### 5. RouterOS-CLI-Injection über beliebige Textfelder (SSH-Transportpfad)
**Status:** fixed (live exploitiert UND live als behoben bestätigt, siehe unten)
**Gitea-Issue:** [#17](http://192.168.178.222:3500/kay/RouterOS/issues/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
@@ -143,6 +148,7 @@ Neuer Regressionstest `testCliLineEscapesEmbeddedQuotesPreventingCommandInjectio
### 6. Genereller RouterOS-Antwort-Parser trunkiert mehrwortige Werte
**Status:** fixed (Ursache live nachgewiesen, Fix per Unit-Test abgesichert)
**Gitea-Issue:** [#18](http://192.168.178.222:3500/kay/RouterOS/issues/18)
**Confidence:** sehr hoch — live gegen zwei unabhängige Menüs nachgewiesen
`RouterOSCliParser.keyValues(from:)` nahm an, `print terse` quote mehrwortige Werte in
@@ -169,9 +175,10 @@ gelesen — deutlich seltener als die vorherige 100%-Fehlerquote bei jedem mehrw
Regressionstest `testParseGenericItemsPreservesUnquotedMultiWordValue`, abgeleitet vom echten
Live-Dump. Build + alle 101 Unit-Tests grün.
### 7. Beobachtung (kein Fix): Isolation wirkt nicht rückwirkend auf bereits bestehende Verbindungen
**Status:** offen (bewusst nicht automatisch gefixt — Risikoabwägung, siehe unten)
**Confidence:** hoch (Regel-Reihenfolge im Code nachvollzogen), nicht live reproduziert
### 7. Isolation wirkte nicht rückwirkend auf bereits bestehende Verbindungen
**Status:** fixed (best-effort, Mechanismus teilweise live verifiziert — Details unten)
**Gitea-Issue:** [#19](http://192.168.178.222:3500/kay/RouterOS/issues/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
@@ -179,10 +186,39 @@ established,related → accept" auf Position 4, die Isolations-Drop-Regeln erst
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
greift dagegen weiterhin Regel 4 zuerst — sie bleibt offen, bis sie von selbst endet. Das ist
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, und der bisher einzige Live-Test dieser Funktion (M8, 2026-09-15) betraf
einen frisch aufgeteilten Port ohne bestehende Verbindung — dieser Randfall wurde nie geprüft.
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,
@@ -204,6 +240,7 @@ Fallback-Schwäche. Zwei weitere Lücken gefunden und gehärtet (defensiv, nicht
### 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](http://192.168.178.222:3500/kay/RouterOS/issues/20)
**Confidence:** hoch (dieselbe Bugklasse wie #5, nur im REST- statt SSH-Pfad)
`RestTransport.fetchFieldValues` baute den Query-String `"\(restPath)?\(whereField)=\(whereValue)"`
@@ -217,6 +254,7 @@ 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](http://192.168.178.222:3500/kay/RouterOS/issues/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)
@@ -245,3 +283,38 @@ Build + alle 101 Unit-Tests grün nach beiden Fixes.
- 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](http://192.168.178.222:3500/kay/RouterOS/issues/22)
**Confidence:** hoch (Logikfehler direkt im Code nachvollzogen, nicht live reproduziert)
1. `ReviewApplyView`s "Anwenden fehlgeschlagen"-Alert: OK-Button-Action leer, `isPresented`-
Bindings-Setter ebenfalls No-Op — `applyError` wurde nie zurückgesetzt.
2. `ConnectView`s "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. `ConnectView`s "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.
+66
View File
@@ -10,6 +10,7 @@ Notizzettel für Feedback/Funde des Users. Neue Einträge unten anhängen, Statu
### 1. NTP Server bricht mit Error HTTP 400 ab
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#3](http://192.168.178.222:3500/kay/RouterOS/issues/3)
Ursache 1 (behoben): Experte-Schema PATCHte `servers` als kommagetrenntes Textfeld direkt auf `/system/ntp/client` — Feld existiert dort in RouterOS 7.x nicht, Server liegen in eigener Liste `/system ntp client servers`. Schema aufgeteilt: Singleton-Menü (enabled/mode) bleibt, neues Listen-Menü `/system ntp client servers` (Feld `address`) dazu.
@@ -17,6 +18,7 @@ Ursache 2 (behoben): Live-Test meldete beim Anlegen eines Server-Eintrags `HTTP
### 2. Interfaces-Ansicht zu unübersichtlich
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#4](http://192.168.178.222:3500/kay/RouterOS/issues/4)
Interfaces-Einträge sehr umfangreich beim Öffnen. Zweispaltige Ansicht gewünscht, um Scrollen zu reduzieren.
@@ -32,6 +34,7 @@ Nachbesserung 4 (User-Feedback: letzte Zeile überschneidet sich mit dem Button
### 3. Übersicht: Fokus-Modus beim Anklicken eines Items
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#5](http://192.168.178.222:3500/kay/RouterOS/issues/5)
Wunsch: Klick auf einen Node in der Übersicht (Graph-Ansicht, `OverviewView.swift`) soll den Node in den freien Bereich schieben, alle anderen Nodes ausfaden, und die Parents/Childs des aktuell gewählten Nodes nach unten schieben und sauber neu anordnen.
@@ -73,6 +76,7 @@ Nachbesserung 8 (User-Wunsch: Verhalten+Layout des Close-Buttons bei ALLEN Popup
### 4. LAN-Scanner: Sparkline-Breite + Abfrage-Intervall
**Status:** fixed (live bestätigt: "ja, alles flüssig" — auch 0,1s-Takt keine spürbare CPU-Last)
**Gitea-Issue:** [#6](http://192.168.178.222:3500/kay/RouterOS/issues/6)
Wunsch: Sparkline-Breite verdoppeln, Traffic-Abfrage auf 0,5s setzen (statt 3s).
@@ -86,6 +90,7 @@ Nachbesserung 3 (User-Wunsch: Aktualisierungsrate auf 0,1s): `startTrafficPollin
### 5. App-Einstellungen / Settings — eigener Menüpunkt
**Status:** fixed (live bestätigt: "passt, lassen wir so")
**Gitea-Issue:** [#7](http://192.168.178.222:3500/kay/RouterOS/issues/7)
Wunsch: eigener Einstellungen-Bereich für Personalisierung, Layout, Farbverwaltung, Refreshraten, Updates, Schriftgrößen, Responsiveness, etc.
@@ -119,6 +124,7 @@ Build grün, alle 98 Unit-Tests grün. Bitte nochmal live testen — diesmal an
### 6. Mode-Button Setup
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#8](http://192.168.178.222:3500/kay/RouterOS/issues/8)
Wunsch: an der Seite des Router ist eine Taste "Mode", diese ist belegbar (Scripte, Deaktivierung, etc). Einlesen, was geht, und bauen.
@@ -159,6 +165,7 @@ Mode-Taste (Fund #6) ist damit vollständig live verifiziert: Anlegen über App
### 9. Übersicht: Spaltenüberschriften fehlten im Fokus-Popup
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#11](http://192.168.178.222:3500/kay/RouterOS/issues/11)
Wunsch: Fokus-Popup (Klick auf Knoten in der Übersicht) soll dieselben Spaltenüberschriften (Interfaces, IP-Adressen, Pools & DHCP, Routen, Firewall & NAT) über den Knoten zeigen wie das Hauptdiagramm.
@@ -168,6 +175,7 @@ Build+alle 99 Unit-Tests grün, live bestätigt.
### 7. Manual direkt in die App integrieren
**Status:** fixed (live bestätigt)
**Gitea-Issue:** [#9](http://192.168.178.222:3500/kay/RouterOS/issues/9)
Wunsch: Manual so in die App integrieren, dass ein Hilfepunkt in der aktuell geöffneten Sektion direkt zur passenden Stelle im Manual springt (Textanker).
@@ -193,6 +201,7 @@ Build grün, alle 99 Unit-Tests grün, `Manual.html` + `Manual_en.html` beide be
### 8. Abwechselnde Farbkombis bei Tabellenansichten
**Status:** fixed (live bestätigt: "passt")
**Gitea-Issue:** [#10](http://192.168.178.222:3500/kay/RouterOS/issues/10)
Bessere Lesbarkeit durch alternierende Zeilenfarben in Tabellenansichten (LAN-Scanner, Experte-Listen, etc.).
@@ -212,6 +221,7 @@ Build grün, alle 99 Unit-Tests grün. Bitte erneut live testen.
### 10. Selbständiger Wiederverbindungsversuch nach Disconnect
**Status:** fixed (live bestätigt, zusätzlich beim echten Firmware-Update-Neustart mitgetestet)
**Gitea-Issue:** [#12](http://192.168.178.222:3500/kay/RouterOS/issues/12)
Wunsch: fällt die Verbindung zum Router weg (z.B. während einer laufenden Sitzung), soll die App selbständig versuchen, die Verbindung wiederherzustellen, statt einfach im getrennten Zustand zu bleiben.
@@ -295,3 +305,59 @@ live exploitiert — anders als #5/#6 im vorigen Durchgang):
hartem Verbindungsabbruch statt einem Vertrauens-Dialog mit unverifizierbarer Kennung.
Build grün, alle 101 Unit-Tests grün. Details in `bugs.md`.
### 14. README-Nachcheck: offener Milestone (Port-Konflikt/"Fertig") + 3 Alert-Dismiss-Bugs
**Status:** fixed (Build + 102 Unit-Tests grün, Live-Klicktest weiterhin offen)
Nutzer wies auf einen noch offenen Milestone in der README hin (LAN-Port-Konflikt-Prüfung +
"Fertig"-Button, nie live durchgeklickt). Beim erneuten Code-Review dieses Bereichs (u.a. wegen
des in dieser Session bereits gefixten Race-Bugs in genau dieser Feature) drei echte,
eigenständige Bugs derselben Klasse gefunden:
1. `ReviewApplyView`s "Anwenden fehlgeschlagen"-Alert: OK-Button-Action war leer, die
`isPresented`-Bindings-Setter-Closure ebenfalls ein No-Op — `applyError` wurde nie
zurückgesetzt, der Alert konnte sich nach dem Schließen theoretisch sofort wieder öffnen.
2. `ConnectView`s "Unbekanntes Zertifikat"-Alert: "Abbrechen"-Button-Action war komplett leer —
`connectionService.state` blieb für immer auf `.needsCertificateConfirmation` hängen, es gab
keinen Weg zurück außer dem Zertifikat zu vertrauen.
3. `ConnectView`s "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 dieses Alerts, 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 — sowohl der jeweilige Cancel/OK-Button als auch die Bindings-Setter-Closure (deckt
auch Tap-Outside/Esc-Dismiss ab). Totes `dismissPendingSSHTrust()` entfernt. Ein neuer
Regressionstest (`testCancelPendingTrustConfirmationResetsCertificateConfirmationToIdle`).
Build grün, alle 102 Unit-Tests grün. Der ursprünglich gemeldete Milestone (Port-Konflikt-Prüfung
selbst, inkl. Warndialoge, "Weiter"-Sperre, "Fertig"-Button) bleibt beim Status "Code-Review
bestätigt" — ein echter Live-Klicktest durch den Nutzer in der App-UI steht weiterhin aus, da
UI-Automatisierung in dieser Session nicht verfügbar ist.
### 15. bugs.md #7 bearbeitet: Isolation trennt jetzt auch bereits bestehende Verbindungen
**Status:** fixed (best-effort, Mechanismus teilweise live verifiziert)
**Gitea-Issue:** [#19](http://192.168.178.222:3500/kay/RouterOS/issues/19)
Auf Nutzerwunsch ("bearbeite #7") den zuvor bewusst zurückgestellten Punkt jetzt umgesetzt: 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 zwei Netzen, sobald sie als isoliert angewendet werden — aufgerufen direkt
nach den Firewall-Befehlen in `SetupViewModel.apply()`.
Bemerkenswert am Weg dorthin: eine erste Live-Verifikation sah erfolgreich aus (Test-Verbindung
verschwand nach `remove`), erwies sich bei genauerem Hinsehen aber als Messfehler — die
ICMP-Test-Verbindung war einfach von selbst abgelaufen (RouterOS' sehr kurzer ICMP-Conntrack-
Timeout), nicht durch den `remove`-Befehl entfernt worden. Ein sauberer Nachtest an einer
tatsächlich noch aktiven TCP-Verbindung zeigte den Eintrag sofort wieder auftauchen. Root Cause
geklärt (Web-Recherche + Verhalten selbst nachvollzogen): Connection-Tracking-Removal sendet kein
RST, eine aktiv weiterlaufende Verbindung wird beim nächsten Paket einfach neu getrackt — kein
Beweis, dass `remove` nichts tut, aber auch kein Beweis, dass die neue Isolations-Regel das neu
getrackte Paket abfängt. Für den vollständigen Beweis fehlen zwei echte, getrennte Testnetze mit
echten Endgeräten. Dokumentation entsprechend ehrlich mit dem tatsächlichen Verifikationsstand
statt einer überzogenen "live bestätigt"-Behauptung versehen (siehe `bugs.md` #7 für die volle
Herleitung).
Build grün, alle 103 Unit-Tests grün (1 neuer Regressionstest für `FirewallConfig.isolatedNetworkPairs`).