forked from kay/RouterOS
M3: WAN + LAN/DHCP-Wizard mit Anwenden-Logik
Neuer "Einrichten"-Tab führt durch Internet-Anschluss (DHCP/statisch/ PPPoE) und Heimnetzwerk+DHCP-Server, zeigt vor dem Anwenden eine Klartext-Übersicht (optional mit den exakten RouterOS-Befehlen) und erstellt automatisch eine Sicherung, bevor Änderungen geschrieben werden. Änderungen laufen über beide Transporte: CLI-Zeile für SSH, JSON-POST für REST — beide aus einem gemeinsamen RouterOSCommand gebaut. RouterOS-CLI-Syntax ist Standard und langjährig stabil, aber nicht gegen ein echtes Gerät verifiziert; deshalb die Detailanzeige im Übersichtsschritt vor dem Anwenden. ConnectionService ist jetzt der einzige App-weite Zustand (ersetzt SessionStore) und wird explizit an alle drei Tabs durchgereicht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
This commit is contained in:
@@ -2,17 +2,18 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct RouterOSAssistantApp: App {
|
||||
@StateObject private var session = SessionStore()
|
||||
@StateObject private var connectionService = ConnectionService()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
TabView {
|
||||
ConnectView()
|
||||
ConnectView(connectionService: connectionService)
|
||||
.tabItem { Label("Verbinden", systemImage: "network") }
|
||||
BackupListView()
|
||||
SetupView(connectionService: connectionService)
|
||||
.tabItem { Label("Einrichten", systemImage: "checklist") }
|
||||
BackupListView(connectionService: connectionService)
|
||||
.tabItem { Label("Sicherungen", systemImage: "clock.arrow.circlepath") }
|
||||
}
|
||||
.environmentObject(session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
struct LanDhcpConfig: Equatable {
|
||||
var interfaceName: String = "bridge"
|
||||
var networkAddress: String = "192.168.88.0/24"
|
||||
var routerAddress: String = "192.168.88.1/24"
|
||||
var poolRangeStart: String = "192.168.88.10"
|
||||
var poolRangeEnd: String = "192.168.88.254"
|
||||
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"
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
/// A single RouterOS configuration change, expressed once and executed over either transport
|
||||
/// (rendered as a CLI line for SSH, as a JSON body for REST).
|
||||
struct RouterOSCommand: Equatable, Identifiable {
|
||||
var id: String { cliPath + summary }
|
||||
|
||||
/// CLI add-path, e.g. "/ip address add".
|
||||
let cliPath: String
|
||||
/// REST resource path, e.g. "ip/address", posted to create the same item.
|
||||
let restPath: String
|
||||
/// RouterOS "words" (key=value arguments).
|
||||
let arguments: [String: String]
|
||||
/// Human-readable description shown on the review screen before applying.
|
||||
let summary: String
|
||||
|
||||
/// Renders as a RouterOS CLI line, e.g. `/ip address add address=192.168.88.1/24 interface=bridge`.
|
||||
var cliLine: String {
|
||||
let args = arguments
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key)=\(Self.quoteIfNeeded($0.value))" }
|
||||
.joined(separator: " ")
|
||||
return args.isEmpty ? cliPath : "\(cliPath) \(args)"
|
||||
}
|
||||
|
||||
private static func quoteIfNeeded(_ value: String) -> String {
|
||||
value.contains(" ") ? "\"\(value)\"" : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
|
||||
enum WanConnectionMode: String, CaseIterable, Identifiable {
|
||||
case dhcpClient
|
||||
case staticIP
|
||||
case pppoe
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .dhcpClient: return "Automatisch (DHCP)"
|
||||
case .staticIP: return "Statische IP-Adresse"
|
||||
case .pppoe: return "PPPoE (DSL-Zugangsdaten)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WanConfig: Equatable {
|
||||
var interfaceName: String
|
||||
var mode: WanConnectionMode = .dhcpClient
|
||||
var staticAddress: String = ""
|
||||
var staticGateway: String = ""
|
||||
var pppoeUsername: String = ""
|
||||
var pppoePassword: String = ""
|
||||
|
||||
/// RouterOS commands for this WAN 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] {
|
||||
switch mode {
|
||||
case .dhcpClient:
|
||||
return [
|
||||
RouterOSCommand(
|
||||
cliPath: "/ip dhcp-client add",
|
||||
restPath: "ip/dhcp-client",
|
||||
arguments: ["interface": interfaceName, "disabled": "no"],
|
||||
summary: "Internetadresse auf \(interfaceName) automatisch beziehen (DHCP-Client)"
|
||||
)
|
||||
]
|
||||
case .staticIP:
|
||||
var commands = [
|
||||
RouterOSCommand(
|
||||
cliPath: "/ip address add",
|
||||
restPath: "ip/address",
|
||||
arguments: ["address": staticAddress, "interface": interfaceName],
|
||||
summary: "Statische IP-Adresse \(staticAddress) auf \(interfaceName) setzen"
|
||||
)
|
||||
]
|
||||
if !staticGateway.isEmpty {
|
||||
commands.append(
|
||||
RouterOSCommand(
|
||||
cliPath: "/ip route add",
|
||||
restPath: "ip/route",
|
||||
arguments: ["gateway": staticGateway],
|
||||
summary: "Standard-Route über \(staticGateway) einrichten"
|
||||
)
|
||||
)
|
||||
}
|
||||
return commands
|
||||
case .pppoe:
|
||||
return [
|
||||
RouterOSCommand(
|
||||
cliPath: "/interface pppoe-client add",
|
||||
restPath: "interface/pppoe-client",
|
||||
arguments: [
|
||||
"interface": interfaceName,
|
||||
"user": pppoeUsername,
|
||||
"password": pppoePassword,
|
||||
"disabled": "no"
|
||||
],
|
||||
summary: "PPPoE-Internetverbindung auf \(interfaceName) mit Zugangsdaten deines Anbieters einrichten"
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,17 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the item described by `command` via `POST /rest/<restPath>`.
|
||||
func apply(_ command: RouterOSCommand) async throws {
|
||||
_ = try await send(path: command.restPath, method: "POST", jsonBody: command.arguments)
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
|
||||
private func getJSONObject(path: String) async throws -> [String: Any] {
|
||||
let data = try await getData(path: path)
|
||||
let data = try await send(path: path, method: "GET", jsonBody: nil)
|
||||
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw RouterOSError.invalidResponse(path)
|
||||
}
|
||||
@@ -69,15 +74,17 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
}
|
||||
|
||||
private func getJSONArray(path: String) async throws -> [[String: Any]] {
|
||||
let data = try await getData(path: path)
|
||||
let data = try await send(path: path, method: "GET", jsonBody: nil)
|
||||
guard let array = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
||||
throw RouterOSError.invalidResponse(path)
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
private func getData(path: String) async throws -> Data {
|
||||
private func send(path: String, method: String, jsonBody: [String: String]?) async throws -> Data {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
request.httpMethod = method
|
||||
|
||||
let authString = "\(credentials.username):\(credentials.password)"
|
||||
guard let authData = authString.data(using: .utf8) else {
|
||||
throw RouterOSError.invalidResponse("credentials")
|
||||
@@ -85,6 +92,11 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
request.setValue("Basic \(authData.base64EncodedString())", forHTTPHeaderField: "Authorization")
|
||||
request.timeoutInterval = 5
|
||||
|
||||
if let jsonBody {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
lastRejectedFingerprint = nil
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
@@ -10,5 +10,6 @@ protocol RouterOSTransport: AnyObject {
|
||||
func connect() async throws
|
||||
func fetchDeviceInfo() async throws -> RouterDeviceInfo
|
||||
func fetchInterfaces() async throws -> [NetworkInterface]
|
||||
func apply(_ command: RouterOSCommand) async throws
|
||||
func disconnect() async
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ final class SSHTransport: RouterOSTransport {
|
||||
try await run("/export terse")
|
||||
}
|
||||
|
||||
func apply(_ command: RouterOSCommand) async throws {
|
||||
_ = try await run(command.cliLine)
|
||||
}
|
||||
|
||||
private func run(_ command: String) async throws -> String {
|
||||
guard let client else { throw RouterOSError.notConnected }
|
||||
let buffer = try await client.executeCommand(command)
|
||||
|
||||
@@ -14,10 +14,12 @@ final class ConnectionService: ObservableObject {
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var deviceInfo: RouterDeviceInfo?
|
||||
@Published private(set) var interfaces: [NetworkInterface] = []
|
||||
/// Credentials of the current (or last attempted) connection, shared with features
|
||||
/// that need their own dedicated connection, e.g. BackupService's SSH export.
|
||||
@Published private(set) var credentials: RouterOSCredentials?
|
||||
|
||||
private let certificateTrust: CertificateTrustStore
|
||||
private var activeTransport: RouterOSTransport?
|
||||
private var pendingCredentials: RouterOSCredentials?
|
||||
|
||||
init(certificateTrust: CertificateTrustStore = CertificateTrustStore()) {
|
||||
self.certificateTrust = certificateTrust
|
||||
@@ -26,7 +28,7 @@ final class ConnectionService: ObservableObject {
|
||||
/// Injection point for tests: bypasses the real REST/SSH transports.
|
||||
func connect(with credentials: RouterOSCredentials, makeRestTransport: () -> RouterOSTransport, makeSSHTransport: () -> RouterOSTransport) async {
|
||||
state = .connecting
|
||||
pendingCredentials = credentials
|
||||
self.credentials = credentials
|
||||
|
||||
let rest = makeRestTransport()
|
||||
do {
|
||||
@@ -58,11 +60,17 @@ final class ConnectionService: ObservableObject {
|
||||
}
|
||||
|
||||
func trustCurrentCertificateAndRetry(fingerprint: String) async {
|
||||
guard let credentials = pendingCredentials else { return }
|
||||
guard let credentials else { return }
|
||||
certificateTrust.trust(host: credentials.host, fingerprint: fingerprint)
|
||||
await connect(with: credentials)
|
||||
}
|
||||
|
||||
/// Applies a single configuration change on the active transport (REST or SSH).
|
||||
func apply(_ command: RouterOSCommand) async throws {
|
||||
guard let activeTransport else { throw RouterOSError.notConnected }
|
||||
try await activeTransport.apply(command)
|
||||
}
|
||||
|
||||
private func finishConnecting(using transport: RouterOSTransport) async {
|
||||
activeTransport = transport
|
||||
do {
|
||||
@@ -79,6 +87,7 @@ final class ConnectionService: ObservableObject {
|
||||
activeTransport = nil
|
||||
deviceInfo = nil
|
||||
interfaces = []
|
||||
credentials = nil
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Holds the currently connected router's credentials so other screens (e.g. Backup)
|
||||
/// can act on the same device without re-prompting for login.
|
||||
@MainActor
|
||||
final class SessionStore: ObservableObject {
|
||||
@Published var credentials: RouterOSCredentials?
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BackupListView: View {
|
||||
@EnvironmentObject private var session: SessionStore
|
||||
@ObservedObject var connectionService: ConnectionService
|
||||
@StateObject private var viewModel = BackupViewModel()
|
||||
|
||||
var body: some View {
|
||||
@@ -28,7 +28,7 @@ struct BackupListView: View {
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
if let credentials = session.credentials {
|
||||
if let credentials = connectionService.credentials {
|
||||
viewModel.createBackup(for: credentials)
|
||||
}
|
||||
} label: {
|
||||
@@ -38,8 +38,8 @@ struct BackupListView: View {
|
||||
Label("Jetzt sichern", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
.disabled(session.credentials == nil || viewModel.isCreatingBackup)
|
||||
.help(session.credentials == nil ? "Zuerst im Tab 'Verbinden' mit dem Router verbinden." : "Sicherung jetzt erstellen")
|
||||
.disabled(connectionService.credentials == nil || viewModel.isCreatingBackup)
|
||||
.help(connectionService.credentials == nil ? "Zuerst im Tab 'Verbinden' mit dem Router verbinden." : "Sicherung jetzt erstellen")
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
@@ -60,5 +60,5 @@ struct BackupListView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
BackupListView().environmentObject(SessionStore())
|
||||
BackupListView(connectionService: ConnectionService())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectView: View {
|
||||
@EnvironmentObject private var session: SessionStore
|
||||
@StateObject private var viewModel = ConnectViewModel()
|
||||
@StateObject private var viewModel: ConnectViewModel
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
_viewModel = StateObject(wrappedValue: ConnectViewModel(connectionService: connectionService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
@@ -29,15 +32,6 @@ struct ConnectView: View {
|
||||
deviceDetail
|
||||
}
|
||||
.onAppear { viewModel.onAppear() }
|
||||
.onChange(of: viewModel.connectionService.state) { _, newState in
|
||||
if case .connected = newState {
|
||||
session.credentials = RouterOSCredentials(
|
||||
host: viewModel.host,
|
||||
username: viewModel.username,
|
||||
password: viewModel.password
|
||||
)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"Unbekanntes Zertifikat",
|
||||
isPresented: certificateAlertBinding,
|
||||
@@ -126,5 +120,5 @@ struct ConnectView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConnectView().environmentObject(SessionStore())
|
||||
ConnectView(connectionService: ConnectionService())
|
||||
}
|
||||
|
||||
@@ -7,9 +7,13 @@ final class ConnectViewModel: ObservableObject {
|
||||
@Published var password: String = ""
|
||||
@Published var rememberPassword: Bool = true
|
||||
|
||||
let connectionService = ConnectionService()
|
||||
let connectionService: ConnectionService
|
||||
private let keychain = KeychainService()
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
}
|
||||
|
||||
func onAppear() {
|
||||
if let saved = keychain.loadPassword(forHost: host, username: username) {
|
||||
password = saved
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LanStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
let availableInterfaces: [NetworkInterface]
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
TextField("Router-Adresse (z.B. 192.168.88.1/24)", text: $viewModel.lanConfig.routerAddress)
|
||||
TextField("Netzwerk (z.B. 192.168.88.0/24)", text: $viewModel.lanConfig.networkAddress)
|
||||
}
|
||||
|
||||
Section("Automatische Adressvergabe (DHCP)") {
|
||||
TextField("Von", text: $viewModel.lanConfig.poolRangeStart)
|
||||
TextField("Bis", text: $viewModel.lanConfig.poolRangeEnd)
|
||||
Stepper(
|
||||
"Adresse behalten für \(viewModel.lanConfig.leaseTimeHours) Stunden",
|
||||
value: $viewModel.lanConfig.leaseTimeHours,
|
||||
in: 1...168
|
||||
)
|
||||
TextField("DNS-Server", text: $viewModel.lanConfig.dnsServers)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Zurück") { viewModel.goBack() }
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
.disabled(!isStepValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Heimnetzwerk einrichten")
|
||||
}
|
||||
|
||||
private var isStepValid: Bool {
|
||||
!viewModel.lanConfig.interfaceName.isEmpty
|
||||
&& !viewModel.lanConfig.routerAddress.isEmpty
|
||||
&& !viewModel.lanConfig.networkAddress.isEmpty
|
||||
&& !viewModel.lanConfig.poolRangeStart.isEmpty
|
||||
&& !viewModel.lanConfig.poolRangeEnd.isEmpty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ReviewApplyView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
let credentials: RouterOSCredentials?
|
||||
|
||||
@State private var showCliDetails = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Geplante Änderungen") {
|
||||
ForEach(viewModel.plannedCommands) { command in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(command.summary)
|
||||
if showCliDetails {
|
||||
Text(command.cliLine)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Toggle("Details anzeigen (RouterOS-Befehle)", isOn: $showCliDetails)
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Vor dem Anwenden wird automatisch eine Sicherung der aktuellen Konfiguration erstellt (Tab \"Sicherungen\"). Ein garantiertes automatisches Zurückrollen bei Verbindungsabbruch gibt es nicht — bei Problemen die Sicherung im Tab \"Sicherungen\" verwenden oder den Router lokal (Ethernet/Konsole) wiederherstellen.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !viewModel.applyLog.isEmpty {
|
||||
Section("Ablauf") {
|
||||
ForEach(viewModel.applyLog, id: \.self) { line in
|
||||
Text(line).font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.didApplySuccessfully {
|
||||
Section {
|
||||
Label("Änderungen erfolgreich angewendet", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Button("Zurück") { viewModel.goBack() }
|
||||
.disabled(viewModel.isApplying)
|
||||
Spacer()
|
||||
Button {
|
||||
if let credentials {
|
||||
viewModel.apply(credentials: credentials)
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isApplying {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Jetzt anwenden")
|
||||
}
|
||||
}
|
||||
.disabled(credentials == nil || viewModel.isApplying || viewModel.didApplySuccessfully)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Übersicht & Anwenden")
|
||||
.alert(
|
||||
"Anwenden fehlgeschlagen",
|
||||
isPresented: Binding(
|
||||
get: { viewModel.applyError != nil },
|
||||
set: { _ in }
|
||||
)
|
||||
) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text(viewModel.applyError ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SetupView: View {
|
||||
@ObservedObject var connectionService: ConnectionService
|
||||
@StateObject private var viewModel: SetupViewModel
|
||||
|
||||
init(connectionService: ConnectionService) {
|
||||
self.connectionService = connectionService
|
||||
_viewModel = StateObject(wrappedValue: SetupViewModel(connectionService: connectionService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if connectionService.credentials == nil {
|
||||
ContentUnavailableView(
|
||||
"Nicht verbunden",
|
||||
systemImage: "network.slash",
|
||||
description: Text("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.")
|
||||
)
|
||||
} else {
|
||||
switch viewModel.step {
|
||||
case .wan:
|
||||
WanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces)
|
||||
case .lan:
|
||||
LanStepView(viewModel: viewModel, availableInterfaces: connectionService.interfaces)
|
||||
case .review:
|
||||
ReviewApplyView(viewModel: viewModel, credentials: connectionService.credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.prepareDefaults(from: connectionService.interfaces)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SetupView(connectionService: ConnectionService())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
enum SetupStep: Int, CaseIterable {
|
||||
case wan
|
||||
case lan
|
||||
case review
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SetupViewModel: ObservableObject {
|
||||
@Published var step: SetupStep = .wan
|
||||
@Published var wanConfig = WanConfig(interfaceName: "ether1")
|
||||
@Published var lanConfig = LanDhcpConfig()
|
||||
|
||||
@Published private(set) var isApplying = false
|
||||
@Published private(set) var applyLog: [String] = []
|
||||
@Published private(set) var applyError: String?
|
||||
@Published private(set) var didApplySuccessfully = false
|
||||
|
||||
private let connectionService: ConnectionService
|
||||
private let backupService: BackupService
|
||||
|
||||
init(connectionService: ConnectionService, backupService: BackupService = BackupService()) {
|
||||
self.connectionService = connectionService
|
||||
self.backupService = backupService
|
||||
}
|
||||
|
||||
var plannedCommands: [RouterOSCommand] {
|
||||
wanConfig.buildCommands() + lanConfig.buildCommands()
|
||||
}
|
||||
|
||||
/// Picks a sensible default WAN interface (first Ethernet-like port) once interfaces are known.
|
||||
func prepareDefaults(from interfaces: [NetworkInterface]) {
|
||||
guard wanConfig.interfaceName.isEmpty || wanConfig.interfaceName == "ether1" else { return }
|
||||
if let firstEthernet = interfaces.first(where: { $0.type.lowercased().contains("ether") }) {
|
||||
wanConfig.interfaceName = firstEthernet.name
|
||||
}
|
||||
}
|
||||
|
||||
func goNext() {
|
||||
guard let next = SetupStep(rawValue: step.rawValue + 1) else { return }
|
||||
step = next
|
||||
}
|
||||
|
||||
func goBack() {
|
||||
guard let previous = SetupStep(rawValue: step.rawValue - 1) else { return }
|
||||
step = previous
|
||||
}
|
||||
|
||||
func apply(credentials: RouterOSCredentials) {
|
||||
isApplying = true
|
||||
applyError = nil
|
||||
applyLog = []
|
||||
didApplySuccessfully = false
|
||||
|
||||
Task {
|
||||
do {
|
||||
applyLog.append("Sichere aktuelle Konfiguration…")
|
||||
_ = try await backupService.createBackup(for: credentials)
|
||||
|
||||
for command in plannedCommands {
|
||||
applyLog.append(command.summary)
|
||||
try await connectionService.apply(command)
|
||||
}
|
||||
|
||||
didApplySuccessfully = true
|
||||
} catch {
|
||||
applyError = error.localizedDescription
|
||||
}
|
||||
isApplying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WanStepView: View {
|
||||
@ObservedObject var viewModel: SetupViewModel
|
||||
let availableInterfaces: [NetworkInterface]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Internet-Anschluss") {
|
||||
Picker("Anschluss (Port)", selection: $viewModel.wanConfig.interfaceName) {
|
||||
ForEach(availableInterfaces) { interface in
|
||||
Text(interface.name).tag(interface.name)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Verbindungsart", selection: $viewModel.wanConfig.mode) {
|
||||
ForEach(WanConnectionMode.allCases) { mode in
|
||||
Text(mode.label).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
}
|
||||
|
||||
switch viewModel.wanConfig.mode {
|
||||
case .dhcpClient:
|
||||
Section {
|
||||
Text("Der Router bezieht seine Internetadresse automatisch von deinem Anbieter. Für die meisten Kabel-/Glasfaser-Anschlüsse die richtige Wahl.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .staticIP:
|
||||
Section("Statische Adresse") {
|
||||
TextField("IP-Adresse (z.B. 203.0.113.5/24)", text: $viewModel.wanConfig.staticAddress)
|
||||
TextField("Gateway (z.B. 203.0.113.1)", text: $viewModel.wanConfig.staticGateway)
|
||||
}
|
||||
case .pppoe:
|
||||
Section("PPPoE-Zugangsdaten") {
|
||||
TextField("Benutzername", text: $viewModel.wanConfig.pppoeUsername)
|
||||
SecureField("Passwort", text: $viewModel.wanConfig.pppoePassword)
|
||||
Text("Diese Zugangsdaten bekommst du von deinem Internetanbieter (z.B. Telekom, Vodafone).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Weiter") { viewModel.goNext() }
|
||||
.disabled(!isStepValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Internet einrichten")
|
||||
}
|
||||
|
||||
private var isStepValid: Bool {
|
||||
guard !viewModel.wanConfig.interfaceName.isEmpty else { return false }
|
||||
switch viewModel.wanConfig.mode {
|
||||
case .dhcpClient:
|
||||
return true
|
||||
case .staticIP:
|
||||
return !viewModel.wanConfig.staticAddress.isEmpty
|
||||
case .pppoe:
|
||||
return !viewModel.wanConfig.pppoeUsername.isEmpty && !viewModel.wanConfig.pppoePassword.isEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ private final class MockTransport: RouterOSTransport {
|
||||
|
||||
func fetchDeviceInfo() async throws -> RouterDeviceInfo { deviceInfo }
|
||||
func fetchInterfaces() async throws -> [NetworkInterface] { [] }
|
||||
func apply(_ command: RouterOSCommand) async throws {}
|
||||
func disconnect() async {}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
@testable import RouterOSAssistant
|
||||
|
||||
final class RouterOSCommandBuilderTests: XCTestCase {
|
||||
func testWanDhcpClientCommand() {
|
||||
let config = WanConfig(interfaceName: "ether1", mode: .dhcpClient)
|
||||
let commands = config.buildCommands()
|
||||
|
||||
XCTAssertEqual(commands.count, 1)
|
||||
XCTAssertEqual(commands[0].cliPath, "/ip dhcp-client add")
|
||||
XCTAssertEqual(commands[0].arguments["interface"], "ether1")
|
||||
}
|
||||
|
||||
func testWanStaticIPCommandsIncludeRouteOnlyWhenGatewayGiven() {
|
||||
var config = WanConfig(interfaceName: "ether1", mode: .staticIP)
|
||||
config.staticAddress = "203.0.113.5/24"
|
||||
|
||||
XCTAssertEqual(config.buildCommands().count, 1)
|
||||
|
||||
config.staticGateway = "203.0.113.1"
|
||||
let commands = config.buildCommands()
|
||||
|
||||
XCTAssertEqual(commands.count, 2)
|
||||
XCTAssertEqual(commands[0].arguments["address"], "203.0.113.5/24")
|
||||
XCTAssertEqual(commands[1].arguments["gateway"], "203.0.113.1")
|
||||
}
|
||||
|
||||
func testWanPppoeCommand() {
|
||||
var config = WanConfig(interfaceName: "ether1", mode: .pppoe)
|
||||
config.pppoeUsername = "user@isp"
|
||||
config.pppoePassword = "secret"
|
||||
|
||||
let commands = config.buildCommands()
|
||||
|
||||
XCTAssertEqual(commands.count, 1)
|
||||
XCTAssertEqual(commands[0].cliPath, "/interface pppoe-client add")
|
||||
XCTAssertEqual(commands[0].arguments["user"], "user@isp")
|
||||
XCTAssertEqual(commands[0].arguments["password"], "secret")
|
||||
}
|
||||
|
||||
func testLanDhcpCommandsCoverAddressPoolServerAndNetwork() {
|
||||
let config = LanDhcpConfig()
|
||||
let commands = config.buildCommands()
|
||||
|
||||
XCTAssertEqual(commands.count, 4)
|
||||
XCTAssertEqual(commands[0].cliPath, "/ip address add")
|
||||
XCTAssertEqual(commands[1].cliPath, "/ip pool add")
|
||||
XCTAssertEqual(commands[2].cliPath, "/ip dhcp-server add")
|
||||
XCTAssertEqual(commands[3].cliPath, "/ip dhcp-server network add")
|
||||
XCTAssertEqual(commands[3].arguments["gateway"], "192.168.88.1")
|
||||
}
|
||||
|
||||
func testCliLineRendersSortedQuotedArguments() {
|
||||
let command = RouterOSCommand(
|
||||
cliPath: "/interface pppoe-client add",
|
||||
restPath: "interface/pppoe-client",
|
||||
arguments: ["user": "user@isp", "password": "a secret"],
|
||||
summary: "test"
|
||||
)
|
||||
|
||||
XCTAssertEqual(command.cliLine, "/interface pppoe-client add password=\"a secret\" user=user@isp")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user