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