M4: VLAN-Schritt (separates virtuelles Netz)

Neuer optionaler Schritt im Einrichten-Wizard, standardmäßig
übersprungen (Toggle): legt ein VLAN-Interface auf einem gewählten
Basis-Anschluss an, mit eigenem IP-Bereich und eigenem DHCP-Server —
z.B. für Gäste/IoT. Bewusst ohne Port-Zuweisung (Access/Trunk): das
bräuchte RouterOS Bridge-VLAN-Filtering mit "set"-Operationen auf
bestehende Einträge, die unser bisheriges reines "add"-Befehlsmodell
(identisch für REST+SSH) nicht abdeckt. Wer ein VLAN auf einem
bestimmten Switch-Port braucht, muss den Trunk weiterhin manuell
einrichten. Entscheidung mit Nutzer abgestimmt.

DHCP-Befehlsbau (Adresse+Pool+Server+Netzwerk) aus dem LAN-Schritt in
DhcpServerCommandBuilder extrahiert, da jetzt zweimal identisch
gebraucht (LAN direkt, VLAN pro Eintrag).

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 22:13:31 +02:00
co-authored by Claude Sonnet 5
parent 582eced109
commit 9f2ad2c9e9
7 changed files with 234 additions and 44 deletions
@@ -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"
)
]
}
}
@@ -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)"
)
}
}
@@ -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
}
}
@@ -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)
}
@@ -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
@@ -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)")
}
}
@@ -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")
}
}