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:
Kay
2026-09-16 14:02:27 +02:00
co-authored by Claude Sonnet 5
parent 8a5df3338a
commit 9de9311bee
11 changed files with 294 additions and 45 deletions
@@ -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
)
}
}