M3: WAN + LAN/DHCP-Wizard mit Anwenden-Logik

Neuer "Einrichten"-Tab führt durch Internet-Anschluss (DHCP/statisch/
PPPoE) und Heimnetzwerk+DHCP-Server, zeigt vor dem Anwenden eine
Klartext-Übersicht (optional mit den exakten RouterOS-Befehlen) und
erstellt automatisch eine Sicherung, bevor Änderungen geschrieben
werden. Änderungen laufen über beide Transporte: CLI-Zeile für SSH,
JSON-POST für REST — beide aus einem gemeinsamen RouterOSCommand
gebaut. RouterOS-CLI-Syntax ist Standard und langjährig stabil, aber
nicht gegen ein echtes Gerät verifiziert; deshalb die Detailanzeige
im Übersichtsschritt vor dem Anwenden.

ConnectionService ist jetzt der einzige App-weite Zustand (ersetzt
SessionStore) und wird explizit an alle drei Tabs durchgereicht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
This commit is contained in:
Kay
2026-09-11 21:07:34 +02:00
co-authored by Claude Sonnet 5
parent c4ed26b8dd
commit 87f4fa188c
19 changed files with 595 additions and 36 deletions
@@ -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)
}
}
}
@@ -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"
)
]
}
}
@@ -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
}
}
@@ -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"
)
]
}
}
}
@@ -56,12 +56,17 @@ final class RestTransport: NSObject, RouterOSTransport {
}
}
/// Creates the item described by `command` via `POST /rest/<restPath>`.
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)
@@ -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
}
@@ -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)
@@ -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
}
}
@@ -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?
}
@@ -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())
}
@@ -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())
}
@@ -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
@@ -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
}
}
@@ -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 ?? "")
}
}
}
@@ -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())
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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 {}
}
@@ -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")
}
}