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
95 lines
4.9 KiB
Swift
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
|
|
}
|
|
}
|