Files
RouterOS/RouterOSAssistant/Core/Services/ConnectionService.swift
T
KayandClaude Sonnet 5 c9ecd3a0a9 M9/M10: Einfach/Experte-Modus + Experte-Tab (generischer RouterOS-Zugriff)
M9: Einrichten-Wizard bekommt einen Einfach/Experte-Modusschalter
(ModeStepView). Einfach überspringt VLAN, erlaubt nur ein LAN-Netzwerk
ohne Isolation, Firewall-Grundschutz fest an.

M10: neuer "Experte"-Tab mit generischem Motor (RouterOSMenuItem,
RouterOSCommand.remove, ConnectionService.fetchMenuItems, freies
"eigener Menüpfad"-Feld) plus kuratierten Formularen mit Tooltips
(RouterOSSchemaCatalog) für Firewall/NAT/Mangle/Raw/Adress-Listen,
Interfaces, IP, VPN, WLAN, Queues, System, Werkzeuge.

Live gegen einen hEX-Testrouter verifiziert (erst per SSH, dann vom
Nutzer selbst in der App), dabei 7 reale Bugs gefunden und gefixt —
der wichtigste: RouterOS' SSH-CLI gibt bei fehlgeschlagenen Befehlen
Exit-Code 0 zurück, wodurch apply() app-weit Fehler verschluckte statt
sie zu melden. Danach ergänzt: Bestätigungsdialog vor Anlegen/Ändern
+ Auto-Backup vor dem ersten Experte-Tab-Schreibvorgang je Sitzung
(Angleichung an den Wizard), sowie ein Dauer-Editor (Tage/Std/Min/Sek)
für Lease-/Ablaufzeit-Felder statt Freitext.

Details zu allen Bugs/Fixes: HANDOFF.md, CHATLOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EW3r6rW1xCf6UT5jNvt6rn
2026-09-14 00:03:50 +02:00

129 lines
5.2 KiB
Swift

import Foundation
/// Drives the REST-first, SSH-fallback connection flow and holds the connected device's state.
@MainActor
final class ConnectionService: ObservableObject {
enum State: Equatable {
case idle
case connecting
case connected(kind: RouterOSTransportKind)
case needsCertificateConfirmation(fingerprint: String)
case needsSSHHostKeyConfirmation(fingerprint: String)
case failed(String)
}
@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 let sshHostKeyTrust: SSHHostKeyTrustStore
private var activeTransport: RouterOSTransport?
/// Whether the Expert tool has already made its one automatic safety backup for this
/// connection — it backs up before its first write, not before every single edit (unlike
/// the Einrichten-Wizard, which backs up before every "Jetzt anwenden"). Reset on
/// `disconnect()` so a new connection gets a fresh backup again.
private(set) var hasExpertToolBackedUpThisSession = false
func markExpertToolBackedUpThisSession() {
hasExpertToolBackedUpThisSession = true
}
init(
certificateTrust: CertificateTrustStore = CertificateTrustStore(),
sshHostKeyTrust: SSHHostKeyTrustStore = SSHHostKeyTrustStore()
) {
self.certificateTrust = certificateTrust
self.sshHostKeyTrust = sshHostKeyTrust
}
/// Injection point for tests: bypasses the real REST/SSH transports.
func connect(with credentials: RouterOSCredentials, makeRestTransport: () -> RouterOSTransport, makeSSHTransport: () -> RouterOSTransport) async {
state = .connecting
self.credentials = credentials
let rest = makeRestTransport()
do {
try await rest.connect()
await finishConnecting(using: rest)
return
} catch RouterOSError.untrustedCertificate(let fingerprint) {
state = .needsCertificateConfirmation(fingerprint: fingerprint)
return
} catch {
// REST fehlgeschlagen (z.B. altes RouterOS ohne REST-API) -> SSH-Fallback versuchen.
}
let ssh = makeSSHTransport()
do {
try await ssh.connect()
await finishConnecting(using: ssh)
} catch RouterOSError.untrustedSSHHostKey(let fingerprint) {
state = .needsSSHHostKeyConfirmation(fingerprint: fingerprint)
} catch {
state = .failed(error.localizedDescription)
}
}
func connect(with credentials: RouterOSCredentials) async {
await connect(
with: credentials,
makeRestTransport: { RestTransport(credentials: credentials, certificateTrust: self.certificateTrust) },
makeSSHTransport: { SSHTransport(credentials: credentials, hostKeyTrust: self.sshHostKeyTrust) }
)
}
func trustCurrentCertificateAndRetry(fingerprint: String) async {
guard let credentials else { return }
certificateTrust.trust(host: credentials.host, fingerprint: fingerprint)
await connect(with: credentials)
}
func trustCurrentSSHHostKeyAndRetry(fingerprint: String) async {
guard let credentials else { return }
sshHostKeyTrust.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)
}
func fetchFirewallRuleCounts() async throws -> FirewallRuleCounts {
guard let activeTransport else { throw RouterOSError.notConnected }
return try await activeTransport.fetchFirewallRuleCounts()
}
/// Generic read for the Expert tool — lists existing items under any RouterOS menu path.
func fetchMenuItems(menuPath: String, restPath: String) async throws -> [RouterOSMenuItem] {
guard let activeTransport else { throw RouterOSError.notConnected }
return try await activeTransport.fetchMenuItems(menuPath: menuPath, restPath: restPath)
}
private func finishConnecting(using transport: RouterOSTransport) async {
activeTransport = transport
do {
deviceInfo = try await transport.fetchDeviceInfo()
interfaces = try await transport.fetchInterfaces()
state = .connected(kind: transport.kind)
} catch {
state = .failed(error.localizedDescription)
}
}
func disconnect() async {
await activeTransport?.disconnect()
activeTransport = nil
deviceInfo = nil
interfaces = []
credentials = nil
hasExpertToolBackedUpThisSession = false
state = .idle
}
}