M7: REST-Transport erstmals live verifiziert, vier Bugs gefunden+gefixt

Erstmaliger REST-Schreibtest gegen echte Hardware (www-ssl vorher auf
jedem Testgerät aus, Pfad lief nur gegen Mocks). Vier Bugs gefunden:

- baseURL fehlte trailing slash, relative URL-Auflösung warf "/rest"
  aus jedem Pfad (404, fiel still auf SSH zurück statt Zertifikat-
  Dialog zu zeigen)
- .add nutzte POST statt PUT (RouterOS' REST-API erwartet PUT für neue
  Einträge)
- RouterOS sendet Cache-Control: max-age=31536000 auf jede REST-
  Antwort, URLSession cachte dadurch die erste (leere) GET-Antwort für
  den Rest der App-Laufzeit
- Löschen aktualisierte die Experte-Tab-Liste nicht in-place (SwiftUI-
  Render-Problem, kein Datenfehler) — Anlegen/Bearbeiten laufen über
  ein Sheet, dessen Schließen automatisch neu rendert, Löschen über
  ein confirmationDialog ohne Remount

Anlegen/Bearbeiten (inkl. Feld-Leeren)/Löschen live bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-16 10:51:55 +02:00
co-authored by Claude Sonnet 5
parent 0237b3afbe
commit a0e7e53f22
4 changed files with 135 additions and 16 deletions
+80 -9
View File
@@ -629,13 +629,79 @@ wiederholen.
nur zum Löschen dieser einen Datei erschien nicht aufwandsgerecht. nur zum Löschen dieser einen Datei erschien nicht aufwandsgerecht.
Harmlos (überschreibt sich beim nächsten Restore selbst), aber bewusst Harmlos (überschreibt sich beim nächsten Restore selbst), aber bewusst
in Kauf genommen, nicht vergessen. in Kauf genommen, nicht vergessen.
- **REST-Pfad ungetestet für Schreibvorgänge** — auf beiden bisherigen - ~~REST-Pfad ungetestet für Schreibvorgänge~~ — am 2026-09-16 erstmals
Testgeräten war `www-ssl` (Port 443) aus, jeder Schreibtest lief über live getestet: `www-ssl` auf dem hEX-Testrouter aktiviert (eigene CA
SSH. Der REST-`apply()`-Pfad (`POST`/`PATCH`, `findItemID` für `.set`) `local-ca` + davon signiertes `rest-cert` angelegt, da direktes
ist nur gegen Mocks getestet. Der REST-Zweig von `fetchFieldValues` Selbstsignieren ohne separate CA mit "failure: CA not found"
(M12/Bug 14) ist zusätzlich auf einer nirgends offiziell dokumentierten scheiterte — RouterOS braucht dafür `key-usage=key-cert-sign,crl-sign`
Annahme aufgebaut (Query-String-Filter `?feld=wert` auf einem GET) — auf einem eigenen CA-Zertifikat, das per `ca=<name>` referenziert
komplett unverifiziert. wird). Dabei **drei echte, vorher unentdeckte Bugs gefunden und
gefixt**, alle drei ausschließlich möglich, weil kein Testgerät vorher
je `www-ssl` an hatte:
- **Bug 30:** `RestTransport.baseURL` fehlte der abschließende `/`
(`".../rest"` statt `".../rest/"`). `URL(string:relativeTo:)`
ersetzt bei relativer Auflösung das *letzte Pfadsegment* der Basis
(RFC 3986 §5.3) — ohne trailing slash ist "rest" selbst das letzte
Segment, ein Pfad wie "system/resource" ersetzt es also statt
danach angehängt zu werden (`.../rest` + "system/resource" →
`.../system/resource`, 404). Jede REST-Verbindung scheiterte dadurch
mit stillschweigend verschlucktem "HTTP 404" (der generische
`catch`-Zweig in `ConnectionService.connect` loggte den Fehler
nirgends), fiel auf SSH zurück, ohne je den Zertifikats-Dialog zu
zeigen — sah aus wie "kein Zertifikat-Prompt trotz neuem
Zertifikat", war tatsächlich ein komplett anderer, tieferliegender
Bug. Gefunden über einen temporären Debug-`print` im Fallback-Pfad.
- **Bug 31:** `RestTransport.apply()`s `.add`-Fall nutzte `POST`,
RouterOS' REST-API erwartet dafür aber `PUT``POST` auf
`/rest/ip/firewall/address-list` lieferte `{"detail":"no such
command","error":400,"message":"Bad Request"}`, `PUT` mit
identischem Body `201` mit vollem Echo des neuen Items. Nebenbei
`send()`s Fehlerbehandlung verbessert: zeigte bisher nur den nackten
Statuscode ("HTTP 400"), jetzt wird RouterOS' eigener JSON-Fehler-
Body (`detail`-Feld) mit ausgegeben, falls vorhanden — genau dieser
Body war es, der den Bug überhaupt auffindbar machte (per direktem
`curl`-Replay derselben Anfrage).
- **Bug 32, am folgenreichsten:** RouterOS sendet auf *jede*
`/rest/...`-Antwort `Cache-Control: max-age=31536000` (ein Jahr) +
passenden `Expires`-Header — vermutlich ein Blanket-Default für
statische Web-UI-Assets, der auch auf die REST-API durchschlägt.
`URLSession(configuration: .ephemeral)` bedeutet nur "nichts auf
Platte persistieren", hält aber weiterhin einen Im-Speicher-
`URLCache` und hält sich an diese Header — die *erste* GET-Antwort
auf einen Pfad (z.B. eine leere Artikelliste, abgerufen durch
`loadCrossReferenceOptions` oder ein früheres Öffnen desselben
Menüs) wurde für den Rest der App-Laufzeit unverändert zurückgegeben,
komplett unabhängig von späteren `.add`/`.set`/`.remove`-Änderungen.
Live-Symptom: ein frisch angelegter Adress-Listen-Eintrag (per
`curl` als real bestätigt) erschien nie in der Experte-Tab-Liste,
auch nach Weg-und-zurück-Navigation nicht. Fix: `urlCache = nil` +
`.reloadIgnoringLocalCacheData` auf Session- *und* Request-Ebene
(RouterOS' Schreib-Endpunkte sind nicht idempotent genug, um sich
nur auf eine der beiden Ebenen zu verlassen).
Nach allen drei Fixes live durchgespielt: Anlegen (`PUT`), Bearbeiten
inkl. Feld-Leeren (`PATCH`, Kommentar gesetzt dann wieder gelöscht —
REST-seitig direkt per `curl` verifiziert, dass ein leerer String das
Feld tatsächlich löscht, genau wie bei SSH/Bug 25), Löschen (`DELETE`)
— alle drei vom Nutzer bestätigt ("funktioniert"). Damit ist der
REST-Transport erstmals end-to-end gegen echte Hardware verifiziert,
nicht mehr nur gegen Mocks. Der REST-Zweig von `fetchFieldValues`
(M12/Bug 14, Query-String-Filter `?feld=wert` auf einem GET) bleibt
weiterhin unverifiziert — dabei nicht auf den Weg gekreuzt.
- **Bug 33, direkt danach gefunden:** Löschen eines Eintrags im
Experte-Tab aktualisierte die sichtbare Liste nicht, bis der Nutzer
in einen anderen Tab und zurück wechselte — sah zunächst wieder wie
ein Cache-/Refresh-Problem aus, war es aber nicht: ein temporärer
Debug-`print` in `confirmRemoval` bestätigte `items.count` geht
korrekt von 1 auf 0 nach `reloadItems()`, das `@Published`-Modell
war also die ganze Zeit richtig. Reines SwiftUI-Render-Problem:
Anlegen/Bearbeiten laufen über `ExpertItemEditView` als `.sheet(item:)`,
dessen Schließen `ExpertMenuDetailView` automatisch neu mountet —
genau das erzwingt implizit den Refresh, den Löschen (über ein
direkt an `ExpertMenuDetailView` gehängtes `.confirmationDialog`,
kein Sheet, kein Remount) nie bekam. Fix: `.id(viewModel.items.count)`
auf der Einträge-`Section`, zwingt SwiftUI bei jeder Mengenänderung
zu einer frischen View-Identität statt sich auf In-Place-Diffing zu
verlassen. Live bestätigt ("funktioniert").
- **`fetchMenuItems`s `.id`-Positions-Überlagerung: Verlässlichkeit für - **`fetchMenuItems`s `.id`-Positions-Überlagerung: Verlässlichkeit für
andere Menüs ungeprüft** (siehe Bug 15) — bei `/ip dhcp-server lease` andere Menüs ungeprüft** (siehe Bug 15) — bei `/ip dhcp-server lease`
live als falsch bestätigt (`.id` landete auf der falschen Zeile), für live als falsch bestätigt (`.id` landete auf der falschen Zeile), für
@@ -1331,8 +1397,13 @@ passenden Namen lesbar. Live bestätigt ("sieht gut aus").
alles erledigt, live bestätigt ("funktioniert"). Damit sind alle alles erledigt, live bestätigt ("funktioniert"). Damit sind alle
fünf Tabs vollständig zweisprachig (DE/EN). fünf Tabs vollständig zweisprachig (DE/EN).
2. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen. 2. WLAN (M5) an einem Gerät mit echtem WLAN-Chip nachholen.
2. Rest von M7: Fehlerzustände/Politur, REST-Schreibtest an einem Gerät 2. ~~Rest von M7: REST-Schreibtest an einem Gerät mit aktivem
mit aktivem `www-ssl`. `www-ssl`~~ — erledigt (2026-09-16), siehe "Bekannte Einschränkungen"
oben (Bug 3032). `www-ssl` bleibt auf dem hEX-Testrouter aktiv
(eigene CA `local-ca` + `rest-cert`), damit die App weiterhin
REST-zuerst verbindet statt SSH-Fallback. Fehlerzustände/Politur im
REST-Pfad noch nicht gezielt geprüft (z.B. Verbindungsabbruch
mitten im Apply) — optional für später.
3. M9 UI (Einfach/Experte-Modusumschalter im Einrichten-Tab selbst) noch 3. M9 UI (Einfach/Experte-Modusumschalter im Einrichten-Tab selbst) noch
manuell durchklicken — M10s Experte-Tab wurde bereits vom Nutzer manuell durchklicken — M10s Experte-Tab wurde bereits vom Nutzer
bestätigt (siehe oben), der Moduswechsel im Wizard noch nicht. bestätigt (siehe oben), der Moduswechsel im Wizard noch nicht.
+1
View File
@@ -151,6 +151,7 @@ nur die zugehörigen Passwörter liegen weiterhin im macOS-Schlüsselbund.
| M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ live verifiziert | | M19 | Übersicht-Tab: Flussanimation + verschiebbare Knoten | ✅ live verifiziert |
| M20 | LAN-Scanner: Umbenennung, Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan) | ✅ live verifiziert | | M20 | LAN-Scanner: Umbenennung, Netzwerk-Tools (Ping/Traceroute/DNS/Port-Scan) | ✅ live verifiziert |
| M21 | Zweisprachigkeit (DE/EN) auf alle fünf Tabs ausgerollt (Einrichten/Übersicht/LAN-Scanner/Sicherungen) | ✅ live verifiziert | | M21 | Zweisprachigkeit (DE/EN) auf alle fünf Tabs ausgerollt (Einrichten/Übersicht/LAN-Scanner/Sicherungen) | ✅ live verifiziert |
| M22 | REST-Transport (M7) erstmals live gegen Hardware verifiziert, 3 Bugs gefunden+gefixt | ✅ live verifiziert |
| — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen | | — | LAN-Port-Konflikt-Prüfung + "Fertig"-Button (Einrichten) | 🔶 gebaut, Live-Test offen |
Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und Ausführlicher Stand inkl. aller gefundenen Bugs, offener Punkte und
@@ -8,19 +8,40 @@ final class RestTransport: NSObject, RouterOSTransport {
private let certificateTrust: CertificateTrustStore private let certificateTrust: CertificateTrustStore
private var lastRejectedFingerprint: String? private var lastRejectedFingerprint: String?
private lazy var session: URLSession = URLSession( /// RouterOS sends `Cache-Control: max-age=31536000` (one year) + a matching `Expires` header
configuration: .ephemeral, /// on every `/rest/...` response, live-confirmed (2026-09-16) almost certainly a blanket
delegate: self, /// default meant for static web-UI assets that leaks onto the REST API too. `.ephemeral`
delegateQueue: nil /// only means "no data persisted to disk", it still keeps an in-memory `URLCache` by default
) /// and honors those headers so the *first* GET to a given path (e.g. an empty item list,
/// fetched once by `loadCrossReferenceOptions` or an earlier menu open) got served back for
/// every subsequent identical GET for the rest of the app's lifetime, even after a `.set`/
/// `.add`/`.remove` changed the underlying data. Symptom, live: a freshly-created address-list
/// entry (confirmed to exist via `curl`) never appeared in the Experte-Tab list, even after
/// navigating away and back. `urlCache = nil` + `.reloadIgnoringLocalCacheData` disable
/// caching at both the session and per-request level, since RouterOS' write endpoints (unlike
/// its GETs) aren't idempotent enough to risk relying on just one of the two.
private lazy var session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
}()
init(credentials: RouterOSCredentials, certificateTrust: CertificateTrustStore) { init(credentials: RouterOSCredentials, certificateTrust: CertificateTrustStore) {
self.credentials = credentials self.credentials = credentials
self.certificateTrust = certificateTrust self.certificateTrust = certificateTrust
} }
/// Trailing slash is load-bearing: `URL(string:relativeTo:)` resolves a relative reference
/// like "system/resource" against a base's *last path segment* per RFC 3986 §5.3 without
/// the trailing "/", that segment is "rest" itself, so the merge replaces it instead of
/// appending after it (`.../rest` + "system/resource" `.../system/resource`, silently
/// dropping "/rest" and 404ing). Confirmed live (2026-09-16, first-ever successful REST
/// connection to real hardware): app fell back to SSH with a swallowed "HTTP 404" instead of
/// showing the certificate-trust dialog, traced via a temporary debug print in
/// `ConnectionService.connect`.
private var baseURL: URL { private var baseURL: URL {
URL(string: "https://\(credentials.host):\(credentials.httpsPort)/rest")! URL(string: "https://\(credentials.host):\(credentials.httpsPort)/rest/")!
} }
func connect() async throws { func connect() async throws {
@@ -108,7 +129,13 @@ final class RestTransport: NSObject, RouterOSTransport {
func apply(_ command: RouterOSCommand) async throws { func apply(_ command: RouterOSCommand) async throws {
switch command.operation { switch command.operation {
case .add: case .add:
_ = try await send(path: command.restPath, method: "POST", jsonBody: command.arguments) // RouterOS' REST API creates new entries via PUT, not POST confirmed live
// (2026-09-16, first-ever REST write test against real hardware): POST returned
// HTTP 400 `{"detail":"no such command","error":400,"message":"Bad Request"}` on
// `/rest/ip/firewall/address-list`, PUT with the identical body succeeded (201,
// full item echoed back). Never caught before every prior test device had
// `www-ssl` off, so this path only ever ran against mocks (see HANDOFF.md).
_ = try await send(path: command.restPath, method: "PUT", jsonBody: command.arguments)
case .set(let matchField, let matchValue): case .set(let matchField, let matchValue):
// Empty matchField = singleton menu (e.g. "/ip dns") PATCH the resource directly, // Empty matchField = singleton menu (e.g. "/ip dns") PATCH the resource directly,
// there's no list/id to look up. See RouterOSCommand.cliLine for the SSH counterpart. // there's no list/id to look up. See RouterOSCommand.cliLine for the SSH counterpart.
@@ -170,6 +197,7 @@ final class RestTransport: NSObject, RouterOSTransport {
} }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = method request.httpMethod = method
request.cachePolicy = .reloadIgnoringLocalCacheData
let authString = "\(credentials.username):\(credentials.password)" let authString = "\(credentials.username):\(credentials.password)"
guard let authData = authString.data(using: .utf8) else { guard let authData = authString.data(using: .utf8) else {
@@ -193,6 +221,15 @@ final class RestTransport: NSObject, RouterOSTransport {
throw RouterOSError.authenticationFailed throw RouterOSError.authenticationFailed
} }
guard (200...299).contains(httpResponse.statusCode) else { guard (200...299).contains(httpResponse.statusCode) else {
// RouterOS' REST error body is JSON (e.g. `{"detail":"no such command",
// "error":400,"message":"Bad Request"}`) surface "detail" when present instead
// of just the bare status code, or diagnosing a REST write failure means guessing
// blind (confirmed live, 2026-09-16: the PUT-vs-POST bug below was only found by
// replaying the exact same request with curl, since the app only showed "HTTP 400").
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let detail = object["detail"] as? String {
throw RouterOSError.invalidResponse("HTTP \(httpResponse.statusCode): \(detail)")
}
throw RouterOSError.invalidResponse("HTTP \(httpResponse.statusCode)") throw RouterOSError.invalidResponse("HTTP \(httpResponse.statusCode)")
} }
return data return data
@@ -30,6 +30,15 @@ struct ExpertMenuDetailView: View {
} else if viewModel.items.isEmpty { } else if viewModel.items.isEmpty {
Text(L10n.t("Keine Einträge unter", appLanguage) + " \(schema.menuPath).").foregroundStyle(.secondary) Text(L10n.t("Keine Einträge unter", appLanguage) + " \(schema.menuPath).").foregroundStyle(.secondary)
} else { } else {
// `.id(viewModel.items.count)` forces SwiftUI to treat this as a fresh view
// identity whenever the count changes without it, deleting an item (via the
// `.confirmationDialog` below, not a `.sheet`) updates `viewModel.items`
// correctly (confirmed live via a temporary debug print: item count goes
// 1 0) but the rendered list still showed the removed row until switching
// tabs and back forced a full remount. Add/edit never hit this because their
// `.sheet(item:)` dismissal already forces a remount of this view on its own;
// delete's confirmationDialog doesn't tear this view down at all, so nothing
// was forcing SwiftUI to re-diff the Section in place.
ForEach(viewModel.items) { item in ForEach(viewModel.items) { item in
HStack { HStack {
Button { Button {
@@ -63,6 +72,7 @@ struct ExpertMenuDetailView: View {
} }
} }
} }
.id(viewModel.items.count)
} }
.formStyle(.grouped) .formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage))) .navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))