M8: Mehrere LAN-Interfaces + Netzwerk-Isolation (Firewall-Regeln pro Netzwerk)

lanConfig wird zu lanConfigs: [LanDhcpConfig] (analog zum VLAN-Listen-
Muster) — mehrere physische Interfaces mit je eigenem DHCP-Server.
Neues isolated-Feld auf LanDhcpConfig/VlanEntry: FirewallConfig erzeugt
daraus paarweise Forward-Drop-Regeln zwischen jedem isolierten Netzwerk
und allen anderen konfigurierten Netzwerken (Pair-Dedup bei gegenseitiger
Isolation). Behebt nebenbei, dass VlanStepView bisher Isolation im
Hilfetext behauptete, ohne dass eine Regel das durchsetzte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYoWFMLHACvzzRC8u4iKF9
This commit is contained in:
Kay
2026-09-13 19:57:15 +02:00
co-authored by Claude Sonnet 5
parent c1e6ec73b5
commit 5c8b5fd9d2
8 changed files with 189 additions and 39 deletions
@@ -16,7 +16,14 @@ struct FirewallRuleCounts: Equatable {
/// otherwise, on a device that already has firewall rules, a pre-existing catch-all rule
/// earlier in the chain could make our rules unreachable.
struct FirewallConfig: Equatable {
/// One configured LAN/VLAN network, for the pairwise isolation rules below.
struct NetworkSegment: Equatable {
var interfaceName: String
var isolated: Bool
}
var wanInterface: String
var networks: [NetworkSegment] = []
func buildCommands() -> [RouterOSCommand] {
let natCommand = RouterOSCommand.add(
@@ -79,6 +86,45 @@ struct FirewallConfig: Equatable {
)
}
return [natCommand] + filterCommands
let isolationCommands = buildIsolationCommands(startingPlaceBefore: filterCommands.count)
return [natCommand] + filterCommands + isolationCommands
}
/// Forward-drop rules between every network marked `isolated` and every other configured
/// network (both directions). Pairs are deduplicated so two mutually isolated networks
/// still only get one pair of rules, not two.
private func buildIsolationCommands(startingPlaceBefore: Int) -> [RouterOSCommand] {
var seenPairs = Set<Set<String>>()
var commands: [RouterOSCommand] = []
var placeBefore = startingPlaceBefore
for network in networks where network.isolated {
for other in networks where other.interfaceName != network.interfaceName {
let pair = Set([network.interfaceName, other.interfaceName])
guard !seenPairs.contains(pair) else { continue }
seenPairs.insert(pair)
for (from, to) in [(network.interfaceName, other.interfaceName), (other.interfaceName, network.interfaceName)] {
commands.append(
RouterOSCommand.add(
menuPath: "/ip firewall filter",
restPath: "ip/firewall/filter",
arguments: [
"chain": "forward",
"in-interface": from,
"out-interface": to,
"action": "drop",
"place-before": "\(placeBefore)"
],
summary: "Netzwerk \"\(from)\" von \"\(to)\" isolieren"
)
)
placeBefore += 1
}
}
}
return commands
}
}
@@ -1,6 +1,7 @@
import Foundation
struct LanDhcpConfig: Equatable {
struct LanDhcpConfig: Identifiable, Equatable {
var id = UUID()
var interfaceName: String = "bridge"
var networkAddress: String = "192.168.88.0/24"
var routerAddress: String = "192.168.88.1/24"
@@ -8,6 +9,9 @@ struct LanDhcpConfig: Equatable {
var poolRangeEnd: String = "192.168.88.254"
var leaseTimeHours: Int = 24
var dnsServers: String = "192.168.88.1"
/// Blocks forward traffic to/from every other configured LAN/VLAN network see
/// `FirewallConfig.NetworkSegment`. Internet access (WAN NAT) is unaffected.
var isolated: Bool = false
/// 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
@@ -17,6 +17,9 @@ struct VlanEntry: Identifiable, Equatable {
var poolRangeEnd: String
var leaseTimeHours: Int = 24
var dnsServers: String
/// Blocks forward traffic to/from every other configured LAN/VLAN network see
/// `FirewallConfig.NetworkSegment`. Internet access (WAN NAT) is unaffected.
var isolated: Bool = false
init(name: String = "Gäste", vlanID: Int, parentInterface: String) {
self.name = name
@@ -47,6 +47,15 @@ struct FirewallStepView: View {
}
}
if !viewModel.isolatedNetworkNames.isEmpty {
Section("Isolierte Netzwerke") {
Text(viewModel.isolatedNetworkNames.joined(separator: ", "))
Text("Diese Netzwerke werden vom Datenverkehr aller anderen konfigurierten Netzwerke abgeschottet, sobald der Firewall-Grundschutz aktiv ist.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section {
HStack {
Button("Zurück") { viewModel.goBack() }
@@ -6,32 +6,46 @@ struct LanStepView: View {
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)
ForEach($viewModel.lanConfigs) { $config in
Section("Netzwerk auf \(config.interfaceName)") {
Picker("Anschluss/Bridge", selection: $config.interfaceName) {
ForEach(availableInterfaces) { interface in
Text(interface.name).tag(interface.name)
}
}
.help("Der interne Netzwerk-Anschluss, an dem deine Geräte hängen (dein lokales Netzwerk, LAN).")
TextField("Router-Adresse (z.B. 192.168.88.1/24)", text: $config.routerAddress)
.help("Die Adresse, unter der der Router selbst in diesem Netzwerk erreichbar ist.")
TextField("Netzwerk (z.B. 192.168.88.0/24)", text: $config.networkAddress)
.help("Der komplette Adressbereich dieses Netzwerks (z.B. /24 erlaubt bis zu 254 Geräte).")
TextField("Automatische Adressvergabe von", text: $config.poolRangeStart)
.help("Ab welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.")
TextField("bis", text: $config.poolRangeEnd)
.help("Bis zu welcher Adresse der Router automatisch Adressen an Geräte in diesem Netzwerk vergibt.")
Stepper(
"Adresse behalten für \(config.leaseTimeHours) Stunden",
value: $config.leaseTimeHours,
in: 1...168
)
.help("Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.")
TextField("DNS-Server", text: $config.dnsServers)
.help("Welcher Server Geräten in diesem Netzwerk Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.")
Toggle("Von anderen Netzwerken isolieren", isOn: $config.isolated)
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
if viewModel.lanConfigs.count > 1 {
Button("Netzwerk entfernen", role: .destructive) {
viewModel.removeLan(config.id)
}
}
}
.help("Der interne Netzwerk-Anschluss, an dem deine Geräte zu Hause hängen (dein lokales Netzwerk, LAN).")
TextField("Router-Adresse (z.B. 192.168.88.1/24)", text: $viewModel.lanConfig.routerAddress)
.help("Die Adresse, unter der der Router selbst in deinem Heimnetz erreichbar ist.")
TextField("Netzwerk (z.B. 192.168.88.0/24)", text: $viewModel.lanConfig.networkAddress)
.help("Der komplette Adressbereich deines Heimnetzes (z.B. /24 erlaubt bis zu 254 Geräte).")
}
Section("Automatische Adressvergabe (DHCP)") {
TextField("Von", text: $viewModel.lanConfig.poolRangeStart)
.help("Ab welcher Adresse der Router automatisch Adressen an deine Geräte vergibt.")
TextField("Bis", text: $viewModel.lanConfig.poolRangeEnd)
.help("Bis zu welcher Adresse der Router automatisch Adressen an deine Geräte vergibt.")
Stepper(
"Adresse behalten für \(viewModel.lanConfig.leaseTimeHours) Stunden",
value: $viewModel.lanConfig.leaseTimeHours,
in: 1...168
)
.help("Wie lange ein Gerät seine zugewiesene Adresse behält, bevor sie erneuert werden muss.")
TextField("DNS-Server", text: $viewModel.lanConfig.dnsServers)
.help("Welcher Server deinen Geräten Internetadressen in Namen übersetzt (z.B. www.google.de). Meist der Router selbst.")
Section {
Button("Weiteres LAN-Netzwerk hinzufügen") {
viewModel.addLan()
}
}
Section {
@@ -48,10 +62,12 @@ struct LanStepView: View {
}
private var isStepValid: Bool {
!viewModel.lanConfig.interfaceName.isEmpty
&& !viewModel.lanConfig.routerAddress.isEmpty
&& !viewModel.lanConfig.networkAddress.isEmpty
&& !viewModel.lanConfig.poolRangeStart.isEmpty
&& !viewModel.lanConfig.poolRangeEnd.isEmpty
viewModel.lanConfigs.allSatisfy { config in
!config.interfaceName.isEmpty
&& !config.routerAddress.isEmpty
&& !config.networkAddress.isEmpty
&& !config.poolRangeStart.isEmpty
&& !config.poolRangeEnd.isEmpty
}
}
}
@@ -13,7 +13,7 @@ enum SetupStep: Int, CaseIterable {
final class SetupViewModel: ObservableObject {
@Published var step: SetupStep = .wan
@Published var wanConfig = WanConfig(interfaceName: "ether1")
@Published var lanConfig = LanDhcpConfig()
@Published var lanConfigs: [LanDhcpConfig] = [LanDhcpConfig()]
@Published private(set) var vlanSectionEnabled = false
@Published var vlans: [VlanEntry] = []
@Published var wifiNetworks: [WifiNetworkConfig] = []
@@ -39,10 +39,23 @@ final class SetupViewModel: ObservableObject {
var plannedCommands: [RouterOSCommand] {
wanConfig.buildCommands()
+ lanConfig.buildCommands()
+ lanConfigs.flatMap { $0.buildCommands() }
+ vlans.flatMap { $0.buildCommands() }
+ wifiNetworks.flatMap { $0.buildCommands() }
+ (firewallSectionEnabled ? FirewallConfig(wanInterface: wanConfig.interfaceName).buildCommands() : [])
+ (firewallSectionEnabled
? FirewallConfig(wanInterface: wanConfig.interfaceName, networks: networkSegments).buildCommands()
: [])
}
/// All configured LAN + VLAN networks, for the firewall isolation rules.
private var networkSegments: [FirewallConfig.NetworkSegment] {
lanConfigs.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
+ vlans.map { FirewallConfig.NetworkSegment(interfaceName: $0.interfaceName, isolated: $0.isolated) }
}
/// Interface names of networks marked isolated, for display on the Firewall step.
var isolatedNetworkNames: [String] {
networkSegments.filter(\.isolated).map(\.interfaceName)
}
func setFirewallSectionEnabled(_ enabled: Bool) {
@@ -78,13 +91,24 @@ final class SetupViewModel: ObservableObject {
let usedIDs = Set(vlans.map(\.vlanID))
var nextID = 20
while usedIDs.contains(nextID) { nextID += 1 }
vlans.append(VlanEntry(vlanID: nextID, parentInterface: lanConfig.interfaceName))
vlans.append(VlanEntry(vlanID: nextID, parentInterface: lanConfigs.first?.interfaceName ?? "bridge"))
}
func removeVlan(_ id: VlanEntry.ID) {
vlans.removeAll { $0.id == id }
}
func addLan() {
let usedNames = Set([wanConfig.interfaceName] + lanConfigs.map(\.interfaceName))
let nextInterface = connectionService.interfaces.first { !usedNames.contains($0.name) }
lanConfigs.append(LanDhcpConfig(interfaceName: nextInterface?.name ?? "bridge"))
}
func removeLan(_ id: LanDhcpConfig.ID) {
guard lanConfigs.count > 1 else { return }
lanConfigs.removeAll { $0.id == id }
}
/// Replaces the placeholder WAN/LAN interface names ("ether1"/"bridge") with real ones
/// from the connected device, whenever the current value isn't actually one of its
/// interfaces otherwise the Picker selections don't match any of their tags.
@@ -100,13 +124,13 @@ final class SetupViewModel: ObservableObject {
}
}
if !names.contains(lanConfig.interfaceName) {
if !lanConfigs.isEmpty, !names.contains(lanConfigs[0].interfaceName) {
if let bridge = interfaces.first(where: { $0.type.lowercased().contains("bridge") }) {
lanConfig.interfaceName = bridge.name
lanConfigs[0].interfaceName = bridge.name
} else if let fallback = interfaces.first(where: { $0.name != wanConfig.interfaceName }) {
lanConfig.interfaceName = fallback.name
lanConfigs[0].interfaceName = fallback.name
} else {
lanConfig.interfaceName = interfaces[0].name
lanConfigs[0].interfaceName = interfaces[0].name
}
}
@@ -14,8 +14,8 @@ struct VlanStepView: View {
set: { viewModel.setVlanSectionEnabled($0) }
)
)
.help("Erstellt ein komplett getrenntes Netzwerk — Geräte darin können nicht auf dein Hauptnetzwerk zugreifen und umgekehrt.")
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.")
.help("Erstellt ein zusätzliches Netzwerk mit eigenem Adressbereich und eigenem DHCP-Server.")
Text("Ein VLAN ist ein zusätzliches Netzwerk mit eigenem Adressbereich — z.B. für Gäste oder smarte Geräte. Ob es vom Hauptnetzwerk abgeschottet ist, legst du unten pro Netzwerk über \"Von anderen Netzwerken isolieren\" fest. Wenn du unsicher bist, ob du das brauchst, überspring diesen Schritt einfach.")
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -42,6 +42,9 @@ struct VlanStepView: View {
TextField("bis", text: $vlan.poolRangeEnd)
.help("Bis zu welcher Adresse Geräte in diesem Netzwerk automatisch eine Adresse bekommen.")
Toggle("Von anderen Netzwerken isolieren", isOn: $vlan.isolated)
.help("Verhindert Datenverkehr zwischen diesem und allen anderen konfigurierten LAN-/VLAN-Netzwerken. Der Internetzugriff bleibt erhalten. Wird im Firewall-Schritt umgesetzt.")
Button("Netzwerk entfernen", role: .destructive) {
viewModel.removeVlan(vlan.id)
}
@@ -36,4 +36,49 @@ final class FirewallConfigTests: XCTestCase {
XCTAssertEqual(dropRule.arguments["in-interface"], "ether1")
XCTAssertEqual(dropRule.arguments["action"], "drop")
}
func testIsolatedNetworkGetsBidirectionalDropRulesAgainstOtherNetworks() {
let config = FirewallConfig(
wanInterface: "ether1",
networks: [
FirewallConfig.NetworkSegment(interfaceName: "bridge", isolated: true),
FirewallConfig.NetworkSegment(interfaceName: "vlan20", isolated: false)
]
)
let commands = config.buildCommands()
let isolationCommands = commands.dropFirst(8)
XCTAssertEqual(isolationCommands.count, 2)
XCTAssertTrue(isolationCommands.allSatisfy { $0.arguments["chain"] == "forward" && $0.arguments["action"] == "drop" })
let directions = Set(isolationCommands.map { "\($0.arguments["in-interface"]!)->\($0.arguments["out-interface"]!)" })
XCTAssertEqual(directions, ["bridge->vlan20", "vlan20->bridge"])
XCTAssertEqual(isolationCommands.map { $0.arguments["place-before"] }, ["7", "8"])
}
func testMutuallyIsolatedNetworksProduceOnlyOnePairOfRules() {
let config = FirewallConfig(
wanInterface: "ether1",
networks: [
FirewallConfig.NetworkSegment(interfaceName: "bridge", isolated: true),
FirewallConfig.NetworkSegment(interfaceName: "vlan20", isolated: true)
]
)
let isolationCommands = config.buildCommands().dropFirst(8)
XCTAssertEqual(isolationCommands.count, 2)
}
func testNonIsolatedNetworksProduceNoIsolationRules() {
let config = FirewallConfig(
wanInterface: "ether1",
networks: [
FirewallConfig.NetworkSegment(interfaceName: "bridge", isolated: false),
FirewallConfig.NetworkSegment(interfaceName: "vlan20", isolated: false)
]
)
XCTAssertEqual(config.buildCommands().count, 8)
}
}