diff --git a/RouterOSAssistant/Core/Models/FirewallConfig.swift b/RouterOSAssistant/Core/Models/FirewallConfig.swift new file mode 100644 index 0000000..3f58174 --- /dev/null +++ b/RouterOSAssistant/Core/Models/FirewallConfig.swift @@ -0,0 +1,84 @@ +import Foundation + +struct FirewallRuleCounts: Equatable { + var filterRuleCount: Int + var natRuleCount: Int +} + +/// A safe-default firewall + NAT preset, standard Mikrotik best practice (matches the ruleset +/// shipped in RouterOS' own factory-default home-router configurations, unchanged across +/// RouterOS versions for well over a decade): NAT/masquerade the WAN interface, allow +/// established/related traffic, drop invalid packets, and drop unsolicited connections +/// arriving from the WAN that aren't destination-NATed (i.e. not an explicit port forward). +/// +/// Every rule gets an incrementing `place-before` so it lands ahead of whatever the router +/// already has in that chain (filter and NAT are separate, independently numbered lists) — +/// 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 { + var wanInterface: String + + func buildCommands() -> [RouterOSCommand] { + let natCommand = RouterOSCommand.add( + menuPath: "/ip firewall nat", + restPath: "ip/firewall/nat", + arguments: [ + "chain": "srcnat", + "out-interface": wanInterface, + "action": "masquerade", + "place-before": "0" + ], + summary: "Internetfreigabe (NAT/Masquerade) über \(wanInterface) einrichten" + ) + + let filterRules: [(arguments: [String: String], summary: String)] = [ + ( + ["chain": "input", "connection-state": "established,related", "action": "accept"], + "Bestehende Verbindungen zum Router erlauben" + ), + ( + ["chain": "input", "connection-state": "invalid", "action": "drop"], + "Ungültige Pakete zum Router verwerfen" + ), + ( + ["chain": "input", "in-interface": wanInterface, "protocol": "icmp", "action": "accept"], + "Ping (ICMP) vom Internet zum Router erlauben" + ), + ( + ["chain": "input", "in-interface": wanInterface, "action": "drop"], + "Restlichen Zugriff vom Internet auf den Router blockieren" + ), + ( + ["chain": "forward", "connection-state": "established,related", "action": "accept"], + "Bestehende Verbindungen durch den Router erlauben" + ), + ( + ["chain": "forward", "connection-state": "invalid", "action": "drop"], + "Ungültige Pakete verwerfen" + ), + ( + [ + "chain": "forward", + "connection-state": "new", + "connection-nat-state": "!dstnat", + "in-interface": wanInterface, + "action": "drop" + ], + "Unaufgeforderte Verbindungen aus dem Internet zu Geräten im Heimnetz blockieren" + ) + ] + + let filterCommands = filterRules.enumerated().map { index, rule -> RouterOSCommand in + var arguments = rule.arguments + arguments["place-before"] = "\(index)" + return RouterOSCommand.add( + menuPath: "/ip firewall filter", + restPath: "ip/firewall/filter", + arguments: arguments, + summary: rule.summary + ) + } + + return [natCommand] + filterCommands + } +} diff --git a/RouterOSAssistant/Core/Networking/RestTransport.swift b/RouterOSAssistant/Core/Networking/RestTransport.swift index 8095a99..5d064b2 100644 --- a/RouterOSAssistant/Core/Networking/RestTransport.swift +++ b/RouterOSAssistant/Core/Networking/RestTransport.swift @@ -56,6 +56,12 @@ final class RestTransport: NSObject, RouterOSTransport { } } + func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { + let filterItems = try await getJSONArray(path: "ip/firewall/filter") + let natItems = try await getJSONArray(path: "ip/firewall/nat") + return FirewallRuleCounts(filterRuleCount: filterItems.count, natRuleCount: natItems.count) + } + /// Creates or modifies the item described by `command`. `.add` is a plain /// `POST /rest/`. `.set` has no CLI-style inline lookup on REST, so it first /// `GET`s the collection to find the item whose `matchField` equals `matchValue`, reads diff --git a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift index fcac32f..22bee3c 100644 --- a/RouterOSAssistant/Core/Networking/RouterOSTransport.swift +++ b/RouterOSAssistant/Core/Networking/RouterOSTransport.swift @@ -10,6 +10,7 @@ protocol RouterOSTransport: AnyObject { func connect() async throws func fetchDeviceInfo() async throws -> RouterDeviceInfo func fetchInterfaces() async throws -> [NetworkInterface] + func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts func apply(_ command: RouterOSCommand) async throws func disconnect() async } diff --git a/RouterOSAssistant/Core/Networking/SSHTransport.swift b/RouterOSAssistant/Core/Networking/SSHTransport.swift index d552d2c..515f17f 100644 --- a/RouterOSAssistant/Core/Networking/SSHTransport.swift +++ b/RouterOSAssistant/Core/Networking/SSHTransport.swift @@ -58,6 +58,14 @@ final class SSHTransport: RouterOSTransport { try await run("/export terse") } + func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { + let filterOutput = try await run("/ip firewall filter print count-only") + let natOutput = try await run("/ip firewall nat print count-only") + let filterCount = Int(filterOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + let natCount = Int(natOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + return FirewallRuleCounts(filterRuleCount: filterCount, natRuleCount: natCount) + } + func apply(_ command: RouterOSCommand) async throws { _ = try await run(command.cliLine) } diff --git a/RouterOSAssistant/Core/Services/ConnectionService.swift b/RouterOSAssistant/Core/Services/ConnectionService.swift index 341ba36..1329c11 100644 --- a/RouterOSAssistant/Core/Services/ConnectionService.swift +++ b/RouterOSAssistant/Core/Services/ConnectionService.swift @@ -71,6 +71,11 @@ final class ConnectionService: ObservableObject { try await activeTransport.apply(command) } + func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { + guard let activeTransport else { throw RouterOSError.notConnected } + return try await activeTransport.fetchFirewallRuleCounts() + } + private func finishConnecting(using transport: RouterOSTransport) async { activeTransport = transport do { diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift new file mode 100644 index 0000000..b0120d0 --- /dev/null +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/FirewallStepView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct FirewallStepView: View { + @ObservedObject var viewModel: SetupViewModel + + var body: some View { + Form { + Section { + Toggle( + "Firewall-Grundschutz einrichten (empfohlen)", + isOn: Binding( + get: { viewModel.firewallSectionEnabled }, + set: { viewModel.setFirewallSectionEnabled($0) } + ) + ) + Text( + "Richtet einen Standard-Schutz ein: Internetfreigabe (NAT) für dein Heimnetz, " + + "und blockiert unaufgeforderte Zugriffe aus dem Internet auf deinen Router " + + "und deine Geräte. Bestehende, selbst eingerichtete Regeln bleiben erhalten — " + + "die neuen Regeln werden vorangestellt." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + if viewModel.firewallSectionEnabled { + Section("Bereits vorhandene Regeln") { + if viewModel.isLoadingFirewallRuleCounts { + ProgressView() + } else if let counts = viewModel.existingFirewallRuleCounts { + LabeledContent("Filter-Regeln", value: "\(counts.filterRuleCount)") + LabeledContent("NAT-Regeln", value: "\(counts.natRuleCount)") + if counts.filterRuleCount > 0 || counts.natRuleCount > 0 { + Text( + "Dein Router hat bereits eigene Firewall-Regeln. Die neuen Regeln werden " + + "vorangestellt, bestehende bleiben erhalten — prüfe nach dem Anwenden " + + "trotzdem die Reihenfolge, z.B. über Winbox oder " + + "\"/ip firewall filter print\"." + ) + .font(.caption) + .foregroundStyle(.orange) + } + } else if let error = viewModel.firewallRuleCountsError { + Text(error).font(.caption).foregroundStyle(.red) + } + } + } + + Section { + HStack { + Button("Zurück") { viewModel.goBack() } + Spacer() + Button("Weiter") { viewModel.goNext() } + } + } + } + .formStyle(.grouped) + .navigationTitle("Firewall (optional)") + .onAppear { + if viewModel.firewallSectionEnabled { + viewModel.loadExistingFirewallRuleCounts() + } + } + } +} diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift index 5a53150..e388b98 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupView.swift @@ -28,6 +28,8 @@ struct SetupView: View { VlanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces) case .wifi: WifiStepView(viewModel: viewModel) + case .firewall: + FirewallStepView(viewModel: viewModel) case .review: ReviewApplyView(viewModel: viewModel, credentials: connectionService.credentials) } diff --git a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift index 93cbaf6..cb3300b 100644 --- a/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift +++ b/RouterOSAssistant/Features/Wizard/Steps/Setup/SetupViewModel.swift @@ -5,6 +5,7 @@ enum SetupStep: Int, CaseIterable { case lan case vlan case wifi + case firewall case review } @@ -17,6 +18,10 @@ final class SetupViewModel: ObservableObject { @Published var vlans: [VlanEntry] = [] @Published var wifiNetworks: [WifiNetworkConfig] = [] @Published private(set) var unsupportedWifiInterfaces: [NetworkInterface] = [] + @Published private(set) var firewallSectionEnabled = false + @Published private(set) var existingFirewallRuleCounts: FirewallRuleCounts? + @Published private(set) var isLoadingFirewallRuleCounts = false + @Published private(set) var firewallRuleCountsError: String? @Published private(set) var isApplying = false @Published private(set) var applyLog: [String] = [] @@ -37,6 +42,31 @@ final class SetupViewModel: ObservableObject { + lanConfig.buildCommands() + vlans.flatMap { $0.buildCommands() } + wifiNetworks.flatMap { $0.buildCommands() } + + (firewallSectionEnabled ? FirewallConfig(wanInterface: wanConfig.interfaceName).buildCommands() : []) + } + + func setFirewallSectionEnabled(_ enabled: Bool) { + firewallSectionEnabled = enabled + if enabled { + loadExistingFirewallRuleCounts() + } else { + existingFirewallRuleCounts = nil + firewallRuleCountsError = nil + } + } + + func loadExistingFirewallRuleCounts() { + guard firewallSectionEnabled, existingFirewallRuleCounts == nil, !isLoadingFirewallRuleCounts else { return } + isLoadingFirewallRuleCounts = true + firewallRuleCountsError = nil + Task { + do { + existingFirewallRuleCounts = try await connectionService.fetchFirewallRuleCounts() + } catch { + firewallRuleCountsError = error.localizedDescription + } + isLoadingFirewallRuleCounts = false + } } func setVlanSectionEnabled(_ enabled: Bool) { diff --git a/RouterOSAssistantTests/ConnectionServiceTests.swift b/RouterOSAssistantTests/ConnectionServiceTests.swift index c358a2b..08c17d0 100644 --- a/RouterOSAssistantTests/ConnectionServiceTests.swift +++ b/RouterOSAssistantTests/ConnectionServiceTests.swift @@ -17,6 +17,7 @@ private final class MockTransport: RouterOSTransport { func fetchDeviceInfo() async throws -> RouterDeviceInfo { deviceInfo } func fetchInterfaces() async throws -> [NetworkInterface] { [] } + func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts { FirewallRuleCounts(filterRuleCount: 0, natRuleCount: 0) } func apply(_ command: RouterOSCommand) async throws {} func disconnect() async {} } diff --git a/RouterOSAssistantTests/FirewallConfigTests.swift b/RouterOSAssistantTests/FirewallConfigTests.swift new file mode 100644 index 0000000..5f1588b --- /dev/null +++ b/RouterOSAssistantTests/FirewallConfigTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import RouterOSAssistant + +final class FirewallConfigTests: XCTestCase { + func testBuildCommandsCoversNatAndSafeFilterDefaults() { + let config = FirewallConfig(wanInterface: "ether1") + let commands = config.buildCommands() + + XCTAssertEqual(commands.count, 8) + + XCTAssertEqual(commands[0].menuPath, "/ip firewall nat") + XCTAssertEqual(commands[0].arguments["action"], "masquerade") + XCTAssertEqual(commands[0].arguments["out-interface"], "ether1") + XCTAssertEqual(commands[0].arguments["place-before"], "0") + + for command in commands.dropFirst() { + XCTAssertEqual(command.menuPath, "/ip firewall filter") + } + } + + func testFilterRulesGetIncrementingPlaceBeforeInDeclaredOrder() { + let config = FirewallConfig(wanInterface: "ether1") + let filterCommands = config.buildCommands().dropFirst() + + let placeBeforeValues = filterCommands.map { $0.arguments["place-before"] } + XCTAssertEqual(placeBeforeValues, ["0", "1", "2", "3", "4", "5", "6"]) + } + + func testFinalRuleBlocksUnsolicitedWanTrafficUnlessPortForwarded() { + let config = FirewallConfig(wanInterface: "ether1") + let commands = config.buildCommands() + + let dropRule = commands.last! + XCTAssertEqual(dropRule.arguments["chain"], "forward") + XCTAssertEqual(dropRule.arguments["connection-nat-state"], "!dstnat") + XCTAssertEqual(dropRule.arguments["in-interface"], "ether1") + XCTAssertEqual(dropRule.arguments["action"], "drop") + } +}