Native, lightweight, production-grade HTTP REST API for OpenWrt. Translates standard REST verbs (GET, POST, PUT, PATCH, DELETE) into ubus/uci operations so modern edge routers become first-class targets for Infrastructure-as-Code workflows. Primary design validation is serving as the backend for the raspbeguy/uapi Terraform provider, but the API is client-agnostic.
- Direct-to-bus: runs as a uhttpd-mod-ucode handler. No daemon, no
/etc/config/file mangling. - Atomic transactions (single AND multi-package via
POST /batch): snapshot, validate, commit, reload, restore-on-failure in a single request. - Bearer tokens with hierarchical scopes, expiry, source-IP scoping, HTTP-side rotation (
POST /tokens). - 32 curated resources plus a generic
/raw/<package>/<id>passthrough for the long tail. - Conditional GET (304), idempotency keys, cursor pagination, per-resource ETags +
If-Match, JSON Patch (RFC 6902), Prometheus/metrics,/diagnostics. - OpenAPI 3.1 spec shipped at
/usr/share/uapi/openapi.jsonand served at/api/v2/openapi.json.
Prior REST-for-OpenWrt attempts fell into three buckets, each with a structural reason it stalled. Thin wrappers over rpcd or luci-rpc exposed uci's anonymous-section IDs verbatim, so Terraform could never track a resource across applies, and rpcd sessions are awkward for service accounts. Out-of-process daemons in Python or Go drifted the moment LuCI or sysupgrade touched /etc/config/, and blew the footprint budget. Centralized config-push platforms (OpenWISP and friends) own the truth from a server and don't slot into a Terraform pull workflow.
uapi sidesteps all three:
- Lives inside OpenWrt's runtime. uhttpd-mod-ucode plus direct ubus calls means uci stays the single source of truth: no shadow state, no drift, no extra daemon to babysit.
- Stable IDs by construction. uapi-created sections are ULID-named; pre-existing anonymous sections surface as
managed: falsewith an explicitadoptflow. - Atomic transactions with honest failure codes. Snapshot, validate, commit, reload, restore-on-fail under per-package flocks. Every outcome is a distinct status (
200,412,422,423,429,500 reload_failed_restored,500 reload_failed_unrecovered). - Bearer tokens with hierarchical scopes. No login dance, no session timeout. Optional expiry + IP scoping + per-token rate limit. Exactly the primitive a CI pipeline wants.
This works now because the substrate finally caught up: ucode-mod-ubus, uhttpd-mod-ucode, and apk-based packaging all shipped in the last cycle. Older attempts had to pick between lua, Python, and C; uapi picked the right moment to be small.
Target platform: OpenWrt 25.12+ (apk-based releases).
Signed APK feed (recommended):
curl -fsSL https://openwrt-iac.github.io/feed/uapi-feed.pub.pem \
> /etc/apk/keys/uapi-feed.pub.pem
echo 'https://openwrt-iac.github.io/feed/packages/all/uapi/packages.adb' \
> /etc/apk/repositories.d/uapi.list
apk update && apk add uapiOr a local APK file:
apk add /tmp/uapi-<version>-r1.apkThe package's uci-defaults hook adds list ucode_prefix '/api/v2=/usr/share/uapi/main.uc' to /etc/config/uhttpd (the main instance) and restarts uhttpd. After that, /api/v2/healthz is reachable on the same ports as LuCI.
# On the router (local console or SSH):
uapi-token create --name terraform_prod --scope '*:rw' --expires-in 90dThe cleartext bearer is printed exactly once. Save it. The router stores only salt + sha256(salt:bearer) going forward. Tokens can also carry --allowed-cidr (source-IP pinning) and per-token rate-limit overrides.
After the first admin token exists, subsequent tokens can be minted over the wire:
curl -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-X POST https://<router>/api/v2/tokens \
-d '{ "name": "ci-bot", "scopes": ["firewall:rw"], "expires_in_seconds": 3600 }'Requested scopes must be a strict subset of the caller's (escalation returns 403 scope_escalation_blocked).
TOKEN=<value-from-above>
curl -H "Authorization: Bearer $TOKEN" https://<router>/api/v2/system
curl -H "Authorization: Bearer $TOKEN" https://<router>/api/v2/auth/whoamicurl -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-X POST https://<router>/api/v2/firewall/rules \
-d '{
"target": "ACCEPT",
"match": { "src_zone": "wan", "dest_port": [22], "proto": ["tcp"] }
}'The response carries the rule's stable id (a ULID with a one-character type prefix like r_01HX...). That ID survives reorders and rewrites, so Terraform can track it across applies. The response also carries an ETag header (use it in If-Match on next mutation for optimistic concurrency).
Operator-facing:
docs/installation.md: production install, TLS hardening pointers.docs/tokens.md: scope tree, CLI reference, HTTP token mint, expiry + IP scoping + per-token rate.docs/errors.md: error envelope, response codes, field-level error codes, response headers.docs/operations.md: NTP, persistent syslog,/metrics,/diagnostics, rate limiting.docs/raw.md:/raw/<package>/<id>semantics and stability disclaimer.docs/resources.md: curated resource catalog.docs/non-uci-state.md: resources whose source of truth is not/etc/config/.docs/migration-v1-to-v2.md: v1 -> v2 field renames, strict typing, new endpoints, new errors.examples/curl/: one shell script per resource demonstrating CRUD.build/openapi.json(also/api/v2/openapi.jsonon a live router): the API contract.
Contributor-facing:
CONTRIBUTING.md: dev environment, dev loop, PR style, codebase tour.docs/architecture.md: fork-per-request model, lock layout, transaction recipe, ETag derivation, where state lives.docs/concurrency.md: rules and anti-patterns for adding code that survives the fork-per-request model.docs/ucode-quirks.md: language and runtime gotchas that have each cost a CI iteration on this project.docs/security.md: threat model, scope tree, TLS posture, token storage, rate-limit guarantees, audit shape.docs/adding-a-resource.md: how to write a new curated resource module.docs/packaging.md: building the APK from source against the OpenWrt SDK.docs/release-process.md: signed tags, reproducible builds, SBOM, multi-arch verification.docs/roadmap.md: what shipped, what's next, what's intentionally out of scope.docs/lock-state-audit.md: every fd-open / lock site, release proven on every exit.CLAUDE.md: the project's design contract (architectural principles, schema, lock layout, error envelope).CHANGELOG.md: per-release notes.
uapi follows semver, with the major version aligned to the API major:
- MAJOR: breaking on-the-wire change. The URL prefix bumps to
/api/v<x+1>/to match the wire contract major (standard HTTP idiom). A given uapi installation serves exactly one API major - we do NOT mount/api/v(x+1)/alongside/api/v<x>/in the same binary; operators who need to keep an old client working keep the previous package version installed. The install hook handles uhttpd prefix migration automatically onapk upgrade. - MINOR: backwards-compatible additions. New endpoints, new optional request/response fields, new error codes, new scopes.
- PATCH: bug fixes only. No surface change.
Concretely: a client tested against 2.0.0 keeps working against any future 2.y.z. Pin a minimum minor if you depend on something introduced in it (apk add 'uapi>=2.0.0'). See CLAUDE.md "API versioning policy" for the precise list of what counts as a breaking vs additive change, and docs/migration-v1-to-v2.md for the v1 -> v2 migration table.
MIT.