diff --git a/RouterOSAssistant/App/RouterOSAssistantApp.swift b/RouterOSAssistant/App/RouterOSAssistantApp.swift index ba0ae32..da9d542 100644 --- a/RouterOSAssistant/App/RouterOSAssistantApp.swift +++ b/RouterOSAssistant/App/RouterOSAssistantApp.swift @@ -2,17 +2,18 @@ import SwiftUI @main struct RouterOSAssistantApp: App { - @StateObject private var session = SessionStore() + @StateObject private var connectionService = ConnectionService() var body: some Scene { WindowGroup { TabView { - ConnectView() + ConnectView(connectionService: connectionService) .tabItem { Label("Verbinden", systemImage: "network") } - BackupListView() + SetupView(connectionService: connectionService) + .tabItem { Label("Einrichten", systemImage: "checklist") } + BackupListView(connectionService: connectionService) .tabItem { Label("Sicherungen", systemImage: "clock.arrow.circlepath") } } - .environmentObject(session) } } } diff --git a/RouterOSAssistant/Core/Models/LanDhcpConfig.swift b/RouterOSAssistant/Core/Models/LanDhcpConfig.swift new file mode 100644 index 0000000..6e3f163 --- /dev/null +++ b/RouterOSAssistant/Core/Models/LanDhcpConfig.swift @@ -0,0 +1,60 @@ +import Foundation + +struct LanDhcpConfig: Equatable { + var interfaceName: String = "bridge" + var networkAddress: String = "192.168.88.0/24" + var routerAddress: String = "192.168.88.1/24" + var poolRangeStart: String = "192.168.88.10" + var poolRangeEnd: String = "192.168.88.254" + var leaseTimeHours: Int = 24 + var dnsServers: String = "192.168.88.1" + + private var routerIP: String { + routerAddress.components(separatedBy: "/").first ?? routerAddress + } + + /// RouterOS commands for this LAN/DHCP setup. Standard, long-stable RouterOS CLI syntax — + /// not yet verified against a live device; the Review-step shows every command before + /// it runs so this can be caught before anything is applied. + func buildCommands() -> [RouterOSCommand] { + let poolName = "dhcp_pool_\(interfaceName)" + let serverName = "dhcp_\(interfaceName)" + + return [ + RouterOSCommand( + cliPath: "/ip address add", + restPath: "ip/address", + arguments: ["address": routerAddress, "interface": interfaceName], + summary: "IP-Adresse \(routerAddress) auf \(interfaceName) setzen" + ), + RouterOSCommand( + cliPath: "/ip pool add", + restPath: "ip/pool", + arguments: ["name": poolName, "ranges": "\(poolRangeStart)-\(poolRangeEnd)"], + summary: "Adressbereich \(poolRangeStart)–\(poolRangeEnd) für Geräte im Netzwerk anlegen" + ), + RouterOSCommand( + cliPath: "/ip dhcp-server add", + restPath: "ip/dhcp-server", + arguments: [ + "name": serverName, + "interface": interfaceName, + "address-pool": poolName, + "lease-time": "\(leaseTimeHours)h", + "disabled": "no" + ], + summary: "DHCP-Server auf \(interfaceName) aktivieren (vergibt automatisch IP-Adressen)" + ), + RouterOSCommand( + cliPath: "/ip dhcp-server network add", + restPath: "ip/dhcp-server/network", + arguments: [ + "address": networkAddress, + "gateway": routerIP, + "dns-server": dnsServers + ], + summary: "DHCP-Netzwerk \(networkAddress) mit Gateway \(routerIP) und DNS \(dnsServers) konfigurieren" + ) + ] + } +} diff --git a/RouterOSAssistant/Core/Models/RouterOSCommand.swift b/RouterOSAssistant/Core/Models/RouterOSCommand.swift new file mode 100644 index 0000000..5caed22 --- /dev/null +++ b/RouterOSAssistant/Core/Models/RouterOSCommand.swift @@ -0,0 +1,29 @@ +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). +struct RouterOSCommand: Equatable, Identifiable { + var id: String { cliPath + summary } + + /// CLI add-path, e.g. "/ip address add". + let cliPath: String + /// REST resource path, e.g. "ip/address", posted to create the same item. + let restPath: String + /// RouterOS "words" (key=value arguments). + 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`. + var cliLine: String { + let args = 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 { + value.contains(" ") ? "\"\(value)\"" : value + } +} diff --git a/RouterOSAssistant/Core/Models/WanConfig.swift b/RouterOSAssistant/Core/Models/WanConfig.swift new file mode 100644 index 0000000..cb46f9c --- /dev/null +++ b/RouterOSAssistant/Core/Models/WanConfig.swift @@ -0,0 +1,77 @@ +import Foundation + +enum WanConnectionMode: String, CaseIterable, Identifiable { + case dhcpClient + case staticIP + case pppoe + + var id: String { rawValue } + + var label: String { + switch self { + case .dhcpClient: return "Automatisch (DHCP)" + case .staticIP: return "Statische IP-Adresse" + case .pppoe: return "PPPoE (DSL-Zugangsdaten)" + } + } +} + +struct WanConfig: Equatable { + var interfaceName: String + var mode: WanConnectionMode = .dhcpClient + var staticAddress: String = "" + var staticGateway: String = "" + var pppoeUsername: String = "" + var pppoePassword: String = "" + + /// RouterOS commands for this WAN setup. Standard, long-stable RouterOS CLI syntax — + /// not yet verified against a live device; the Review-step shows every command before + /// it runs so this can be caught before anything is applied. + func buildCommands() -> [RouterOSCommand] { + switch mode { + case .dhcpClient: + return [ + RouterOSCommand( + cliPath: "/ip dhcp-client add", + restPath: "ip/dhcp-client", + arguments: ["interface": interfaceName, "disabled": "no"], + summary: "Internetadresse auf \(interfaceName) automatisch beziehen (DHCP-Client)" + ) + ] + case .staticIP: + var commands = [ + RouterOSCommand( + cliPath: "/ip address add", + restPath: "ip/address", + arguments: ["address": staticAddress, "interface": interfaceName], + summary: "Statische IP-Adresse \(staticAddress) auf \(interfaceName) setzen" + ) + ] + if !staticGateway.isEmpty { + commands.append( + RouterOSCommand( + cliPath: "/ip route add", + restPath: "ip/route", + arguments: ["gateway": staticGateway], + summary: "Standard-Route über \(staticGateway) einrichten" + ) + ) + } + return commands + case .pppoe: + return [ + RouterOSCommand( + cliPath: "/interface pppoe-client add", + restPath: "interface/pppoe-client", + arguments: [ + "interface": interfaceName, + "user": pppoeUsername, + "password": pppoePassword, + "disabled": "no" + ], + summary: "PPPoE-Internetverbindung auf \(interfaceName) mit Zugangsdaten deines Anbieters einrichten" + ) + ] + } + } +} diff --git a/RouterOSAssistant/Core/Networking/RestTransport.swift b/RouterOSAssistant/Core/Networking/RestTransport.swift index e767401..34b55b8 100644 --- a/RouterOSAssistant/Core/Networking/RestTransport.swift +++ b/RouterOSAssistant/Core/Networking/RestTransport.swift @@ -56,12 +56,17 @@ final class RestTransport: NSObject, RouterOSTransport { } } + /// Creates the item described by `command` via `POST /rest/`. + func apply(_ command: RouterOSCommand) async throws { + _ = try await send(path: command.restPath, method: "POST", jsonBody: command.arguments) + } + func disconnect() async { session.invalidateAndCancel() } private func getJSONObject(path: String) async throws -> [String: Any] { - let data = try await getData(path: path) + let data = try await send(path: path, method: "GET", jsonBody: nil) guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw RouterOSError.invalidResponse(path) } @@ -69,15 +74,17 @@ final class RestTransport: NSObject, RouterOSTransport { } private func getJSONArray(path: String) async throws -> [[String: Any]] { - let data = try await getData(path: path) + let data = try await send(path: path, method: "GET", jsonBody: nil) guard let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { throw RouterOSError.invalidResponse(path) } return array } - private func getData(path: String) async throws -> Data { + private func send(path: String, method: String, jsonBody: [String: String]?) async throws -> Data { var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = method + let authString = "\(credentials.username):\(credentials.password)" guard let authData = authString.data(using: .utf8) else { throw RouterOSError.invalidResponse("credentials") @@ -85,6 +92,11 @@ final class RestTransport: NSObject, RouterOSTransport { request.setValue("Basic \(authData.base64EncodedString())", forHTTPHeaderField: "Authorization") request.timeoutInterval = 5 + if let jsonBody { + request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + lastRejectedFingerprint = nil do { let (data, response) = try await session.data(for: request) diff --git a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift index 9e8b9a2..fcac32f 100644 --- a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift +++ b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift @@ -10,5 +10,6 @@ protocol RouterOSTransport: AnyObject { func connect() async throws func fetchDeviceInfo() async throws -> RouterDeviceInfo func fetchInterfaces() async throws -> [NetworkInterface] + func apply(_ command: RouterOSCommand) async throws func disconnect() async } diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift index 7eb9e65..c84d80f 100644 --- a/RouterOSAssistant/Core/Networking/SSHTransport.swift +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -51,6 +51,10 @@ final class SSHTransport: RouterOSTransport { try await run("/export terse") } + func apply(_ command: RouterOSCommand) async throws { + _ = try await run(command.cliLine) + } + private func run(_ command: String) async throws -> String { guard let client else { throw RouterOSError.notConnected } let buffer = try await client.executeCommand(command) diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift index b3e86f7..341ba36 100644 --- a/RouterOSAssistant/Core/Services/ConnectionService.swift +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -14,10 +14,12 @@ final class ConnectionService: ObservableObject { @Published private(set) var state: State = .idle @Published private(set) var deviceInfo: RouterDeviceInfo? @Published private(set) var interfaces: [NetworkInterface] = [] + /// Credentials of the current (or last attempted) connection, shared with features + /// that need their own dedicated connection, e.g. BackupService's SSH export. + @Published private(set) var credentials: RouterOSCredentials? private let certificateTrust: CertificateTrustStore private var activeTransport: RouterOSTransport? - private var pendingCredentials: RouterOSCredentials? init(certificateTrust: CertificateTrustStore = CertificateTrustStore()) { self.certificateTrust = certificateTrust @@ -26,7 +28,7 @@ final class ConnectionService: ObservableObject { /// Injection point for tests: bypasses the real REST/SSH transports. func connect(with credentials: RouterOSCredentials, makeRestTransport: () -> RouterOSTransport, makeSSHTransport: () -> RouterOSTransport) async { state = .connecting - pendingCredentials = credentials + self.credentials = credentials let rest = makeRestTransport() do { @@ -58,11 +60,17 @@ final class ConnectionService: ObservableObject { } func trustCurrentCertificateAndRetry(fingerprint: String) async { - guard let credentials = pendingCredentials else { return } + guard let credentials else { return } certificateTrust.trust(host: credentials.host, fingerprint: fingerprint) await connect(with: credentials) } + /// Applies a single configuration change on the active transport (REST or SSH). + func apply(_ command: RouterOSCommand) async throws { + guard let activeTransport else { throw RouterOSError.notConnected } + try await activeTransport.apply(command) + } + private func finishConnecting(using transport: RouterOSTransport) async { activeTransport = transport do { @@ -79,6 +87,7 @@ final class ConnectionService: ObservableObject { activeTransport = nil deviceInfo = nil interfaces = [] + credentials = nil state = .idle } } diff --git a/RouterOSAssistant/Core/Services/SessionStore.swift b/RouterOSAssistant/Core/Services/SessionStore.swift deleted file mode 100644 index 26bf2f7..0000000 --- a/RouterOSAssistant/Core/Services/SessionStore.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -/// Holds the currently connected router's credentials so other screens (e.g. Backup) -/// can act on the same device without re-prompting for login. -@MainActor -final class SessionStore: ObservableObject { - @Published var credentials: RouterOSCredentials? -} diff --git a/RouterOSAssistant/Features/Backup/BackupListView.swift b/RouterOSAssistant/Features/Backup/BackupListView.swift index 54533c3..0ec8493 100644 --- a/RouterOSAssistant/Features/Backup/BackupListView.swift +++ b/RouterOSAssistant/Features/Backup/BackupListView.swift @@ -1,7 +1,7 @@ import SwiftUI struct BackupListView: View { - @EnvironmentObject private var session: SessionStore + @ObservedObject var connectionService: ConnectionService @StateObject private var viewModel = BackupViewModel() var body: some View { @@ -28,7 +28,7 @@ struct BackupListView: View { .toolbar { ToolbarItem { Button { - if let credentials = session.credentials { + if let credentials = connectionService.credentials { viewModel.createBackup(for: credentials) } } label: { @@ -38,8 +38,8 @@ struct BackupListView: View { Label("Jetzt sichern", systemImage: "square.and.arrow.down") } } - .disabled(session.credentials == nil || viewModel.isCreatingBackup) - .help(session.credentials == nil ? "Zuerst im Tab 'Verbinden' mit dem Router verbinden." : "Sicherung jetzt erstellen") + .disabled(connectionService.credentials == nil || viewModel.isCreatingBackup) + .help(connectionService.credentials == nil ? "Zuerst im Tab 'Verbinden' mit dem Router verbinden." : "Sicherung jetzt erstellen") } } .alert( @@ -60,5 +60,5 @@ struct BackupListView: View { } #Preview { - BackupListView().environmentObject(SessionStore()) + BackupListView(connectionService: ConnectionService()) } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift index 59fa97e..845cbc2 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectView.swift @@ -1,8 +1,11 @@ import SwiftUI struct ConnectView: View { - @EnvironmentObject private var session: SessionStore - @StateObject private var viewModel = ConnectViewModel() + @StateObject private var viewModel: ConnectViewModel + + init(connectionService: ConnectionService) { + _viewModel = StateObject(wrappedValue: ConnectViewModel(connectionService: connectionService)) + } var body: some View { NavigationSplitView { @@ -29,15 +32,6 @@ struct ConnectView: View { deviceDetail } .onAppear { viewModel.onAppear() } - .onChange(of: viewModel.connectionService.state) { _, newState in - if case .connected = newState { - session.credentials = RouterOSCredentials( - host: viewModel.host, - username: viewModel.username, - password: viewModel.password - ) - } - } .alert( "Unbekanntes Zertifikat", isPresented: certificateAlertBinding, @@ -126,5 +120,5 @@ struct ConnectView: View { } #Preview { - ConnectView().environmentObject(SessionStore()) + ConnectView(connectionService: ConnectionService()) } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift index 9cc96ee..79a6d44 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Connect/ConnectViewModel.swift @@ -7,9 +7,13 @@ final class ConnectViewModel: ObservableObject { @Published var password: String = "" @Published var rememberPassword: Bool = true - let connectionService = ConnectionService() + let connectionService: ConnectionService private let keychain = KeychainService() + init(connectionService: ConnectionService) { + self.connectionService = connectionService + } + func onAppear() { if let saved = keychain.loadPassword(forHost: host, username: username) { password = saved diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift new file mode 100644 index 0000000..4d9cbc9 --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/LanStepView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct LanStepView: View { + @ObservedObject var viewModel: SetupViewModel + let availableInterfaces: [NetworkInterface] + + var body: some View { + Form { + Section("Heimnetzwerk (LAN)") { + Picker("Anschluss/Bridge", selection: $viewModel.lanConfig.interfaceName) { + ForEach(availableInterfaces) { interface in + Text(interface.name).tag(interface.name) + } + } + TextField("Router-Adresse (z.B. 192.168.88.1/24)", text: $viewModel.lanConfig.routerAddress) + TextField("Netzwerk (z.B. 192.168.88.0/24)", text: $viewModel.lanConfig.networkAddress) + } + + Section("Automatische Adressvergabe (DHCP)") { + TextField("Von", text: $viewModel.lanConfig.poolRangeStart) + TextField("Bis", text: $viewModel.lanConfig.poolRangeEnd) + Stepper( + "Adresse behalten für \(viewModel.lanConfig.leaseTimeHours) Stunden", + value: $viewModel.lanConfig.leaseTimeHours, + in: 1...168 + ) + TextField("DNS-Server", text: $viewModel.lanConfig.dnsServers) + } + + Section { + HStack { + Button("Zurück") { viewModel.goBack() } + Spacer() + Button("Weiter") { viewModel.goNext() } + .disabled(!isStepValid) + } + } + } + .formStyle(.grouped) + .navigationTitle("Heimnetzwerk einrichten") + } + + private var isStepValid: Bool { + !viewModel.lanConfig.interfaceName.isEmpty + && !viewModel.lanConfig.routerAddress.isEmpty + && !viewModel.lanConfig.networkAddress.isEmpty + && !viewModel.lanConfig.poolRangeStart.isEmpty + && !viewModel.lanConfig.poolRangeEnd.isEmpty + } +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/ReviewApplyView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/ReviewApplyView.swift new file mode 100644 index 0000000..8f5d49e --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/ReviewApplyView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct ReviewApplyView: View { + @ObservedObject var viewModel: SetupViewModel + let credentials: RouterOSCredentials? + + @State private var showCliDetails = false + + var body: some View { + Form { + Section("Geplante Änderungen") { + ForEach(viewModel.plannedCommands) { command in + VStack(alignment: .leading, spacing: 2) { + Text(command.summary) + if showCliDetails { + Text(command.cliLine) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + Toggle("Details anzeigen (RouterOS-Befehle)", isOn: $showCliDetails) + } + + Section { + Text("Vor dem Anwenden wird automatisch eine Sicherung der aktuellen Konfiguration erstellt (Tab \"Sicherungen\"). Ein garantiertes automatisches Zurückrollen bei Verbindungsabbruch gibt es nicht — bei Problemen die Sicherung im Tab \"Sicherungen\" verwenden oder den Router lokal (Ethernet/Konsole) wiederherstellen.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if !viewModel.applyLog.isEmpty { + Section("Ablauf") { + ForEach(viewModel.applyLog, id: \.self) { line in + Text(line).font(.caption) + } + } + } + + if viewModel.didApplySuccessfully { + Section { + Label("Änderungen erfolgreich angewendet", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + + Section { + HStack { + Button("Zurück") { viewModel.goBack() } + .disabled(viewModel.isApplying) + Spacer() + Button { + if let credentials { + viewModel.apply(credentials: credentials) + } + } label: { + if viewModel.isApplying { + ProgressView() + } else { + Text("Jetzt anwenden") + } + } + .disabled(credentials == nil || viewModel.isApplying || viewModel.didApplySuccessfully) + } + } + } + .formStyle(.grouped) + .navigationTitle("Übersicht & Anwenden") + .alert( + "Anwenden fehlgeschlagen", + isPresented: Binding( + get: { viewModel.applyError != nil }, + set: { _ in } + ) + ) { + Button("OK") {} + } message: { + Text(viewModel.applyError ?? "") + } + } +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift new file mode 100644 index 0000000..66b5a3f --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct SetupView: View { + @ObservedObject var connectionService: ConnectionService + @StateObject private var viewModel: SetupViewModel + + init(connectionService: ConnectionService) { + self.connectionService = connectionService + _viewModel = StateObject(wrappedValue: SetupViewModel(connectionService: connectionService)) + } + + var body: some View { + NavigationStack { + Group { + if connectionService.credentials == nil { + ContentUnavailableView( + "Nicht verbunden", + systemImage: "network.slash", + description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.") + ) + } else { + switch viewModel.step { + case .wan: + WanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) + case .lan: + LanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) + case .review: + ReviewApplyView(viewModel: viewModel, credentials: connectionService.credentials) + } + } + } + } + .onAppear { + viewModel.prepareDefaults(from: connectionService.interfaces) + } + } +} + +#Preview { + SetupView(connectionService: ConnectionService()) +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift new file mode 100644 index 0000000..1790a80 --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -0,0 +1,73 @@ +import Foundation + +enum SetupStep: Int, CaseIterable { + case wan + case lan + case review +} + +@MainActor +final class SetupViewModel: ObservableObject { + @Published var step: SetupStep = .wan + @Published var wanConfig = WanConfig(interfaceName: "ether1") + @Published var lanConfig = LanDhcpConfig() + + @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 + } + + var plannedCommands: [RouterOSCommand] { + wanConfig.buildCommands() + lanConfig.buildCommands() + } + + /// Picks a sensible default WAN interface (first Ethernet-like port) once interfaces are known. + func prepareDefaults(from interfaces: [NetworkInterface]) { + guard wanConfig.interfaceName.isEmpty || wanConfig.interfaceName == "ether1" else { return } + if let firstEthernet = interfaces.first(where: { $0.type.lowercased().contains("ether") }) { + wanConfig.interfaceName = firstEthernet.name + } + } + + func goNext() { + guard let next = SetupStep(rawValue: step.rawValue + 1) else { return } + step = next + } + + func goBack() { + guard let previous = SetupStep(rawValue: step.rawValue - 1) else { return } + step = previous + } + + 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 connectionService.apply(command) + } + + didApplySuccessfully = true + } catch { + applyError = error.localizedDescription + } + isApplying = false + } + } +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/WanStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/WanStepView.swift new file mode 100644 index 0000000..5cd54b6 --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/WanStepView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct WanStepView: View { + @ObservedObject var viewModel: SetupViewModel + let availableInterfaces: [NetworkInterface] + + var body: some View { + Form { + Section("Internet-Anschluss") { + Picker("Anschluss (Port)", selection: $viewModel.wanConfig.interfaceName) { + ForEach(availableInterfaces) { interface in + Text(interface.name).tag(interface.name) + } + } + + Picker("Verbindungsart", selection: $viewModel.wanConfig.mode) { + ForEach(WanConnectionMode.allCases) { mode in + Text(mode.label).tag(mode) + } + } + .pickerStyle(.inline) + } + + switch viewModel.wanConfig.mode { + case .dhcpClient: + Section { + Text("Der Router bezieht seine Internetadresse automatisch von deinem Anbieter. Für die meisten Kabel-/Glasfaser-Anschlüsse die richtige Wahl.") + .foregroundStyle(.secondary) + } + case .staticIP: + Section("Statische Adresse") { + TextField("IP-Adresse (z.B. 203.0.113.5/24)", text: $viewModel.wanConfig.staticAddress) + TextField("Gateway (z.B. 203.0.113.1)", text: $viewModel.wanConfig.staticGateway) + } + case .pppoe: + Section("PPPoE-Zugangsdaten") { + TextField("Benutzername", text: $viewModel.wanConfig.pppoeUsername) + SecureField("Passwort", text: $viewModel.wanConfig.pppoePassword) + Text("Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section { + HStack { + Spacer() + Button("Weiter") { viewModel.goNext() } + .disabled(!isStepValid) + } + } + } + .formStyle(.grouped) + .navigationTitle("Internet einrichten") + } + + private var isStepValid: Bool { + guard !viewModel.wanConfig.interfaceName.isEmpty else { return false } + switch viewModel.wanConfig.mode { + case .dhcpClient: + return true + case .staticIP: + return !viewModel.wanConfig.staticAddress.isEmpty + case .pppoe: + return !viewModel.wanConfig.pppoeUsername.isEmpty && !viewModel.wanConfig.pppoePassword.isEmpty + } + } +} diff --git a/RouterOSAssistantTests/ConnectionServiceTests.swift b/RouterOSAssistantTests/ConnectionServiceTests.swift index a80f639..c358a2b 100644 --- a/RouterOSAssistantTests/ConnectionServiceTests.swift +++ b/RouterOSAssistantTests/ConnectionServiceTests.swift @@ -17,6 +17,7 @@ private final class MockTransport: RouterOSTransport { func fetchDeviceInfo() async throws -> RouterDeviceInfo { deviceInfo } func fetchInterfaces() async throws -> [NetworkInterface] { [] } + func apply(_ command: RouterOSCommand) async throws {} func disconnect() async {} } diff --git a/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift b/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift new file mode 100644 index 0000000..3867eb9 --- /dev/null +++ b/RouterOSAssistantTests/RouterOSCommandBuilderTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import RouterOSAssistant + +final class RouterOSCommandBuilderTests: XCTestCase { + func testWanDhcpClientCommand() { + let config = WanConfig(interfaceName: "ether1", mode: .dhcpClient) + let commands = config.buildCommands() + + XCTAssertEqual(commands.count, 1) + XCTAssertEqual(commands[0].cliPath, "/ip dhcp-client add") + XCTAssertEqual(commands[0].arguments["interface"], "ether1") + } + + func testWanStaticIPCommandsIncludeRouteOnlyWhenGatewayGiven() { + var config = WanConfig(interfaceName: "ether1", mode: .staticIP) + config.staticAddress = "203.0.113.5/24" + + XCTAssertEqual(config.buildCommands().count, 1) + + config.staticGateway = "203.0.113.1" + let commands = config.buildCommands() + + XCTAssertEqual(commands.count, 2) + XCTAssertEqual(commands[0].arguments["address"], "203.0.113.5/24") + XCTAssertEqual(commands[1].arguments["gateway"], "203.0.113.1") + } + + func testWanPppoeCommand() { + var config = WanConfig(interfaceName: "ether1", mode: .pppoe) + config.pppoeUsername = "user@isp" + config.pppoePassword = "secret" + + let commands = config.buildCommands() + + XCTAssertEqual(commands.count, 1) + XCTAssertEqual(commands[0].cliPath, "/interface pppoe-client add") + XCTAssertEqual(commands[0].arguments["user"], "user@isp") + XCTAssertEqual(commands[0].arguments["password"], "secret") + } + + func testLanDhcpCommandsCoverAddressPoolServerAndNetwork() { + let config = LanDhcpConfig() + 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[3].arguments["gateway"], "192.168.88.1") + } + + func testCliLineRendersSortedQuotedArguments() { + let command = RouterOSCommand( + cliPath: "/interface pppoe-client add", + restPath: "interface/pppoe-client", + arguments: ["user": "user@isp", "password": "a secret"], + summary: "test" + ) + + XCTAssertEqual(command.cliLine, "/interface pppoe-client add password=\"a secret\" user=user@isp") + } +}