MAQPNADocs

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
  1. 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's auditCheckpointKey. It seals when segmentMaxRecords records are waiting, every sealIntervalSeconds, on POST /v1/audit/forward:seal, and at shutdown.
  2. Forward. A background loop, never on the request path, pushes pending segments in order. It backs off while the collector is unreachable.
  3. 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-accepted or audit/forward-refused.
  4. 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 cursor ackedSeq only 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 stateBackend postgres is 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 (or admin).

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.

  1. 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 SEQ exports only segments with records after SEQ. When in doubt, export everything: the collector ignores records it already holds.

  2. 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.token
    

    Receipts are written to /media/usb/receipts/. The command exits with status 3 when the collector refused a segment.

  3. Back at the edge, mount the stick at offline.receiptDropDir. The edge reads the receipt and moves ackedSeq.

Verify it worked#

  1. On the edge, read the forwarding status (role auditor or admin):

    curl -s -H "Authorization: Bearer $MAQPNA_ADMIN_TOKEN" https://edge-7.example.eu/v1/audit/forward/status
    

    Check ackedSeq against headSeq, unforwardedRecords, pendingSegments, full and connected. On the collector, the same endpoint lists each edge's stored seq, hash, ledgerId and lastAcceptedAt.

  2. 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.json
    

    The 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:

  • failClosed never deletes a segment by itself. At spoolMaxBytes, every new tool call is denied (policy offline, rule spool-full, reason offline_spool_full) until you delete acknowledged segments with POST /v1/audit/forward:prune (role admin) or raise the limit. A connected edge still fills up over time, so watch prunableBytes.
  • dropOldestForwarded deletes the oldest acknowledged segments when it needs room. A spool full of unacknowledged segments denies calls exactly like failClosed.

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-forwarder credential 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#