Files
RouterOS/RouterOSAssistant/Core/Networking/RouterOSCliParser.swift
T
KayandClaude Sonnet 5 c9ecd3a0a9 M9/M10: Einfach/Experte-Modus + Experte-Tab (generischer RouterOS-Zugriff)
M9: Einrichten-Wizard bekommt einen Einfach/Experte-Modusschalter
(ModeStepView). Einfach überspringt VLAN, erlaubt nur ein LAN-Netzwerk
ohne Isolation, Firewall-Grundschutz fest an.

M10: neuer "Experte"-Tab mit generischem Motor (RouterOSMenuItem,
RouterOSCommand.remove, ConnectionService.fetchMenuItems, freies
"eigener Menüpfad"-Feld) plus kuratierten Formularen mit Tooltips
(RouterOSSchemaCatalog) für Firewall/NAT/Mangle/Raw/Adress-Listen,
Interfaces, IP, VPN, WLAN, Queues, System, Werkzeuge.

Live gegen einen hEX-Testrouter verifiziert (erst per SSH, dann vom
Nutzer selbst in der App), dabei 7 reale Bugs gefunden und gefixt —
der wichtigste: RouterOS' SSH-CLI gibt bei fehlgeschlagenen Befehlen
Exit-Code 0 zurück, wodurch apply() app-weit Fehler verschluckte statt
sie zu melden. Danach ergänzt: Bestätigungsdialog vor Anlegen/Ändern
+ Auto-Backup vor dem ersten Experte-Tab-Schreibvorgang je Sitzung
(Angleichung an den Wizard), sowie ein Dauer-Editor (Tage/Std/Min/Sek)
für Lease-/Ablaufzeit-Felder statt Freitext.

Details zu allen Bugs/Fixes: HANDOFF.md, CHATLOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EW3r6rW1xCf6UT5jNvt6rn
2026-09-14 00:03:50 +02:00

95 lines
4.9 KiB
Swift

import Foundation
/// Best-effort parser for RouterOS CLI output over SSH.
/// Not verified against a live device yet — check against real router output during the M2 dry-run pass.
enum RouterOSCliParser {
/// Parses `/system resource print` output (colon-separated "key: value" lines).
static func parseDeviceInfo(_ raw: String) -> RouterDeviceInfo {
var fields: [String: String] = [:]
for line in raw.split(whereSeparator: \.isNewline) {
guard let colonIndex = line.firstIndex(of: ":") else { continue }
let key = String(line[line.startIndex..<colonIndex]).trimmingCharacters(in: .whitespaces)
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
fields[key] = value
}
return RouterDeviceInfo(
boardName: fields["board-name"] ?? "unbekannt",
routerOSVersion: fields["version"] ?? "unbekannt",
architecture: fields["architecture-name"] ?? "unbekannt",
uptime: fields["uptime"] ?? "-"
)
}
/// Parses `/interface print terse` output (one line per interface, `key=value` pairs).
///
/// Splits on any newline-like character, not just "\n" — a real hEX device's SSH output
/// turned out not to split on bare "\n", collapsing every interface into a single "line"
/// whose repeated keys (name=, type=, ...) then overwrote each other in the fields
/// dictionary, leaving only the last interface (`lo`) in the result. Found via live testing.
static func parseInterfaces(_ raw: String) -> [NetworkInterface] {
raw.split(whereSeparator: \.isNewline).compactMap { line in
let fields = keyValues(from: String(line))
guard let name = fields["name"] else { return nil }
return NetworkInterface(
name: name,
type: fields["type"] ?? "unbekannt",
running: isTrue(fields["running"]),
disabled: isTrue(fields["disabled"]),
macAddress: fields["mac-address"]
)
}
}
/// Parses `<any menu> print without-paging terse` output generically — used by the Expert
/// tool for menus without a curated parser (i.e. almost all of them). Each line's `.id=*N`
/// field (present in terse output) becomes the item's `id`; everything else becomes `fields`.
/// A line without `.id` falls back to a synthesized "row-<index>" rather than being dropped,
/// so a still-listable-but-not-editable item is visible instead of silently missing.
static func parseGenericItems(_ raw: String) -> [RouterOSMenuItem] {
raw.split(whereSeparator: \.isNewline).enumerated().compactMap { index, line in
var fields = keyValues(from: String(line))
guard !fields.isEmpty else { return nil }
let id = fields.removeValue(forKey: ".id") ?? "row-\(index)"
return RouterOSMenuItem(id: id, fields: fields)
}
}
/// Parses `<singleton menu> print` output (colon-separated "key: value" lines, same shape as
/// `/system resource print`) into one generic item with the fixed id "singleton" — used for
/// menus that hold exactly one settable item rather than a list (confirmed live: these
/// reject "print terse" with "bad parameter terse").
static func parseSingletonItem(_ raw: String) -> RouterOSMenuItem {
var fields: [String: String] = [:]
for line in raw.split(whereSeparator: \.isNewline) {
guard let colonIndex = line.firstIndex(of: ":") else { continue }
let key = String(line[line.startIndex..<colonIndex]).trimmingCharacters(in: .whitespaces)
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespaces)
guard !key.isEmpty else { continue }
fields[key] = value
}
return RouterOSMenuItem(id: "singleton", fields: fields)
}
/// RouterOS CLI output mixes "true"/"false" and "yes"/"no" for booleans depending on field/version.
private static func isTrue(_ value: String?) -> Bool {
value == "true" || value == "yes"
}
private static func keyValues(from text: String) -> [String: String] {
var result: [String: String] = [:]
let pattern = #"([.a-zA-Z0-9-]+)=("[^"]*"|\S+)"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return result }
let nsText = text as NSString
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
for match in matches {
let key = nsText.substring(with: match.range(at: 1))
var value = nsText.substring(with: match.range(at: 2))
if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 {
value = String(value.dropFirst().dropLast())
}
result[key] = value
}
return result
}
}