forked from kay/RouterOS
M26: SavedRouter bekommt ein optionales serialNumber-Feld. Zwei physisch verschiedene Router mit identischem Host+Benutzername (Werks-Adresse) blieben bisher ein gemeinsamer "Bekannte Router"- Eintrag; recordSuccessfulConnection matcht jetzt zusätzlich nach Seriennummer, mit sauberer Migration bestehender Einträge ohne Seriennummer. Drei neue Tests. M27, beim Live-Test von M26 gefunden: - Passwort-Anzeige-Button (Augen-Symbol) im Verbinden-Tab - Bug 35: KeychainService speicherte Passwörter nur nach "username@host" — beide Router teilten sich denselben Schlüsselbund-Eintrag trotz getrennter SavedRouter-Einträge. Fix: optionaler serialNumber-Parameter qualifiziert den Account-Key, mit Fallback auf den alten Key für bereits gespeicherte Passwörter. - Bug 36: neuer Router zeigte keine Routerboard-Infos/Seriennummer — derselbe Root Cause wie der zuvor gemeldete "Jetzt prüfen"-Fehler: /system routerboard und /system package update scheitern auf diesem Router mit "expected end of command" statt dem bisher einzig abgefangenen "bad parameter terse". SSHTransport.fetchMenuItems erkennt jetzt beide Formulierungen. Alles live bestätigt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
67 lines
3.1 KiB
Swift
67 lines
3.1 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// Stores router login passwords in the macOS Keychain, never in plaintext.
|
|
///
|
|
/// Account keys are `"username@host"`, optionally suffixed `"#<serialNumber>"` when the caller
|
|
/// knows which physical device it is. Without the serial, two different routers left at
|
|
/// MikroTik's factory default (192.168.88.1/admin) share one Keychain entry — confirmed live,
|
|
/// 2026-09-16: switching between two "Bekannte Router" entries for such devices (already
|
|
/// correctly kept separate by `SavedRoutersStore` since M26) kept loading the *same* remembered
|
|
/// password for both, since this store never looked past host+username. `loadPassword` still
|
|
/// falls back to the plain, unqualified key when no serial-qualified entry exists yet, so
|
|
/// passwords saved before this change (or for a device with no routerboard/serial at all) keep
|
|
/// working.
|
|
struct KeychainService {
|
|
private let service = "com.focus72.RouterOSAssistant"
|
|
|
|
private func account(host: String, username: String, serialNumber: String?) -> String {
|
|
guard let serialNumber else { return "\(username)@\(host)" }
|
|
return "\(username)@\(host)#\(serialNumber)"
|
|
}
|
|
|
|
func save(password: String, forHost host: String, username: String, serialNumber: String? = nil) {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account(host: host, username: username, serialNumber: serialNumber)
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
|
|
var attributes = query
|
|
attributes[kSecValueData as String] = Data(password.utf8)
|
|
SecItemAdd(attributes as CFDictionary, nil)
|
|
}
|
|
|
|
func loadPassword(forHost host: String, username: String, serialNumber: String? = nil) -> String? {
|
|
if let serialNumber,
|
|
let password = loadPassword(account: account(host: host, username: username, serialNumber: serialNumber)) {
|
|
return password
|
|
}
|
|
return loadPassword(account: account(host: host, username: username, serialNumber: nil))
|
|
}
|
|
|
|
private func loadPassword(account: String) -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne
|
|
]
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
func deletePassword(forHost host: String, username: String, serialNumber: String? = nil) {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account(host: host, username: username, serialNumber: serialNumber)
|
|
]
|
|
SecItemDelete(query as CFDictionary)
|
|
}
|
|
}
|