Files
logarchiver/docs/retrieval-runbook.md
benvin c05ccfcb5d
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Initial implementation: NATS->S3 archiver + search/retrieve CLI
logarchiver replaces the plain Vector archiver leg of the centralized
logging stack (argocd-apps #296) with a Go service that archives raw logs
from NATS JetStream to S3 as zstd-compressed, OpenPGP-encrypted, indexed
objects, plus an operator CLI to search the index and retrieve/decrypt
archived logs. It adds the things that outgrew Vector: zstd compression,
encryption keyed from Ben's Vault GPG secrets engine, a searchable
ClickHouse index, and sink-conditional acks (a batch is acknowledged to
JetStream only after the object is durably in S3 AND indexed).

Service (`logarchiver run`):
- Durable JetStream pull consumer (stream LOGS, durable archiver, subject
  filter default logs.k8s.vault.>), explicit acks, independent offsets.
- Batch per subject by size/count/time -> NDJSON -> zstd -> encrypt -> S3
  PUT -> ClickHouse index row -> ack. On any failure the batch is Nak'd and
  redelivered, so nothing is lost on a sink outage.
- Encryption is a wrapped-DEK envelope (container LARC1): the bulk is
  AES-256-GCM framed under a random data key, and only that 32-byte key is
  OpenPGP-encrypted to the engine's public key. This is because the Vault
  GPG engine does whole-payload decrypt only; retrieval round-trips just the
  tiny wrapped key regardless of object size. Public key fetched from the
  engine or a mounted file (configurable); key fingerprint recorded per
  object; periodic pubkey refresh for rotation.
- Prometheus metrics, structured slog, graceful drain on shutdown.

CLI:
- `search` queries the index (subject/host/time) and lists matching objects.
- `fetch` downloads, decrypts via the Vault GPG engine, unzstds and emits
  NDJSON (optionally re-filtered by host/time).
- `init-schema` creates/prints the ClickHouse archive_index DDL.
- cobra `completion` subcommands.

Config via file+env (k8s-friendly, secrets from env), boundaries (NATS/S3/
ClickHouse/Vault) behind interfaces with unit tests (config, batching,
host/subject extraction, crypto roundtrip with a test key, ack-after-persist
with fakes, search query building). go build/vet/test -race clean;
golangci-lint v2 clean. Woodpecker CI: build/test/pre-commit on PR; on v*
tag a container image plus a Gitea binary release + rpm-internal RPM. Docs
per subcommand + architecture + retrieval runbook + deployment drop-in.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 23:22:40 +10:00

6.0 KiB

Retrieval runbook & crypto design

This document is the honest account of how encryption and retrieval work, what the Vault GPG engine actually supports, and the operational steps to get plain NDJSON back out of an archived object.

What the Vault GPG engine supports (and doesn't)

logarchiver encrypts to a key in Ben's vault-plugin-secrets-gpg engine (transit-style OpenPGP, mounted at gpg/). Relevant endpoints:

  • GET gpg/keys/<name> → returns data.public_key (armored) and data.fingerprint (40-hex uppercase, no spaces). Used to encrypt locally.
  • POST gpg/decrypt/<name> with { "ciphertext": "<base64 of a whole OpenPGP message>" } → returns { "plaintext": "<base64>" }.

Crucial limitation: the decrypt endpoint does whole-payload inline decryption only. It reads the entire OpenPGP message and returns the entire plaintext. There is no session-key (PKESK) extraction, no chunking, and no streaming. The whole ciphertext must fit in a base64 JSON request body (Vault's default max_request_size is 32 MiB), and the whole plaintext comes back base64 in the response. So you cannot feed a large archive object straight to the engine, and you cannot ask it to decrypt only the session key.

The design logarchiver chose

Rather than bound object sizes to the engine's request limit, logarchiver does hybrid encryption itself and sends the engine only a tiny key blob:

  1. Encrypt (service, local): generate a random 256-bit DEK; encrypt the zstd-compressed NDJSON locally with AES-256-GCM in frames; OpenPGP-encrypt just the 32-byte DEK to the engine's public key. The wrapped DEK is a small standard OpenPGP message. The private key is never present.
  2. Decrypt (CLI, retrieval): read the object header, send only the wrapped DEK (a few hundred bytes) to gpg/decrypt/<name>, get the DEK back, then stream-decrypt the AES-GCM frames locally and zstd-decompress to NDJSON.

The Vault round-trip is tiny and constant regardless of object size, the engine needs no changes, and the private key stays in Vault. See architecture.md for the container layout.

Possible engine enhancement (follow-up, not required): if the engine ever grows a "decrypt session key only / return PKESK plaintext" operation, objects could be plain standard OpenPGP messages while retaining tiny Vault round-trips. logarchiver's self-managed DEK envelope already achieves the operational goal without it, so this is optional.

One-time key setup

Create the archive key in the engine (if it doesn't already exist) and export the public key for the service:

export VAULT_ADDR=https://vault.service.consul:8200

# Create the key (idempotent; rsa-3072 default, or ed25519).
vault write gpg/keys/logarchive identity="logarchiver <ops@unkin.net>" algorithm=ed25519

# Fingerprint the service records per object (sanity check):
vault read -field=fingerprint gpg/keys/logarchive

# Export the armored public key for the service's file-mounted pubkey source:
vault read -field=public_key gpg/keys/logarchive > pubkey.asc

In k8s the service can instead read the public key directly from Vault (pubkey_source: vault, k8s auth) — no mounted file needed. The file source is the default because it removes any Vault dependency from the hot path.

Retrieve archived logs

The CLI needs: S3 read creds (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY for the logs-archive bucket), the internal CA (s3.ca_file), and a Vault token with decrypt on the key (ambient VAULT_TOKEN or ~/.vault-token, exactly like passv).

export VAULT_ADDR=https://vault.service.consul:8200
vault login ...            # or have ~/.vault-token
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...

# 1. Find objects via the index.
logarchiver search --subject 'logs.k8s.vault.>' --host node-1 \
  --from 2026-07-01 --to 2026-07-27

# 2a. Retrieve by the object keys search printed, to stdout:
logarchiver fetch <object-key> [<object-key> ...] -o -

# 2b. Or retrieve straight from a query, re-filtered to just the matching
#     host/time events, into a directory (one NDJSON file per object):
logarchiver fetch --subject 'logs.vm.*' --host db-1 --from -24h -o ./out

fetch downloads each object, reads its header to learn which Vault key wrapped it (key_name), decrypts the wrapped DEK via the engine, streams the frames through local AES-GCM + zstd, and emits NDJSON. When --host/--from/--to are supplied they also re-filter the emitted lines (events lacking a parseable timestamp are kept, never silently dropped on time grounds).

Failure modes

Symptom Likely cause Action
decrypt via gpg/decrypt/<name>: permission denied Token lacks the decrypt policy Grant update on gpg/decrypt/<name> (and read on gpg/keys/<name>).
unwrap dek: … no plaintext Wrong key name / key rotated below min_decryption_version Confirm object key_name; the engine tries versions ≥ min_decryption_version.
bad magic: not a logarchiver (LARC1) object Fetching a non-logarchiver object (e.g. an old Vector .log.gz) Use the right prefix; Vector objects are plain gzip, decrypt them with gzip.
decrypt frame N: cipher: message authentication failed Object corruption/tampering Object integrity is broken; re-archive from JetStream if still within retention.
pubkey fingerprint mismatch (service) Armored pubkey doesn't match the engine's reported fingerprint Re-export the pubkey; the service refuses to encrypt to a mismatched key.

Key rotation

Rotating the engine key (vault write gpg/keys/logarchive/rotate ...) starts encrypting new objects to the new version; the service picks up the new public key within crypto.refresh_interval (default 1h) or on restart. Old objects remain decryptable because the engine tries every key version from min_decryption_version up. Each object records the fingerprint it was sealed with, so you can always tell which key version applies.