MAQPNADocs

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 pkcs11 builds).
  • 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 notBefore and expiresAt, with 60 seconds of clock skew allowed.

Prerequisites#

  • The maqpna CLI, and maqpna-operator if 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_total and maqpna_gateway_config_bundles_refused_total{reason}.
  • Audit ledger: each bundle is a record with server config, tool bundle, rule config/bundle-applied or config/bundle-refused. The approver is admin:<user>, config-bundle-pull or dropDir:<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.url or a shared dropDir. 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 statePath on durable storage. Set an expiry longer than your longest planned disconnection, for example one to four weeks.
  • Clocks. Without NTP, omit expiresAt and notBefore and 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.json is 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 extra signatures field.

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.

Next steps#