diff --git a/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift b/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift new file mode 100644 index 0000000..b3e42d7 --- /dev/null +++ b/RouterOSAssistant/Core/Models/DhcpServerCommandBuilder.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Shared command set for "IP address + DHCP pool + DHCP server + DHCP network on one +/// interface" — used by the LAN step directly and, per entry, by the VLAN step. +enum DhcpServerCommandBuilder { + static func buildCommands( + interfaceName: String, + routerAddress: String, + networkAddress: String, + poolRangeStart: String, + poolRangeEnd: String, + leaseTimeHours: Int, + dnsServers: String, + context: String + ) -> [RouterOSCommand] { + let poolName = "dhcp_pool_\(interfaceName)" + let serverName = "dhcp_\(interfaceName)" + let routerIP = routerAddress.components(separatedBy: "/").first ?? routerAddress + + return [ + RouterOSCommand( + cliPath: "/ip address add", + restPath: "ip/address", + arguments: ["address": routerAddress, "interface": interfaceName], + summary: "IP-Adresse \(routerAddress) \(context) setzen" + ), + RouterOSCommand( + cliPath: "/ip pool add", + restPath: "ip/pool", + arguments: ["name": poolName, "ranges": "\(poolRangeStart)-\(poolRangeEnd)"], + summary: "Adressbereich \(poolRangeStart)–\(poolRangeEnd) \(context) 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 \(context) aktivieren" + ), + RouterOSCommand( + cliPath: "/ip dhcp-server network add", + restPath: "ip/dhcp-server/network", + arguments: [ + "address": networkAddress, + "gateway": routerIP, + "dns-server": dnsServers + ], + summary: "DHCP-Netzwerk \(networkAddress) \(context) konfigurieren" + ) + ] + } +} diff --git a/RouterOSAssistant/Core/Models/LanDhcpConfig.swift b/RouterOSAssistant/Core/Models/LanDhcpConfig.swift index 6e3f163..52fd786 100644 --- a/RouterOSAssistant/Core/Models/LanDhcpConfig.swift +++ b/RouterOSAssistant/Core/Models/LanDhcpConfig.swift @@ -9,52 +9,19 @@ struct LanDhcpConfig: Equatable { 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" - ) - ] + DhcpServerCommandBuilder.buildCommands( + interfaceName: interfaceName, + routerAddress: routerAddress, + networkAddress: networkAddress, + poolRangeStart: poolRangeStart, + poolRangeEnd: poolRangeEnd, + leaseTimeHours: leaseTimeHours, + dnsServers: dnsServers, + context: "auf \(interfaceName)" + ) } } diff --git a/RouterOSAssistant/Core/Models/VlanEntry.swift b/RouterOSAssistant/Core/Models/VlanEntry.swift new file mode 100644 index 0000000..2515cd5 --- /dev/null +++ b/RouterOSAssistant/Core/Models/VlanEntry.swift @@ -0,0 +1,56 @@ +import Foundation + +/// One additional virtual network: a VLAN interface on top of an existing (typically LAN +/// bridge) interface, with its own IP range and DHCP server — e.g. a guest or IoT network. +/// Deliberately not port-based (no access/trunk assignment): that needs RouterOS bridge +/// VLAN filtering, which requires "set" operations our REST/SSH command model doesn't +/// support yet (it only ever creates new items). Someone who needs a VLAN on a specific +/// switch port still has to configure that port as a tagged trunk manually. +struct VlanEntry: Identifiable, Equatable { + var id = UUID() + var name: String + var vlanID: Int + var parentInterface: String + var networkAddress: String + var routerAddress: String + var poolRangeStart: String + var poolRangeEnd: String + var leaseTimeHours: Int = 24 + var dnsServers: String + + init(name: String = "Gäste", vlanID: Int, parentInterface: String) { + self.name = name + self.vlanID = vlanID + self.parentInterface = parentInterface + let octet = vlanID % 256 + self.networkAddress = "192.168.\(octet).0/24" + self.routerAddress = "192.168.\(octet).1/24" + self.poolRangeStart = "192.168.\(octet).10" + self.poolRangeEnd = "192.168.\(octet).254" + self.dnsServers = "192.168.\(octet).1" + } + + var interfaceName: String { "vlan\(vlanID)" } + + func buildCommands() -> [RouterOSCommand] { + let createVlan = RouterOSCommand( + cliPath: "/interface vlan add", + restPath: "interface/vlan", + arguments: ["name": interfaceName, "vlan-id": "\(vlanID)", "interface": parentInterface], + summary: "VLAN \"\(name)\" (ID \(vlanID)) auf \(parentInterface) anlegen" + ) + + let dhcpCommands = DhcpServerCommandBuilder.buildCommands( + interfaceName: interfaceName, + routerAddress: routerAddress, + networkAddress: networkAddress, + poolRangeStart: poolRangeStart, + poolRangeEnd: poolRangeEnd, + leaseTimeHours: leaseTimeHours, + dnsServers: dnsServers, + context: "für VLAN \"\(name)\"" + ) + + return [createVlan] + dhcpCommands + } +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift index 66b5a3f..5f4863b 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift @@ -24,6 +24,8 @@ struct SetupView: View { WanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) case .lan: LanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) + case .vlan: + VlanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) 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 74b0d41..a95d151 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -3,6 +3,7 @@ import Foundation enum SetupStep: Int, CaseIterable { case wan case lan + case vlan case review } @@ -11,6 +12,8 @@ final class SetupViewModel: ObservableObject { @Published var step: SetupStep = .wan @Published var wanConfig = WanConfig(interfaceName: "ether1") @Published var lanConfig = LanDhcpConfig() + @Published private(set) var vlanSectionEnabled = false + @Published var vlans: [VlanEntry] = [] @Published private(set) var isApplying = false @Published private(set) var applyLog: [String] = [] @@ -27,7 +30,23 @@ final class SetupViewModel: ObservableObject { } var plannedCommands: [RouterOSCommand] { - wanConfig.buildCommands() + lanConfig.buildCommands() + wanConfig.buildCommands() + lanConfig.buildCommands() + vlans.flatMap { $0.buildCommands() } + } + + 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: lanConfig.interfaceName)) + } + + func removeVlan(_ id: VlanEntry.ID) { + vlans.removeAll { $0.id == id } } /// Replaces the placeholder WAN/LAN interface names ("ether1"/"bridge") with real ones diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/VlanStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/VlanStepView.swift new file mode 100644 index 0000000..021eeb3 --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/VlanStepView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct VlanStepView: View { + @ObservedObject var viewModel: SetupViewModel + let availableInterfaces: [NetworkInterface] + + var body: some View { + Form { + Section { + Toggle( + "Zusätzliches Netzwerk einrichten (VLAN, optional)", + isOn: Binding( + get: { viewModel.vlanSectionEnabled }, + set: { viewModel.setVlanSectionEnabled($0) } + ) + ) + Text("Ein VLAN ist ein zusätzliches, komplett getrenntes Netzwerk mit eigenem Adressbereich — z.B. für Gäste oder smarte Geräte. Wenn du unsicher bist, ob du das brauchst, überspring diesen Schritt einfach.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if viewModel.vlanSectionEnabled { + ForEach($viewModel.vlans) { $vlan in + Section("Netzwerk \"\(vlan.name)\"") { + TextField("Name", text: $vlan.name) + Stepper("VLAN-ID: \(vlan.vlanID)", value: $vlan.vlanID, in: 2...4094) + Picker("Basis-Anschluss", selection: $vlan.parentInterface) { + ForEach(availableInterfaces) { interface in + Text(interface.name).tag(interface.name) + } + } + TextField("Router-Adresse (z.B. 192.168.20.1/24)", text: $vlan.routerAddress) + TextField("Netzwerk (z.B. 192.168.20.0/24)", text: $vlan.networkAddress) + TextField("Automatische Adressvergabe von", text: $vlan.poolRangeStart) + TextField("bis", text: $vlan.poolRangeEnd) + + Button("Netzwerk entfernen", role: .destructive) { + viewModel.removeVlan(vlan.id) + } + } + } + + Button("Weiteres Netzwerk hinzufügen") { + viewModel.addVlan() + } + } + + Section { + HStack { + Button("Zurück") { viewModel.goBack() } + Spacer() + Button("Weiter") { viewModel.goNext() } + } + } + } + .formStyle(.grouped) + .navigationTitle("Zusätzliche Netzwerke (VLAN)") + } +} diff --git a/RouterOSAssistantTests/VlanEntryTests.swift b/RouterOSAssistantTests/VlanEntryTests.swift new file mode 100644 index 0000000..3b6999c --- /dev/null +++ b/RouterOSAssistantTests/VlanEntryTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import RouterOSAssistant + +final class VlanEntryTests: XCTestCase { + func testBuildCommandsCreatesVlanInterfaceThenDhcpStack() { + let vlan = VlanEntry(name: "Gäste", vlanID: 20, parentInterface: "bridge") + let commands = vlan.buildCommands() + + XCTAssertEqual(commands.count, 5) + XCTAssertEqual(commands[0].cliPath, "/interface vlan add") + 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].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") + } + + func testDefaultAddressesAreDerivedFromVlanID() { + let vlan = VlanEntry(vlanID: 30, parentInterface: "bridge") + + XCTAssertEqual(vlan.routerAddress, "192.168.30.1/24") + XCTAssertEqual(vlan.networkAddress, "192.168.30.0/24") + XCTAssertEqual(vlan.poolRangeStart, "192.168.30.10") + XCTAssertEqual(vlan.poolRangeEnd, "192.168.30.254") + } +}