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 leaseTimeHours: Int = 24
|
||||||
var dnsServers: String = "192.168.88.1"
|
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 —
|
/// 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
|
/// 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.
|
/// it runs so this can be caught before anything is applied.
|
||||||
func buildCommands() -> [RouterOSCommand] {
|
func buildCommands() -> [RouterOSCommand] {
|
||||||
let poolName = "dhcp_pool_\(interfaceName)"
|
DhcpServerCommandBuilder.buildCommands(
|
||||||
let serverName = "dhcp_\(interfaceName)"
|
interfaceName: interfaceName,
|
||||||
|
routerAddress: routerAddress,
|
||||||
return [
|
networkAddress: networkAddress,
|
||||||
RouterOSCommand(
|
poolRangeStart: poolRangeStart,
|
||||||
cliPath: "/ip address add",
|
poolRangeEnd: poolRangeEnd,
|
||||||
restPath: "ip/address",
|
leaseTimeHours: leaseTimeHours,
|
||||||
arguments: ["address": routerAddress, "interface": interfaceName],
|
dnsServers: dnsServers,
|
||||||
summary: "IP-Adresse \(routerAddress) auf \(interfaceName) setzen"
|
context: "auf \(interfaceName)"
|
||||||
),
|
)
|
||||||
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,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)
|
WanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces)
|
||||||
case .lan:
|
case .lan:
|
||||||
LanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces)
|
LanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces)
|
||||||
|
case .vlan:
|
||||||
|
VlanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces)
|
||||||
case .review:
|
case .review:
|
||||||
ReviewApplyView(viewModel: viewModel, credentials: connectionService.credentials)
|
ReviewApplyView(viewModel: viewModel, credentials: connectionService.credentials)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
enum SetupStep: Int, CaseIterable {
|
enum SetupStep: Int, CaseIterable {
|
||||||
case wan
|
case wan
|
||||||
case lan
|
case lan
|
||||||
|
case vlan
|
||||||
case review
|
case review
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,6 +12,8 @@ final class SetupViewModel: ObservableObject {
|
|||||||
@Published var step: SetupStep = .wan
|
@Published var step: SetupStep = .wan
|
||||||
@Published var wanConfig = WanConfig(interfaceName: "ether1")
|
@Published var wanConfig = WanConfig(interfaceName: "ether1")
|
||||||
@Published var lanConfig = LanDhcpConfig()
|
@Published var lanConfig = LanDhcpConfig()
|
||||||
|
@Published private(set) var vlanSectionEnabled = false
|
||||||
|
@Published var vlans: [VlanEntry] = []
|
||||||
|
|
||||||
@Published private(set) var isApplying = false
|
@Published private(set) var isApplying = false
|
||||||
@Published private(set) var applyLog: [String] = []
|
@Published private(set) var applyLog: [String] = []
|
||||||
@@ -27,7 +30,23 @@ final class SetupViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var plannedCommands: [RouterOSCommand] {
|
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
|
/// 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