Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc4557496b | ||
|
|
c791693d7e | ||
|
|
87395a5164 | ||
|
|
547c3d778a | ||
|
|
3c84d3fbaf | ||
|
|
b503bac82c | ||
|
|
57850cfe03 | ||
|
|
cae59db39e | ||
|
|
e9e701a94d | ||
|
|
bad6328c8f | ||
|
|
dcb9d9e03c | ||
|
|
06ddd837f1 | ||
|
|
4810b7df63 | ||
|
|
510e750b3c | ||
|
|
bace9fe3b2 |
@@ -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/
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
Alle nennenswerten Änderungen an RouterOS Assistant. Format angelehnt an
|
||||
[Keep a Changelog](https://keepachangelog.com/), Versionierung nach [SemVer](https://semver.org/).
|
||||
Wird nur bei einem Release gefüllt (nicht laufend während der Entwicklung) — Details zu jedem
|
||||
einzelnen Fund/Fix stehen in [`bugs.md`](bugs.md)/[`found.md`](found.md), der volle
|
||||
Entwicklungsverlauf in [`HANDOFF.md`](HANDOFF.md)/[`CHATLOG.md`](CHATLOG.md).
|
||||
|
||||
## [1.2.0] — 2026-09-17
|
||||
|
||||
### Hinzugefügt
|
||||
- Experte-Tab: dieselbe Port-Konflikt-Prüfung ("Port freimachen?") wie im Einrichten-Assistenten,
|
||||
jetzt auch für jedes Schema mit Interface-Feld (bugs.md #12) — live bestätigt.
|
||||
|
||||
### Behoben
|
||||
- Hänger beim Anlegen eines DHCP-Pools (und generell bei jedem ersten Schreibvorgang einer
|
||||
Session): `SSHTransport.connect()`/`run()` hatten keinen Timeout, ein hängender
|
||||
Verbindungsaufbau blockierte die App unbegrenzt ohne Fehlermeldung (bugs.md #11) — live
|
||||
bestätigt.
|
||||
|
||||
## [1.1.0] — 2026-09-17
|
||||
|
||||
### Hinzugefügt
|
||||
- Firewall-Isolation trennt jetzt auch bereits bestehende (nicht nur neue) Verbindungen zwischen
|
||||
isolierten Netzen (Connection-Flush, bugs.md #7).
|
||||
|
||||
### Behoben
|
||||
- Drei nicht funktionierende Alert-"Abbrechen"/"OK"-Buttons (Zertifikat-Vertrauen-,
|
||||
SSH-Hostkey-Vertrauen- und Apply-Fehler-Dialog blieben nach Klick hängen, `state` wurde nie
|
||||
zurückgesetzt) (bugs.md #10).
|
||||
- `wizard_flow`-Diagramm im Handbuch zeigte den Wizard fälschlich als reine lineare Kette —
|
||||
Einfach/Experte-Verzweigung (VLAN-Schritt wird im Einfach-Modus übersprungen) ergänzt.
|
||||
|
||||
### Dokumentation
|
||||
- Alle bisherigen Funde aus `found.md`/`bugs.md` als einzelne Gitea-Issues nachgetragen (#3-22).
|
||||
|
||||
## [1.0.0] — 2026-09-17
|
||||
|
||||
Erste getaggte Version. Vollständiger Funktionsumfang: Setup-Wizard (WAN/LAN/VLAN/WLAN/Firewall,
|
||||
Einfach- und Experte-Modus), Experte-Tab (generischer RouterOS-Zugriff, 45 kuratierte Menüs),
|
||||
Übersicht-Diagramm, Geräte-Tab (LAN-Scanner), Sicherungen, Einstellungen, Handbuch in der App
|
||||
(DE/EN), automatisches Wiederverbinden nach Verbindungsabbruch — live gegen echte MikroTik-
|
||||
Hardware (hEX, hAP lite) verifiziert.
|
||||
|
||||
### Sicherheit
|
||||
- **RouterOS-CLI-Injection behoben** (schwerwiegendster Fund der gesamten Entwicklung): beliebige
|
||||
Textfelder (Kommentare, SSID, Freitextparameter) konnten über ein eingebettetes `"` gefolgt von
|
||||
`;` einen zweiten, unabhängigen RouterOS-Befehl einschleusen — live exploitiert und live als
|
||||
behoben bestätigt (bugs.md #5).
|
||||
- REST-Pendant zur selben Injection-Klasse geschlossen (`RestTransport.fetchFieldValues`, RFC-3986-
|
||||
Encoding, bugs.md #8).
|
||||
- TOFU-Zertifikatsprüfung gehärtet: ein Extraktionsfehler führte zu einem festen Fallback-String
|
||||
statt eines echten Fingerabdrucks — theoretisches Pinning-Bypass-Fenster geschlossen (bugs.md #9).
|
||||
|
||||
### Behoben
|
||||
- Genereller RouterOS-Antwort-Parser trunkierte jeden mehrwortigen Wert beim ersten Leerzeichen,
|
||||
da RouterOS' `print terse` (anders als angenommen) keine mehrwortigen Werte quotet (bugs.md #6).
|
||||
- Race Condition bei der Port-Konflikt-Prüfung im LAN-Schritt (bugs.md #1).
|
||||
- Sich selbst widersprechender Firewall-Schritt-Titel im Einfach-Modus, zwei fehlende
|
||||
Englisch-Übersetzungen, Health-Check-Herzschlag ignorierte laufende Schreibvorgänge
|
||||
(bugs.md #2-4).
|
||||
@@ -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
Binary file not shown.
Binary file not shown.
@@ -170,13 +170,19 @@ 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) |
|
||||
| M36 | Hänger beim Anlegen eines DHCP-Pools gefixt (SSHTransport ohne Timeout, bugs.md #11) | ✅ live bestätigt, Build+106 Tests grün |
|
||||
| M37 | Experte-Tab: Port-Konflikt-Prüfung wie im Einrichten-Assistenten (bugs.md #12) | ✅ live bestätigt, Build+111 Tests grün |
|
||||
|
||||
Release-Historie (was sich zwischen den Versionen geändert hat): [`CHANGELOG.md`](CHANGELOG.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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,4 +62,22 @@ struct PortConflict: Equatable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `resolutionCommands()` alone for the Setup-Wizard context, where `DhcpServerCommandBuilder`
|
||||
/// already unconditionally detaches the port from any bridge as its own separate safety net —
|
||||
/// see that method's doc comment. Contexts without that separate detach (the Experte tab's
|
||||
/// generic "Port freimachen?" flow, applied to whatever menu the user is actually editing, not
|
||||
/// specifically the LAN/DHCP command set) need the bridge-membership removal included here
|
||||
/// instead, or acknowledging a bridge-member conflict there would silently do nothing for
|
||||
/// that specific reason.
|
||||
func resolutionCommandsIncludingBridgeDetach() -> [RouterOSCommand] {
|
||||
let bridgeDetach: [RouterOSCommand] = reasons.contains(where: { if case .bridgeMember = $0 { return true } else { return false } })
|
||||
? [RouterOSCommand.remove(
|
||||
menuPath: "/interface bridge port", restPath: "interface/bridge/port",
|
||||
matchField: "interface", matchValue: interfaceName,
|
||||
summary: "\(interfaceName) aus Bridge lösen"
|
||||
)]
|
||||
: []
|
||||
return bridgeDetach + resolutionCommands()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,19 +16,35 @@ final class SSHTransport: RouterOSTransport {
|
||||
self.hostKeyTrust = hostKeyTrust
|
||||
}
|
||||
|
||||
/// Unlike `RestTransport` (explicit `request.timeoutInterval = 5` on every request), neither
|
||||
/// this nor `run(_:)` used to bound how long they'd wait at all — `Citadel.SSHClient.connect`
|
||||
/// has no built-in timeout. Live-confirmed as a real, reproducible bug (bugs.md #11,
|
||||
/// 2026-09-17): creating an `/ip pool` entry in the Experte tab hung the app permanently (no
|
||||
/// error, no recovery, force-quit needed) — traced to this connect call, reached via
|
||||
/// `ExpertViewModel.saveEditingItem()`'s mandatory `ensureSessionBackup()`, which opens a
|
||||
/// fresh, dedicated SSH connection (`BackupService`) before every session's first write. A
|
||||
/// connection attempt that stalls (transient network hiccup, or the hAP-lite test router
|
||||
/// itself being slow under load — MIPS 24Kc/650MHz/1 core, seen at 80% CPU this session) had
|
||||
/// no way to ever resolve, so `isApplying` never cleared. `withTimeout` below races the real
|
||||
/// operation against a deadline and cancels whichever loses.
|
||||
private static let connectTimeout: Duration = .seconds(10)
|
||||
private static let commandTimeout: Duration = .seconds(30)
|
||||
|
||||
func connect() async throws {
|
||||
do {
|
||||
client = try await SSHClient.connect(
|
||||
host: credentials.host,
|
||||
port: credentials.sshPort,
|
||||
authenticationMethod: .passwordBased(username: credentials.username, password: credentials.password),
|
||||
hostKeyValidator: .custom(self),
|
||||
reconnect: .never,
|
||||
// RouterOS' SSH server typically only offers legacy algorithms
|
||||
// (diffie-hellman-group14-sha1 key exchange, RSA host keys) that
|
||||
// Citadel's defaults don't include — `.all` adds them.
|
||||
algorithms: .all
|
||||
)
|
||||
client = try await Self.withTimeout(Self.connectTimeout) {
|
||||
try await SSHClient.connect(
|
||||
host: self.credentials.host,
|
||||
port: self.credentials.sshPort,
|
||||
authenticationMethod: .passwordBased(username: self.credentials.username, password: self.credentials.password),
|
||||
hostKeyValidator: .custom(self),
|
||||
reconnect: .never,
|
||||
// RouterOS' SSH server typically only offers legacy algorithms
|
||||
// (diffie-hellman-group14-sha1 key exchange, RSA host keys) that
|
||||
// Citadel's defaults don't include — `.all` adds them.
|
||||
algorithms: .all
|
||||
)
|
||||
}
|
||||
} catch let error as RouterOSError {
|
||||
throw error
|
||||
} catch {
|
||||
@@ -39,6 +55,21 @@ final class SSHTransport: RouterOSTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Races `operation` against `duration`, cancelling whichever loses — the generic mechanism
|
||||
/// behind both `connect()`'s and `run(_:)`'s timeouts (bugs.md #11). `operation` must be
|
||||
/// `@Sendable`: it runs inside a detached task group child, not on the caller's isolation.
|
||||
static func withTimeout<T: Sendable>(_ duration: Duration, operation: @escaping @Sendable () async throws -> T) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(for: duration)
|
||||
throw RouterOSError.transportUnavailable("Zeitüberschreitung (\(Int(duration.components.seconds))s) — Router antwortet nicht.")
|
||||
}
|
||||
defer { group.cancelAll() }
|
||||
return try await group.next()!
|
||||
}
|
||||
}
|
||||
|
||||
func fetchDeviceInfo() async throws -> RouterDeviceInfo {
|
||||
let output = try await run("/system resource print without-paging")
|
||||
return RouterOSCliParser.parseDeviceInfo(output)
|
||||
@@ -208,6 +239,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 {
|
||||
@@ -293,25 +376,33 @@ final class SSHTransport: RouterOSTransport {
|
||||
/// `executeCommand` discards whatever output it already collected the moment the command
|
||||
/// exits non-zero — exactly the RouterOS error text we need. Collecting the stream ourselves
|
||||
/// keeps that text available even when the command fails.
|
||||
/// Wrapped in `withTimeout` for the same reason as `connect()` (bugs.md #11) — a command that
|
||||
/// never returns (router hangs mid-execution, connection drops without a clean error) used to
|
||||
/// block forever with no recovery. The `CommandFailed` handling stays inside the timed
|
||||
/// closure so `output` (partial text collected before the failure) is still in scope to build
|
||||
/// the error detail — the already-established domain error (`RouterOSError.invalidResponse`)
|
||||
/// is what actually crosses the timeout race, not the raw Citadel type.
|
||||
private func run(_ command: String) async throws -> String {
|
||||
guard let client else { throw RouterOSError.notConnected }
|
||||
|
||||
var output = ""
|
||||
do {
|
||||
let stream = try await client.executeCommandStream(command)
|
||||
for try await chunk in stream {
|
||||
switch chunk {
|
||||
case .stdout(let buffer), .stderr(let buffer):
|
||||
output += String(buffer: buffer)
|
||||
return try await Self.withTimeout(Self.commandTimeout) {
|
||||
var output = ""
|
||||
do {
|
||||
let stream = try await client.executeCommandStream(command)
|
||||
for try await chunk in stream {
|
||||
switch chunk {
|
||||
case .stdout(let buffer), .stderr(let buffer):
|
||||
output += String(buffer: buffer)
|
||||
}
|
||||
}
|
||||
return output
|
||||
} catch let failure as SSHClient.CommandFailed {
|
||||
let detail = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw RouterOSError.invalidResponse(
|
||||
"RouterOS meldete Fehler (Exit-Code \(failure.exitCode)) für \"\(command)\""
|
||||
+ (detail.isEmpty ? "" : ": \(detail)")
|
||||
)
|
||||
}
|
||||
return output
|
||||
} catch let failure as SSHClient.CommandFailed {
|
||||
let detail = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw RouterOSError.invalidResponse(
|
||||
"RouterOS meldete Fehler (Exit-Code \(failure.exitCode)) für \"\(command)\""
|
||||
+ (detail.isEmpty ? "" : ": \(detail)")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -154,6 +154,25 @@ struct ExpertItemEditView: View {
|
||||
fieldEditor(for: field)
|
||||
}
|
||||
|
||||
// Gleiche "Port-Konflikt-Prüfung" wie im Einrichten-Assistenten (LAN-Schritt,
|
||||
// found.md #1), hier auf das `.interfacePick`-Feld dieses Schemas angewendet — per
|
||||
// explizitem Nutzerwunsch ("die Abfrage vom Einrichten-Assistenten auf Expert
|
||||
// anwenden ... mit allen Warnungen").
|
||||
if let conflict = viewModel.interfacePortConflict {
|
||||
PortConflictWarningView(
|
||||
conflict: conflict,
|
||||
isAcknowledged: viewModel.acknowledgedInterfacePortConflict,
|
||||
appLanguage: appLanguage,
|
||||
immediateApply: true,
|
||||
onConfirm: { viewModel.acknowledgeInterfacePortConflict() }
|
||||
)
|
||||
} else if viewModel.isCheckingInterfacePortConflict {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(L10n.t("Prüfe, ob der Port frei ist…", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section(LocalizedStringKey(L10n.t("Weitere Parameter (frei)", appLanguage))) {
|
||||
Text(L10n.t("Für alles, was oben nicht als eigenes Feld aufgeführt ist — RouterOS-Parametername genau wie in der Dokumentation.", appLanguage))
|
||||
.appFont(.caption)
|
||||
@@ -223,13 +242,19 @@ struct ExpertItemEditView: View {
|
||||
Button(L10n.t(isNew ? "Anlegen" : "Speichern", appLanguage)) {
|
||||
showApplyConfirmation = true
|
||||
}
|
||||
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil)
|
||||
.disabled(viewModel.isApplying || viewModel.pendingCommand == nil || viewModel.hasUnresolvedInterfacePortConflict)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
.frame(minWidth: 900, idealWidth: 900, minHeight: 520, idealHeight: 660)
|
||||
.onAppear {
|
||||
viewModel.checkInterfacePortConflict()
|
||||
}
|
||||
.onChange(of: viewModel.formValues[interfaceFieldKey ?? "", default: ""]) { _, _ in
|
||||
viewModel.checkInterfacePortConflict()
|
||||
}
|
||||
.confirmationDialog(
|
||||
L10n.t("Jetzt am Router anwenden?", appLanguage),
|
||||
isPresented: $showApplyConfirmation,
|
||||
@@ -249,6 +274,13 @@ struct ExpertItemEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The current schema's `.interfacePick` field key, if it has one — mirrors
|
||||
/// `ExpertViewModel.interfaceFieldKey`, kept here too since `.onChange` needs a concrete
|
||||
/// key path to observe (a schema only ever has at most one such field).
|
||||
private var interfaceFieldKey: String? {
|
||||
schema.fields.first { if case .interfacePick = $0.kind { return true } else { return false } }?.key
|
||||
}
|
||||
|
||||
private func extraColumnHeader(_ key: String) -> some View {
|
||||
Text(L10n.t(key, appLanguage))
|
||||
.appFont(.caption)
|
||||
|
||||
@@ -37,6 +37,19 @@ final class ExpertViewModel: ObservableObject {
|
||||
@Published private(set) var applyError: String?
|
||||
@Published var pendingRemoval: RouterOSMenuItem?
|
||||
|
||||
/// Same "Port-Konflikt-Prüfung" as the Setup Wizard's LAN step (found.md #1), applied here to
|
||||
/// whichever `.interfacePick` field the current schema has — per explicit request ("die
|
||||
/// Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen"). Unlike
|
||||
/// the wizard (one conflict per `LanDhcpConfig.ID` in a list), the edit sheet only ever has
|
||||
/// one interface field open at a time, so a single value (not a dictionary) is enough.
|
||||
@Published private(set) var interfacePortConflict: PortConflict?
|
||||
@Published private(set) var isCheckingInterfacePortConflict = false
|
||||
@Published private(set) var acknowledgedInterfacePortConflict = false
|
||||
/// Bumped on every `checkInterfacePortConflict()` call, same race-safety reasoning as
|
||||
/// `SetupViewModel.portConflictRequestGeneration` (bugs.md #1) — a slower, older in-flight
|
||||
/// check discards its own result if a newer one has since started.
|
||||
private var interfacePortConflictGeneration = 0
|
||||
|
||||
private let connectionService: ConnectionService
|
||||
private let backupService: BackupService
|
||||
|
||||
@@ -125,6 +138,7 @@ final class ExpertViewModel: ObservableObject {
|
||||
})
|
||||
extraFields = []
|
||||
applyError = nil
|
||||
resetInterfacePortConflictState()
|
||||
}
|
||||
|
||||
func startEditing(_ item: RouterOSMenuItem) {
|
||||
@@ -137,6 +151,56 @@ final class ExpertViewModel: ObservableObject {
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { ExtraField(key: $0.key, value: $0.value) }
|
||||
applyError = nil
|
||||
resetInterfacePortConflictState()
|
||||
}
|
||||
|
||||
/// The key of the current schema's `.interfacePick` field, if it has one — a schema only
|
||||
/// ever has at most one (matches the one physical/logical port the whole menu item applies
|
||||
/// to, e.g. `/ip address`'s "interface").
|
||||
private var interfaceFieldKey: String? {
|
||||
selectedSchema?.fields.first { if case .interfacePick = $0.kind { return true } else { return false } }?.key
|
||||
}
|
||||
|
||||
private func resetInterfacePortConflictState() {
|
||||
interfacePortConflict = nil
|
||||
isCheckingInterfacePortConflict = false
|
||||
acknowledgedInterfacePortConflict = false
|
||||
interfacePortConflictGeneration += 1
|
||||
}
|
||||
|
||||
/// Mirrors `SetupViewModel.checkPortConflict(for:)` — called whenever the `.interfacePick`
|
||||
/// field's value changes (`ExpertMenuDetailView`'s `.onChange`). Best-effort, same as the
|
||||
/// wizard's version: a failed check just means no warning shown for that attempt, never
|
||||
/// blocks the sheet outright.
|
||||
func checkInterfacePortConflict() {
|
||||
guard let key = interfaceFieldKey else { return }
|
||||
let interfaceName = formValues[key] ?? ""
|
||||
acknowledgedInterfacePortConflict = false
|
||||
interfacePortConflict = nil
|
||||
guard !interfaceName.isEmpty else { return }
|
||||
isCheckingInterfacePortConflict = true
|
||||
interfacePortConflictGeneration += 1
|
||||
let generation = interfacePortConflictGeneration
|
||||
Task {
|
||||
let result = try? await connectionService.checkPortConflict(interfaceName: interfaceName)
|
||||
guard generation == interfacePortConflictGeneration else { return }
|
||||
interfacePortConflict = result
|
||||
isCheckingInterfacePortConflict = false
|
||||
}
|
||||
}
|
||||
|
||||
/// The user has been shown what's on this port and, after two explicit confirmations
|
||||
/// (`PortConflictWarningView`), chose to have the app clear it immediately as part of this
|
||||
/// save — unlike the wizard (deferred to "Jetzt anwenden"), the Experte tab has no separate
|
||||
/// review step, so acknowledging here takes effect on the very next "Anlegen"/"Speichern".
|
||||
func acknowledgeInterfacePortConflict() {
|
||||
acknowledgedInterfacePortConflict = true
|
||||
}
|
||||
|
||||
/// Blocks "Anlegen"/"Speichern" until a found conflict is either acknowledged or the
|
||||
/// interface field is changed to something actually free.
|
||||
var hasUnresolvedInterfacePortConflict: Bool {
|
||||
interfacePortConflict != nil && !acknowledgedInterfacePortConflict
|
||||
}
|
||||
|
||||
func cancelEditing() {
|
||||
@@ -144,6 +208,7 @@ final class ExpertViewModel: ObservableObject {
|
||||
formValues = [:]
|
||||
extraFields = []
|
||||
applyError = nil
|
||||
resetInterfacePortConflictState()
|
||||
}
|
||||
|
||||
/// The command that saving the current edit sheet would run — shown to the user before it
|
||||
@@ -221,6 +286,14 @@ final class ExpertViewModel: ObservableObject {
|
||||
defer { connectionService.endWrite() }
|
||||
do {
|
||||
try await ensureSessionBackup()
|
||||
// Port-Konflikt-Auflösung (found.md #1-style, siehe `PortConflictWarningView`) läuft
|
||||
// hier sofort vor dem eigentlichen Befehl — anders als im Wizard gibt es im Experte-
|
||||
// Tab keinen separaten Review-Schritt, an dem das gebündelt würde.
|
||||
if let conflict = interfacePortConflict, acknowledgedInterfacePortConflict {
|
||||
for resolutionCommand in conflict.resolutionCommandsIncludingBridgeDetach() {
|
||||
try await connectionService.apply(resolutionCommand)
|
||||
}
|
||||
}
|
||||
if selectedSchema?.writesRequireSSH == true {
|
||||
try await connectionService.applyViaSSH(command)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Shown inline wherever a user is about to configure a physical port that
|
||||
/// `ConnectionService.checkPortConflict(interfaceName:)` found already carrying other
|
||||
/// configuration — originally the Setup Wizard's LAN step only (found.md #1's "Port-Konflikt-
|
||||
/// Prüfung"), now also the Experte tab's generic `.interfacePick` fields (per explicit request:
|
||||
/// "die Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen").
|
||||
/// Requires two separate confirmations before the app is allowed to clear it — per explicit
|
||||
/// request: this silently overriding a port's existing role (an active WAN dial-up, a bridge
|
||||
/// membership, a manually-set address) previously wasn't visible to the user at all beyond
|
||||
/// `DhcpServerCommandBuilder`'s own unconditional bridge detach; this makes the consequences
|
||||
/// explicit and opt-in instead.
|
||||
struct PortConflictWarningView: View {
|
||||
let conflict: PortConflict
|
||||
let isAcknowledged: Bool
|
||||
let appLanguage: String
|
||||
/// Wizard: resolution runs later, batched with everything else at "Jetzt anwenden" — the
|
||||
/// final confirmation dialog says so, and that a port switch above still undoes it. Experte
|
||||
/// tab: there's no separate review/apply step, "Anlegen"/"Speichern" runs it immediately —
|
||||
/// a different final-confirmation message reflects that instead.
|
||||
let immediateApply: Bool
|
||||
let onConfirm: () -> Void
|
||||
|
||||
@State private var showConsequencesConfirmation = false
|
||||
@State private var showFinalConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
if isAcknowledged {
|
||||
Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield")
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("ist nicht frei", appLanguage), systemImage: "exclamationmark.triangle.fill")
|
||||
.appFont(.subheadline, bold: true)
|
||||
.foregroundStyle(.red)
|
||||
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
|
||||
Text("• \(reason.description)")
|
||||
.appFont(.caption)
|
||||
}
|
||||
Text(L10n.t("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.", appLanguage))
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) {
|
||||
showConsequencesConfirmation = true
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08)))
|
||||
.confirmationDialog(
|
||||
L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("freimachen?", appLanguage),
|
||||
isPresented: $showConsequencesConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(L10n.t("Fortfahren", appLanguage), role: .destructive) {
|
||||
showFinalConfirmation = true
|
||||
}
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
||||
} message: {
|
||||
Text(consequenceText)
|
||||
}
|
||||
.confirmationDialog(
|
||||
L10n.t("Wirklich sicher?", appLanguage),
|
||||
isPresented: $showFinalConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(L10n.t("Ja, endgültig freimachen", appLanguage), role: .destructive) {
|
||||
onConfirm()
|
||||
}
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
||||
} message: {
|
||||
Text(immediateApply
|
||||
? L10n.t("Wird sofort ausgeführt, sobald du unten auf \"Anlegen\"/\"Speichern\" klickst.", appLanguage)
|
||||
: L10n.t("Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.", appLanguage))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var consequenceText: String {
|
||||
([L10n.t("Folgendes wird entfernt, bevor", appLanguage) + " \(conflict.interfaceName) " + L10n.t("als neues Netzwerk eingerichtet wird:", appLanguage)]
|
||||
+ conflict.reasons.map { "• \($0.description)" }
|
||||
+ [L10n.t("Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen.", appLanguage)])
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -32,6 +32,7 @@ struct LanStepView: View {
|
||||
conflict: conflict,
|
||||
isAcknowledged: viewModel.acknowledgedPortConflicts.contains(config.id),
|
||||
appLanguage: appLanguage,
|
||||
immediateApply: false,
|
||||
onConfirm: { viewModel.acknowledgePortConflict(for: config.id) }
|
||||
)
|
||||
}
|
||||
@@ -100,75 +101,5 @@ struct LanStepView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown inline under a LAN config's port Picker once `SetupViewModel.checkPortConflict(for:)`
|
||||
/// finds the chosen port already carries other configuration. Requires two separate confirmations
|
||||
/// before the app is allowed to clear it — per explicit request: this silently overriding a port's
|
||||
/// existing role (an active WAN dial-up, a bridge membership, a manually-set address) previously
|
||||
/// wasn't visible to the user at all beyond `DhcpServerCommandBuilder`'s own unconditional bridge
|
||||
/// detach; this makes the consequences explicit and opt-in instead.
|
||||
private struct PortConflictWarningView: View {
|
||||
let conflict: PortConflict
|
||||
let isAcknowledged: Bool
|
||||
let appLanguage: String
|
||||
let onConfirm: () -> Void
|
||||
|
||||
@State private var showConsequencesConfirmation = false
|
||||
@State private var showFinalConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
if isAcknowledged {
|
||||
Label("\(conflict.interfaceName) " + L10n.t("wird beim Anwenden freigemacht", appLanguage), systemImage: "checkmark.shield")
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label(L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("ist nicht frei", appLanguage), systemImage: "exclamationmark.triangle.fill")
|
||||
.appFont(.subheadline, bold: true)
|
||||
.foregroundStyle(.red)
|
||||
ForEach(Array(conflict.reasons.enumerated()), id: \.offset) { _, reason in
|
||||
Text("• \(reason.description)")
|
||||
.appFont(.caption)
|
||||
}
|
||||
Text(L10n.t("Wähle oben einen anderen, freien Port — oder mache diesen jetzt frei. Die bestehende Konfiguration wird dabei entfernt.", appLanguage))
|
||||
.appFont(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(L10n.t("Port jetzt freimachen…", appLanguage), role: .destructive) {
|
||||
showConsequencesConfirmation = true
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.08)))
|
||||
.confirmationDialog(
|
||||
L10n.t("Port", appLanguage) + " \(conflict.interfaceName) " + L10n.t("freimachen?", appLanguage),
|
||||
isPresented: $showConsequencesConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(L10n.t("Fortfahren", appLanguage), role: .destructive) {
|
||||
showFinalConfirmation = true
|
||||
}
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
||||
} message: {
|
||||
Text(consequenceText)
|
||||
}
|
||||
.confirmationDialog(
|
||||
L10n.t("Wirklich sicher?", appLanguage),
|
||||
isPresented: $showFinalConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(L10n.t("Ja, endgültig freimachen", appLanguage), role: .destructive) {
|
||||
onConfirm()
|
||||
}
|
||||
Button(L10n.t("Abbrechen", appLanguage), role: .cancel) {}
|
||||
} message: {
|
||||
Text(L10n.t("Tatsächlich ausgeführt wird das erst mit \"Jetzt anwenden\" am Ende des Assistenten — bis dahin kannst du das rückgängig machen, indem du hier oben einen anderen Port wählst.", appLanguage))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var consequenceText: String {
|
||||
([L10n.t("Folgendes wird entfernt, bevor", appLanguage) + " \(conflict.interfaceName) " + L10n.t("als neues Netzwerk eingerichtet wird:", appLanguage)]
|
||||
+ conflict.reasons.map { "• \($0.description)" }
|
||||
+ [L10n.t("Bestehender Datenverkehr über diesen Port (z.B. eine laufende Internetverbindung oder Geräte im bisherigen Netz) wird dadurch unterbrochen.", appLanguage)])
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
// `PortConflictWarningView` now lives in Features/Shared/ — shared with the Experte tab's
|
||||
// equivalent flow (see that file's doc comment for why).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<string>1.2.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>3</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()
|
||||
|
||||
|
||||
@@ -77,4 +77,58 @@ final class ExpertViewModelTests: XCTestCase {
|
||||
XCTAssertNil(viewModel.pendingCommand?.arguments["distance"])
|
||||
XCTAssertEqual(viewModel.pendingCommand?.arguments["comment"], "bridge")
|
||||
}
|
||||
|
||||
/// Regression tests for the Experte-tab port-conflict check (per explicit request: "die
|
||||
/// Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen Warnungen"). No live
|
||||
/// connection is set up here, so `checkInterfacePortConflict()`'s actual network call always
|
||||
/// fails/is skipped — these only cover the surrounding logic that doesn't need one: schemas
|
||||
/// without an `.interfacePick` field are correctly ignored, and the unresolved-conflict gate
|
||||
/// starts clear.
|
||||
private func makeSchemaWithInterfaceField() -> RouterOSMenuSchema {
|
||||
RouterOSMenuSchema(
|
||||
menuPath: "/ip address", restPath: "ip/address", category: .ipAddressing,
|
||||
displayName: "Test", summary: "", explanation: "",
|
||||
fields: [
|
||||
RouterOSFieldSchema(key: "address", label: "Adresse", kind: .text, help: ""),
|
||||
RouterOSFieldSchema(key: "interface", label: "Interface", kind: .interfacePick, help: "")
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testCheckInterfacePortConflictNoOpsForSchemaWithoutInterfaceField() {
|
||||
let viewModel = ExpertViewModel(connectionService: ConnectionService())
|
||||
viewModel.selectedSchema = makeSchema() // no .interfacePick field
|
||||
viewModel.startNewItem()
|
||||
|
||||
viewModel.checkInterfacePortConflict()
|
||||
|
||||
XCTAssertFalse(viewModel.isCheckingInterfacePortConflict)
|
||||
XCTAssertNil(viewModel.interfacePortConflict)
|
||||
XCTAssertFalse(viewModel.hasUnresolvedInterfacePortConflict)
|
||||
}
|
||||
|
||||
func testCheckInterfacePortConflictNoOpsWhenInterfaceFieldIsEmpty() {
|
||||
let viewModel = ExpertViewModel(connectionService: ConnectionService())
|
||||
viewModel.selectedSchema = makeSchemaWithInterfaceField()
|
||||
viewModel.startNewItem() // "interface" has no default, starts empty
|
||||
|
||||
viewModel.checkInterfacePortConflict()
|
||||
|
||||
XCTAssertFalse(viewModel.isCheckingInterfacePortConflict)
|
||||
XCTAssertNil(viewModel.interfacePortConflict)
|
||||
}
|
||||
|
||||
func testStartingOrCancelingEditResetsAcknowledgedConflictState() {
|
||||
let viewModel = ExpertViewModel(connectionService: ConnectionService())
|
||||
viewModel.selectedSchema = makeSchemaWithInterfaceField()
|
||||
viewModel.startNewItem()
|
||||
viewModel.acknowledgeInterfacePortConflict()
|
||||
XCTAssertFalse(viewModel.hasUnresolvedInterfacePortConflict, "acknowledging clears the block even with no conflict object set")
|
||||
|
||||
viewModel.cancelEditing()
|
||||
viewModel.startNewItem()
|
||||
|
||||
// A fresh edit must not inherit a stale acknowledgement from a previous one.
|
||||
XCTAssertNil(viewModel.interfacePortConflict)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,26 @@ final class PortConflictTests: XCTestCase {
|
||||
XCTAssertEqual(commands[1].menuPath, "/interface pppoe-client")
|
||||
XCTAssertEqual(commands[1].operation, .remove(matchField: "interface", matchValue: "ether1"))
|
||||
}
|
||||
|
||||
/// Regression test for the Experte-tab "Port freimachen?" flow (per explicit request): unlike
|
||||
/// the Setup Wizard, the Experte tab has no separate step that unconditionally detaches a
|
||||
/// bridge port on its own, so its resolution list must include that removal explicitly —
|
||||
/// `resolutionCommands()` alone (the wizard's version) deliberately leaves it out.
|
||||
func testResolutionCommandsIncludingBridgeDetachAddsBridgePortRemoval() {
|
||||
let conflict = PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge")])
|
||||
let commands = conflict.resolutionCommandsIncludingBridgeDetach()
|
||||
|
||||
XCTAssertEqual(commands.count, 1)
|
||||
XCTAssertEqual(commands[0].menuPath, "/interface bridge port")
|
||||
XCTAssertEqual(commands[0].operation, .remove(matchField: "interface", matchValue: "ether4"))
|
||||
}
|
||||
|
||||
func testResolutionCommandsIncludingBridgeDetachKeepsOtherReasonsToo() {
|
||||
let conflict = PortConflict(interfaceName: "ether4", reasons: [.bridgeMember(bridgeName: "bridge"), .dhcpClient])
|
||||
let commands = conflict.resolutionCommandsIncludingBridgeDetach()
|
||||
|
||||
XCTAssertEqual(commands.count, 2)
|
||||
XCTAssertEqual(commands[0].menuPath, "/interface bridge port")
|
||||
XCTAssertEqual(commands[1].menuPath, "/ip dhcp-client")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import XCTest
|
||||
@testable import RouterOSAssistant
|
||||
|
||||
/// Regression tests for bugs.md #11 (2026-09-17): creating an `/ip pool` entry in the Experte tab
|
||||
/// hung the app permanently, traced to `SSHTransport.connect()`/`run(_:)` having no timeout at
|
||||
/// all — a stalled connection attempt or command execution had no way to ever resolve. These
|
||||
/// tests exercise the generic race mechanism (`SSHTransport.withTimeout`) directly, independent
|
||||
/// of Citadel/real network I/O, since that's the actual bug: the race logic itself, not anything
|
||||
/// SSH-specific.
|
||||
final class SSHTransportTimeoutTests: XCTestCase {
|
||||
func testFastOperationReturnsItsResultBeforeTheDeadline() async throws {
|
||||
let result = try await SSHTransport.withTimeout(.seconds(1)) {
|
||||
"done"
|
||||
}
|
||||
XCTAssertEqual(result, "done")
|
||||
}
|
||||
|
||||
func testHangingOperationThrowsAfterTheDeadlineInsteadOfBlockingForever() async {
|
||||
let start = ContinuousClock.now
|
||||
do {
|
||||
_ = try await SSHTransport.withTimeout(.milliseconds(200)) {
|
||||
try await Task.sleep(for: .seconds(60))
|
||||
return "never reached"
|
||||
}
|
||||
XCTFail("Expected a timeout error")
|
||||
} catch {
|
||||
let elapsed = ContinuousClock.now - start
|
||||
XCTAssertLessThan(elapsed, .seconds(5), "Timeout should fire close to the deadline, not wait for the full 60s operation")
|
||||
}
|
||||
}
|
||||
|
||||
func testOperationsOwnThrownErrorPropagatesUnchangedWhenItFinishesFirst() async {
|
||||
struct SampleError: Error, Equatable {}
|
||||
do {
|
||||
_ = try await SSHTransport.withTimeout(.seconds(1)) {
|
||||
throw SampleError()
|
||||
}
|
||||
XCTFail("Expected SampleError to propagate")
|
||||
} catch is SampleError {
|
||||
// expected
|
||||
} catch {
|
||||
XCTFail("Expected SampleError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,125 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-17 (Nachtrag 4: Nutzer-gemeldet, live in der App)
|
||||
|
||||
### 11. Hänger beim Anlegen eines DHCP-Pools
|
||||
**Status:** fixed (Build + 106 Unit-Tests grün, 3 neue Regressionstests — Mechanismus isoliert
|
||||
verifiziert, Root Cause vom Nutzer bestätigt, nicht erneut live im UI nachgestellt)
|
||||
**Gitea-Issue:** [#24](http://192.168.178.222:3500/kay/RouterOS/issues/24)
|
||||
**Confidence:** hoch (Nutzer bestätigte exakt das vorhergesagte Bild — Experte-Tab, dauerhaft
|
||||
hängend, kein Fehlertext — bevor der Fix geschrieben wurde)
|
||||
|
||||
Nutzer-Meldung: "Hänger beim Anlegen eines DHCP-Pools" — App bleibt beim Anlegen eines
|
||||
`/ip pool`-Eintrags hängen. Rückfrage bestätigte: Experte-Tab, bleibt dauerhaft hängen (kein
|
||||
Selbst-Erholen, Neustart nötig) — exakt das Bild, das der Code-Audit-Kandidat vorhergesagt hatte.
|
||||
|
||||
**Code-Audit-Befund, plausibler Kandidat:** `SSHTransport.connect()` (`SSHTransport.swift`) setzt
|
||||
für den `Citadel.SSHClient.connect(...)`-Aufruf **keinerlei Timeout** — im Gegensatz zu
|
||||
`RestTransport`, das für jede Anfrage `request.timeoutInterval = 5` explizit setzt. Jeder erste
|
||||
Schreibvorgang einer Session (egal ob Experte-Tab oder Einrichten-Assistent) löst zuerst
|
||||
`ensureSessionBackup()` aus, was `BackupService.createBackup(for:)` über eine **dedizierte, neue
|
||||
SSH-Verbindung** aufruft (`BackupService.swift`, "always over SSH"-Muster). Hängt dieser
|
||||
`connect()`-Aufruf (z.B. durch einen kurzen Netzwerk-Aussetzer oder einen unter Last
|
||||
langsam/nicht antwortenden Router — der hAP-lite-Testrouter ist mit MIPS 24Kc/650MHz/1 Kern sehr
|
||||
schwach, in dieser Session bereits bis 80% CPU-Last beobachtet), gibt es keinen Timeout, der die
|
||||
App wieder freigibt — `isApplying`/der Ladeindikator bliebe dann unbegrenzt aktiv, exakt das vom
|
||||
Nutzer beschriebene Bild eines "Hängers" ohne Fehlermeldung.
|
||||
|
||||
Noch nicht bestätigt, ob das tatsächlich die Ursache ist — nur ein durch Code-Lesen gefundener,
|
||||
plausibler Kandidat, kein reproduzierter Fehler. Gleiche Lücke beträfe auch `ConnectionService.
|
||||
applyViaSSH`/`flushConnections` (beide nutzen ebenfalls `SSHTransport.connect()` ohne Timeout) und
|
||||
`UpdateService`/`FactoryResetService`/`NetworkToolsService`, die dieselbe Transport-Klasse nutzen.
|
||||
|
||||
**Fix:** neuer generischer `SSHTransport.withTimeout(_:operation:)` (`withThrowingTaskGroup`-Race
|
||||
zwischen der echten Operation und einem `Task.sleep`-Deadline-Task, verliert die Operation den
|
||||
Wettlauf wird sie abgebrochen). Angewendet auf `connect()` (10s) und `run(_:)` (30s, großzügiger
|
||||
bemessen, da Exports/Backups auf schwacher Hardware legitim länger brauchen können). Beide
|
||||
Methoden bleiben `async throws`, keine Signaturänderung für Aufrufer.
|
||||
|
||||
**Verifikation:** die Race-Logik selbst ist per 3 neuer Unit-Tests isoliert bewiesen (unabhängig
|
||||
von Citadel/echtem Netzwerk) — ein hängender Vorgang wirft nach der Deadline (getestet mit
|
||||
200ms statt Produktions-Werten, damit der Test schnell bleibt), ein schneller Vorgang liefert sein
|
||||
Ergebnis unverändert, ein eigener Fehler der Operation selbst propagiert unverändert durch. Nicht
|
||||
erneut live im Experte-Tab nachgestellt (bräuchte einen erneuten, absichtlich provozierten
|
||||
Netzwerk-Hänger) — der Nutzer hat die Ursache aber bereits vor dem Fix exakt bestätigt.
|
||||
|
||||
Nebenbefund beim Umsetzen: `xcodegen generate` überschreibt `Info.plist` komplett aus
|
||||
`project.yml`s `info.properties` (kein Merge mit der Datei auf der Platte) — ein Regenerieren für
|
||||
diese neue Testdatei setzte die App-Version dabei stillschweigend von 1.1.0 auf 1.0 zurück.
|
||||
`CFBundleShortVersionString`/`CFBundleVersion` jetzt explizit in `project.yml` verankert, damit
|
||||
das nicht wieder passiert.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-17 (Nachtrag 5: Nutzer-gemeldet, Feature-Erweiterung)
|
||||
|
||||
### 12. Experte-Tab: Port-Konflikt-Prüfung wie im Einrichten-Assistenten
|
||||
**Status:** fixed (Build + 111 Unit-Tests grün, 6 neue Regressionstests, live vom Nutzer bestätigt:
|
||||
"funktioniert")
|
||||
**Gitea-Issue:** [#25](http://192.168.178.222:3500/kay/RouterOS/issues/25)
|
||||
|
||||
Live-Anlass: Nutzer legte über den Experte-Tab manuell ein eigenes Netz auf `ether4` an
|
||||
(IP-Adresse, Pool, DHCP-Server). `ether4` blieb dabei unbemerkt Bridge-Mitglied der Haupt-Bridge —
|
||||
zwei DHCP-Server im selben Broadcast-Domain, Isolation dadurch grundsätzlich nicht möglich. Kein
|
||||
App-Bug im engeren Sinn, aber genau der Fall, den die Wizard-eigene Port-Konflikt-Prüfung
|
||||
(found.md #1, bugs.md #1) normalerweise abfängt — beim manuellen Anlegen über den Experte-Tab gab
|
||||
es diese Warnung bisher nicht.
|
||||
|
||||
Auf Nutzerwunsch ("die Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen
|
||||
Warnungen") dieselbe Prüfung samt "Port freimachen?"-Dialog jetzt auch im Experte-Tab:
|
||||
|
||||
- `PortConflictWarningView` aus `LanStepView.swift` nach `Features/Shared/` extrahiert (jetzt von
|
||||
Wizard UND Experte-Tab genutzt), neuer `immediateApply`-Parameter für die kontextabhängige
|
||||
Abschluss-Meldung (Wizard: erst bei "Jetzt anwenden"; Experte-Tab: sofort bei
|
||||
"Anlegen"/"Speichern", da es dort keinen separaten Review-Schritt gibt).
|
||||
- Neue `PortConflict.resolutionCommandsIncludingBridgeDetach()` — anders als der Wizard hat der
|
||||
Experte-Tab keinen automatischen, unbedingten Bridge-Detach-Schritt
|
||||
(`DhcpServerCommandBuilder`), muss die Bridge-Entfernung bei Bestätigung also selbst mit
|
||||
ausführen (sonst würde "Port freimachen" für genau den Fall, der diese Erweiterung ausgelöst
|
||||
hat, wirkungslos bleiben).
|
||||
- `ExpertViewModel` bekommt dieselbe race-sichere Generation-Zähler-Logik wie `SetupViewModel`
|
||||
(bugs.md #1), angewendet auf das `.interfacePick`-Feld des jeweils geöffneten Schemas.
|
||||
- Speichern-Button gesperrt, bis der Konflikt bestätigt oder ein anderer Port gewählt wurde.
|
||||
|
||||
**Verifikation:** Build grün, alle 111 Unit-Tests grün (6 neue Regressionstests — Schema-Erkennung
|
||||
ohne/mit Interface-Feld, Acknowledge-State-Reset, `resolutionCommandsIncludingBridgeDetach()`
|
||||
inkl. Bridge-Entfernung). Live im Experte-Tab durchgeklickt vom Nutzer bestätigt ("funktioniert").
|
||||
|
||||
@@ -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,84 @@ 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`).
|
||||
|
||||
### 16. Experte-Tab: Port-Konflikt-Prüfung wie im Einrichten-Assistenten
|
||||
**Status:** fixed (Build + 111 Unit-Tests grün, 6 neue Regressionstests)
|
||||
|
||||
Live-Anlass: Nutzer legte über den Experte-Tab manuell ein eigenes Netz auf `ether4` an
|
||||
(IP-Adresse, Pool, DHCP-Server) — `ether4` blieb dabei unbemerkt Bridge-Mitglied der Haupt-Bridge,
|
||||
weshalb zwei DHCP-Server im selben Broadcast-Domain konkurrierten (kein App-Bug, aber genau der
|
||||
Fall, den die Wizard-eigene Port-Konflikt-Prüfung normalerweise abfängt — beim manuellen Anlegen
|
||||
über den Experte-Tab gab es diese Warnung bisher nicht).
|
||||
|
||||
Auf Nutzerwunsch ("die Abfrage vom Einrichten-Assistenten auf Expert anwenden ... mit allen
|
||||
Warnungen") dieselbe Prüfung samt "Port freimachen?"-Dialog jetzt auch im Experte-Tab, angewendet
|
||||
auf das `.interfacePick`-Feld des jeweils geöffneten Schemas (`/ip address`, `/ip dhcp-server`
|
||||
etc.). `PortConflictWarningView` aus `LanStepView.swift` in `Features/Shared/` extrahiert, neuer
|
||||
`immediateApply`-Parameter für die kontextabhängige Abschluss-Meldung (Wizard: erst bei "Jetzt
|
||||
anwenden"; Experte-Tab: sofort bei "Anlegen"/"Speichern", da es dort keinen separaten Review-
|
||||
Schritt gibt). Neue `PortConflict.resolutionCommandsIncludingBridgeDetach()` — anders als der
|
||||
Wizard hat der Experte-Tab keinen automatischen, unbedingten Bridge-Detach-Schritt
|
||||
(`DhcpServerCommandBuilder`), muss die Bridge-Entfernung bei Bestätigung also selbst mit
|
||||
ausführen. `ExpertViewModel` bekommt dieselbe race-sichere Generation-Zähler-Logik wie
|
||||
`SetupViewModel` (bugs.md #1). Speichern-Button gesperrt, bis der Konflikt bestätigt oder ein
|
||||
anderer Port gewählt wurde.
|
||||
|
||||
Build grün, alle 111 Unit-Tests grün (6 neue Regressionstests). Live im Experte-Tab durchgeklickt
|
||||
vom Nutzer bestätigt ("funktioniert").
|
||||
|
||||
@@ -29,6 +29,14 @@ targets:
|
||||
path: RouterOSAssistant/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: RouterOS Assistant
|
||||
# xcodegen regenerates this whole file from these properties (plus its own
|
||||
# defaults for anything unlisted) every time it runs — NOT a merge with
|
||||
# whatever's already on disk. Without these two explicit here, `xcodegen
|
||||
# generate` silently resets the app's version back to its built-in default
|
||||
# "1.0"/"1", discarding any release version bump (found live, 2026-09-17,
|
||||
# while adding a new test file triggered a regen and reverted v1.1.0 -> 1.0).
|
||||
CFBundleShortVersionString: "1.2.0"
|
||||
CFBundleVersion: "3"
|
||||
LSApplicationCategoryType: public.app-category.utilities
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoads: true
|
||||
|
||||
Reference in New Issue
Block a user