Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<seconds>` (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/<token>` 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/<token>` 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.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
112 changes: 109 additions & 3 deletions build/gen_openapi.uc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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=).",
Expand All @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 <token> --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.
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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.",
},
},
},
};
Expand Down
Loading