forked from kay/RouterOS
Neuer Tab: eine Tabelle pro physischem Ethernet/WLAN-Port mit den dort gefundenen Geräten (Name, IP, MAC, Fest/Dynamisch/Kein-DHCP), gebaut aus DHCP-Leases + ARP + Bridge-Host-Tabelle. Rechtsklick auf ein dynamisches Gerät -> "Feste IP zuweisen" (RouterOS' "Make Static", per /ip dhcp-server lease make-static), mit Bestätigungsdialog und Session-Backup vor dem ersten Schreibvorgang (geteilter Mechanismus mit dem Experte-Tab). Vier reale Bugs live gefunden und gefixt (siehe HANDOFF.md Bug 14-17): - "print terse" gibt das "dynamic"-Feld von /ip dhcp-server lease nie aus, in keinem Zustand -> Status kommt jetzt über RouterOS' find/get gegen die interne Eigenschaft, nicht aus gelesenen Feldern. - fetchMenuItems' .id-Positionsüberlagerung ordnete für dieses Menü die falsche .id der falschen Zeile zu -> Erkennung und make-static-Ziel laufen jetzt über die MAC-Adresse statt .id. - Ein SwiftUI-.confirmationDialog löschte sein eigenes Ziel-Objekt vor der Ausführung der bestätigten Aktion (Setter feuert bei jedem Knopfdruck, nicht nur Abbrechen) -> Dialog-Sichtbarkeit und Nutzlast entkoppelt, wie in BackupListView. - Die eigene Verifikations-Abfrage (get [find ...] feld als ein kombinierter Befehl) war selbst eine nie verifizierte Annahme und lieferte falsche Negative -> ersetzt durch :foreach aus zwei einzeln bestätigten Bausteinen (find, get <id> feld). RouterOSCommand bekommt einen neuen .action-Operationstyp für RouterOS-"Menü-spezifische Befehle" jenseits von add/set/remove (aktuell nur make-static). HANDOFF.md/CHATLOG.md mit allen vier Bugs, neuen Milestones M11/M12 und offenen Punkten aktualisiert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CTgRxJTzaQwaRkngbaE1GJ
135 lines
5.7 KiB
Swift
135 lines
5.7 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)
|
|
}
|
|
|
|
/// Field values matching an internal-property filter — see `RouterOSTransport.fetchFieldValues`.
|
|
func fetchFieldValues(menuPath: String, restPath: String, whereField: String, whereValue: String, returnField: String) async throws -> Set<String> {
|
|
guard let activeTransport else { throw RouterOSError.notConnected }
|
|
return try await activeTransport.fetchFieldValues(menuPath: menuPath, restPath: restPath, whereField: whereField, whereValue: whereValue, returnField: returnField)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|