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:
@@ -8,19 +8,40 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
private let certificateTrust: CertificateTrustStore
|
||||
private var lastRejectedFingerprint: String?
|
||||
|
||||
private lazy var session: URLSession = URLSession(
|
||||
configuration: .ephemeral,
|
||||
delegate: self,
|
||||
delegateQueue: nil
|
||||
)
|
||||
/// RouterOS sends `Cache-Control: max-age=31536000` (one year) + a matching `Expires` header
|
||||
/// on every `/rest/...` response, live-confirmed (2026-09-16) — almost certainly a blanket
|
||||
/// default meant for static web-UI assets that leaks onto the REST API too. `.ephemeral`
|
||||
/// 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) {
|
||||
self.credentials = credentials
|
||||
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 {
|
||||
URL(string: "https://\(credentials.host):\(credentials.httpsPort)/rest")!
|
||||
URL(string: "https://\(credentials.host):\(credentials.httpsPort)/rest/")!
|
||||
}
|
||||
|
||||
func connect() async throws {
|
||||
@@ -108,7 +129,13 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
func apply(_ command: RouterOSCommand) async throws {
|
||||
switch command.operation {
|
||||
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):
|
||||
// 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.
|
||||
@@ -170,6 +197,7 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let authString = "\(credentials.username):\(credentials.password)"
|
||||
guard let authData = authString.data(using: .utf8) else {
|
||||
@@ -193,6 +221,15 @@ final class RestTransport: NSObject, RouterOSTransport {
|
||||
throw RouterOSError.authenticationFailed
|
||||
}
|
||||
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)")
|
||||
}
|
||||
return data
|
||||
|
||||
@@ -30,6 +30,15 @@ struct ExpertMenuDetailView: View {
|
||||
} else if viewModel.items.isEmpty {
|
||||
Text(L10n.t("Keine Einträge unter", appLanguage) + " \(schema.menuPath).").foregroundStyle(.secondary)
|
||||
} 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
|
||||
HStack {
|
||||
Button {
|
||||
@@ -63,6 +72,7 @@ struct ExpertMenuDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.id(viewModel.items.count)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))
|
||||
|
||||
Reference in New Issue
Block a user