M26+M27: Seriennummer bei Bekannte Router, Schlüsselbund-Trennung, terse-Fallback verallgemeinert
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>
This commit is contained in:
@@ -541,6 +541,8 @@ enum L10n {
|
||||
"Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.":
|
||||
"The password for this user. Often empty in an unchanged factory state.",
|
||||
"Passwort merken": "Remember Password",
|
||||
"Passwort verbergen": "Hide Password",
|
||||
"Passwort anzeigen": "Show Password",
|
||||
"Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.":
|
||||
"Stores the password encrypted in macOS Keychain so you don't have to type it in every time.",
|
||||
"Verbinde…": "Connecting…",
|
||||
|
||||
@@ -19,23 +19,39 @@ struct SavedRouter: Identifiable, Codable, Equatable {
|
||||
/// user-supplied, no default; empty means "not set", never shown then.
|
||||
var location: String
|
||||
var lastConnectedAt: Date
|
||||
/// RouterOS' own hardware serial number (`/system routerboard`'s "serial-number" field) —
|
||||
/// the only thing that actually identifies *this physical device*, unlike host+username,
|
||||
/// which two different routers can share (e.g. both left at MikroTik's factory default
|
||||
/// 192.168.88.1/admin). Nil for a device with no physical routerboard at all (e.g. a CHR
|
||||
/// virtual router) or for an entry saved before this field existed — never a placeholder
|
||||
/// string, so it's never mistaken for a real match. `SavedRoutersStore.
|
||||
/// recordSuccessfulConnection` uses this to tell two physically different routers apart even
|
||||
/// when they briefly share the same host/username, instead of silently merging them into one
|
||||
/// entry (confirmed live, 2026-09-16: connecting to a second, same-model router at the same
|
||||
/// factory-default address/username just renamed the *existing* entry from the first router
|
||||
/// in place, with no way to tell the two apart afterwards).
|
||||
var serialNumber: String?
|
||||
|
||||
init(id: UUID = UUID(), host: String, username: String, name: String, location: String = "", lastConnectedAt: Date = Date()) {
|
||||
init(
|
||||
id: UUID = UUID(), host: String, username: String, name: String, location: String = "",
|
||||
lastConnectedAt: Date = Date(), serialNumber: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.name = name
|
||||
self.location = location
|
||||
self.lastConnectedAt = lastConnectedAt
|
||||
self.serialNumber = serialNumber
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, host, username, name, location, lastConnectedAt
|
||||
case id, host, username, name, location, lastConnectedAt, serialNumber
|
||||
}
|
||||
|
||||
/// Custom decoding so an already-saved list from before `location` existed keeps loading
|
||||
/// (missing key -> "") instead of the whole list silently vanishing — `SavedRoutersStore.
|
||||
/// load()` treats any decode failure as "no saved routers at all".
|
||||
/// Custom decoding so an already-saved list from before `location`/`serialNumber` existed
|
||||
/// keeps loading (missing key -> "" / nil) instead of the whole list silently vanishing —
|
||||
/// `SavedRoutersStore.load()` treats any decode failure as "no saved routers at all".
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
@@ -44,5 +60,6 @@ struct SavedRouter: Identifiable, Codable, Equatable {
|
||||
name = try container.decode(String.self, forKey: .name)
|
||||
location = try container.decodeIfPresent(String.self, forKey: .location) ?? ""
|
||||
lastConnectedAt = try container.decode(Date.self, forKey: .lastConnectedAt)
|
||||
serialNumber = try container.decodeIfPresent(String.self, forKey: .serialNumber)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ final class SSHTransport: RouterOSTransport {
|
||||
let output: String
|
||||
do {
|
||||
output = try await run("\(menuPath) print without-paging terse")
|
||||
} catch RouterOSError.invalidResponse(let detail) where detail.contains("bad parameter terse") {
|
||||
} catch RouterOSError.invalidResponse(let detail) where Self.rejectsTerse(detail) {
|
||||
// Singleton menu (e.g. "/ip dns", "/system identity") — no list, no "terse" support.
|
||||
let plain = try await run("\(menuPath) print without-paging")
|
||||
return [RouterOSCliParser.parseSingletonItem(plain)]
|
||||
@@ -132,7 +132,7 @@ final class SSHTransport: RouterOSTransport {
|
||||
// lesson from Bug 10 applying to a *read* command here, not just add/set/remove. Without
|
||||
// this, such a menu's data silently comes back empty rather than throwing or falling
|
||||
// back — no crash, no error, just nothing, which is worse than either.
|
||||
if output.contains("bad parameter terse") {
|
||||
if Self.rejectsTerse(output) {
|
||||
let plain = try await run("\(menuPath) print without-paging")
|
||||
return [RouterOSCliParser.parseSingletonItem(plain)]
|
||||
}
|
||||
@@ -149,6 +149,20 @@ final class SSHTransport: RouterOSTransport {
|
||||
return items
|
||||
}
|
||||
|
||||
/// RouterOS rejects `terse` on a singleton menu in more than one wording, confirmed live
|
||||
/// across two different devices/RouterOS versions (2026-09-16): "bad parameter terse"
|
||||
/// (original hEX test device) and a harder parser error, "expected end of command (line 1
|
||||
/// column N)" (a second, different router) — both were seen on `/system routerboard`, the
|
||||
/// second one also on `/system package update`, silently leaving Routerboard info (including
|
||||
/// the serial number "Bekannte Router" needs to tell two same-address devices apart) and the
|
||||
/// software-update check both empty with no visible error. Scoped safely to this one call
|
||||
/// site: `detail`/`output` here only ever come from running `"<menuPath> print
|
||||
/// without-paging terse"`, so any parser error on that exact line is — by construction —
|
||||
/// about the trailing "terse" token, not some unrelated syntax problem elsewhere.
|
||||
private static func rejectsTerse(_ text: String) -> Bool {
|
||||
text.contains("bad parameter terse") || text.contains("expected end of command")
|
||||
}
|
||||
|
||||
/// `:foreach i in=[<menuPath> find whereField=whereValue] do={:put [<menuPath> get $i
|
||||
/// returnField]}` — built from two independently confirmed-live primitives only: a bare
|
||||
/// `find` with one condition (verified repeatedly this session, e.g.
|
||||
|
||||
@@ -2,15 +2,29 @@ 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"
|
||||
|
||||
func save(password: String, forHost host: String, username: String) {
|
||||
let account = "\(username)@\(host)"
|
||||
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
|
||||
kSecAttrAccount as String: account(host: host, username: username, serialNumber: serialNumber)
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
@@ -19,8 +33,15 @@ struct KeychainService {
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func loadPassword(forHost host: String, username: String) -> String? {
|
||||
let account = "\(username)@\(host)"
|
||||
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,
|
||||
@@ -34,12 +55,11 @@ struct KeychainService {
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
func deletePassword(forHost host: String, username: String) {
|
||||
let account = "\(username)@\(host)"
|
||||
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
|
||||
kSecAttrAccount as String: account(host: host, username: username, serialNumber: serialNumber)
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
@@ -39,17 +39,35 @@ struct SavedRoutersStore {
|
||||
}
|
||||
|
||||
/// Called after a successful connection: adds a new entry (name defaulting to `defaultName`)
|
||||
/// if this host/username pair isn't known yet, or just bumps `lastConnectedAt` on the existing
|
||||
/// one. An existing entry's `name` is never touched here — that's what preserves a user's own
|
||||
/// rename across later reconnects.
|
||||
/// if this host/username/serial combination isn't known yet, or just bumps `lastConnectedAt`
|
||||
/// on the existing one. An existing entry's `name` is never touched here — that's what
|
||||
/// preserves a user's own rename across later reconnects.
|
||||
///
|
||||
/// `serialNumber` disambiguates two physically different routers that happen to share the
|
||||
/// same host/username (e.g. both left at MikroTik's factory default 192.168.88.1/admin) —
|
||||
/// without it, connecting to a second such device would silently rename the first one's
|
||||
/// existing entry in place instead of creating a new one, live-confirmed 2026-09-16. Matching
|
||||
/// order: (1) an entry with the exact same serial wins outright; (2) failing that, exactly one
|
||||
/// same-host/username entry with no recorded serial yet is treated as the same device seen for
|
||||
/// the first time since this field existed, and gets its serial filled in now; (3) otherwise
|
||||
/// this is a genuinely new device (or an ambiguous multi-match this app won't guess at) and
|
||||
/// gets its own new entry.
|
||||
@discardableResult
|
||||
func recordSuccessfulConnection(host: String, username: String, defaultName: String) -> [SavedRouter] {
|
||||
func recordSuccessfulConnection(host: String, username: String, defaultName: String, serialNumber: String? = nil) -> [SavedRouter] {
|
||||
var routers = loadRaw()
|
||||
if let index = routers.firstIndex(where: { $0.host == host && $0.username == username }) {
|
||||
let candidateIndices = routers.indices.filter { routers[$0].host == host && routers[$0].username == username }
|
||||
|
||||
if let serialNumber, let index = candidateIndices.first(where: { routers[$0].serialNumber == serialNumber }) {
|
||||
routers[index].lastConnectedAt = Date()
|
||||
} else {
|
||||
routers.append(SavedRouter(host: host, username: username, name: defaultName))
|
||||
return save(routers)
|
||||
}
|
||||
let unrecordedSerialIndices = candidateIndices.filter { routers[$0].serialNumber == nil }
|
||||
if unrecordedSerialIndices.count == 1, let index = unrecordedSerialIndices.first {
|
||||
routers[index].lastConnectedAt = Date()
|
||||
routers[index].serialNumber = serialNumber
|
||||
return save(routers)
|
||||
}
|
||||
routers.append(SavedRouter(host: host, username: username, name: defaultName, serialNumber: serialNumber))
|
||||
return save(routers)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ struct ConnectView: View {
|
||||
@State private var showUpdateInstallConfirmation = false
|
||||
@State private var showFirmwareUpgradeConfirmation = false
|
||||
@State private var showRebootConfirmation = false
|
||||
/// Per explicit request: a way to check what's actually in the password field, e.g. after
|
||||
/// selecting a "Bekannte Router" entry ("setze hinter das Passwort ein Auge-Symbol ... so
|
||||
/// kann ich prüfen, ob die Daten übernommen wurden").
|
||||
@State private var isPasswordVisible = false
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
/// How many "Bekannte Router" rows are visible before the list scrolls in place — per
|
||||
@@ -71,8 +75,23 @@ struct ConnectView: View {
|
||||
.help(L10n.t("Die Adresse deines Routers im Netzwerk. Werkseinstellung bei Mikrotik ist meist 192.168.88.1.", appLanguage))
|
||||
TextField(L10n.t("Benutzername", appLanguage), text: $viewModel.username)
|
||||
.help(L10n.t("Der Admin-Benutzername deines Routers. Werkseinstellung ist meist \"admin\".", appLanguage))
|
||||
SecureField(L10n.t("Passwort", appLanguage), text: $viewModel.password)
|
||||
HStack {
|
||||
Group {
|
||||
if isPasswordVisible {
|
||||
TextField(L10n.t("Passwort", appLanguage), text: $viewModel.password)
|
||||
} else {
|
||||
SecureField(L10n.t("Passwort", appLanguage), text: $viewModel.password)
|
||||
}
|
||||
}
|
||||
.help(L10n.t("Das Passwort für diesen Benutzer. Bei unverändertem Werkszustand oft leer.", appLanguage))
|
||||
Button {
|
||||
isPasswordVisible.toggle()
|
||||
} label: {
|
||||
Image(systemName: isPasswordVisible ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(isPasswordVisible ? L10n.t("Passwort verbergen", appLanguage) : L10n.t("Passwort anzeigen", appLanguage))
|
||||
}
|
||||
Toggle(L10n.t("Passwort merken", appLanguage), isOn: $viewModel.rememberPassword)
|
||||
.help(L10n.t("Speichert das Passwort verschlüsselt in der macOS-Schlüsselbundverwaltung, damit du es nicht jedes Mal neu eingeben musst.", appLanguage))
|
||||
}
|
||||
@@ -560,6 +579,16 @@ private struct SavedRouterRow: View {
|
||||
Text("\(router.username)@\(router.host)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
// Nutzerwunsch (2026-09-16): Seriennummer mit hinterlegen, damit zwei
|
||||
// physisch unterschiedliche Router mit identischem Host+Benutzername (z.B.
|
||||
// beide auf MikroTiks Werks-Adresse 192.168.88.1/admin) trotzdem als
|
||||
// getrennte Einträge unterscheidbar bleiben — siehe
|
||||
// SavedRoutersStore.recordSuccessfulConnection.
|
||||
if let serialNumber = router.serialNumber {
|
||||
Text("SN: \(serialNumber)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
|
||||
@@ -49,7 +49,7 @@ final class ConnectViewModel: ObservableObject {
|
||||
func selectSavedRouter(_ router: SavedRouter) {
|
||||
host = router.host
|
||||
username = router.username
|
||||
password = keychain.loadPassword(forHost: router.host, username: router.username) ?? ""
|
||||
password = keychain.loadPassword(forHost: router.host, username: router.username, serialNumber: router.serialNumber) ?? ""
|
||||
}
|
||||
|
||||
func renameSavedRouter(_ id: SavedRouter.ID, to newName: String) {
|
||||
@@ -66,15 +66,27 @@ final class ConnectViewModel: ObservableObject {
|
||||
|
||||
func connect() {
|
||||
let credentials = RouterOSCredentials(host: host, username: username, password: password)
|
||||
if rememberPassword {
|
||||
keychain.save(password: password, forHost: host, username: username)
|
||||
}
|
||||
Task {
|
||||
await connectionService.connect(with: credentials)
|
||||
if case .connected = connectionService.state {
|
||||
let defaultName = connectionService.deviceInfo?.boardName ?? host
|
||||
// "unbekannt" is RouterOSModels' own fallback when RouterOS reports the
|
||||
// routerboard menu but leaves this one field out — treated as "no real serial"
|
||||
// here too, or two such devices would falsely look like the same one.
|
||||
let rawSerial = connectionService.routerBoardInfo?.serialNumber
|
||||
let serialNumber = (rawSerial?.isEmpty == false && rawSerial != "unbekannt") ? rawSerial : nil
|
||||
// Saved only now, not before attempting the connection — the serial (needed to
|
||||
// keep two same-host/username devices' passwords apart, see `KeychainService`'s
|
||||
// doc comment) isn't known until the connection actually succeeds. Confirmed
|
||||
// live (2026-09-16): saving unqualified beforehand made both of two same-model
|
||||
// routers, left at MikroTik's factory default, silently share one Keychain
|
||||
// entry — reconnecting to either one always loaded whichever password was saved
|
||||
// most recently, regardless of which device the user actually selected.
|
||||
if rememberPassword {
|
||||
keychain.save(password: password, forHost: host, username: username, serialNumber: serialNumber)
|
||||
}
|
||||
savedRouters = savedRoutersStore.recordSuccessfulConnection(
|
||||
host: host, username: username, defaultName: defaultName
|
||||
host: host, username: username, defaultName: defaultName, serialNumber: serialNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user