Files
RouterOS/RouterOSAssistant/Core/Services/ConnectionService.swift
T
KayandClaude Sonnet 5 772549c991 M6: Firewall-Schritt (opt-in Sicherheits-Grundschutz)
Standardmäßig aus (Toggle wie VLAN) -- höchstes Risiko aller bisherigen
Schritte, falsche Regeln können Fernzugriff kappen. Preset ist
Mikrotiks eigener Standard-Ansatz (unverändert seit Jahren in
RouterOS-Werkskonfigurationen): NAT/Masquerade auf WAN, established/
related erlauben, invalid verwerfen, unaufgeforderte WAN-Verbindungen
zu LAN-Geräten blocken (außer explizitem Port-Forward via
connection-nat-state=!dstnat).

Jede neue Regel bekommt ein place-before mit aufsteigendem Index,
damit sie vor eventuell schon vorhandenen Regeln des Routers landet --
sonst könnte eine bereits vorhandene "alles blocken"-Regel unsere
neuen Regeln wirkungslos machen. NAT und Filter sind getrennte,
unabhängig nummerierte RouterOS-Listen.

Vor dem Anwenden zeigt der Schritt die Anzahl bereits vorhandener
Filter-/NAT-Regeln (neuer fetchFirewallRuleCounts()-Aufruf in
RouterOSTransport/RestTransport/SSHTransport/ConnectionService) --
Transparenz, bevor auf einem möglicherweise schon konfigurierten
Router weitere Regeln landen. Nutzer-Entscheidung, extra Lese-Aufruf
in Kauf zu nehmen statt nur Warntext.

Build + Test-Compile (build-for-testing) sind grün. Der eigentliche
Testlauf (xcodebuild test) hängt aktuell an einem macOS-Gatekeeper-
Netzwerk-Check für ad-hoc-signierte Binaries (amfid: "adhoc signed or
signed by an unknown certificate chain", GK performScan über
syspolicyd) -- kein Code-Bug, tritt nur bei CLI-Testläufen auf, nicht
beim normalen Xcode-Cmd+R-Weg. Nutzer verifiziert M6 deshalb direkt in
Xcode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HReLXMbmPvtQ23p1iWiJNW
2026-09-12 19:57:53 +02:00

99 lines
3.6 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 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 var activeTransport: RouterOSTransport?
init(certificateTrust: CertificateTrustStore = CertificateTrustStore()) {
self.certificateTrust = certificateTrust
}
/// 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 {
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) }
)
}
func trustCurrentCertificateAndRetry(fingerprint: String) async {
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)
}
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 {
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
state = .idle
}
}