diff --git a/CHANGELOG.md b/CHANGELOG.md index e125061..aafb300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Surfaces the known scope tree through a sanctioned interface so external consume ### Added +- Commit-confirmed apply (safe apply). A config write can arm a rollback deadline via `?confirm=` (or the `X-Uapi-Confirm` header behind a proxy that forwards it; uhttpd's CGI strips custom headers, so the query form is the portable interface): uapi snapshots the affected uci packages, commits, and returns `202 Accepted` with a `confirm` token; unless the client acks via `POST /confirm/` before the deadline, the pre-change snapshot is restored automatically (surviving reboot / process kill / a dead management path). The ack is client-driven, so a network blip that hides the response also prevents the ack, keeping the auto-revert and the client's view consistent. New endpoints `GET /confirm`, `GET|POST|DELETE /confirm/` and scope `uapi:confirm` (`:ro` for status/list, `:rw` for ack/rollback; arming needs no extra scope). The rollback timer and durable state live in a separate package, `apply-confirm` (uapi invokes its CLI, runs no daemon of its own); the integration is optional and feature-detected, returning `501 confirm_unavailable` when apply-confirm is not installed. See `docs/commit-confirm.md`. (The 2.3.0 tag is held until apply-confirm reaches a stable feed release.) + - New CLI subcommand `uapi-token scopes` printing one scope path per line (sorted, greppable). Pair with `--json` for a JSON array suitable for piping into `jq` or any other consumer. The CLI is the durable cross-package interface; it works from any shell, Ansible playbook, or fleet inventory tool that can `ssh` to the router. - New `scope.known_paths()` module export returning a sorted array of scope paths. ucode consumers on the same box (the LuCI frontend in particular) `require('scope')` and call this directly, matching how `uapi-token` itself imports the module. diff --git a/CLAUDE.md b/CLAUDE.md index 9aaf4b3..46067d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ CLAUDE.md is the every-turn meta document (principles, style, workflow). Topic-s | Raw passthrough stability + semantics | `docs/raw.md` | Working on `/raw/` or considering using it. | | Non-uci resources (apk, leases, password, authorized_keys) | `docs/non-uci-state.md` | Adding or modifying a resource whose source of truth isn't `/etc/config/`. | | Token CLI, HTTP token mint, scope syntax, raw-access composition, per-token rate limit overrides | `docs/tokens.md` | Anything auth-shaped from the operator angle. | +| Commit-confirmed apply (X-Uapi-Confirm, /confirm endpoints, apply-confirm integration) | `docs/commit-confirm.md` | Working on the confirm surface, the write-path stage seam, or the apply-confirm integration. | | Threat model, TLS posture, public endpoints, design exclusions | `docs/security.md` | Security review, threat-model questions, "why not rpcd sessions?" | | Error envelope, top-level + field-level codes, response headers | `docs/errors.md` | Defining a new error code or auditing error shapes. | | Observability (log categories, format, global rate limit, metrics, diagnostics, healthz, capacity) | `docs/operations.md` | Operator-facing setup, log forwarding, debugging in the field. Cross-references `docs/architecture.md` ยง Rate limit / Metrics for the implementation-side mechanics. | diff --git a/build/gen_openapi.uc b/build/gen_openapi.uc index 29fd13c..89e8710 100755 --- a/build/gen_openapi.uc +++ b/build/gen_openapi.uc @@ -195,6 +195,21 @@ function responses(verb, success) { return r; } +const CONFIRM_PARAM = { "$ref": "#/components/parameters/Confirm" }; +const CONFIRM_202 = { "$ref": "#/components/responses/PendingConfirm" }; + +// Mark a uci-config-write operation as commit-confirm-capable: it accepts the +// X-Uapi-Confirm header and may return 202 with an armed rollback window. +// Scoped to config writes (resource CRUD, singleton patch, batch) - NOT token / +// package / system-access writes, which apply-confirm does not snapshot. +function with_confirm(op) { + if (op == null) return op; + op.parameters = op.parameters ?? []; + push(op.parameters, CONFIRM_PARAM); + op.responses["202"] = CONFIRM_202; + return op; +} + function build_crud_paths(ep) { let schema_ref = schema_name(ep); let mod = load_resource(ep.file); @@ -286,12 +301,18 @@ function build_crud_paths(ep) { }, }; + with_confirm(paths[ep.path].post); + with_confirm(paths[ep.path + "/{id}"].put); + with_confirm(paths[ep.path + "/{id}"].patch); + with_confirm(paths[ep.path + "/{id}"].delete); + with_confirm(paths[ep.path + "/{id}/adopt"].post); + return paths; } function build_singleton_paths(ep) { let schema_ref = schema_name(ep); - return { + let paths = { [ep.path]: { "get": { "summary": sprintf("Get the %s singleton", ep.domain), "description": "Conditional GET via If-None-Match (or ?if_none_match=).", @@ -308,6 +329,8 @@ function build_singleton_paths(ep) { "responses": responses("patch", { "200": make_response(200, "Updated", schema_ref) }) }, }, }; + with_confirm(paths[ep.path].patch); + return paths; } function build_collection_paths(ep) { @@ -397,6 +420,7 @@ const TAGS = [ { name: "Operational / Metrics", group: "Operational endpoints", description: "Prometheus 0.0.4 text. Path-template labels normalize concrete ids.", path_prefix: "/metrics" }, { name: "Operational / Diagnostics", group: "Operational endpoints", description: "Lock state, uptime, loaded resources.", path_prefix: "/diagnostics" }, { name: "Operational / Batch", group: "Operational endpoints", description: "Multi-package atomic transaction (max 50 ops). 207 Multi-Status on success.", path_prefix: "/batch" }, + { name: "Operational / Commit-confirm", group: "Operational endpoints", description: "Confirm or roll back a commit-confirmed apply (apply-confirm). Requires the apply-confirm package.", path_prefix: "/confirm" }, ]; function build_tags() { @@ -681,6 +705,55 @@ function build_paths() { }), }, }; + // Confirm-capable; the batch 202 carries the BatchResponse plus one confirm + // window spanning all touched packages. + paths["/batch"].post.parameters = [CONFIRM_PARAM]; + paths["/batch"].post.responses["202"] = { + "description": "Accepted: every sub-request succeeded and a commit-confirmed rollback is armed over all touched packages. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", + "headers": { + "X-Confirm-Token": { "description": "The armed rollback token; pass to /confirm/{token}.", "schema": { "type": "string", "pattern": "^ac_[0-9]+_[0-9a-f]{8}$" } }, + "X-Confirm-Deadline": { "description": "Best-effort unix epoch of auto-rollback.", "schema": { "type": "integer" } }, + }, + "content": { "application/json": { "schema": { "allOf": [ + { "$ref": "#/components/schemas/BatchResponse" }, + { "type": "object", "required": ["confirm"], "properties": { "confirm": { "$ref": "#/components/schemas/ConfirmWindow" } } }, + ] } } }, + }; + + let confirm_token_param = { "name": "token", "in": "path", "required": true, + "schema": { "type": "string", "pattern": "^ac_[0-9]+_[0-9a-f]{8}$" } }; + paths["/confirm"] = { + "get": { + "summary": "List pending confirm windows", + "description": "Passthrough to `apply-confirm list --json`. Scope: uapi:confirm:ro (or *:ro). Requires the apply-confirm package. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", + "responses": responses("get", { + "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ConfirmWindow" } } } } }, + }), + }, + }; + paths["/confirm/{token}"] = { + "parameters": [confirm_token_param], + "get": { + "summary": "Status of a pending confirm window", + "description": "Passthrough to `apply-confirm status --json` (authoritative remaining time). Scope: uapi:confirm:ro. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", + "responses": responses("get", { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConfirmWindow" } } } } }), + }, + "post": { + "summary": "Confirm (ack) a pending apply", + "description": "Acks the window so the change is NOT rolled back. Scope: uapi:confirm:rw. 409 confirm_window_closed if the window already expired or closed. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", + "responses": responses("post", { "200": { "description": "Confirmed", "content": { "application/json": { "schema": { "type": "object", "properties": { "confirmed": { "type": "string" } } } } } } }), + }, + "delete": { + "summary": "Roll back a pending apply now", + "description": "Restores the snapshot immediately (early/forced revert). Scope: uapi:confirm:rw. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", + "responses": responses("delete", { "200": { "description": "Rolled back", "content": { "application/json": { "schema": { "type": "object", "properties": { "rolled_back": { "type": "string" } } } } } } }), + }, + }; // Tag non-curated paths. Longest-prefix-wins so /system/password doesn't // get the bare "System" tag. @@ -714,6 +787,17 @@ function build_paths() { function build_schemas() { let schemas = { + "ConfirmWindow": { + "type": "object", + "required": ["token", "timeout", "deadline", "packages"], + "description": "An armed commit-confirmed rollback window (apply-confirm). Returned in the 202 body of a confirmed write.", + "properties": { + "token": { "type": "string", "pattern": "^ac_[0-9]+_[0-9a-f]{8}$", "description": "Pass to POST /confirm/{token} to ack, or DELETE /confirm/{token} to roll back now." }, + "timeout": { "type": "integer", "description": "Seconds the window stays armed." }, + "deadline": { "type": "integer", "description": "Best-effort unix epoch of auto-rollback; GET /confirm/{token} is authoritative." }, + "packages": { "type": "array", "items": { "type": "string" }, "description": "uci packages snapshotted and restored on rollback." }, + }, + }, "ErrorEnvelope": { "type": "object", "required": ["code", "message", "request_id"], @@ -1159,8 +1243,30 @@ function build_doc() { "ValidationFailed": { "description": "Request body failed validation (per-field errors in `errors[]`)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "Locked": { "description": "Another write holds the same per-package lock; retry after Retry-After seconds", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }, "headers": { "Retry-After": { "$ref": "#/components/headers/RetryAfter" } } }, "TooManyRequests": { "description": "Per-token rate limit exceeded; retry after Retry-After seconds", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } }, "headers": { "Retry-After": { "$ref": "#/components/headers/RetryAfter" } } }, - "InternalError": { "description": "Server error (codes: internal_error, reload_failed_restored, reload_failed_unrecovered)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, - "ServiceUnavailable": { "description": "Service unavailable (codes: service_unavailable, init_script_missing)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "InternalError": { "description": "Server error (codes: internal_error, reload_failed_restored, reload_failed_unrecovered, rollback_reload_failed)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "ServiceUnavailable": { "description": "Service unavailable (codes: service_unavailable, init_script_missing, confirm_stage_failed)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "PendingConfirm": { + "description": "Write committed with a commit-confirmed rollback armed. The body is the normal resource representation plus a `confirm` block; unless POST /confirm/{token} acks before the deadline, apply-confirm restores the pre-change snapshot. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", + "headers": { + "X-Confirm-Token": { "description": "The armed rollback token; pass to /confirm/{token}.", "schema": { "type": "string", "pattern": "^ac_[0-9]+_[0-9a-f]{8}$" } }, + "X-Confirm-Deadline": { "description": "Best-effort unix epoch of auto-rollback; poll GET /confirm/{token} for authoritative remaining time.", "schema": { "type": "integer" } }, + }, + "content": { "application/json": { "schema": { + "type": "object", + "required": ["confirm"], + "description": "The resource representation plus the armed `confirm` window.", + "properties": { "confirm": { "$ref": "#/components/schemas/ConfirmWindow" } }, + } } }, + }, + }, + "parameters": { + "Confirm": { + "name": "confirm", "in": "query", "required": false, + "schema": { "type": "integer", "minimum": 1, "maximum": 3600 }, + "x-uapi-requires": "apply-confirm", + "description": "Arm a commit-confirmed rollback on this write: snapshot the affected uci packages and auto-revert after this many seconds unless POST /confirm/{token} acks first. The query form is the portable interface; the `X-Uapi-Confirm` header also works but only behind a proxy that forwards it (uhttpd's CGI env strips custom headers via a hard-coded allowlist). When set, the success status is 202 with a `confirm` block. Requires the apply-confirm package; without it the write returns 501 confirm_unavailable.", + }, }, }, }; diff --git a/build/openapi.json b/build/openapi.json index b70d9f1..9097402 100644 --- a/build/openapi.json +++ b/build/openapi.json @@ -243,6 +243,10 @@ { "name": "Operational / Batch", "description": "Multi-package atomic transaction (max 50 ops). 207 Multi-Status on success." + }, + { + "name": "Operational / Commit-confirm", + "description": "Confirm or roll back a commit-confirmed apply (apply-confirm). Requires the apply-confirm package." } ], "x-tagGroups": [ @@ -348,7 +352,8 @@ "Operational / Schema discovery", "Operational / Metrics", "Operational / Diagnostics", - "Operational / Batch" + "Operational / Batch", + "Operational / Commit-confirm" ] } ], @@ -500,8 +505,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Rules" ] @@ -645,8 +658,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Rules" ] @@ -727,8 +748,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Rules" ] @@ -782,8 +811,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Rules" ] @@ -864,8 +901,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Rules" ] @@ -1012,8 +1057,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Zones" ] @@ -1157,8 +1210,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Zones" ] @@ -1239,8 +1300,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Zones" ] @@ -1294,8 +1363,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Zones" ] @@ -1376,8 +1453,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Zones" ] @@ -1524,8 +1609,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Redirects" ] @@ -1669,8 +1762,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Redirects" ] @@ -1751,8 +1852,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Redirects" ] @@ -1806,8 +1915,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Redirects" ] @@ -1888,8 +2005,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Redirects" ] @@ -2036,8 +2161,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Forwardings" ] @@ -2181,8 +2314,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Forwardings" ] @@ -2263,8 +2404,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Forwardings" ] @@ -2318,8 +2467,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Forwardings" ] @@ -2400,8 +2557,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Forwardings" ] @@ -2539,8 +2704,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Firewall / Defaults" ] @@ -2687,8 +2860,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Interfaces" ] @@ -2832,8 +3013,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Interfaces" ] @@ -2914,8 +3103,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Interfaces" ] @@ -2969,8 +3166,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Interfaces" ] @@ -3051,8 +3256,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Interfaces" ] @@ -3199,8 +3412,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Devices" ] @@ -3344,8 +3565,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Devices" ] @@ -3426,8 +3655,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Devices" ] @@ -3481,8 +3718,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Devices" ] @@ -3563,8 +3808,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Devices" ] @@ -3711,8 +3964,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Routes" ] @@ -3856,8 +4117,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Routes" ] @@ -3938,8 +4207,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Routes" ] @@ -3993,8 +4270,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Routes" ] @@ -4075,8 +4360,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Routes" ] @@ -4223,8 +4516,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Rules" ] @@ -4368,8 +4669,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Rules" ] @@ -4450,8 +4759,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Rules" ] @@ -4505,8 +4822,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Rules" ] @@ -4587,8 +4912,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Rules" ] @@ -4735,8 +5068,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Bridge Vlans" ] @@ -4880,8 +5221,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Bridge Vlans" ] @@ -4962,8 +5311,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Bridge Vlans" ] @@ -5017,8 +5374,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Bridge Vlans" ] @@ -5099,8 +5464,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Bridge Vlans" ] @@ -5247,8 +5620,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Wireguard Peers" ] @@ -5392,8 +5773,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Wireguard Peers" ] @@ -5474,8 +5863,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Wireguard Peers" ] @@ -5529,8 +5926,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Network / Wireguard Peers" ] @@ -5611,11 +6016,19 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, - "tags": [ - "Network / Wireguard Peers" - ] + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], + "tags": [ + "Network / Wireguard Peers" + ] } }, "/wireless/devices": { @@ -5759,8 +6172,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Devices" ] @@ -5904,8 +6325,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Devices" ] @@ -5986,8 +6415,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Devices" ] @@ -6041,8 +6478,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Devices" ] @@ -6123,8 +6568,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Devices" ] @@ -6271,8 +6724,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Interfaces" ] @@ -6416,8 +6877,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Interfaces" ] @@ -6498,8 +6967,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Interfaces" ] @@ -6553,8 +7030,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Interfaces" ] @@ -6635,8 +7120,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Wireless / Interfaces" ] @@ -6783,8 +7276,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Hosts" ] @@ -6928,8 +7429,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Hosts" ] @@ -7010,8 +7519,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Hosts" ] @@ -7065,8 +7582,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Hosts" ] @@ -7147,8 +7672,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Hosts" ] @@ -7511,8 +8044,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Servers" ] @@ -7656,8 +8197,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Servers" ] @@ -7738,8 +8287,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Servers" ] @@ -7793,8 +8350,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Servers" ] @@ -7875,8 +8440,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Servers" ] @@ -8014,8 +8587,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Dnsmasq" ] @@ -8153,8 +8734,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dhcp / Odhcpd" ] @@ -8292,8 +8881,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "System" ] @@ -8440,8 +9037,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "System / Timeservers" ] @@ -8585,8 +9190,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "System / Timeservers" ] @@ -8667,8 +9280,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "System / Timeservers" ] @@ -8722,8 +9343,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "System / Timeservers" ] @@ -8804,8 +9433,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "System / Timeservers" ] @@ -8952,8 +9589,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dropbear / Instances" ] @@ -9097,8 +9742,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dropbear / Instances" ] @@ -9179,8 +9832,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dropbear / Instances" ] @@ -9234,8 +9895,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dropbear / Instances" ] @@ -9316,8 +9985,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Dropbear / Instances" ] @@ -9464,8 +10141,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Instances" ] @@ -9609,8 +10294,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Instances" ] @@ -9691,8 +10384,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Instances" ] @@ -9746,8 +10447,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Instances" ] @@ -9828,8 +10537,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Instances" ] @@ -9976,8 +10693,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Certs" ] @@ -10121,8 +10846,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Certs" ] @@ -10203,8 +10936,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Certs" ] @@ -10258,8 +10999,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Certs" ] @@ -10340,8 +11089,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Uhttpd / Certs" ] @@ -10479,8 +11236,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Unbound / Server" ] @@ -10618,8 +11383,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Unbound / Srv" ] @@ -10757,8 +11530,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Unbound / Ext" ] @@ -10905,8 +11686,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Sqm / Queues" ] @@ -11050,8 +11839,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Sqm / Queues" ] @@ -11132,8 +11929,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Sqm / Queues" ] @@ -11187,11 +11992,19 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, - "tags": [ - "Sqm / Queues" - ] + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], + "tags": [ + "Sqm / Queues" + ] } }, "/sqm/queues/{id}/adopt": { @@ -11269,8 +12082,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Sqm / Queues" ] @@ -11417,8 +12238,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Agents" ] @@ -11562,8 +12391,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Agents" ] @@ -11644,8 +12481,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Agents" ] @@ -11699,8 +12544,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Agents" ] @@ -11781,8 +12634,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Agents" ] @@ -11929,8 +12790,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Com2secs" ] @@ -12074,8 +12943,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Com2secs" ] @@ -12156,8 +13033,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Com2secs" ] @@ -12211,8 +13096,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Com2secs" ] @@ -12293,8 +13186,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Com2secs" ] @@ -12441,8 +13342,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Groups" ] @@ -12586,8 +13495,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Groups" ] @@ -12668,8 +13585,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Groups" ] @@ -12723,8 +13648,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Groups" ] @@ -12805,8 +13738,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Groups" ] @@ -12953,8 +13894,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Accesses" ] @@ -13098,8 +14047,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Accesses" ] @@ -13180,8 +14137,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Accesses" ] @@ -13235,8 +14200,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Accesses" ] @@ -13317,8 +14290,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / Accesses" ] @@ -13456,8 +14437,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Snmpd / System" ] @@ -13595,8 +14584,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Lldpd / Config" ] @@ -13734,8 +14731,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Prometheus Node Exporter Lua / Config" ] @@ -13873,8 +14878,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Vnstat / Config" ] @@ -14021,8 +15034,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Vnstat / Interfaces" ] @@ -14166,8 +15187,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Vnstat / Interfaces" ] @@ -14248,8 +15277,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Vnstat / Interfaces" ] @@ -14303,8 +15340,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Vnstat / Interfaces" ] @@ -14385,8 +15430,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Vnstat / Interfaces" ] @@ -14524,8 +15577,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Globals" ] @@ -14672,8 +15733,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Interfaces" ] @@ -14817,8 +15886,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Interfaces" ] @@ -14899,8 +15976,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Interfaces" ] @@ -14954,8 +16039,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Interfaces" ] @@ -15036,8 +16129,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Interfaces" ] @@ -15184,8 +16285,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Members" ] @@ -15329,8 +16438,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Members" ] @@ -15411,8 +16528,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Members" ] @@ -15466,8 +16591,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Members" ] @@ -15548,8 +16681,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Members" ] @@ -15696,8 +16837,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Policies" ] @@ -15841,8 +16990,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Policies" ] @@ -15923,8 +17080,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Policies" ] @@ -15978,8 +17143,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Policies" ] @@ -16060,8 +17233,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Policies" ] @@ -16208,8 +17389,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Rules" ] @@ -16353,8 +17542,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Rules" ] @@ -16435,8 +17632,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Rules" ] @@ -16490,11 +17695,19 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, - "tags": [ - "Mwan3 / Rules" - ] + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], + "tags": [ + "Mwan3 / Rules" + ] } }, "/mwan3/rules/{id}/adopt": { @@ -16572,8 +17785,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Mwan3 / Rules" ] @@ -16711,8 +17932,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Usteer / Config" ] @@ -16859,8 +18088,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Openvpn / Instances" ] @@ -17004,8 +18241,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Openvpn / Instances" ] @@ -17086,8 +18331,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Openvpn / Instances" ] @@ -17141,8 +18394,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Openvpn / Instances" ] @@ -17223,8 +18484,16 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "$ref": "#/components/responses/PendingConfirm" } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ "Openvpn / Instances" ] @@ -18774,29 +20043,322 @@ } }, "tags": [ - "Auth / Tokens" + "Auth / Tokens" + ] + }, + "post": { + "summary": "Mint a new token over HTTP", + "description": "Scope: uapi:tokens:rw (or *:rw). Requested scopes MUST be a strict subset of the caller's own; escalation returns 403 scope_escalation_blocked. The cleartext bearer is returned exactly once.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenCreateResponse" + } + } + }, + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/XRequestId" + }, + "X-Reload-Status": { + "$ref": "#/components/headers/XReloadStatus" + }, + "X-Reload-Services": { + "$ref": "#/components/headers/XReloadServices" + }, + "Idempotent-Replayed": { + "$ref": "#/components/headers/IdempotentReplayed" + }, + "ETag": { + "$ref": "#/components/headers/ETag" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/PreconditionFailed" + }, + "422": { + "$ref": "#/components/responses/ValidationFailed" + }, + "423": { + "$ref": "#/components/responses/Locked" + } + }, + "tags": [ + "Auth / Tokens" + ] + } + }, + "/tokens/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "Get one token's metadata", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenMetadata" + } + } + }, + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/XRequestId" + }, + "ETag": { + "$ref": "#/components/headers/ETag" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "tags": [ + "Auth / Tokens" + ] + }, + "delete": { + "summary": "Revoke a token", + "responses": { + "204": { + "description": "Revoked", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/XRequestId" + }, + "X-Reload-Status": { + "$ref": "#/components/headers/XReloadStatus" + }, + "X-Reload-Services": { + "$ref": "#/components/headers/XReloadServices" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/PreconditionFailed" + }, + "422": { + "$ref": "#/components/responses/ValidationFailed" + }, + "423": { + "$ref": "#/components/responses/Locked" + } + }, + "tags": [ + "Auth / Tokens" + ] + } + }, + "/metrics": { + "get": { + "summary": "Prometheus 0.0.4 text exposition", + "description": "Scope: uapi:metrics:ro (or *:ro). Series: uapi_requests_total, uapi_request_duration_seconds_bucket, uapi_request_duration_seconds_count, uapi_rate_limit_drops_total, uapi_lock_contention_total, uapi_validate_errors_total. Path-template labels normalize concrete ids to :id to keep cardinality bounded.", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "uapi_requests_total{method=\"GET\",path=\"/firewall/rules\",status=\"200\"} 42\n" + } + }, + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/XRequestId" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "tags": [ + "Operational / Metrics" + ] + } + }, + "/diagnostics": { + "get": { + "summary": "Operational snapshot (lock state, uptime, loaded resources)", + "description": "Scope: uapi:diagnostics:ro (or *:ro).", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiagnosticsResponse" + } + } + }, + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/XRequestId" + }, + "ETag": { + "$ref": "#/components/headers/ETag" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "tags": [ + "Operational / Diagnostics" ] - }, + } + }, + "/batch": { "post": { - "summary": "Mint a new token over HTTP", - "description": "Scope: uapi:tokens:rw (or *:rw). Requested scopes MUST be a strict subset of the caller's own; escalation returns 403 scope_escalation_blocked. The cleartext bearer is returned exactly once.", + "summary": "Multi-package atomic transaction", + "description": "Each sub-request is scope-checked independently. Pure-read batches acquire no lock. Writes acquire per-package EX locks in sorted order (deadlock-free) under one combined snapshot/restore. First sub-request failure aborts the batch and reverts all packages; success returns 207 Multi-Status with the per-sub-request results. Max 50 ops.", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenCreateRequest" + "$ref": "#/components/schemas/BatchRequest" } } } }, "responses": { - "200": { - "description": "Created", + "207": { + "description": "Multi-Status: every sub-request succeeded", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenCreateResponse" + "$ref": "#/components/schemas/BatchResponse" } } }, @@ -18812,9 +20374,6 @@ }, "Idempotent-Replayed": { "$ref": "#/components/headers/IdempotentReplayed" - }, - "ETag": { - "$ref": "#/components/headers/ETag" } } }, @@ -18850,85 +20409,80 @@ }, "423": { "$ref": "#/components/responses/Locked" + }, + "202": { + "description": "Accepted: every sub-request succeeded and a commit-confirmed rollback is armed over all touched packages. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", + "headers": { + "X-Confirm-Token": { + "description": "The armed rollback token; pass to /confirm/{token}.", + "schema": { + "type": "string", + "pattern": "^ac_[0-9]+_[0-9a-f]{8}$" + } + }, + "X-Confirm-Deadline": { + "description": "Best-effort unix epoch of auto-rollback.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/BatchResponse" + }, + { + "type": "object", + "required": [ + "confirm" + ], + "properties": { + "confirm": { + "$ref": "#/components/schemas/ConfirmWindow" + } + } + } + ] + } + } + } } }, + "parameters": [ + { + "$ref": "#/components/parameters/Confirm" + } + ], "tags": [ - "Auth / Tokens" + "Operational / Batch" ] } }, - "/tokens/{id}": { - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], + "/confirm": { "get": { - "summary": "Get one token's metadata", + "summary": "List pending confirm windows", + "description": "Passthrough to `apply-confirm list --json`. Scope: uapi:confirm:ro (or *:ro). Requires the apply-confirm package. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TokenMetadata" + "type": "array", + "items": { + "$ref": "#/components/schemas/ConfirmWindow" + } } } }, "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" - }, - "ETag": { - "$ref": "#/components/headers/ETag" - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/TooManyRequests" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - }, - "tags": [ - "Auth / Tokens" - ] - }, - "delete": { - "summary": "Revoke a token", - "responses": { - "204": { - "description": "Revoked", - "headers": { - "X-Request-Id": { - "$ref": "#/components/headers/XRequestId" - }, - "X-Reload-Status": { - "$ref": "#/components/headers/XReloadStatus" - }, - "X-Reload-Services": { - "$ref": "#/components/headers/XReloadServices" } } }, @@ -18952,43 +20506,45 @@ }, "503": { "$ref": "#/components/responses/ServiceUnavailable" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "412": { - "$ref": "#/components/responses/PreconditionFailed" - }, - "422": { - "$ref": "#/components/responses/ValidationFailed" - }, - "423": { - "$ref": "#/components/responses/Locked" } }, "tags": [ - "Auth / Tokens" + "Operational / Commit-confirm" ] } }, - "/metrics": { + "/confirm/{token}": { + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^ac_[0-9]+_[0-9a-f]{8}$" + } + } + ], "get": { - "summary": "Prometheus 0.0.4 text exposition", - "description": "Scope: uapi:metrics:ro (or *:ro). Series: uapi_requests_total, uapi_request_duration_seconds_bucket, uapi_request_duration_seconds_count, uapi_rate_limit_drops_total, uapi_lock_contention_total, uapi_validate_errors_total. Path-template labels normalize concrete ids to :id to keep cardinality bounded.", + "summary": "Status of a pending confirm window", + "description": "Passthrough to `apply-confirm status --json` (authoritative remaining time). Scope: uapi:confirm:ro. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", "responses": { "200": { "description": "OK", "content": { - "text/plain": { + "application/json": { "schema": { - "type": "string" - }, - "example": "uapi_requests_total{method=\"GET\",path=\"/firewall/rules\",status=\"200\"} 42\n" + "$ref": "#/components/schemas/ConfirmWindow" + } } }, "headers": { "X-Request-Id": { "$ref": "#/components/headers/XRequestId" + }, + "ETag": { + "$ref": "#/components/headers/ETag" } } }, @@ -19015,21 +20571,25 @@ } }, "tags": [ - "Operational / Metrics" + "Operational / Commit-confirm" ] - } - }, - "/diagnostics": { - "get": { - "summary": "Operational snapshot (lock state, uptime, loaded resources)", - "description": "Scope: uapi:diagnostics:ro (or *:ro).", + }, + "post": { + "summary": "Confirm (ack) a pending apply", + "description": "Acks the window so the change is NOT rolled back. Scope: uapi:confirm:rw. 409 confirm_window_closed if the window already expired or closed. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", "responses": { "200": { - "description": "OK", + "description": "Confirmed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DiagnosticsResponse" + "type": "object", + "properties": { + "confirmed": { + "type": "string" + } + } } } }, @@ -19037,6 +20597,15 @@ "X-Request-Id": { "$ref": "#/components/headers/XRequestId" }, + "X-Reload-Status": { + "$ref": "#/components/headers/XReloadStatus" + }, + "X-Reload-Services": { + "$ref": "#/components/headers/XReloadServices" + }, + "Idempotent-Replayed": { + "$ref": "#/components/headers/IdempotentReplayed" + }, "ETag": { "$ref": "#/components/headers/ETag" } @@ -19062,34 +20631,40 @@ }, "503": { "$ref": "#/components/responses/ServiceUnavailable" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "412": { + "$ref": "#/components/responses/PreconditionFailed" + }, + "422": { + "$ref": "#/components/responses/ValidationFailed" + }, + "423": { + "$ref": "#/components/responses/Locked" } }, "tags": [ - "Operational / Diagnostics" + "Operational / Commit-confirm" ] - } - }, - "/batch": { - "post": { - "summary": "Multi-package atomic transaction", - "description": "Each sub-request is scope-checked independently. Pure-read batches acquire no lock. Writes acquire per-package EX locks in sorted order (deadlock-free) under one combined snapshot/restore. First sub-request failure aborts the batch and reverts all packages; success returns 207 Multi-Status with the per-sub-request results. Max 50 ops.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchRequest" - } - } - } - }, + }, + "delete": { + "summary": "Roll back a pending apply now", + "description": "Restores the snapshot immediately (early/forced revert). Scope: uapi:confirm:rw. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", "responses": { - "207": { - "description": "Multi-Status: every sub-request succeeded", + "200": { + "description": "Rolled back", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BatchResponse" + "type": "object", + "properties": { + "rolled_back": { + "type": "string" + } + } } } }, @@ -19103,8 +20678,8 @@ "X-Reload-Services": { "$ref": "#/components/headers/XReloadServices" }, - "Idempotent-Replayed": { - "$ref": "#/components/headers/IdempotentReplayed" + "ETag": { + "$ref": "#/components/headers/ETag" } } }, @@ -19143,13 +20718,45 @@ } }, "tags": [ - "Operational / Batch" + "Operational / Commit-confirm" ] } } }, "components": { "schemas": { + "ConfirmWindow": { + "type": "object", + "required": [ + "token", + "timeout", + "deadline", + "packages" + ], + "description": "An armed commit-confirmed rollback window (apply-confirm). Returned in the 202 body of a confirmed write.", + "properties": { + "token": { + "type": "string", + "pattern": "^ac_[0-9]+_[0-9a-f]{8}$", + "description": "Pass to POST /confirm/{token} to ack, or DELETE /confirm/{token} to roll back now." + }, + "timeout": { + "type": "integer", + "description": "Seconds the window stays armed." + }, + "deadline": { + "type": "integer", + "description": "Best-effort unix epoch of auto-rollback; GET /confirm/{token} is authoritative." + }, + "packages": { + "type": "array", + "items": { + "type": "string" + }, + "description": "uci packages snapshotted and restored on rollback." + } + } + }, "ErrorEnvelope": { "type": "object", "required": [ @@ -19183,6 +20790,11 @@ "reload_failed_unrecovered", "service_unavailable", "init_script_missing", + "confirm_unavailable", + "already_armed", + "confirm_window_closed", + "confirm_stage_failed", + "rollback_reload_failed", "batch_partial_failure" ], "description": "Machine-readable error code. Stable within a major. Clients should branch on HTTP status first and treat unknown codes gracefully (the project's additive contract permits new codes within a major)." @@ -24169,7 +25781,7 @@ } }, "InternalError": { - "description": "Server error (codes: internal_error, reload_failed_restored, reload_failed_unrecovered)", + "description": "Server error (codes: internal_error, reload_failed_restored, reload_failed_unrecovered, rollback_reload_failed)", "content": { "application/json": { "schema": { @@ -24179,7 +25791,7 @@ } }, "ServiceUnavailable": { - "description": "Service unavailable (codes: service_unavailable, init_script_missing)", + "description": "Service unavailable (codes: service_unavailable, init_script_missing, confirm_stage_failed)", "content": { "application/json": { "schema": { @@ -24187,6 +25799,55 @@ } } } + }, + "PendingConfirm": { + "description": "Write committed with a commit-confirmed rollback armed. The body is the normal resource representation plus a `confirm` block; unless POST /confirm/{token} acks before the deadline, apply-confirm restores the pre-change snapshot. (x-uapi-requires: apply-confirm)", + "x-uapi-requires": "apply-confirm", + "headers": { + "X-Confirm-Token": { + "description": "The armed rollback token; pass to /confirm/{token}.", + "schema": { + "type": "string", + "pattern": "^ac_[0-9]+_[0-9a-f]{8}$" + } + }, + "X-Confirm-Deadline": { + "description": "Best-effort unix epoch of auto-rollback; poll GET /confirm/{token} for authoritative remaining time.", + "schema": { + "type": "integer" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "confirm" + ], + "description": "The resource representation plus the armed `confirm` window.", + "properties": { + "confirm": { + "$ref": "#/components/schemas/ConfirmWindow" + } + } + } + } + } + } + }, + "parameters": { + "Confirm": { + "name": "confirm", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 3600 + }, + "x-uapi-requires": "apply-confirm", + "description": "Arm a commit-confirmed rollback on this write: snapshot the affected uci packages and auto-revert after this many seconds unless POST /confirm/{token} acks first. The query form is the portable interface; the `X-Uapi-Confirm` header also works but only behind a proxy that forwards it (uhttpd's CGI env strips custom headers via a hard-coded allowlist). When set, the success status is 202 with a `confirm` block. Requires the apply-confirm package; without it the write returns 501 confirm_unavailable." } } } diff --git a/docs/architecture.md b/docs/architecture.md index baf6deb..92be995 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -236,11 +236,12 @@ current ETag doesn't match, return `412 precondition_failed` BEFORE any uci write. `If-Match: *` matches any existing resource. Absent header preserves last-write-wins (opt-in concurrency). -uhttpd's CGI env strips `If-Match`, `If-None-Match`, `X-Request-Id`, and -`Idempotency-Key` (hard-coded allowlist in uhttpd source). All four have -`?if_match=` / `?if_none_match=` / `?request_id=` / `?idempotency_key=` -query-string fallbacks; a reverse proxy in front of uhttpd that forwards -the headers still works via the header path. +uhttpd's CGI env strips `If-Match`, `If-None-Match`, `X-Request-Id`, +`Idempotency-Key`, and `X-Uapi-Confirm` (hard-coded allowlist in uhttpd +source). All have query-string fallbacks (`?if_match=` / `?if_none_match=` / +`?request_id=` / `?idempotency_key=` / `?confirm=`) which are the portable +interface; a reverse proxy in front of uhttpd that forwards the headers also +works via the header path. ## Rate limit token bucket @@ -353,6 +354,16 @@ batch's request_id with each sub-request's `.` line. | Apk install lock | (apk-internal) | Per-operation | | uapi global flock | `/var/lock/uapi.lock` | Per-transaction | | uapi per-package flock | `/var/lock/uapi.pkg..lock` | Per-transaction | +| Commit-confirm pending window | apply-confirm-owned (its durable state) | Until ack/timeout | + +The commit-confirm pending window (snapshot + deadline) is owned entirely by +the `apply-confirm` package, not uapi: uapi stages it via the CLI and surfaces +it read-only through `/confirm`. uapi holds no timer and no persisted confirm +state. Note the unconfirmed-window lock gap: the per-package flock is released +when the write's fork exits, so a second normal write to an armed package can +commit and then be clobbered by the auto-revert. apply-confirm's one-pending- +apply rule bounds this, and the Terraform provider serializes its own applies; +see `docs/commit-confirm.md`. `/tmp` is tmpfs on OpenWrt: rate-limit buckets, idempotency entries, and metrics counters reset on reboot. This is acceptable: operational diff --git a/docs/commit-confirm.md b/docs/commit-confirm.md new file mode 100644 index 0000000..2287180 --- /dev/null +++ b/docs/commit-confirm.md @@ -0,0 +1,112 @@ +# Commit-confirmed apply + +A uapi write is atomic per request (stage, validate, commit, reload, with an +in-band rollback if the reload command fails). That protects against a *failed* +reload. It does not protect against a reload that succeeds yet severs the +operator's only path to the box: a firewall or network change can reload +cleanly (the init script exits 0) and still lock you out, with no one left to +undo it. + +Commit-confirmed apply closes that gap. A write can arm a deadline: uapi +snapshots the affected uci packages and commits the change, and unless the +client confirms within the window, the snapshot is restored automatically. The +rollback fires locally and survives a reboot, a process kill, and the +management interface going down. + +uapi does not implement the timer or the durable state itself; that would mean a +long-running daemon, which the zero-bloat / no-aux-process principle forbids. +The supervisor lives in a separate package, `apply-confirm`, and uapi integrates +by invoking its CLI. See `apply-confirm`'s own docs for the rollback mechanism. + +## Optional, feature-detected + +The integration is optional. uapi probes for `/usr/sbin/apply-confirm` per +request: + +- Installed: the confirm surface is live. +- Absent: a write that asks to confirm returns `501 confirm_unavailable`, and an + ordinary write (no confirm) behaves exactly as before. There is no hard + package dependency; `apk add apply-confirm` is the opt-in. + +## Arming a write + +Add `?confirm=` to any config write (`POST`/`PUT`/`PATCH`/`DELETE` on a +resource, or `POST /batch`). The value is 1..3600. Arming needs no extra scope: +it is a safety modifier on a write the token is already authorized to perform. + +```sh +# Arm a 60s rollback on a firewall change. +curl -fsS -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -X PUT 'https://router/api/v2/firewall/rules/?confirm=60' -d @rule.json +``` + +The query parameter is the portable interface. uapi also reads an +`X-Uapi-Confirm: ` header, but uhttpd's CGI env strips it via a +hard-coded allowlist (the same reason `If-Match` and `Idempotency-Key` have +`?if_match=` / `?idempotency_key=` fallbacks); the header path only works when a +reverse proxy in front of uhttpd forwards it. + +A confirmed write returns **202 Accepted** instead of 200/204. The body is the +normal resource representation plus a `confirm` block, and the token is also in +the `X-Confirm-Token` response header: + +```json +{ + "...": "normal resource body", + "confirm": { + "token": "ac_1718900000_a1b2c3d4", + "timeout": 60, + "deadline": 1718900060, + "packages": ["firewall"] + } +} +``` + +`deadline` is best-effort (`now + timeout`); poll `GET /confirm/` for the +authoritative remaining time. + +## Confirming or rolling back + +Verify the management path still works, then confirm. If you cannot reach the +box, do nothing: the deadline fires and the change is reverted. + +| Method + path | Action | Scope | +|--------------------------|-------------------------------------|------------------| +| `POST /confirm/` | ack: keep the change | `uapi:confirm:rw`| +| `DELETE /confirm/`| roll back now (early/forced) | `uapi:confirm:rw`| +| `GET /confirm/` | status (authoritative remaining) | `uapi:confirm:ro`| +| `GET /confirm` | list pending windows | `uapi:confirm:ro`| + +A console operator with no network can still force the revert directly: +`apply-confirm rollback`. + +## What uapi reloads vs what apply-confirm reloads + +uapi owns the forward reload (applying the change) and the in-band rollback (if +that reload fails). apply-confirm owns the out-of-band rollback reload (timeout +or forced) because uapi's process is gone by then. uapi passes its own +authoritative reload set (`resource.reload`, unioned across a batch) to +`apply-confirm stage --service ...`, so a rollback reloads exactly what the +apply reloaded. + +## Failure modes + +| Condition | Response | +|-----------|----------| +| apply-confirm not installed | `501 confirm_unavailable`; nothing staged | +| another apply already armed (one pending at a time, across uapi/LuCI/shell) | `409 already_armed`; reverted | +| bad timeout | `400 bad_request`; reverted | +| snapshot/state write failed | `503 confirm_stage_failed`; nothing committed | +| apply reload fails while uapi is alive | in-band `reload_failed_restored`, and the now-pointless window is disarmed | +| apply bricks so hard the request never returns | apply-confirm's supervisor auto-reverts at the deadline, surviving reboot | +| ack after the window closed / double ack | `409 confirm_window_closed` | +| forced rollback restored config but a reload failed | `500 rollback_reload_failed` (the config WAS restored) | + +## Known limit: the unconfirmed window + +While a window is armed, the write's per-package lock has already been released +(uapi forks per request). A *second* normal write to the same package during the +window will commit, and then the pending auto-rollback can clobber it. The +single-pending-apply rule (one armed window globally) bounds the blast radius, +and the Terraform provider serializes its own applies, so this is a non-issue +for the primary consumer. Do not interleave manual writes with an armed window. diff --git a/docs/errors.md b/docs/errors.md index 1d6a5e0..173ae33 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -49,8 +49,13 @@ For DELETE success, the response is `204 No Content` with the `X-Request-Id` hea | 500 | `internal_error` | Bug or unexpected condition | | 500 | `reload_failed_restored` | Daemon reload failed; uapi rolled back the uci change | | 500 | `reload_failed_unrecovered` | Reload AND restore failed. Loudest case; manual recovery | +| 500 | `rollback_reload_failed` | Forced commit-confirm rollback restored uci but a reload failed (config WAS restored) | +| 501 | `confirm_unavailable` | `X-Uapi-Confirm` used but the apply-confirm package is not installed | | 503 | `service_unavailable` | ubus unreachable, service not running | | 503 | `init_script_missing` | `/etc/init.d/` not present for a resource's reload list | +| 503 | `confirm_stage_failed` | apply-confirm could not snapshot/arm the rollback window | +| 409 | `already_armed` | A commit-confirm window is already pending (one at a time) | +| 409 | `confirm_window_closed` | ack/rollback of a token with no open window (expired/unknown)| `batch_partial_failure` is special: it appears only in the body of a `POST /batch` abort response, with the HTTP status taken from the failing diff --git a/docs/installation.md b/docs/installation.md index a263139..a549ca6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -48,6 +48,10 @@ The feed carries **stable releases only**. Release candidates (`-rc`, `-alpha`, The stable line currently is `1.2.x` (`v2.0.0` is in RC at time of writing). `v1.2.1` stays available indefinitely for operators who need to pin to the v1 wire contract; `apk add 'uapi<2.0.0'` (or `apk add uapi=1.2.1-r1`) gets you there. +## Optional: commit-confirmed apply + +uapi does not depend on it, but installing the `apply-confirm` package enables the safe-apply (commit-confirmed rollback) surface: `apk add apply-confirm`. Without it, an ordinary write works as usual and a write that sets `X-Uapi-Confirm` returns `501 confirm_unavailable`. See `docs/commit-confirm.md`. + ## TLS uapi inherits TLS from the `main` uhttpd instance. By default OpenWrt ships a self-signed certificate (regenerated at first boot via `px5g`); browsers and curl complain, and over a real network this is **not adequate**. Two well-trodden options on OpenWrt: diff --git a/docs/roadmap.md b/docs/roadmap.md index 831ba56..e22d5ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -49,31 +49,32 @@ Items that are interesting but conflict with an architectural principle or have an unresolved design question. Not "later"; "later if the right shape appears". -### `commit-confirmed` timed rollback +### `commit-confirmed` timed rollback (shipped 2.3.0) -Apply, wait N seconds for client `POST /commits//confirm`, auto-revert -if no ack. Cancelled in v2 planning after user-driven analysis of the -failure mode: +Resolved by the sidecar path (option 1 below), built as a separate package +(`apply-confirm`) so uapi gains no daemon of its own; uapi integrates by +invoking its CLI. See `docs/commit-confirm.md`. The original +state-divergence objection ("the router reverts but Terraform thinks it's OK") +is handled by making the ack client-driven: a confirmed write returns 202 + a +token, and the client confirms only after verifying reachability. If the client +never sees the response, it also never acks, so its own apply is not marked +complete; the auto-revert and the client's view stay consistent. + +Sequencing: the wire surface ships in 2.3.0 but the 2.3.0 tag is held until +`apply-confirm` reaches a stable, feed-published release (it is a safety +primitive that soaks RC-first). The integration is optional and +feature-detected, so an install without apply-confirm is unaffected. + +The original v2-planning analysis, kept for context: > If the router gets no confirmation because of a network temporary issue > between the router and Terraform, it reverts, but Terraform thinks it's > OK. That state-divergence is worse than the original race we're trying > to solve. -Two viable redesigns to keep open: - -1. **Sidecar with webhook-on-revert.** Auto-revert posts to a client-side - webhook URL on rollback so the client knows to refresh its state. Breaks - "no daemon of our own" (needs a procd-managed timer holder), but the - sidecar is small and bounded. -2. **Fully synchronous "stage-and-test" pattern.** Stage uci changes to a - shadow config, run a separate `POST /commit//test` request that the - server runs internally with full reload + a programmable acceptance - probe, and only then accept-or-revert. No timer; the entire decision is - server-side and the wire response is the final answer. - -Either path is post-v2 work; needs a real Terraform-provider use case to -drive the choice. +The webhook-on-revert refinement (push a rollback notification to the client) +and the fully-synchronous "stage-and-test" pattern remain open as future +enhancements, not requirements. ## Features (additive, future minor bumps in v2.x) diff --git a/docs/tokens.md b/docs/tokens.md index 06952e7..d3cc83e 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -122,7 +122,7 @@ The authoritative source is `src/lib/scope.uc` `KNOWN_PATHS`. | `prometheus_node_exporter_lua` | `config` | | `vnstat` | `config`, `interfaces` | | `packages` | `installed`, `feeds` | -| `uapi` | `tokens`, `metrics`, `diagnostics` | +| `uapi` | `tokens`, `metrics`, `diagnostics`, `confirm` | | `raw` | `` (composes with the curated domain tree) | ## Deepest-match-wins diff --git a/src/lib/apply_confirm.uc b/src/lib/apply_confirm.uc new file mode 100644 index 0000000..ad081d4 --- /dev/null +++ b/src/lib/apply_confirm.uc @@ -0,0 +1,136 @@ +let fs = require('fs'); + +// Thin wrapper around the apply-confirm CLI (a separate OpenWrt package). uapi +// invokes it, never absorbs it: the rollback timer and durable state live in +// apply-confirm's procd supervisor, so uapi keeps its zero-daemon footprint. +// The integration is optional - when the binary is absent the confirm surface +// degrades to 501 and ordinary writes are unaffected. +const BIN = "/usr/sbin/apply-confirm"; + +// Package and service names that reach the shell. Matches transaction.uc's +// SAFE_NAME_RE. popen runs the string through sh, so every interpolated value +// is validated against this BEFORE it is concatenated - that is the injection +// control (ucode fs.popen has no argv-vector form). +const NAME_RE = /^[A-Za-z0-9_-]+$/; +// apply-confirm token: ac__<8 lowercase hex> (see its cli-contract). +const TOKEN_RE = /^ac_[0-9]+_[0-9a-f]{8}$/; + +function ac_present() { + return fs.stat(BIN) != null; +} + +function _run(args) { + let p = fs.popen(BIN + " " + args, "r"); + if (p == null) return { exit: -1, out: "" }; + let out = trim(p.read("all") ?? ""); + let exit = p.close(); + return { exit, out }; +} + +// Snapshot `packages`, arm a `timeout`-second rollback that reloads `services` +// on restore. Returns { ok: true, token, timeout, deadline } or +// { ok: false, kind, message } with kind a registered error code. +function ac_stage(packages, services, timeout) { + if (type(timeout) != "int" || timeout <= 0) + return { ok: false, kind: "bad_request", + message: "confirm timeout must be a positive integer" }; + if (type(packages) != "array" || length(packages) == 0) + return { ok: false, kind: "confirm_stage_failed", + message: "no packages to stage" }; + + let args = "stage --timeout " + timeout; + for (let pkg in packages) { + if (type(pkg) != "string" || !match(pkg, NAME_RE)) + return { ok: false, kind: "confirm_stage_failed", + message: sprintf("unsafe package name %J", pkg) }; + args += " --package " + pkg; + } + for (let svc in (services ?? [])) { + if (type(svc) != "string" || !match(svc, NAME_RE)) + return { ok: false, kind: "confirm_stage_failed", + message: sprintf("unsafe service name %J", svc) }; + args += " --service " + svc; + } + + if (!ac_present()) + return { ok: false, kind: "confirm_unavailable", + message: "apply-confirm is not installed" }; + + let r = _run(args); + if (r.exit == 0) { + if (!match(r.out, TOKEN_RE)) + return { ok: false, kind: "confirm_stage_failed", + message: sprintf("apply-confirm returned an unrecognized token %J", r.out) }; + return { ok: true, token: r.out, timeout, deadline: time() + timeout }; + } + if (r.exit == 3) + return { ok: false, kind: "already_armed", + message: "another apply is already armed (one pending apply at a time)" }; + if (r.exit == 2) + return { ok: false, kind: "bad_request", + message: "apply-confirm rejected the stage parameters" }; + return { ok: false, kind: "confirm_stage_failed", + message: sprintf("apply-confirm stage failed (exit %d)", r.exit) }; +} + +function _control(verb, token) { + if (!match(token ?? "", TOKEN_RE)) + return { ok: false, kind: "bad_request", message: "malformed confirm token" }; + if (!ac_present()) + return { ok: false, kind: "confirm_unavailable", + message: "apply-confirm is not installed" }; + let r = _run(verb + " " + token); + return { ok: r.exit == 0, exit: r.exit, out: r.out }; +} + +// ack: confirm the pending apply so it is NOT rolled back. +function ac_ack(token) { + let r = _control("ack", token); + if (r.kind != null) return r; + if (r.exit == 0) return { ok: true }; + if (r.exit == 4) + return { ok: false, kind: "confirm_window_closed", + message: "no such token, or the confirm window already closed" }; + return { ok: false, kind: "confirm_stage_failed", + message: sprintf("apply-confirm ack failed (exit %d)", r.exit) }; +} + +// rollback: restore the snapshot now (early/forced revert). +function ac_rollback(token) { + let r = _control("rollback", token); + if (r.kind != null) return r; + if (r.exit == 0) return { ok: true }; + if (r.exit == 4) + return { ok: false, kind: "confirm_window_closed", + message: "no such token, or nothing pending" }; + if (r.exit == 5) + return { ok: false, kind: "rollback_reload_failed", + message: "config WAS restored but a service reload failed; check logread" }; + return { ok: false, kind: "confirm_stage_failed", + message: sprintf("apply-confirm rollback failed (exit %d)", r.exit) }; +} + +// status / list are read passthroughs returning apply-confirm's JSON verbatim. +function ac_status(token) { + if (!match(token ?? "", TOKEN_RE)) + return { ok: false, kind: "bad_request", message: "malformed confirm token" }; + if (!ac_present()) + return { ok: false, kind: "confirm_unavailable", message: "apply-confirm is not installed" }; + let r = _run("status " + token + " --json"); + if (r.exit == 0) return { ok: true, json: r.out }; + if (r.exit == 4) + return { ok: false, kind: "not_found", message: "no such token" }; + return { ok: false, kind: "confirm_stage_failed", + message: sprintf("apply-confirm status failed (exit %d)", r.exit) }; +} + +function ac_list() { + if (!ac_present()) + return { ok: false, kind: "confirm_unavailable", message: "apply-confirm is not installed" }; + let r = _run("list --json"); + if (r.exit == 0) return { ok: true, json: r.out }; + return { ok: false, kind: "confirm_stage_failed", + message: sprintf("apply-confirm list failed (exit %d)", r.exit) }; +} + +return { ac_present, ac_stage, ac_ack, ac_rollback, ac_status, ac_list }; diff --git a/src/lib/errors.uc b/src/lib/errors.uc index 2338aa2..be25a8e 100644 --- a/src/lib/errors.uc +++ b/src/lib/errors.uc @@ -26,6 +26,12 @@ const STATUS_BY_CODE = { reload_failed_unrecovered: 500, service_unavailable: 503, init_script_missing: 503, + // commit-confirmed apply (apply-confirm integration) + confirm_unavailable: 501, + already_armed: 409, + confirm_window_closed: 409, + confirm_stage_failed: 503, + rollback_reload_failed: 500, }; const FIELD_CODES = { @@ -51,6 +57,8 @@ const ALL_CODES = [ "validation_failed", "locked", "too_many_requests", "internal_error", "reload_failed_restored", "reload_failed_unrecovered", "service_unavailable", "init_script_missing", + "confirm_unavailable", "already_armed", "confirm_window_closed", + "confirm_stage_failed", "rollback_reload_failed", "batch_partial_failure", ]; diff --git a/src/lib/handler.uc b/src/lib/handler.uc index 2057bcc..2ab4b61 100644 --- a/src/lib/handler.uc +++ b/src/lib/handler.uc @@ -337,8 +337,24 @@ function attach_reload_headers(resp, result) { function translate_tx(ctx, result) { if (result.ok) { let resp = attach_reload_headers(errors.ok(ctx, result.body), result); - return (result.body != null) ? set_etag_header(resp, result.body) : resp; + if (result.body != null) resp = set_etag_header(resp, result.body); + // A commit-confirmed write returns 202 with the armed window so the + // client knows it must confirm (or the change auto-rolls back). + if (result.confirm != null) { + resp.status = 202; + // DELETE's result body is null; synthesize one so the 202 carries the + // required `confirm` block (PendingConfirm schema), not just the header. + if (type(resp.body) != "object") resp.body = {}; + resp.body.confirm = result.confirm; + resp.headers["X-Confirm-Token"] = result.confirm.token; + resp.headers["X-Confirm-Deadline"] = "" + result.confirm.deadline; + } + return resp; } + if (result.kind == "confirm_unavailable" || result.kind == "already_armed" + || result.kind == "confirm_window_closed" || result.kind == "confirm_stage_failed" + || result.kind == "rollback_reload_failed" || result.kind == "bad_request") + return errors.error(ctx, result.kind, result.message); if (result.kind == "locked") return errors.locked_from(ctx, null, result); if (result.kind == "lock_unavailable") @@ -507,6 +523,7 @@ function make(resource, opts) { function create(conn, ctx, body) { let result = transaction.transaction(conn, tx_params({ + confirm: ctx.confirm, fn: function(c, p) { // Section-name resolution: caller's body.id wins, else the // per-resource id_for_create hook (e.g. network.interfaces @@ -553,6 +570,7 @@ function make(resource, opts) { function replace(conn, ctx, id, body) { let result = transaction.transaction(conn, tx_params({ + confirm: ctx.confirm, fn: function(c, p) { let errs = _validate_with_schema(resource, body, body, c, id); if (length(errs) > 0) @@ -589,6 +607,7 @@ function make(resource, opts) { function patch(conn, ctx, id, body) { let result = transaction.transaction(conn, tx_params({ + confirm: ctx.confirm, fn: function(c, p) { let existing = load_section(c, p, id); if (!existing || !type_predicate(existing['.type'])) @@ -630,6 +649,7 @@ function make(resource, opts) { function remove(conn, ctx, id) { let result = transaction.transaction(conn, tx_params({ + confirm: ctx.confirm, fn: function(c, p) { let existing = load_section(c, p, id); if (!existing || !type_predicate(existing['.type'])) @@ -648,7 +668,12 @@ function make(resource, opts) { }, })); - if (result.ok) + // A confirmed delete armed a rollback window; route through translate_tx + // so the 202 + X-Confirm-Token reaches the client. Without this the 204 + // short-circuit would drop the token: the section is deleted and the + // window armed, but the client has no token to ack and the delete + // silently auto-reverts at the deadline. + if (result.ok && result.confirm == null) return attach_reload_headers(errors.no_content(ctx), result); return translate_tx(ctx, result); } @@ -674,6 +699,7 @@ function make(resource, opts) { // name, so we go through the full transaction (snapshot + commit + // reload + restore-on-failure). let result = transaction.transaction(conn, tx_params({ + confirm: ctx.confirm, fn: function(c, p) { let existing = load_section(c, p, id); if (!existing || !type_predicate(existing['.type'])) @@ -740,6 +766,7 @@ function make_singleton(resource, opts) { function patch(conn, ctx, body) { let result = transaction.transaction(conn, tx_params({ + confirm: ctx.confirm, fn: function(c, p) { let existing = find(c); if (!existing) { diff --git a/src/lib/scope.uc b/src/lib/scope.uc index 4bfc262..91be111 100644 --- a/src/lib/scope.uc +++ b/src/lib/scope.uc @@ -68,6 +68,7 @@ const KNOWN_PATHS = { "uapi:tokens": true, "uapi:metrics": true, "uapi:diagnostics": true, + "uapi:confirm": true, "raw": true, }; diff --git a/src/lib/transaction.uc b/src/lib/transaction.uc index a269d55..7ef43f5 100644 --- a/src/lib/transaction.uc +++ b/src/lib/transaction.uc @@ -1,4 +1,5 @@ let fs = require('fs'); +let apply_confirm = require('apply_confirm'); // Lock layout (introduced to let concurrent writes to different uci packages // proceed in parallel without losing the apk-vs-uci serialization guarantee): @@ -127,7 +128,24 @@ function _finalize_after_reload(reload_err, restore_fn, body, services) { reload_error: reload_err }; } -function run_inner(conn, pkg, services, fn, snapshot, reload) { +// Attach the confirm block to a successful result, or disarm the window when +// uapi already reverted in-band (the apply reload failed, so the staged +// rollback is now pointless and must not fire later). Disarm with ac_ack, not +// ac_rollback: uapi has already restored the pre-change config in-band, so we +// only need to cancel apply-confirm's timer. ac_rollback would re-import the +// (identical) snapshot and run a redundant second service reload whose failure +// we could not surface. `armed` is ac_stage's return; null when no confirm. +function _attach_confirm(result, armed, packages) { + if (armed == null) return result; + if (result.ok) + result.confirm = { token: armed.token, timeout: armed.timeout, + deadline: armed.deadline, packages }; + else + apply_confirm.ac_ack(armed.token); + return result; +} + +function run_inner(conn, pkg, services, fn, snapshot, reload, confirm) { let result = fn(conn, pkg); if (!result || result.ok === false) { @@ -135,13 +153,34 @@ function run_inner(conn, pkg, services, fn, snapshot, reload) { return result ?? { ok: false, kind: "unknown" }; } - conn.uci_commit(pkg); + // Arm the commit-confirmed rollback BEFORE commit: apply-confirm snapshots + // the on-disk pre-change config, which uci_commit is about to overwrite. + let armed = null; + if (confirm != null) { + armed = apply_confirm.ac_stage([pkg], services, confirm); + if (!armed.ok) { + conn.uci_revert(pkg); + return { ok: false, kind: armed.kind, message: armed.message }; + } + } - return _finalize_after_reload(reload(services), function() { - conn.uci_import(pkg, snapshot); + // If commit or reload throws after the window is armed, disarm it before + // propagating: otherwise a stale rollback fires at the deadline and reverts + // whatever state exists then, with no token ever delivered to ack. + let fin; + try { conn.uci_commit(pkg); - return reload(services); - }, result.body, services); + fin = _finalize_after_reload(reload(services), function() { + conn.uci_import(pkg, snapshot); + conn.uci_commit(pkg); + return reload(services); + }, result.body, services); + } catch (e) { + if (armed != null) apply_confirm.ac_rollback(armed.token); + die(e); + } + + return _attach_confirm(fin, armed, [pkg]); } function transaction(conn, params) { @@ -181,7 +220,7 @@ function transaction(conn, params) { let caught = null; try { let snapshot = conn.uci_export(pkg); - result = run_inner(conn, pkg, services, fn, snapshot, reload); + result = run_inner(conn, pkg, services, fn, snapshot, reload, params.confirm); } catch (e) { caught = e; } @@ -247,6 +286,7 @@ function multi_transaction(conn, params) { let snapshots = {}; let result = null; let caught = null; + let armed = null; try { for (let pkg in sorted) snapshots[pkg] = conn.uci_export(pkg); let inner = fn(conn); @@ -254,34 +294,50 @@ function multi_transaction(conn, params) { for (let pkg in sorted) conn.uci_revert(pkg); result = inner ?? { ok: false, kind: "unknown" }; } else { - // Commit each package, but capture the first failure so we can - // still attempt a restore on every package (committed or not). - // Without this, a mid-loop commit throw leaves earlier packages - // committed and breaks the across-packages atomicity contract. - let commit_err = null; - for (let pkg in sorted) { - let caught_commit = null; - try { conn.uci_commit(pkg); } catch (e) { caught_commit = "" + e; } - if (caught_commit != null) { - commit_err = sprintf("uci_commit(%s) failed: %s", - pkg, caught_commit); - break; + // Arm the commit-confirmed rollback over all packages before any + // commit (apply-confirm snapshots the pre-change on-disk config). + if (params.confirm != null) { + armed = apply_confirm.ac_stage(sorted, services, params.confirm); + if (!armed.ok) { + for (let pkg in sorted) conn.uci_revert(pkg); + result = { ok: false, kind: armed.kind, message: armed.message }; } } - let reload_err = (commit_err == null) ? reload(services) : commit_err; - result = _finalize_after_reload(reload_err, function() { + if (result == null) { + // Commit each package, but capture the first failure so we can + // still attempt a restore on every package (committed or not). + // Without this, a mid-loop commit throw leaves earlier packages + // committed and breaks the across-packages atomicity contract. + let commit_err = null; for (let pkg in sorted) { - conn.uci_import(pkg, snapshots[pkg]); - conn.uci_commit(pkg); + let caught_commit = null; + try { conn.uci_commit(pkg); } catch (e) { caught_commit = "" + e; } + if (caught_commit != null) { + commit_err = sprintf("uci_commit(%s) failed: %s", + pkg, caught_commit); + break; + } } - return reload(services); - }, inner.body, services); + let reload_err = (commit_err == null) ? reload(services) : commit_err; + result = _attach_confirm(_finalize_after_reload(reload_err, function() { + for (let pkg in sorted) { + conn.uci_import(pkg, snapshots[pkg]); + conn.uci_commit(pkg); + } + return reload(services); + }, inner.body, services), armed, sorted); + } } } catch (e) { caught = e; } for (let h in acquired) _release_one(h); _release_one(g); - if (caught != null) die(caught); + if (caught != null) { + // A throw after arming (e.g. reload throwing) would leave the window + // armed; disarm before propagating so a stale rollback can't fire. + if (armed != null) apply_confirm.ac_rollback(armed.token); + die(caught); + } return result; } diff --git a/src/main.uc b/src/main.uc index 63ed79a..36fa5b5 100644 --- a/src/main.uc +++ b/src/main.uc @@ -19,6 +19,7 @@ let ratelimit = require("ratelimit"); let metrics = require("metrics"); let idempotency = require("idempotency"); let error_ring = require("error_ring"); +let apply_confirm = require("apply_confirm"); // /schema endpoint needs the raw resource modules; handler.make hides them. const RESOURCE_SOURCES = {}; @@ -106,6 +107,7 @@ const INSECURE_MARKER = "/etc/uapi.insecure"; const REASON = { "200": "OK", + "202": "Accepted", "204": "No Content", "207": "Multi-Status", "304": "Not Modified", @@ -121,6 +123,7 @@ const REASON = { "422": "Unprocessable Entity", "423": "Locked", "500": "Internal Server Error", + "501": "Not Implemented", "503": "Service Unavailable", }; @@ -560,6 +563,7 @@ function batch_dispatch(conn, ctx, token, method, body) { r = transaction.multi_transaction(conn, { packages: pkgs, reload_services: reloads, + confirm: ctx.confirm, fn: run_ops, }); } @@ -592,16 +596,25 @@ function batch_dispatch(conn, ctx, token, method, body) { if (r.kind == "lock_unavailable") return errors.error(ctx, "internal_error", sprintf("batch lock unavailable: %s", r.error)); + if (r.kind == "confirm_unavailable" || r.kind == "already_armed" + || r.kind == "confirm_stage_failed" || r.kind == "bad_request") + return errors.error(ctx, r.kind, r.message); if (!r.ok) return errors.error(ctx, "internal_error", sprintf("batch returned unknown kind %J", r.kind)); - return { - status: 207, - headers: { "Content-Type": "application/json", - "X-Request-Id": ctx.request_id }, - body: { results, request_id: ctx.request_id }, - }; + let headers = { "Content-Type": "application/json", + "X-Request-Id": ctx.request_id }; + let resp_body = { results, request_id: ctx.request_id }; + // A commit-confirmed batch returns 202 + the armed window (one token for + // all packages) instead of the usual 207. + if (r.confirm != null) { + resp_body.confirm = r.confirm; + headers["X-Confirm-Token"] = r.confirm.token; + headers["X-Confirm-Deadline"] = "" + r.confirm.deadline; + return { status: 202, headers, body: resp_body }; + } + return { status: 207, headers, body: resp_body }; } function metrics_response(ctx) { @@ -616,6 +629,41 @@ function metrics_response(ctx) { }; } +// /confirm/* control endpoints: thin passthroughs to the apply-confirm CLI. +function confirm_ack_response(ctx, tok) { + let r = apply_confirm.ac_ack(tok); + if (r.ok) return errors.ok(ctx, { confirmed: tok }); + return errors.error(ctx, r.kind, r.message); +} + +function confirm_rollback_response(ctx, tok) { + let r = apply_confirm.ac_rollback(tok); + if (r.ok) return errors.ok(ctx, { rolled_back: tok }); + return errors.error(ctx, r.kind, r.message); +} + +function confirm_status_response(ctx, tok) { + let r = apply_confirm.ac_status(tok); + if (!r.ok) return errors.error(ctx, r.kind, r.message); + let parsed = null; + try { parsed = json(r.json); } catch (e) {} + if (parsed == null) + return errors.error(ctx, "confirm_stage_failed", + "apply-confirm status returned unparseable JSON"); + return errors.ok(ctx, parsed); +} + +function confirm_list_response(ctx) { + let r = apply_confirm.ac_list(); + if (!r.ok) return errors.error(ctx, r.kind, r.message); + let parsed = null; + try { parsed = json(r.json); } catch (e) {} + if (parsed == null) + return errors.error(ctx, "confirm_stage_failed", + "apply-confirm list returned unparseable JSON"); + return errors.ok(ctx, parsed); +} + function diagnostics_response(ctx) { let resources_loaded = []; for (let k in RESOURCE_SOURCES) push(resources_loaded, k); @@ -728,6 +776,23 @@ function dispatch(env) { } ctx.body_text = body_text; ctx.idempotency_key = env.HTTP_IDEMPOTENCY_KEY ?? qs.idempotency_key ?? null; + // Commit-confirmed apply: ?confirm= arms an apply-confirm rollback + // window on the write. The X-Uapi-Confirm header is also read but uhttpd's + // CGI env strips custom headers, so the query form is the portable path (see + // the If-Match / Idempotency-Key fallbacks). Cap at 3600 so uapi's + // best-effort deadline matches apply-confirm's own max_timeout clamp. + ctx.confirm = null; + // confirm only modifies a write; ignore the param on reads (a stray + // ?confirm= on a GET must not 400). + let is_write = (method == "POST" || method == "PUT" || method == "PATCH" || method == "DELETE"); + let confirm_raw = is_write ? (env.HTTP_X_UAPI_CONFIRM ?? qs.confirm ?? null) : null; + if (confirm_raw != null && confirm_raw != "") { + if (type(confirm_raw) != "string" || !match(confirm_raw, /^[0-9]{1,4}$/) + || int(confirm_raw) < 1 || int(confirm_raw) > 3600) + return { ctx, resp: errors.error(ctx, "bad_request", + "confirm must be a positive integer 1..3600 (seconds)") }; + ctx.confirm = int(confirm_raw); + } // PATCH with Content-Type: application/json-patch+json switches the // handler from merge-patch (RFC 7396, the default) to RFC 6902 ops. let ctype = env.CONTENT_TYPE ?? ""; @@ -805,6 +870,15 @@ function dispatch(env) { let parts = split_path(path); + // commit-confirm only wraps uci-config transactions. Reject ?confirm= on + // the non-uci write endpoints (apk shell-out, /etc/shadow, authorized_keys, + // token mint) rather than silently ignoring it and reporting plain success. + if (ctx.confirm != null && (parts[0] == "packages" || parts[0] == "tokens" + || (parts[0] == "system" && length(parts) >= 2 + && (parts[1] == "password" || parts[1] == "authorized_keys")))) + return { ctx, token, resp: errors.error(ctx, "bad_request", + "confirm is not supported on this endpoint (uci-config writes only)") }; + if (path == "/metrics") { if (method != "GET") return { ctx, token, @@ -831,6 +905,45 @@ function dispatch(env) { return { ctx, token, resp: diagnostics_response(ctx) }; } + if (length(parts) >= 1 && parts[0] == "confirm") { + // GET /confirm -> list pending; {GET,POST,DELETE} /confirm/ -> + // status / ack / rollback. ro for reads, rw for ack+rollback. + if (length(parts) == 1) { + if (method != "GET") + return { ctx, token, resp: errors.error(ctx, "method_not_allowed", + "confirm collection only supports GET") }; + let denied = scope.require_or_deny(errors, ctx, token.scopes, ["uapi", "confirm"], "ro", + "listing confirm windows"); + if (denied != null) return { ctx, token, resp: denied }; + return { ctx, token, resp: confirm_list_response(ctx) }; + } + if (length(parts) == 2) { + let tok = parts[1]; + if (method == "GET") { + let denied = scope.require_or_deny(errors, ctx, token.scopes, ["uapi", "confirm"], "ro", + "reading a confirm window"); + if (denied != null) return { ctx, token, resp: denied }; + return { ctx, token, resp: confirm_status_response(ctx, tok) }; + } + if (method == "POST") { + let denied = scope.require_or_deny(errors, ctx, token.scopes, ["uapi", "confirm"], "rw", + "confirming an apply"); + if (denied != null) return { ctx, token, resp: denied }; + return { ctx, token, resp: confirm_ack_response(ctx, tok) }; + } + if (method == "DELETE") { + let denied = scope.require_or_deny(errors, ctx, token.scopes, ["uapi", "confirm"], "rw", + "rolling back an apply"); + if (denied != null) return { ctx, token, resp: denied }; + return { ctx, token, resp: confirm_rollback_response(ctx, tok) }; + } + return { ctx, token, resp: errors.error(ctx, "method_not_allowed", + "confirm token supports GET, POST, DELETE") }; + } + return { ctx, token, resp: errors.error(ctx, "not_found", + sprintf("Unknown confirm sub-path %J", path)) }; + } + if (length(parts) >= 1 && parts[0] == "auth") { if (length(parts) == 2 && parts[1] == "whoami") { if (method != "GET") @@ -1032,9 +1145,14 @@ global.handle_request = function(env) { // Idempotency store: cache 2xx POST responses so a repeated key replays. // Only when the request actually carried a key (not on replay itself, the // replay path returns before metrics/idempotency-store). + // 202 = a commit-confirmed write with a live, single-use rollback token. + // Caching it would replay a dead token on retry (the window auto-reverts + // and closes long before the 24h idempotency TTL) without re-arming, which + // silently defeats the retry-after-network-blip case confirm exists for. A + // retry of a confirmed write must re-dispatch (re-arm, or 409 already_armed). if (method == "POST" && token != null && ctx != null && ctx.idempotency_key != null - && resp.status >= 200 && resp.status < 300 + && resp.status >= 200 && resp.status < 300 && resp.status != 202 && (resp.headers == null || resp.headers["Idempotent-Replayed"] == null)) { try { idempotency.store(token.name, ctx.idempotency_key, ctx.body_text ?? "", resp); } diff --git a/src/raw.uc b/src/raw.uc index 63e475b..6d9c799 100644 --- a/src/raw.uc +++ b/src/raw.uc @@ -96,7 +96,22 @@ function build_response_body(view, reload_info) { } function translate_raw_tx(ctx, result) { - if (result.ok) return errors.ok(ctx, result.body); + if (result.ok) { + let resp = errors.ok(ctx, result.body); + // Commit-confirmed raw write: 202 + armed window (mirrors handler.translate_tx). + if (result.confirm != null) { + resp.status = 202; + if (type(resp.body) != "object") resp.body = {}; + resp.body.confirm = result.confirm; + resp.headers["X-Confirm-Token"] = result.confirm.token; + resp.headers["X-Confirm-Deadline"] = "" + result.confirm.deadline; + } + return resp; + } + if (result.kind == "confirm_unavailable" || result.kind == "already_armed" + || result.kind == "confirm_window_closed" || result.kind == "confirm_stage_failed" + || result.kind == "rollback_reload_failed" || result.kind == "bad_request") + return errors.error(ctx, result.kind, result.message); if (result.kind == "locked") return errors.locked_from(ctx, null, result); if (result.kind == "lock_unavailable") @@ -191,6 +206,7 @@ function create(conn, ctx, scopes, pkg, body) { let result = transaction.transaction(conn, { package: pkg, reload_services: reload.services, + confirm: ctx.confirm, fn: function(c, p) { if (client_supplied_id && load_section(c, p, new_id) != null) return { ok: false, kind: "conflict", @@ -233,6 +249,7 @@ function replace(conn, ctx, scopes, pkg, id, body) { let result = transaction.transaction(conn, { package: pkg, reload_services: reload.services, + confirm: ctx.confirm, fn: function(c, p) { let existing = load_section(c, p, id); if (!existing) @@ -280,6 +297,7 @@ function patch(conn, ctx, scopes, pkg, id, body) { let result = transaction.transaction(conn, { package: pkg, reload_services: reload.services, + confirm: ctx.confirm, fn: function(c, p) { let existing = load_section(c, p, id); if (!existing) @@ -309,6 +327,7 @@ function remove(conn, ctx, scopes, pkg, id) { let result = transaction.transaction(conn, { package: pkg, reload_services: reload.services, + confirm: ctx.confirm, fn: function(c, p) { let existing = load_section(c, p, id); if (!existing) @@ -318,7 +337,10 @@ function remove(conn, ctx, scopes, pkg, id) { return { ok: true, body: null }; }, }); - if (result.ok) return errors.no_content(ctx); + // A confirmed delete armed a window; route through translate_raw_tx so the + // 202 + X-Confirm-Token reaches the client instead of a bare 204 that drops + // the token and silently auto-reverts at the deadline (cf. handler.uc remove). + if (result.ok && result.confirm == null) return errors.no_content(ctx); return translate_raw_tx(ctx, result); } @@ -329,6 +351,7 @@ return { replace, patch, remove, + translate_raw_tx, inferred_domain_path, TYPE_DOMAIN_MAP, }; diff --git a/tests/integration/45_apply_confirm_test.sh b/tests/integration/45_apply_confirm_test.sh new file mode 100755 index 0000000..0f2a782 --- /dev/null +++ b/tests/integration/45_apply_confirm_test.sh @@ -0,0 +1,84 @@ +#!/bin/sh +set -eu + +. tests/integration/lib/install_uapi.sh +install_uapi + +URL=http://127.0.0.1:8080/api/v2 +ADMIN="Authorization: Bearer $ADMIN_TOKEN" +fail() { echo "FAIL: $*"; exit 1; } + +# apply-confirm is a separate package (RC-soak; not on the stable feed yet). +# When it is installable, run the full stage -> ack / stage -> timeout-rollback +# happy path; otherwise verify the graceful-degrade contract (501 when absent, +# ordinary writes unaffected). Same shape as 40_unbound_uci_ext_test.sh. +if $SSH 'apk add apply-confirm 2>/dev/null' && $SSH 'test -x /usr/sbin/apply-confirm'; then + HAVE_AC=1 + echo "[apply-confirm] installed; running full commit-confirm path" +else + HAVE_AC=0 + echo "[apply-confirm] not installable on this VM; running 501 graceful-degrade smoke only" +fi + +cleanup() { $SSH 'uci -q delete firewall.acc_test 2>/dev/null; uci -q commit firewall 2>/dev/null || true'; } +trap cleanup EXIT INT TERM + +if [ "$HAVE_AC" = 0 ]; then + echo "--- confirmed write returns 501 confirm_unavailable when apply-confirm is absent ---" + # Use the ?confirm= query fallback: uhttpd CGI drops custom request headers + # (the reason the fallback exists), so X-Uapi-Confirm is unreliable here. + code=$(curl -sS -o /tmp/ac_body.json -w '%{http_code}' -H "$ADMIN" \ + -H 'Content-Type: application/json' \ + -X POST "$URL/firewall/rules?confirm=60" \ + -d '{"id":"acc_test","target":"ACCEPT","match":{"src_zone":"lan"}}') + [ "$code" = "501" ] || { cat /tmp/ac_body.json; fail "expected 501, got $code"; } + grep -q '"code": "confirm_unavailable"' /tmp/ac_body.json || fail "expected confirm_unavailable" + + echo "--- the rejected confirmed write committed nothing ---" + [ -z "$($SSH 'uci -q get firewall.acc_test.target' 2>/dev/null || true)" ] \ + || fail "501 confirmed write must not have committed the section" + + echo "--- a normal (unconfirmed) write is unaffected ---" + code=$(curl -sS -o /dev/null -w '%{http_code}' -H "$ADMIN" \ + -H 'Content-Type: application/json' \ + -X POST "$URL/firewall/rules" \ + -d '{"id":"acc_test","target":"ACCEPT","match":{"src_zone":"lan"}}') + [ "$code" = "200" ] || fail "normal write expected 200, got $code" + + echo "--- GET /confirm also degrades to 501 ---" + code=$(curl -sS -o /dev/null -w '%{http_code}' -H "$ADMIN" "$URL/confirm") + [ "$code" = "501" ] || fail "GET /confirm expected 501 when absent, got $code" + + echo "apply-confirm graceful-degrade smoke ok." + exit 0 +fi + +echo "--- confirmed write returns 202 + a confirm token ---" +resp=$(curl -sS -D /tmp/ac_hdr.txt -H "$ADMIN" -H 'Content-Type: application/json' \ + -X POST "$URL/firewall/rules?confirm=30" \ + -d '{"id":"acc_test","target":"ACCEPT","match":{"src_zone":"lan"}}') +echo "$resp" | grep -q '"confirm"' || fail "202 body missing confirm block: $resp" +head -1 /tmp/ac_hdr.txt | grep -q '202' || fail "expected 202 status" +token=$(echo "$resp" | jq -r '.confirm.token') +[ -n "$token" ] && [ "$token" != "null" ] || fail "no confirm token in body" +[ "$($SSH 'uci -q get firewall.acc_test.target')" = "ACCEPT" ] || fail "confirmed write did not commit" + +echo "--- POST /confirm/ acks; the change persists ---" +code=$(curl -sS -o /dev/null -w '%{http_code}' -H "$ADMIN" -X POST "$URL/confirm/$token") +[ "$code" = "200" ] || fail "ack expected 200, got $code" +sleep 2 +[ "$($SSH 'uci -q get firewall.acc_test.target')" = "ACCEPT" ] || fail "acked change was rolled back" +$SSH 'uci -q delete firewall.acc_test; uci commit firewall' + +echo "--- confirmed write left unacked rolls back at the deadline ---" +resp=$(curl -sS -H "$ADMIN" -H 'Content-Type: application/json' \ + -X POST "$URL/firewall/rules?confirm=3" \ + -d '{"id":"acc_test","target":"ACCEPT","match":{"src_zone":"lan"}}') +token=$(echo "$resp" | jq -r '.confirm.token') +[ -n "$token" ] && [ "$token" != "null" ] || fail "no token for timeout test" +[ "$($SSH 'uci -q get firewall.acc_test.target')" = "ACCEPT" ] || fail "write not committed pre-timeout" +sleep 8 +[ -z "$($SSH 'uci -q get firewall.acc_test.target' 2>/dev/null || true)" ] \ + || fail "unacked change was not rolled back after the deadline" + +echo "apply-confirm commit-confirm path ok." diff --git a/tests/unit/apply_confirm_test.uc b/tests/unit/apply_confirm_test.uc new file mode 100644 index 0000000..f12571d --- /dev/null +++ b/tests/unit/apply_confirm_test.uc @@ -0,0 +1,75 @@ +let t = require('harness'); + +let ac = loadfile('src/lib/apply_confirm.uc')(); + +// The build/unit environment has no /usr/sbin/apply-confirm, so ac_present() is +// false. That lets every guard path be reached: input validation runs before +// the presence check, and the presence check itself yields confirm_unavailable. + +t.describe('apply_confirm.ac_present', () => { + t.it('is false when the binary is not installed (unit env)', () => { + t.assert_equal(ac.ac_present(), false); + }); +}); + +t.describe('apply_confirm.ac_stage validation', () => { + t.it('rejects a non-positive timeout with bad_request', () => { + let r = ac.ac_stage(["network"], ["network"], 0); + t.assert_equal(r.kind, "bad_request"); + }); + + t.it('rejects a non-integer timeout with bad_request', () => { + let r = ac.ac_stage(["network"], ["network"], "60"); + t.assert_equal(r.kind, "bad_request"); + }); + + t.it('rejects an empty package list with confirm_stage_failed', () => { + let r = ac.ac_stage([], [], 60); + t.assert_equal(r.kind, "confirm_stage_failed"); + }); + + t.it('rejects an unsafe package name before it reaches the shell', () => { + let r = ac.ac_stage(["net; rm -rf /"], [], 60); + t.assert_equal(r.kind, "confirm_stage_failed"); + }); + + t.it('rejects an unsafe service name', () => { + let r = ac.ac_stage(["network"], ["svc;evil"], 60); + t.assert_equal(r.kind, "confirm_stage_failed"); + }); + + t.it('returns confirm_unavailable when inputs are valid but the binary is absent', () => { + let r = ac.ac_stage(["network"], ["network"], 60); + t.assert_equal(r.kind, "confirm_unavailable"); + }); +}); + +t.describe('apply_confirm control verbs (token guard + presence)', () => { + t.it('ac_ack rejects a malformed token with bad_request', () => { + t.assert_equal(ac.ac_ack("not-a-token").kind, "bad_request"); + }); + + t.it('ac_ack accepts a well-formed token shape but degrades to confirm_unavailable', () => { + t.assert_equal(ac.ac_ack("ac_1718900000_a1b2c3d4").kind, "confirm_unavailable"); + }); + + t.it('ac_rollback rejects a malformed token with bad_request', () => { + t.assert_equal(ac.ac_rollback("../etc/passwd").kind, "bad_request"); + }); + + t.it('ac_rollback degrades to confirm_unavailable for a valid token shape', () => { + t.assert_equal(ac.ac_rollback("ac_1718900000_a1b2c3d4").kind, "confirm_unavailable"); + }); + + t.it('ac_status rejects a malformed token with bad_request', () => { + t.assert_equal(ac.ac_status("ac_BAD").kind, "bad_request"); + }); + + t.it('ac_status degrades to confirm_unavailable for a valid token shape', () => { + t.assert_equal(ac.ac_status("ac_1718900000_a1b2c3d4").kind, "confirm_unavailable"); + }); + + t.it('ac_list degrades to confirm_unavailable when the binary is absent', () => { + t.assert_equal(ac.ac_list().kind, "confirm_unavailable"); + }); +}); diff --git a/tests/unit/handler_test.uc b/tests/unit/handler_test.uc index 0b7a92b..2951238 100644 --- a/tests/unit/handler_test.uc +++ b/tests/unit/handler_test.uc @@ -383,6 +383,52 @@ t.describe('handler.patch', () => { }); }); +t.describe('translate_tx commit-confirm 202 shaping', () => { + function jctx() { return { request_id: "01hx0000000000000000000000" }; } + let armed = { token: "ac_1718900000_a1b2c3d4", timeout: 60, deadline: 1718900060, packages: ["firewall"] }; + + t.it('a confirmed write becomes 202 with the token header and confirm body', () => { + let resp = handler.translate_tx(jctx(), + { ok: true, body: { id: "r1", target: "ACCEPT" }, confirm: armed }); + t.assert_equal(resp.status, 202); + t.assert_equal(resp.headers["X-Confirm-Token"], "ac_1718900000_a1b2c3d4"); + t.assert_equal(resp.body.confirm.token, "ac_1718900000_a1b2c3d4"); + }); + + t.it('a confirmed delete (null body) is 202 with the token header AND a synthesized confirm body', () => { + let resp = handler.translate_tx(jctx(), { ok: true, body: null, confirm: armed }); + t.assert_equal(resp.status, 202); + t.assert_equal(resp.headers["X-Confirm-Token"], "ac_1718900000_a1b2c3d4"); + // PendingConfirm schema marks body.confirm required even for DELETE. + t.assert_equal(resp.body.confirm.token, "ac_1718900000_a1b2c3d4"); + }); + + t.it('an ordinary write (no confirm) stays 200', () => { + let resp = handler.translate_tx(jctx(), { ok: true, body: { id: "r1" } }); + t.assert_equal(resp.status, 200); + }); +}); + +t.describe('handler commit-confirm threading', () => { + function cctx() { return { request_id: "01hx0000000000000000000000", confirm: 60 }; } + + t.it('a confirmed write degrades to 501 when apply-confirm is absent', () => { + // ctx.confirm threads handler -> tx_params -> transaction -> run_inner + // -> ac_stage; with no binary in the unit env, stage returns + // confirm_unavailable and translate_tx maps it to 501. + let c = with_zones(); + let r = rules.create(c, cctx(), { target: 'ACCEPT', match: { src_zone: 'lan' } }); + t.assert_equal(r.status, 501); + t.assert_equal(r.body.code, "confirm_unavailable"); + }); + + t.it('an unconfirmed write is unaffected (no confirm in ctx)', () => { + let c = with_zones(); + let r = rules.create(c, ctx(), { target: 'ACCEPT', match: { src_zone: 'lan' } }); + t.assert_equal(r.status, 200); + }); +}); + t.describe('handler.patch JSON Patch write-only secret carry-forward', () => { let wiface_mod = loadfile('src/resources/wireless.interfaces.uc')(); let wiface = handler.make(wiface_mod, { diff --git a/tests/unit/raw_test.uc b/tests/unit/raw_test.uc index 073f848..c40d23c 100644 --- a/tests/unit/raw_test.uc +++ b/tests/unit/raw_test.uc @@ -172,3 +172,26 @@ t.describe('raw permission composition for writes', () => { t.assert_equal(r.status, 403); }); }); + +t.describe('raw.translate_raw_tx commit-confirm 202 shaping', () => { + function jctx() { return { request_id: "01hx0000000000000000000000" }; } + let armed = { token: "ac_1718900000_a1b2c3d4", timeout: 60, deadline: 1718900060, packages: ["firewall"] }; + + t.it('a confirmed raw write becomes 202 with the token header and confirm body', () => { + let r = raw.translate_raw_tx(jctx(), { ok: true, body: { id: "r1" }, confirm: armed }); + t.assert_equal(r.status, 202); + t.assert_equal(r.headers["X-Confirm-Token"], "ac_1718900000_a1b2c3d4"); + t.assert_equal(r.body.confirm.token, "ac_1718900000_a1b2c3d4"); + }); + + t.it('a confirmed raw delete (null body) still carries the confirm block', () => { + let r = raw.translate_raw_tx(jctx(), { ok: true, body: null, confirm: armed }); + t.assert_equal(r.status, 202); + t.assert_equal(r.body.confirm.token, "ac_1718900000_a1b2c3d4"); + }); + + t.it('confirm error kinds map to their status (confirm_unavailable -> 501)', () => { + let r = raw.translate_raw_tx(jctx(), { ok: false, kind: "confirm_unavailable", message: "x" }); + t.assert_equal(r.status, 501); + }); +});