M8: Netzwerk-Isolation live verifiziert (manuell + über den Wizard)

Isolation zuerst manuell per SSH nachgebaut (ether5), danach über den
App-Wizard (Experte-Modus, ether4), um die eigentliche Abnahme-
Bedingung ("kompletter Wizard-Durchlauf") zu erfüllen. Dabei drei
App-Bugs gefunden und gefixt:

- Bug 22: neues LAN-/VLAN-Interface wurde nie der defconf-Interface-
  Liste "LAN" hinzugefügt, wodurch DNS-Anfragen an den Router selbst
  blockiert blieben (Werks-Firewall droppt Input von allem außerhalb
  dieser Liste).
- Bug 23: ein voller Wizard-Durchlauf gegen einen bereits konfigurierten
  Router brach am ersten nicht-idempotenten Add-Befehl ab
  (/ip address, /ip pool, /ip dhcp-server, /ip dhcp-server network).
- Bug 24: ein als eigenes isoliertes Netz konfiguriertes Interface
  blieb Bridge-"Slave" (Werks-Bridging), wodurch RouterOS die
  generierten Isolationsregeln selbst als ungültig verwarf.

Alle drei in DhcpServerCommandBuilder/SetupViewModel gefixt, 52 Unit-
Tests grün, Isolation+DNS+Internet am echten Gerät bestätigt. M8 auf
live verifiziert gesetzt. Nebenbei zwei veraltete Doku-Stellen zum
Gitea-Remote korrigiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDmUd93KxsYGr2kLTotWnG
This commit is contained in:
Kay
2026-09-15 10:28:09 +02:00
co-authored by Claude Sonnet 5
parent 19fa1d6ef4
commit fc3b2ca039
7 changed files with 286 additions and 75 deletions
@@ -17,7 +17,26 @@ enum DhcpServerCommandBuilder {
let serverName = "dhcp_\(interfaceName)"
let routerIP = routerAddress.components(separatedBy: "/").first ?? routerAddress
return [
// Detach from any bridge this physical port is still a member of before treating it as
// its own network. Skipped for "bridge" itself (the app's own shared-LAN interface, never
// a bridge port). Confirmed live (2026-09-15): factory-default routers have ether2-5
// pre-bridged, and configuring one of them as a separate isolated network without this
// produces a broken result RouterOS itself refuses to run the interface stays a bridge
// "slave", so its forward-chain isolation rules come back flagged invalid ("in/out-
// interface matcher not possible when interface is slave - use master instead"). No-op if
// the interface was never bridged: SSH's `remove [find ...]` is a silent no-op on no
// match, and REST's not-found is tolerated by `SetupViewModel.applyIdempotently`.
let bridgeDetachCommands: [RouterOSCommand] = interfaceName == "bridge" ? [] : [
RouterOSCommand.remove(
menuPath: "/interface bridge port",
restPath: "interface/bridge/port",
matchField: "interface",
matchValue: interfaceName,
summary: "\(interfaceName) aus evtl. bestehender Bridge lösen"
)
]
return bridgeDetachCommands + [
RouterOSCommand.add(
menuPath: "/ip address",
restPath: "ip/address",
@@ -51,6 +70,27 @@ enum DhcpServerCommandBuilder {
"dns-server": dnsServers
],
summary: "DHCP-Netzwerk \(networkAddress) \(context) konfigurieren"
),
// RouterOS' factory-default firewall (present on most out-of-box routers) has an
// input-chain rule dropping everything not from the "LAN" interface list. Without
// this interface as a member, devices on it get DHCP/routing/internet fine (that's
// forward-chain, untouched) but can never reach the router itself for DNS, Winbox,
// etc. confirmed live (2026-09-15): a manually-isolated port had a bound DHCP
// lease and a working default route, yet every DNS query to the router timed out
// until it was added to "LAN". Both commands are tolerated as already-satisfied by
// `SetupViewModel.applyIdempotently` if the list/membership already exists (e.g. the
// default "bridge" interface, already a defconf LAN member).
RouterOSCommand.add(
menuPath: "/interface list",
restPath: "interface/list",
arguments: ["name": "LAN"],
summary: "Interface-Liste \"LAN\" sicherstellen"
),
RouterOSCommand.add(
menuPath: "/interface list member",
restPath: "interface/list/member",
arguments: ["list": "LAN", "interface": interfaceName],
summary: "\(interfaceName) der Interface-Liste \"LAN\" hinzufügen \(context)"
)
]
}
@@ -224,31 +224,61 @@ final class SetupViewModel: ObservableObject {
}
}
/// RouterOS allows at most one item per interface on some menus (a DHCP client, a PPPoE
/// client) re-running the wizard's WAN step on an interface that already has one makes the
/// wizard's own `.add` fail live-confirmed: "failure: dhcp-client on that interface already
/// exists". Rather than hard-fail the whole apply here, retry once as a `.set` matched by
/// "interface" instead reconfigures the existing entry in place, which is what re-running
/// the wizard on the same interface should mean anyway. Scoped to exactly these two
/// known-unique-per-interface menus, not applied generally to every `.add` other menus
/// (e.g. "/ip address") legitimately allow multiple items per interface, where silently
/// converting a would-be-duplicate `.add` into a `.set` would be wrong, not helpful.
private static let interfaceUniqueMenuPaths: Set<String> = ["/ip dhcp-client", "/interface pppoe-client"]
/// 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
/// "failure: dhcp-client on that interface already exists"; pool/dhcp-server: one per name;
/// dhcp-server network: one per address). The value is which field of the (deterministic,
/// app-generated) arguments identifies the existing entry to match on for the `.set` retry.
/// "/ip address" is deliberately not here an interface can legitimately hold several
/// addresses, so matching a `.set` by "interface" alone could reconfigure the wrong one;
/// see `duplicateTolerantMenuPaths` below for how that menu is handled instead.
private static let retryAsSetMenuPaths: [String: String] = [
"/ip dhcp-client": "interface",
"/interface pppoe-client": "interface",
"/ip pool": "name",
"/ip dhcp-server": "name",
"/ip dhcp-server network": "address"
]
/// Menus where a duplicate `.add` means the desired state already holds, with nothing
/// meaningful left to update "/interface list"/"/interface list member" (fixed,
/// hardcoded arguments; list membership is binary, no ".set" equivalent) and "/ip address"
/// (the address string itself is the app's only identifying argument here if RouterOS
/// already has that exact address on that exact interface, this add's whole job is already
/// done, and matching a `.set` by "interface" would risk touching a different address on a
/// multi-address interface instead, per the note above).
private static let duplicateTolerantMenuPaths: Set<String> = ["/interface list", "/interface list member", "/ip address"]
/// Menus where a `.remove` finding no match means the desired state already holds used for
/// `DhcpServerCommandBuilder`'s unconditional "detach this interface from any bridge" step,
/// which runs even for interfaces that were never bridged (the common case). SSH's
/// `remove [find ...]` is already a silent no-op there; REST's `findItemID` throws
/// "not found" instead (see `RestTransport.apply`), so that specific failure needs to be
/// swallowed here to keep both transports behaving the same way.
private static let missingTolerantRemoveMenuPaths: Set<String> = ["/interface bridge port"]
private func applyIdempotently(_ command: RouterOSCommand) async throws {
do {
try await connectionService.apply(command)
} catch {
guard case .add = command.operation,
Self.interfaceUniqueMenuPaths.contains(command.menuPath),
let interfaceName = command.arguments["interface"] else {
if case .remove = command.operation, Self.missingTolerantRemoveMenuPaths.contains(command.menuPath) {
return
}
guard case .add = command.operation else { throw error }
if Self.duplicateTolerantMenuPaths.contains(command.menuPath) {
return
}
guard let matchField = Self.retryAsSetMenuPaths[command.menuPath],
let matchValue = command.arguments[matchField] else {
throw error
}
let retryCommand = RouterOSCommand.set(
menuPath: command.menuPath,
restPath: command.restPath,
matchField: "interface",
matchValue: interfaceName,
matchField: matchField,
matchValue: matchValue,
arguments: command.arguments,
summary: command.summary
)