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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user