diff --git a/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift b/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift index b3e42d7..3652cb7 100644 --- a/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift +++ b/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift @@ -18,20 +18,20 @@ enum DhcpServerCommandBuilder { let routerIP = routerAddress.components(separatedBy: "/").first ?? routerAddress return [ - RouterOSCommand( - cliPath: "/ip address add", + RouterOSCommand.add( + menuPath: "/ip address", restPath: "ip/address", arguments: ["address": routerAddress, "interface": interfaceName], summary: "IP-Adresse \(routerAddress) \(context) setzen" ), - RouterOSCommand( - cliPath: "/ip pool add", + RouterOSCommand.add( + menuPath: "/ip pool", restPath: "ip/pool", arguments: ["name": poolName, "ranges": "\(poolRangeStart)-\(poolRangeEnd)"], summary: "Adressbereich \(poolRangeStart)–\(poolRangeEnd) \(context) anlegen" ), - RouterOSCommand( - cliPath: "/ip dhcp-server add", + RouterOSCommand.add( + menuPath: "/ip dhcp-server", restPath: "ip/dhcp-server", arguments: [ "name": serverName, @@ -42,8 +42,8 @@ enum DhcpServerCommandBuilder { ], summary: "DHCP-Server \(context) aktivieren" ), - RouterOSCommand( - cliPath: "/ip dhcp-server network add", + RouterOSCommand.add( + menuPath: "/ip dhcp-server network", restPath: "ip/dhcp-server/network", arguments: [ "address": networkAddress, diff --git a/RouterOSAssistant/Core/Models/RouterOSCommand.swift b/RouterOSAssistant/Core/Models/RouterOSCommand.swift index 5caed22..8458336 100644 --- a/RouterOSAssistant/Core/Models/RouterOSCommand.swift +++ b/RouterOSAssistant/Core/Models/RouterOSCommand.swift @@ -1,26 +1,75 @@ import Foundation /// A single RouterOS configuration change, expressed once and executed over either transport -/// (rendered as a CLI line for SSH, as a JSON body for REST). +/// (rendered as a CLI line for SSH, as a JSON body — plus a lookup for `.set` — for REST). struct RouterOSCommand: Equatable, Identifiable { - var id: String { cliPath + summary } + enum Operation: Equatable { + /// Creates a new item under `restPath` (SSH: ` add ...`, REST: `POST`). + case add + /// Modifies an existing item matched by one field's value (SSH: ` set [find + /// field=value] ...`; REST has no such inline lookup, so it needs a GET first to find + /// the item's `.id`, then `PATCH restPath/`). + case set(matchField: String, matchValue: String) + } - /// CLI add-path, e.g. "/ip address add". - let cliPath: String - /// REST resource path, e.g. "ip/address", posted to create the same item. + var id: String { + switch operation { + case .add: + return "add:\(menuPath):\(summary)" + case .set(let field, let value): + return "set:\(menuPath):\(field)=\(value):\(summary)" + } + } + + /// RouterOS menu path without the action word, e.g. "/ip address" or "/interface wireless". + let menuPath: String + /// REST resource path under `/rest/`, e.g. "ip/address" or "interface/wireless". let restPath: String - /// RouterOS "words" (key=value arguments). + let operation: Operation + /// RouterOS "words" (key=value fields) to add or change. let arguments: [String: String] /// Human-readable description shown on the review screen before applying. let summary: String - /// Renders as a RouterOS CLI line, e.g. `/ip address add address=192.168.88.1/24 interface=bridge`. + static func add(menuPath: String, restPath: String, arguments: [String: String], summary: String) -> RouterOSCommand { + RouterOSCommand(menuPath: menuPath, restPath: restPath, operation: .add, arguments: arguments, summary: summary) + } + + static func set( + menuPath: String, + restPath: String, + matchField: String, + matchValue: String, + arguments: [String: String], + summary: String + ) -> RouterOSCommand { + RouterOSCommand( + menuPath: menuPath, + restPath: restPath, + operation: .set(matchField: matchField, matchValue: matchValue), + arguments: arguments, + summary: summary + ) + } + + /// Renders as a RouterOS CLI line, e.g. `/ip address add address=192.168.88.1/24 interface=bridge` + /// or `/interface wireless set [find name=wlan1] ssid=Home`. var cliLine: String { - let args = arguments + let args = renderedArguments + switch operation { + case .add: + return args.isEmpty ? "\(menuPath) add" : "\(menuPath) add \(args)" + case .set(let field, let value): + let finder = "[find \(field)=\(Self.quoteIfNeeded(value))]" + return args.isEmpty ? "\(menuPath) set \(finder)" : "\(menuPath) set \(finder) \(args)" + } + } + + private var renderedArguments: String { + arguments .sorted { $0.key < $1.key } .map { "\($0.key)=\(Self.quoteIfNeeded($0.value))" } .joined(separator: " ") - return args.isEmpty ? cliPath : "\(cliPath) \(args)" } private static func quoteIfNeeded(_ value: String) -> String { diff --git a/RouterOSAssistant/Core/Models/VlanEntry.swift b/RouterOSAssistant/Core/Models/VlanEntry.swift index 2515cd5..ffecfa8 100644 --- a/RouterOSAssistant/Core/Models/VlanEntry.swift +++ b/RouterOSAssistant/Core/Models/VlanEntry.swift @@ -33,8 +33,8 @@ struct VlanEntry: Identifiable, Equatable { var interfaceName: String { "vlan\(vlanID)" } func buildCommands() -> [RouterOSCommand] { - let createVlan = RouterOSCommand( - cliPath: "/interface vlan add", + let createVlan = RouterOSCommand.add( + menuPath: "/interface vlan", restPath: "interface/vlan", arguments: ["name": interfaceName, "vlan-id": "\(vlanID)", "interface": parentInterface], summary: "VLAN \"\(name)\" (ID \(vlanID)) auf \(parentInterface) anlegen" diff --git a/RouterOSAssistant/Core/Models/WanConfig.swift b/RouterOSAssistant/Core/Models/WanConfig.swift index cb46f9c..a57c9f5 100644 --- a/RouterOSAssistant/Core/Models/WanConfig.swift +++ b/RouterOSAssistant/Core/Models/WanConfig.swift @@ -31,8 +31,8 @@ struct WanConfig: Equatable { switch mode { case .dhcpClient: return [ - RouterOSCommand( - cliPath: "/ip dhcp-client add", + RouterOSCommand.add( + menuPath: "/ip dhcp-client", restPath: "ip/dhcp-client", arguments: ["interface": interfaceName, "disabled": "no"], summary: "Internetadresse auf \(interfaceName) automatisch beziehen (DHCP-Client)" @@ -40,8 +40,8 @@ struct WanConfig: Equatable { ] case .staticIP: var commands = [ - RouterOSCommand( - cliPath: "/ip address add", + RouterOSCommand.add( + menuPath: "/ip address", restPath: "ip/address", arguments: ["address": staticAddress, "interface": interfaceName], summary: "Statische IP-Adresse \(staticAddress) auf \(interfaceName) setzen" @@ -49,8 +49,8 @@ struct WanConfig: Equatable { ] if !staticGateway.isEmpty { commands.append( - RouterOSCommand( - cliPath: "/ip route add", + RouterOSCommand.add( + menuPath: "/ip route", restPath: "ip/route", arguments: ["gateway": staticGateway], summary: "Standard-Route über \(staticGateway) einrichten" @@ -60,8 +60,8 @@ struct WanConfig: Equatable { return commands case .pppoe: return [ - RouterOSCommand( - cliPath: "/interface pppoe-client add", + RouterOSCommand.add( + menuPath: "/interface pppoe-client", restPath: "interface/pppoe-client", arguments: [ "interface": interfaceName, diff --git a/RouterOSAssistant/Core/Models/WifiNetworkConfig.swift b/RouterOSAssistant/Core/Models/WifiNetworkConfig.swift new file mode 100644 index 0000000..29fe432 --- /dev/null +++ b/RouterOSAssistant/Core/Models/WifiNetworkConfig.swift @@ -0,0 +1,50 @@ +import Foundation + +/// SSID/password for one existing wireless radio (`/interface wireless`, the legacy RouterOS +/// wireless driver). Devices without any WLAN hardware simply have no entries. +/// +/// Not supported: RouterOS' newer "wifi" package (`/interface wifi`, used on newer wifiwave2/ +/// 802.11ax hardware) — different menu structure entirely. Detecting and handling that is left +/// for a later milestone; the Setup step explains this to the user when it sees `wifi`-typed +/// interfaces instead of `wlan`-typed ones. +struct WifiNetworkConfig: Identifiable, Equatable { + var id: String { interfaceName } + var interfaceName: String + var enabled: Bool = false + var ssid: String = "" + var password: String = "" + + private var securityProfileName: String { "sec_\(interfaceName)" } + + /// The wireless interface already exists out of the box — this only ever `set`s it plus + /// `add`s a security profile, never creates a new wireless interface. + func buildCommands() -> [RouterOSCommand] { + guard enabled else { return [] } + + return [ + RouterOSCommand.add( + menuPath: "/interface wireless security-profiles", + restPath: "interface/wireless/security-profiles", + arguments: [ + "name": securityProfileName, + "mode": "dynamic-keys", + "authentication-types": "wpa2-psk", + "wpa2-pre-shared-key": password + ], + summary: "WLAN-Sicherheitsprofil für \"\(ssid)\" anlegen (WPA2)" + ), + RouterOSCommand.set( + menuPath: "/interface wireless", + restPath: "interface/wireless", + matchField: "name", + matchValue: interfaceName, + arguments: [ + "ssid": ssid, + "security-profile": securityProfileName, + "disabled": "no" + ], + summary: "WLAN \"\(ssid)\" auf \(interfaceName) aktivieren" + ) + ] + } +} diff --git a/RouterOSAssistant/Core/Networking/RestTransport.swift b/RouterOSAssistant/Core/Networking/RestTransport.swift index 34b55b8..8095a99 100644 --- a/RouterOSAssistant/Core/Networking/RestTransport.swift +++ b/RouterOSAssistant/Core/Networking/RestTransport.swift @@ -56,9 +56,29 @@ final class RestTransport: NSObject, RouterOSTransport { } } - /// Creates the item described by `command` via `POST /rest/`. + /// Creates or modifies the item described by `command`. `.add` is a plain + /// `POST /rest/`. `.set` has no CLI-style inline lookup on REST, so it first + /// `GET`s the collection to find the item whose `matchField` equals `matchValue`, reads + /// its RouterOS-internal `.id`, then `PATCH`es `restPath/`. func apply(_ command: RouterOSCommand) async throws { - _ = try await send(path: command.restPath, method: "POST", jsonBody: command.arguments) + switch command.operation { + case .add: + _ = try await send(path: command.restPath, method: "POST", jsonBody: command.arguments) + case .set(let matchField, let matchValue): + let itemID = try await findItemID(path: command.restPath, matchField: matchField, matchValue: matchValue) + _ = try await send(path: "\(command.restPath)/\(itemID)", method: "PATCH", jsonBody: command.arguments) + } + } + + private func findItemID(path: String, matchField: String, matchValue: String) async throws -> String { + let items = try await getJSONArray(path: path) + guard let match = items.first(where: { ($0[matchField] as? String) == matchValue }) else { + throw RouterOSError.invalidResponse("Kein Eintrag mit \(matchField)=\(matchValue) unter \(path) gefunden") + } + guard let id = match[".id"] as? String else { + throw RouterOSError.invalidResponse("Eintrag unter \(path) hat keine .id") + } + return id } func disconnect() async { diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift index 5f4863b..5a53150 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift @@ -26,6 +26,8 @@ struct SetupView: View { LanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) case .vlan: VlanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) + case .wifi: + WifiStepView(viewModel: viewModel) case .review: ReviewApplyView(viewModel: viewModel, credentials: connectionService.credentials) } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift index a95d151..93cbaf6 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -4,6 +4,7 @@ enum SetupStep: Int, CaseIterable { case wan case lan case vlan + case wifi case review } @@ -14,6 +15,8 @@ final class SetupViewModel: ObservableObject { @Published var lanConfig = 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 isApplying = false @Published private(set) var applyLog: [String] = [] @@ -30,7 +33,10 @@ final class SetupViewModel: ObservableObject { } var plannedCommands: [RouterOSCommand] { - wanConfig.buildCommands() + lanConfig.buildCommands() + vlans.flatMap { $0.buildCommands() } + wanConfig.buildCommands() + + lanConfig.buildCommands() + + vlans.flatMap { $0.buildCommands() } + + wifiNetworks.flatMap { $0.buildCommands() } } func setVlanSectionEnabled(_ enabled: Bool) { @@ -73,6 +79,12 @@ final class SetupViewModel: ObservableObject { lanConfig.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() { diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/WifiStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/WifiStepView.swift new file mode 100644 index 0000000..c4a2ae9 --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/WifiStepView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct WifiStepView: View { + @ObservedObject var viewModel: SetupViewModel + + var body: some View { + Form { + if viewModel.wifiNetworks.isEmpty && viewModel.unsupportedWifiInterfaces.isEmpty { + Section { + Text("An diesem Gerät wurde kein WLAN erkannt. Dieser Schritt wird übersprungen.") + .foregroundStyle(.secondary) + } + } + + if !viewModel.unsupportedWifiInterfaces.isEmpty { + Section { + Text( + "Dein Gerät nutzt den neueren RouterOS-WiFi-Treiber (\(viewModel.unsupportedWifiInterfaces.map(\.name).joined(separator: ", "))), " + + "den diese App noch nicht unterstützt. WLAN muss dafür vorerst manuell über Winbox eingerichtet werden." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + ForEach($viewModel.wifiNetworks) { $network in + Section("WLAN auf \(network.interfaceName)") { + Toggle("Aktivieren", isOn: $network.enabled) + if network.enabled { + TextField("Netzwerkname (SSID)", text: $network.ssid) + SecureField("Passwort (mind. 8 Zeichen)", text: $network.password) + } + } + } + + Section { + HStack { + Button("Zurück") { viewModel.goBack() } + Spacer() + Button("Weiter") { viewModel.goNext() } + .disabled(!isStepValid) + } + } + } + .formStyle(.grouped) + .navigationTitle("WLAN (falls vorhanden)") + } + + private var isStepValid: Bool { + viewModel.wifiNetworks.allSatisfy { network in + !network.enabled || (!network.ssid.isEmpty && network.password.count >= 8) + } + } +} diff --git a/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift b/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift index 3867eb9..811fec6 100644 --- a/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift +++ b/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift @@ -7,7 +7,8 @@ final class RouterOSCommandBuilderTests: XCTestCase { let commands = config.buildCommands() XCTAssertEqual(commands.count, 1) - XCTAssertEqual(commands[0].cliPath, "/ip dhcp-client add") + XCTAssertEqual(commands[0].menuPath, "/ip dhcp-client") + XCTAssertEqual(commands[0].operation, .add) XCTAssertEqual(commands[0].arguments["interface"], "ether1") } @@ -33,7 +34,7 @@ final class RouterOSCommandBuilderTests: XCTestCase { let commands = config.buildCommands() XCTAssertEqual(commands.count, 1) - XCTAssertEqual(commands[0].cliPath, "/interface pppoe-client add") + XCTAssertEqual(commands[0].menuPath, "/interface pppoe-client") XCTAssertEqual(commands[0].arguments["user"], "user@isp") XCTAssertEqual(commands[0].arguments["password"], "secret") } @@ -43,16 +44,16 @@ final class RouterOSCommandBuilderTests: XCTestCase { let commands = config.buildCommands() XCTAssertEqual(commands.count, 4) - XCTAssertEqual(commands[0].cliPath, "/ip address add") - XCTAssertEqual(commands[1].cliPath, "/ip pool add") - XCTAssertEqual(commands[2].cliPath, "/ip dhcp-server add") - XCTAssertEqual(commands[3].cliPath, "/ip dhcp-server network add") + XCTAssertEqual(commands[0].menuPath, "/ip address") + XCTAssertEqual(commands[1].menuPath, "/ip pool") + XCTAssertEqual(commands[2].menuPath, "/ip dhcp-server") + XCTAssertEqual(commands[3].menuPath, "/ip dhcp-server network") XCTAssertEqual(commands[3].arguments["gateway"], "192.168.88.1") } - func testCliLineRendersSortedQuotedArguments() { - let command = RouterOSCommand( - cliPath: "/interface pppoe-client add", + func testCliLineRendersSortedQuotedArgumentsForAdd() { + let command = RouterOSCommand.add( + menuPath: "/interface pppoe-client", restPath: "interface/pppoe-client", arguments: ["user": "user@isp", "password": "a secret"], summary: "test" @@ -60,4 +61,17 @@ final class RouterOSCommandBuilderTests: XCTestCase { XCTAssertEqual(command.cliLine, "/interface pppoe-client add password=\"a secret\" user=user@isp") } + + func testCliLineRendersFindLookupForSet() { + let command = RouterOSCommand.set( + menuPath: "/interface wireless", + restPath: "interface/wireless", + matchField: "name", + matchValue: "wlan1", + arguments: ["ssid": "Home"], + summary: "test" + ) + + XCTAssertEqual(command.cliLine, "/interface wireless set [find name=wlan1] ssid=Home") + } } diff --git a/RouterOSAssistantTests/VlanEntryTests.swift b/RouterOSAssistantTests/VlanEntryTests.swift index 3b6999c..532573b 100644 --- a/RouterOSAssistantTests/VlanEntryTests.swift +++ b/RouterOSAssistantTests/VlanEntryTests.swift @@ -7,16 +7,16 @@ final class VlanEntryTests: XCTestCase { let commands = vlan.buildCommands() XCTAssertEqual(commands.count, 5) - XCTAssertEqual(commands[0].cliPath, "/interface vlan add") + XCTAssertEqual(commands[0].menuPath, "/interface vlan") XCTAssertEqual(commands[0].arguments["vlan-id"], "20") XCTAssertEqual(commands[0].arguments["interface"], "bridge") XCTAssertEqual(commands[0].arguments["name"], "vlan20") - XCTAssertEqual(commands[1].cliPath, "/ip address add") + XCTAssertEqual(commands[1].menuPath, "/ip address") XCTAssertEqual(commands[1].arguments["interface"], "vlan20") - XCTAssertEqual(commands[2].cliPath, "/ip pool add") - XCTAssertEqual(commands[3].cliPath, "/ip dhcp-server add") - XCTAssertEqual(commands[4].cliPath, "/ip dhcp-server network add") + XCTAssertEqual(commands[2].menuPath, "/ip pool") + XCTAssertEqual(commands[3].menuPath, "/ip dhcp-server") + XCTAssertEqual(commands[4].menuPath, "/ip dhcp-server network") } func testDefaultAddressesAreDerivedFromVlanID() { diff --git a/RouterOSAssistantTests/WifiNetworkConfigTests.swift b/RouterOSAssistantTests/WifiNetworkConfigTests.swift new file mode 100644 index 0000000..8ac33ae --- /dev/null +++ b/RouterOSAssistantTests/WifiNetworkConfigTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import RouterOSAssistant + +final class WifiNetworkConfigTests: XCTestCase { + func testDisabledNetworkProducesNoCommands() { + let network = WifiNetworkConfig(interfaceName: "wlan1", enabled: false, ssid: "Home", password: "secret123") + XCTAssertEqual(network.buildCommands().count, 0) + } + + func testEnabledNetworkAddsSecurityProfileThenSetsInterface() { + let network = WifiNetworkConfig(interfaceName: "wlan1", enabled: true, ssid: "Home", password: "secret123") + let commands = network.buildCommands() + + XCTAssertEqual(commands.count, 2) + + XCTAssertEqual(commands[0].menuPath, "/interface wireless security-profiles") + XCTAssertEqual(commands[0].operation, .add) + XCTAssertEqual(commands[0].arguments["wpa2-pre-shared-key"], "secret123") + + XCTAssertEqual(commands[1].menuPath, "/interface wireless") + XCTAssertEqual(commands[1].operation, .set(matchField: "name", matchValue: "wlan1")) + XCTAssertEqual(commands[1].arguments["ssid"], "Home") + XCTAssertEqual(commands[1].arguments["security-profile"], "sec_wlan1") + } +}