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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user