Signed configuration bundles
Ship policies, models, MCP servers, budgets and revocations to a gateway without Kubernetes as one signed archive that the gateway verifies before it applies it.
In a cluster, the operator renders policies, model routes, MCP servers with their credentials, budgets, revocations, memory stores and agent-to-agent (A2A) routes into files. The MAQPNA gateway reads them from ConfigMaps. A gateway at the edge, on a ship or in an air-gapped site has no Kubernetes API to read from. A configuration bundle carries the same files as one signed .tar.gz. You can push it to the gateway, let the gateway pull it over HTTP(S), or carry it on removable media. The gateway checks the signature, every file's hash and the revision before it applies anything.
flowchart LR
R["resources<br/>(dir or resource API)"] --> O["maqpna-operator render<br/>-bundle-out"]
F["rendered files"] --> C["maqpna config bundle build"]
O --> B["edge.tar.gz<br/>signed manifest"]
C --> B
B -->|"push (admin)"| G["gateway<br/>configBundle"]
B -->|"pull.url"| G
B -->|"USB stick, dropDir"| G
G --> V{"verify<br/>key, hashes,<br/>issuer, time,<br/>revision"}
V -->|ok| I["install, reload"]
V -->|refused| L["ledger record<br/>config/bundle-refused"]
When to use it#
- A gateway runs without Kubernetes (a VM, an edge box, a vessel, a factory floor).
- The site is disconnected or air-gapped and changes arrive by file.
- You want every configuration change signed by a key the gateway pins, with a record in the audit ledger.
In a normal cluster you do not need bundles. Without a configBundle block, the gateway keeps reading ConfigMaps as before.
What a bundle contains#
A bundle is a gzip-compressed tar archive. The first entry is manifest.json, then the files sorted by path:
{
"version": "maqpna.com/config-bundle/v1",
"revision": 1760700000,
"createdAt": "2026-10-17T11:20:00Z",
"issuer": "maqpna-operator",
"expiresAt": "2026-10-24T11:20:00Z",
"files": [
{"path": "policies.json", "sha256": "41d0...", "bytes": 812},
{"path": "upstream-credentials/team-a.github.token", "sha256": "c7a1...", "bytes": 40}
],
"signature": {"alg": "EdDSA", "kid": "Jx3...", "jws": "eyJhbGciOiJFZERTQSIs..."}
}
- Signature. A detached JWS (
typ: maqpna-config-bundle+jws) over the canonical JSON of the manifest. The manifest binds each file's SHA-256 and size, so it covers the whole bundle. Keys can be Ed25519, P-256, RSA or ML-DSA, in a file, a KMS (kms://aws|gcp|azure|vault|openbao/…,azurekv://…) or a PKCS#11 HSM (pkcs11:…, in-tags pkcs11builds). - Revision. A positive integer. The default is the Unix time of the build. A gateway applies only a revision above the one in force.
- Validity. Optional
notBeforeandexpiresAt, with 60 seconds of clock skew allowed.
Prerequisites#
- The
maqpnaCLI, andmaqpna-operatorif you render from resources. - A gateway on Linux or macOS. The install step uses symbolic links, so Windows gateways are not supported.
- Durable storage for the gateway's bundle state, so rollback protection survives restarts.
Step 1: Create a signing key#
maqpna keys generate --alg Ed25519 --out bundle.pem
This writes bundle.pem (mode 0600) and the public key bundle.pub.pem. Gateways need only the public key. Keep the private key off the edge, or use a KMS or HSM key URI instead of a file.
Step 2: Build a bundle#
Pick one of two ways.
Option A: sign files you rendered earlier#
maqpna-operator render -from ./resources -out ./rendered
maqpna config bundle build ./rendered --key file://$PWD/bundle.pem --out edge.tar.gz --expires 168h
build reads every regular file under the directory, skips hidden files and refuses links.
Option B: render and sign in one step#
-from is the resource API URL (with -token-file and -ca-file) or a directory of resource YAML laid out as <plural>/<namespace>/<name>.yaml (cluster-scoped kinds: <plural>/<name>.yaml), for example toolpolicies/team-a/default.yaml:
maqpna-operator render -from ./resources \
-model-credentials-dir /var/lib/maqpna/config/model-credentials \
-bundle-out edge.tar.gz -bundle-key file://$PWD/bundle.pem -bundle-expires 168h
With -watch 10s, it re-renders and writes a new bundle (the next revision) only when a file changed. The file is replaced atomically, so a web server never serves half a bundle.
Flags#
maqpna-operator render flag |
maqpna config bundle build flag |
Meaning |
|---|---|---|
-bundle-key |
--key |
Signing key URI. |
-bundle-revision |
--revision |
Revision (default: Unix time). |
-bundle-issuer (default maqpna-operator) |
--issuer (default $USER@maqpna-cli) |
Issuer in the manifest. |
-bundle-expires |
--expires |
Validity after creation, for example 168h. |
| — | --not-before |
Not valid before this time (RFC 3339). |
-bundle-pq-key |
--pq-key |
Also sign with an ML-DSA key (hybrid). |
Step 3: Check the bundle before you ship it#
maqpna config bundle inspect edge.tar.gz
maqpna config bundle verify edge.tar.gz --pubkey bundle.pub.pem --current-revision 41
inspect prints the manifest. verify checks the signature against the keys you pin, the files, the issuer, the validity window and the revision. It exits with status 3 when a gateway would refuse the bundle. Use --jwks instead of --pubkey for a JWKS file or URL, and --signature-policy hybrid to require both a classical and an ML-DSA signature.
Step 4: Configure the gateway#
Add a configBundle block to gateway.json and point the file settings into its dir:
{
"policyFile": "/var/lib/maqpna/config/policies.json",
"modelsFile": "/var/lib/maqpna/config/models.json",
"upstreamsFile": "/var/lib/maqpna/config/upstreams.json",
"upstreamCredentialsDir": "/var/lib/maqpna/config/upstream-credentials",
"budgetsFile": "/var/lib/maqpna/config/budgets.json",
"revocationsFile": "/var/lib/maqpna/config/revocations.json",
"memoryFile": "/var/lib/maqpna/config/memory.json",
"a2a": {"routesFile": "/var/lib/maqpna/config/a2a.json"},
"configBundle": {
"enabled": true,
"dir": "/var/lib/maqpna/config",
"trustedKeyFiles": ["/etc/maqpna/bundle-keys/current.pem"],
"trustedIssuers": ["maqpna-operator"],
"push": true,
"pull": {"url": "https://config.example.eu/bundles/edge-7.tar.gz", "tokenFile": "/etc/maqpna/pull.token"},
"dropDir": "/media/maqpna",
"statePath": "/var/lib/maqpna/state/config-bundle.json"
}
}
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Turns bundles on. |
dir |
— | Required. Where bundles are installed. |
trustedKeyFiles |
— | Required. PEM public keys. Re-read for every bundle, so key rotation needs no restart. |
trustedIssuers |
any | Accepted manifest issuers. |
signaturePolicy |
classical |
classical, hybrid (classical and ML-DSA) or pq (ML-DSA). |
push |
false |
Enables POST /v1/admin/config:bundle. |
pull.url |
— | http(s):// URL fetched every pollSeconds with If-None-Match. |
pull.tokenFile, pull.caFile |
— | Bearer token (re-read per fetch; refused over plain http unless loopback) and trusted CA. |
pull.maxBackoffSeconds |
300 |
Cap of the retry delay after failed fetches. |
dropDir |
— | Scanned every pollSeconds for *.tar.gz and *.tgz. The highest valid revision applies. |
pollSeconds |
30 |
Pull and drop-directory interval. |
statePath |
<auditPath>.config-bundle.json |
Revision in force, kept across restarts. |
maxBytes |
64 MiB | Largest compressed bundle. Uncompressed: 256 MiB total, 32 MiB per file, 4096 files. |
leewaySeconds |
60 |
Clock skew allowed for notBefore and expiresAt. |
You need at least one of push, pull.url and dropDir. Check the file before you restart the gateway:
maqpna-gateway -check-config -config /etc/maqpna/gateway.json
Step 5: Deliver the bundle#
Push needs the admin role. With adminListen set, the route is only on the private listener.
maqpna config bundle push edge.tar.gz --gateway https://edge-7.example.eu:8443
curl -sS --data-binary @edge.tar.gz \
-H "Authorization: Bearer <your-admin-token>" \
https://edge-7.example.eu:8443/v1/admin/config:bundle
Copy the file to the drop directory, or mount removable media there. Older bundles next to it are skipped.
cp edge.tar.gz /media/maqpna/
Or publish it at pull.url on any web server or object store. A gateway that was offline during a push catches up on its next poll.
Step 6: Verify#
curl -sS -H "Authorization: Bearer <your-admin-token>" https://edge-7.example.eu:8443/v1/admin/config:bundle
The answer shows the bundle in force: revision, digest, issuer, kid, createdAt, appliedAt, source and files. Also check:
- Metrics:
maqpna_gateway_config_bundle_revision,maqpna_gateway_config_bundles_applied_totalandmaqpna_gateway_config_bundles_refused_total{reason}. - Audit ledger: each bundle is a record with server
config, toolbundle, ruleconfig/bundle-appliedorconfig/bundle-refused. Theapproverisadmin:<user>,config-bundle-pullordropDir:<file>. See the audit ledger guide.
After an install, the gateway reloads policies, models, MCP servers, A2A routes, budgets, revocations and memory stores at once.
How the gateway installs a bundle#
The gateway writes the files into a new directory dir/..rev-<N>-<random>/, then switches dir/..data to it with one rename. Every top-level entry (dir/policies.json) is a link through ..data, as the kubelet does for ConfigMap volumes. A loader sees all of the old revision or all of the new one, never a mix. A real directory where a link should go is a conflict, and nothing changes.
Push responses and refusal reasons#
| Status | reason |
|---|---|
200 |
{"applied": true, ...}, or "unchanged": true for the bundle already in force |
400 |
malformed, unsafe-path (absolute paths, .., hidden elements, links, devices) |
409 |
rollback: the revision is not above the one in force |
413 |
too-large |
422 |
unsigned, signature, no-trusted-keys, tampered, issuer, expired, not-yet-valid |
500 |
install-conflict, error |
Every refusal carries reason and error. The same codes label the refused-bundles metric.
Operating at the edge#
- Push first, pull as fallback. Push from CI when connected; also give each replica
pull.urlor a shareddropDir. A push reaches only the replica that answers. - Rotate keys in three steps. Add the new public key to
trustedKeyFiles, sign with the new key, then remove the old public key. - Limit replay. Keep
statePathon durable storage. Set an expiry longer than your longest planned disconnection, for example one to four weeks. - Clocks. Without NTP, omit
expiresAtandnotBeforeand rely on revisions. - Evidence out. Bundles bring configuration in. The ledger goes out with offline edge mode.
Limits#
- Encryption of bundles: Planned.
- Fan-out of a pushed bundle to the other replicas: Planned.
- The gateway's own
gateway.jsonis not carried in a bundle, only the rendered files. - Windows gateways are not supported.
- Before you add an ML-DSA signature (
--pq-key), upgrade every gateway. Older gateways refuse the extrasignaturesfield.
Troubleshooting#
| Symptom | Fix |
|---|---|
Gateway does not start: configBundle.trustedKeyFiles is required |
Add at least one PEM public key. Unsigned bundles are never applied. |
configBundle: enable push, pull.url or dropDir |
Turn on at least one delivery path. |
409 rollback |
Build with a higher --revision, or leave it unset so it takes the current time. |
422 signature |
The bundle was signed by a key the gateway does not pin. Check with maqpna config bundle verify --pubkey. |
422 expired or not-yet-valid |
Fix the gateway's clock, or rebuild with a new validity window. |
500 install-conflict |
A real file or directory is in the way in dir. Move it out, then push again. |
Push returns 403 |
Your token has no admin role. Run maqpna whoami. |