forked from kay/RouterOS
Wizard erneut auf einem Interface durchlaufen, das schon einen DHCP-Client hat (hier: aus einer zurueckgespielten Sicherung), scheiterte live mit "failure: dhcp-client on that interface already exists" - WanConfig.buildCommands() erzeugt fuer DHCP-Client/PPPoE immer .add, ohne vorher zu pruefen, ob auf dem Interface schon einer existiert. Fix: SetupViewModel.applyIdempotently faengt einen .add-Fehlschlag auf /ip dhcp-client bzw. /interface pppoe-client ab und wiederholt ihn als .set (nach "interface" gematcht) - bewusst nur fuer diese zwei Menues mit "maximal ein Eintrag pro Interface"-Semantik, nicht generell fuer jedes .add (z.B. /ip address erlaubt legitim mehrere Adressen pro Interface). Gefunden beim Versuch, M8 (Netzwerk-Isolation) live durchzutesten - dieser Test selbst ist noch nicht abgeschlossen, naechste Session dort weitermachen (siehe HANDOFF.md Naechste Schritte Punkt 1). HANDOFF.md/CHATLOG.md aktualisiert: Bug 21, Sessionende-Stand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
259 lines
9.8 KiB
Swift
259 lines
9.8 KiB
Swift
import Foundation
|
|
|
|
enum SetupStep: Int, CaseIterable {
|
|
case mode
|
|
case wan
|
|
case lan
|
|
case vlan
|
|
case wifi
|
|
case firewall
|
|
case review
|
|
}
|
|
|
|
/// Simple restricts the wizard to basic setup (single LAN, fixed firewall baseline, no VLANs,
|
|
/// no per-port DHCP/isolation) for users who just want a working router. Expert exposes every
|
|
/// RouterOS feature the wizard supports.
|
|
enum SetupMode {
|
|
case simple
|
|
case expert
|
|
}
|
|
|
|
@MainActor
|
|
final class SetupViewModel: ObservableObject {
|
|
@Published var step: SetupStep = .mode
|
|
@Published var mode: SetupMode = .simple
|
|
@Published var wanConfig = WanConfig(interfaceName: "ether1")
|
|
@Published var lanConfigs: [LanDhcpConfig] = [LanDhcpConfig()]
|
|
@Published private(set) var vlanSectionEnabled = false
|
|
@Published var vlans: [VlanEntry] = []
|
|
@Published var wifiNetworks: [WifiNetworkConfig] = []
|
|
@Published private(set) var unsupportedWifiInterfaces: [NetworkInterface] = []
|
|
@Published private(set) var firewallSectionEnabled = false
|
|
@Published private(set) var existingFirewallRuleCounts: FirewallRuleCounts?
|
|
@Published private(set) var isLoadingFirewallRuleCounts = false
|
|
@Published private(set) var firewallRuleCountsError: String?
|
|
|
|
@Published private(set) var isApplying = false
|
|
@Published private(set) var applyLog: [String] = []
|
|
@Published private(set) var applyError: String?
|
|
@Published private(set) var didApplySuccessfully = false
|
|
|
|
private let connectionService: ConnectionService
|
|
private let backupService: BackupService
|
|
|
|
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
|
|
self.connectionService = connectionService
|
|
self.backupService = backupService
|
|
prepareDefaults(from: connectionService.interfaces)
|
|
}
|
|
|
|
var plannedCommands: [RouterOSCommand] {
|
|
wanConfig.buildCommands()
|
|
+ lanConfigs.flatMap { $0.buildCommands() }
|
|
+ vlans.flatMap { $0.buildCommands() }
|
|
+ wifiNetworks.flatMap { $0.buildCommands() }
|
|
+ (firewallSectionEnabled
|
|
? FirewallConfig(wanInterface: wanConfig.interfaceName, networks: networkSegments).buildCommands()
|
|
: [])
|
|
}
|
|
|
|
/// 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) }
|
|
}
|
|
|
|
/// Interface names of networks marked isolated, for display on the Firewall step.
|
|
var isolatedNetworkNames: [String] {
|
|
networkSegments.filter(\.isolated).map(\.interfaceName)
|
|
}
|
|
|
|
func setFirewallSectionEnabled(_ enabled: Bool) {
|
|
firewallSectionEnabled = enabled
|
|
if enabled {
|
|
loadExistingFirewallRuleCounts()
|
|
} else {
|
|
existingFirewallRuleCounts = nil
|
|
firewallRuleCountsError = nil
|
|
}
|
|
}
|
|
|
|
func loadExistingFirewallRuleCounts() {
|
|
guard firewallSectionEnabled, existingFirewallRuleCounts == nil, !isLoadingFirewallRuleCounts else { return }
|
|
isLoadingFirewallRuleCounts = true
|
|
firewallRuleCountsError = nil
|
|
Task {
|
|
do {
|
|
existingFirewallRuleCounts = try await connectionService.fetchFirewallRuleCounts()
|
|
} catch {
|
|
firewallRuleCountsError = error.localizedDescription
|
|
}
|
|
isLoadingFirewallRuleCounts = false
|
|
}
|
|
}
|
|
|
|
func setVlanSectionEnabled(_ enabled: Bool) {
|
|
vlanSectionEnabled = enabled
|
|
if !enabled { vlans = [] }
|
|
}
|
|
|
|
func addVlan() {
|
|
let usedIDs = Set(vlans.map(\.vlanID))
|
|
var nextID = 20
|
|
while usedIDs.contains(nextID) { nextID += 1 }
|
|
vlans.append(VlanEntry(vlanID: nextID, parentInterface: lanConfigs.first?.interfaceName ?? "bridge"))
|
|
}
|
|
|
|
func removeVlan(_ id: VlanEntry.ID) {
|
|
vlans.removeAll { $0.id == id }
|
|
}
|
|
|
|
func addLan() {
|
|
let usedNames = Set([wanConfig.interfaceName] + lanConfigs.map(\.interfaceName))
|
|
let nextInterface = connectionService.interfaces.first { !usedNames.contains($0.name) }
|
|
lanConfigs.append(LanDhcpConfig(interfaceName: nextInterface?.name ?? "bridge"))
|
|
}
|
|
|
|
func removeLan(_ id: LanDhcpConfig.ID) {
|
|
guard lanConfigs.count > 1 else { return }
|
|
lanConfigs.removeAll { $0.id == id }
|
|
}
|
|
|
|
/// Replaces the placeholder WAN/LAN interface names ("ether1"/"bridge") with real ones
|
|
/// from the connected device, whenever the current value isn't actually one of its
|
|
/// interfaces — otherwise the Picker selections don't match any of their tags.
|
|
func prepareDefaults(from interfaces: [NetworkInterface]) {
|
|
guard !interfaces.isEmpty else { return }
|
|
let names = Set(interfaces.map(\.name))
|
|
|
|
if !names.contains(wanConfig.interfaceName) {
|
|
if let firstEthernet = interfaces.first(where: { $0.type.lowercased().contains("ether") }) {
|
|
wanConfig.interfaceName = firstEthernet.name
|
|
} else {
|
|
wanConfig.interfaceName = interfaces[0].name
|
|
}
|
|
}
|
|
|
|
if !lanConfigs.isEmpty, !names.contains(lanConfigs[0].interfaceName) {
|
|
if let bridge = interfaces.first(where: { $0.type.lowercased().contains("bridge") }) {
|
|
lanConfigs[0].interfaceName = bridge.name
|
|
} else if let fallback = interfaces.first(where: { $0.name != wanConfig.interfaceName }) {
|
|
lanConfigs[0].interfaceName = fallback.name
|
|
} else {
|
|
lanConfigs[0].interfaceName = interfaces[0].name
|
|
}
|
|
}
|
|
|
|
if wifiNetworks.isEmpty {
|
|
let legacyWireless = interfaces.filter { $0.type.lowercased() == "wlan" }
|
|
wifiNetworks = legacyWireless.map { WifiNetworkConfig(interfaceName: $0.name) }
|
|
}
|
|
unsupportedWifiInterfaces = interfaces.filter { $0.type.lowercased() == "wifi" }
|
|
}
|
|
|
|
func goNext() {
|
|
switch step {
|
|
case .mode:
|
|
if mode == .simple { applySimpleModeConstraints() }
|
|
step = .wan
|
|
case .wan:
|
|
step = .lan
|
|
case .lan:
|
|
step = (mode == .expert) ? .vlan : .wifi
|
|
case .vlan:
|
|
step = .wifi
|
|
case .wifi:
|
|
step = .firewall
|
|
case .firewall:
|
|
step = .review
|
|
case .review:
|
|
break
|
|
}
|
|
}
|
|
|
|
func goBack() {
|
|
switch step {
|
|
case .mode:
|
|
break
|
|
case .wan:
|
|
step = .mode
|
|
case .lan:
|
|
step = .wan
|
|
case .vlan:
|
|
step = .lan
|
|
case .wifi:
|
|
step = (mode == .expert) ? .vlan : .lan
|
|
case .firewall:
|
|
step = .wifi
|
|
case .review:
|
|
step = .firewall
|
|
}
|
|
}
|
|
|
|
/// Simple mode allows only one LAN network, no isolation, no VLANs, and a fixed firewall
|
|
/// baseline — strip anything an earlier Expert-mode choice may have left configured.
|
|
private func applySimpleModeConstraints() {
|
|
vlanSectionEnabled = false
|
|
vlans = []
|
|
if lanConfigs.count > 1 { lanConfigs = Array(lanConfigs.prefix(1)) }
|
|
if !lanConfigs.isEmpty { lanConfigs[0].isolated = false }
|
|
setFirewallSectionEnabled(true)
|
|
}
|
|
|
|
func apply(credentials: RouterOSCredentials) {
|
|
isApplying = true
|
|
applyError = nil
|
|
applyLog = []
|
|
didApplySuccessfully = false
|
|
|
|
Task {
|
|
do {
|
|
applyLog.append("Sichere aktuelle Konfiguration…")
|
|
_ = try await backupService.createBackup(for: credentials)
|
|
|
|
for command in plannedCommands {
|
|
applyLog.append(command.summary)
|
|
try await applyIdempotently(command)
|
|
}
|
|
|
|
didApplySuccessfully = true
|
|
} catch {
|
|
applyError = error.localizedDescription
|
|
}
|
|
isApplying = false
|
|
}
|
|
}
|
|
|
|
/// 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"]
|
|
|
|
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 {
|
|
throw error
|
|
}
|
|
let retryCommand = RouterOSCommand.set(
|
|
menuPath: command.menuPath,
|
|
restPath: command.restPath,
|
|
matchField: "interface",
|
|
matchValue: interfaceName,
|
|
arguments: command.arguments,
|
|
summary: command.summary
|
|
)
|
|
try await connectionService.apply(retryCommand)
|
|
}
|
|
}
|
|
}
|