Offline edge gateways
Run a MAQPNA gateway at a disconnected site, keep its audit ledger in a bounded signed spool, and forward it to a central collector over the network or on removable media.
A gateway on a ship, in a factory or at a remote site has to keep governing while it cannot reach the centre. Nothing it decided may be lost or quietly changed before it reconnects. In offline edge mode, the edge gateway cuts its audit ledger into signed segments, keeps them in a bounded local spool, and forwards them to a central gateway (the collector) when it can. The collector verifies each segment and answers with a signed receipt.
Configuration goes into the edge through signed config bundles. This page covers the evidence coming out.
When you need it#
- A gateway runs where the network to the centre is intermittent or absent.
- You need every edge decision in a central place, proven complete and unchanged.
- Data moves only on removable media (sneakernet) at an air-gapped site.
For a fully air-gapped installation with no edge-to-centre flow, see air-gapped install.
How it works#
flowchart LR
subgraph Edge["Edge gateway"]
L["ledger<br/>audit.jsonl"] -->|seal| S["spool<br/>*.segment.json"]
C["ackedSeq<br/>spool-state.json"]
end
subgraph Centre["Central gateway (collector)"]
V{"verify chain<br/>and edge key"} --> E["edges/edge-7/<br/>ledger.jsonl"]
V --> R["central ledger<br/>audit/forward-accepted"]
end
S -->|"POST /v1/audit/forward"| V
S -.->|"USB: export-segments"| V
V -->|"receipt JWS"| C
- Seal. The edge ledger is the gateway's ordinary hash-chained file ledger (
auditPath). The spool copies runs of records byte for byte into segments, with a checkpoint at the last record signed by the edge'sauditCheckpointKey. It seals whensegmentMaxRecordsrecords are waiting, everysealIntervalSeconds, onPOST /v1/audit/forward:seal, and at shutdown. - Forward. A background loop, never on the request path, pushes pending segments in order. It backs off while the collector is unreachable.
- Verify and store. The collector checks the chain, that it continues from the last record it holds for that edge, and that the checkpoint is signed by a key pinned for that edge ID. It stores edge records in a separate ledger per edge. It never appends them to the central chain. The central ledger records only
audit/forward-acceptedoraudit/forward-refused. - Acknowledge. The collector returns a receipt (a JWS signed with its own
auditCheckpointKey) that names the edge, ledger, seq and record hash. The edge moves its cursorackedSeqonly when the receipt verifies against a pinned collector key and names the hash it holds at that seq.
Delivery is exactly once and in order: the collector deduplicates by edge, ledger ID and seq, and answers a segment it already holds with the same receipt.
Prerequisites#
- An edge gateway with a file ledger. An edge with
stateBackendpostgresis refused. - A central gateway to act as collector.
- One checkpoint signing key per edge, and one for the collector (Ed25519, P-256 or RSA; a file, KMS or HSM key).
- For network forwarding: an access token for the collector with the role
audit-forwarder(oradmin).
Step 1: Create the signing keys#
Create an Ed25519 key pair for the edge and one for the collector:
maqpna keygen --out keys --name edge-7
maqpna keygen --out keys --name central-audit
Each command writes <name>.key (private, PKCS#8 PEM) and <name>.pub (public PEM). Give the edge its own private key and the collector's public key. Give the collector its own private key and every edge's public key.
Step 2: Configure the collector#
Add auditCollector to the central gateway's config:
{
"auditCheckpointKey": "file:///etc/maqpna/central-audit.key",
"auditCollector": {
"enabled": true,
"dir": "/var/lib/maqpna/edges",
"edges": [
{"id": "edge-7", "publicKeyFiles": ["/etc/maqpna/edges/edge-7.pub"]}
],
"dropDir": "/media/usb"
},
"adminAuth": {"mode": "oidc", "roles": {"audit-forwarder": ["user:edge-7-forwarder"]}}
}
| Field | Default | Meaning |
|---|---|---|
enabled |
false |
Turns the collector on. Needs auditCheckpointKey (receipts are signed with it). |
id |
the host name | The collector's name in receipts. |
dir |
<auditPath>.edges |
One <edgeId>/ledger.jsonl per edge, an exact copy of that edge's ledger. |
edges[].id, edges[].publicKeyFiles |
(required) | The pinned key registry. Several keys allow rotation. Files are re-read for every segment, so rotation needs no restart. |
dropDir |
Scanned every pollSeconds (default 30) for *.segment.json from removable media. Receipts go to dropDir/receipts/<edgeId>.receipt.jws. |
|
maxSegmentBytes |
64 MiB | Largest accepted segment. |
The audit-forwarder role may call POST /v1/audit/forward and nothing else. Give each edge its own credential.
In a Helm installation, put the block under gateway.config (it becomes /etc/maqpna/gateway.json), map the role in adminAuth.roles, and enable audit.checkpointSigning. Deliver the edge public keys through gateway.secretsStoreCSI:
gateway:
config:
auditCollector:
enabled: true
edges:
- id: edge-7
publicKeyFiles: ["/mnt/secrets-store/edge-7.pub"]
secretsStoreCSI:
enabled: true
secretProviderClass: maqpna-edge-keys
audit:
checkpointSigning:
enabled: true
secretName: maqpna-audit-signing # key audit-signing.pem
adminAuth:
roles:
audit-forwarder: ["user:edge-7-forwarder"]
Step 3: Configure the edge#
Add offline to the edge gateway's config:
{
"auditPath": "/var/lib/maqpna/audit.jsonl",
"auditCheckpointKey": "file:///etc/maqpna/edge-7.key",
"revocationsFile": "/var/lib/maqpna/config/revocations.json",
"offline": {
"enabled": true,
"edgeId": "edge-7",
"spoolMaxBytes": 1073741824,
"fullPolicy": "failClosed",
"collectorKeyFiles": ["/etc/maqpna/central-audit.pub"],
"forward": {"url": "https://central.example.eu", "tokenFile": "/etc/maqpna/forwarder.token"},
"receiptDropDir": "/media/usb",
"maxRevocationStalenessSeconds": 86400,
"highRiskTools": ["github/*", "maqpna-infra/*"],
"approvals": "localApprovers",
"localApprovers": ["user:[email protected]", "site-approvers"]
}
}
| Field | Default | Meaning |
|---|---|---|
edgeId |
(required) | The edge's name at the collector: 1–63 letters, digits, ., _ or -. |
spoolDir |
<auditPath>.spool |
Segment files and spool-state.json. Keep it on durable storage. |
spoolMaxBytes |
1 GiB | Bound on segment files plus records not yet sealed. |
fullPolicy |
failClosed |
failClosed or dropOldestForwarded. See when the spool is full. |
segmentMaxRecords |
1000 | Rollover size of a segment. |
sealIntervalSeconds |
60 | Seals whatever was written in the meantime. |
collectorKeyFiles |
(required with forward.url or receiptDropDir) |
The collector's public keys. Receipts must verify against one. |
forward.url |
(none: sneakernet only) | The collector. https, or http to a loopback host only. |
forward.tokenFile |
Bearer token for the collector, re-read for every request. | |
forward.caFile |
system roots | CA bundle for the collector. |
forward.intervalSeconds, maxBackoffSeconds, timeoutSeconds |
10, 300, 30 | Forwarding cadence, retry ceiling, request timeout. |
receiptDropDir |
Scanned for *.receipt.jws (also in its receipts/ subdirectory). |
|
disconnectedAfterSeconds |
300 | The edge counts as disconnected after this long without a collector answer, or always without forward.url. |
maxRevocationStalenessSeconds |
0 (off) | Denies highRiskTools once the revocation list is older than this. Needs revocationsFile. |
highRiskTools |
every tool | server/tool globs that are denied on a stale revocation list. |
approvals |
deny |
deny or localApprovers: what happens to calls that need approval while disconnected. |
localApprovers |
user:<name> or group entries who may approve while disconnected. |
Check the config before you start the gateway:
maqpna-gateway -config /etc/maqpna/gateway.json -check-config
Step 4: Forward on removable media (optional)#
When there is no network, carry segments and receipts on a USB stick.
-
At the edge, seal now and export the segments:
curl -X POST -H "Authorization: Bearer $MAQPNA_ADMIN_TOKEN" https://edge-7.example.eu/v1/audit/forward:seal maqpna audit export-segments --ledger /var/lib/maqpna/audit.jsonl --out /media/usb--since SEQexports only segments with records afterSEQ. When in doubt, export everything: the collector ignores records it already holds. -
At the centre, push them to the collector (or mount the stick at
auditCollector.dropDir):maqpna audit forward --from /media/usb --to https://central.example.eu --oidc-token-file forwarder.tokenReceipts are written to
/media/usb/receipts/. The command exits with status 3 when the collector refused a segment. -
Back at the edge, mount the stick at
offline.receiptDropDir. The edge reads the receipt and movesackedSeq.
Verify it worked#
-
On the edge, read the forwarding status (role
auditororadmin):curl -s -H "Authorization: Bearer $MAQPNA_ADMIN_TOKEN" https://edge-7.example.eu/v1/audit/forward/statusCheck
ackedSeqagainstheadSeq,unforwardedRecords,pendingSegments,fullandconnected. On the collector, the same endpoint lists each edge's storedseq,hash,ledgerIdandlastAcceptedAt. -
On the collector, verify a forwarded ledger with the edge's public keys. Get them from the edge's
GET /v1/audit/jwks:curl -s https://edge-7.example.eu/v1/audit/jwks > edge-7.jwks.json maqpna audit verify /var/lib/maqpna/edges/edge-7/ledger.jsonl --jwks edge-7.jwks.jsonThe segment checkpoints are in
ledger.jsonl.checkpoints.jws, the default checkpoint file for that path.
When the spool is full#
Unforwarded records are never dropped. The two policies differ only in what happens to segments the collector has already acknowledged:
failClosednever deletes a segment by itself. AtspoolMaxBytes, every new tool call is denied (policyoffline, rulespool-full, reasonoffline_spool_full) until you delete acknowledged segments withPOST /v1/audit/forward:prune(roleadmin) or raise the limit. A connected edge still fills up over time, so watchprunableBytes.dropOldestForwardeddeletes the oldest acknowledged segments when it needs room. A spool full of unacknowledged segments denies calls exactly likefailClosed.
The ledger file itself is not compacted. Plan disk for the ledger and the spool copy of its records.
Decisions while offline#
| Feature | Offline behaviour |
|---|---|
| Policies, tool pins, budgets, model routes | Enforced from the files in force (the last config bundle). |
| Token verification | Local, against identity.publicKeyFiles or the last fetched JWKS. Run the identity broker at the edge so sessions can still get tokens. |
| Revocations | Break-glass revocations on the edge apply at once. Central revocations arrive with the next config bundle. With maxRevocationStalenessSeconds, a stale list denies highRiskTools (rule revocation-stale, reason offline_revocation_stale). |
| Approvals | With approvals: deny, a call needing approval is denied at once (rule approval-offline, reason offline_approval_unavailable). With localApprovers, only those people may approve while disconnected; others get 403 offline_local_approvers_only. |
| Budgets across sites | Enforced locally per edge. Edge spend is not merged into central budgets. |
| SIEM, WORM and transparency-log sinks | Keep their own cursors and catch up when reachable. |
These checks run after policy and never turn a deny into an allow.
Metrics#
| Metric | Side |
|---|---|
maqpna_gateway_audit_forward_unforwarded_records |
edge |
maqpna_gateway_audit_forward_oldest_unforwarded_age_seconds |
edge |
maqpna_gateway_audit_forward_last_ack_timestamp_seconds, maqpna_gateway_audit_forward_acked_seq |
edge |
maqpna_gateway_audit_forward_spool_bytes, _spool_max_bytes, _spool_full, _connected, _errors_total |
edge |
maqpna_gateway_offline_denials_total{rule} |
edge |
maqpna_gateway_audit_collector_segments_total{result}, _refused_total{reason}, _edge_seq{edge} |
collector |
Alert when the oldest unforwarded age passes your longest planned disconnection, when spool_full is 1, and on any increase of audit_collector_refused_total. Refusals are also sent to notifiers that subscribe to audit.forward_refused.
Troubleshooting#
| Refusal or symptom | What it means and what to do |
|---|---|
gap (409) |
The segment starts after the collector's head. The forwarder resends from the head the answer names. For sneakernet, export from an earlier --since. |
fork (409) |
A different record for a seq already stored: the edge ledger was rewritten, or two devices share one edge ID. Give each device its own edgeId and key. |
ledger-mismatch (409) |
The edge ID sends a new ledger, for example after a reinstall. Move the old <dir>/<edgeId> aside to accept it. |
chain (422) |
A record was changed, dropped, inserted or reordered. Investigate the edge. |
signature (422) |
The checkpoint is not signed by a key pinned for the edge. Add the edge's current public key to publicKeyFiles. |
unknown-edge (422) |
No key is pinned for this edge ID. Add it to auditCollector.edges. |
malformed (400), too-large (413) |
Not a segment, or larger than maxSegmentBytes. |
Edge does not start: the ledger does not match the spool |
The ledger was replaced, truncated or rewritten. Decide whether it is tampering or a restore before you move the spool aside. |
Every tool call denied, reason offline_spool_full |
Prune acknowledged segments or raise spoolMaxBytes, and check forwarding. |
offline.collectorKeyFiles is required ... |
Set the collector's public key when you use forward.url or receiptDropDir. |
Limits#
- The edge's own ledger file is not compacted. The spool bounds the forwarding buffer only.
- Segments are not encrypted. They carry hashes of arguments, not arguments, but they carry session, agent and user names in clear.
- An
audit-forwardercredential is not bound to one edge ID. A forwarder can still only replay segments signed by a pinned edge key. - Planned: shared (
postgres) ledgers at the edge, several edge replicas on one spool, merging edge spend into central budgets, and transparency-log anchors for forwarded ledgers. - Tests use temporary directories and in-process collectors. A run against real removable media and intermittent links has not been done yet.
Next steps#
- Config bundles: get policies and revocations into the edge.
- Audit ledger: how the ledger and checkpoints work.
maqpna audit export-segmentsandmaqpna audit forward.- SIEM and audit forwarding: stream records to your SIEM.
- Monitoring and alerts.