chore(asset): hardened node deployment assets (Phase 7 part 2)

Ops files for running a buh node on an untrusted, third-party host: no central
control plane, no shared database, no central PKI.

- manifest.yml: per-node descriptor (prod PQ-mTLS+blob / dev plain loopback).
- systemd/buh-node.service: hardened unit (generic.md §8) — NO .path cert-reload
  unit, since the node is its own CA and rotates its leaf in process.
- systemd/buh-node-sweep.{service,timer}: periodic TTL sweep.
- buh.sysusers.conf, firewalld/buh-node.xml (opens BUH_NODE_PORT 8443).
- config/config.toml.tmpl ([relay]/[blob]/[pki]), deploy.sh (env-driven render,
  generates the node CA, prints the fingerprint), readme.md.

Not CI-tested — kept minimal and honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EB3LjarCdXxqrJ4tFLn8LB
This commit is contained in:
2026-06-30 15:47:15 +03:00
parent 9a9cf0d609
commit e2538c4cb0
9 changed files with 358 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
# buh-api / buh-cli configuration template.
#
# deploy.sh renders this with values from manifest.yml, writing the result to
# /etc/buh/config.toml on the target. {{PLACEHOLDERS}} are substituted at deploy time. Env vars
# (BUH_*, nested with __, e.g. BUH_PKI__NODE_BIND) override any value here.
#
# There are no secrets in this file: the node holds no DB password (the datastore is an embedded
# Turso file) and generates its own CA/PKI on first start. The blob role, when using the `fs`
# backend, needs no credentials either.
bind = "{{BIND}}" # plain loopback health/debug; PQ-mTLS ingress is [pki].node_bind
db_path = "{{DB_PATH}}"
log_format = "{{LOG_FORMAT}}"
[relay]
default_ttl_seconds = {{DEFAULT_TTL_SECONDS}}
max_ttl_seconds = {{MAX_TTL_SECONDS}}
max_payload_bytes = {{MAX_PAYLOAD_BYTES}}
max_pull_limit = {{MAX_PULL_LIMIT}}
max_wait_seconds = {{MAX_WAIT_SECONDS}}
[blob]
# A node opts into the blob role. `fs` stores opaque client-encrypted ciphertext on local disk
# (or ZFS); `s3` (requires buh-api built with the `s3` feature) forwards to S3/MinIO.
enabled = {{BLOB_ENABLED}}
backend = "{{BLOB_BACKEND}}"
fs_root = "{{BLOB_FS_ROOT}}"
max_blob_bytes = {{MAX_BLOB_BYTES}}
s3_endpoint = "{{S3_ENDPOINT}}"
s3_region = "{{S3_REGION}}"
s3_access_key = "{{S3_ACCESS_KEY}}"
s3_secret_key = "{{S3_SECRET_KEY}}"
[pki]
# Decentralised PQ-mTLS (doc/design.md §5.1). When enabled, the node generates and self-serves
# its own CA, binds PQ-mTLS on node_bind (BUH_NODE_PORT), and rotates its leaf in process. There
# is no step-ca and no central PKI. Share the CA fingerprint (`buh-cli ca show`) with peers; trust
# theirs with `buh-cli peer trust <ca-fp>`.
enabled = {{PKI_ENABLED}}
dir = "{{PKI_DIR}}"
node_bind = "{{NODE_BIND}}"
sans = {{PKI_SANS}} # TOML array, e.g. ["node1.example.com"]
leaf_ttl_hours = {{LEAF_TTL_HOURS}}
rotate_every_hours = {{ROTATE_EVERY_HOURS}}

124
asset/deploy.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
#
# Deploy one buh node on the local host (run as root on the target).
#
# buh has no central control plane, so this installs a single self-contained node: the binaries,
# a hardened systemd service, the TTL-sweep timer, the firewalld opening for BUH_NODE_PORT, and a
# rendered /etc/buh/config.toml. It then has the node generate its own CA and prints the
# fingerprint to share with peers. There is deliberately NO step-ca, NO central database
# bootstrap, and NO secret material to provision.
#
# Configuration values are taken from the environment (see DEFAULTS below) so this script stays a
# readable, dependency-free renderer; the committed manifest.yml documents the intended values per
# environment. Override any of them inline, e.g.:
#
# sudo BLOB_ENABLED=false PKI_SANS='["relay.example.com"]' ./deploy.sh
#
set -euo pipefail
if [[ ${EUID} -ne 0 ]]; then
echo "deploy.sh must run as root on the target node" >&2
exit 1
fi
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Configuration (override via environment) --------------------------------------------------
: "${BIN_DIR:=/usr/local/bin}"
: "${CONFIG_DIR:=/etc/buh}"
: "${STATE_DIR:=/var/lib/buh}"
: "${BIND:=127.0.0.1:8080}"
: "${DB_PATH:=${STATE_DIR}/relay.db}"
: "${LOG_FORMAT:=json}"
: "${DEFAULT_TTL_SECONDS:=604800}"
: "${MAX_TTL_SECONDS:=2592000}"
: "${MAX_PAYLOAD_BYTES:=262144}"
: "${MAX_PULL_LIMIT:=100}"
: "${MAX_WAIT_SECONDS:=30}"
: "${BLOB_ENABLED:=true}"
: "${BLOB_BACKEND:=fs}"
: "${BLOB_FS_ROOT:=${STATE_DIR}/blobs}"
: "${MAX_BLOB_BYTES:=67108864}"
: "${S3_ENDPOINT:=}"
: "${S3_REGION:=us-east-1}"
: "${S3_ACCESS_KEY:=}"
: "${S3_SECRET_KEY:=}"
: "${PKI_ENABLED:=true}"
: "${PKI_DIR:=${STATE_DIR}/pki}"
: "${NODE_BIND:=0.0.0.0:8443}"
: "${PKI_SANS:=[\"localhost\"]}"
: "${LEAF_TTL_HOURS:=48}"
: "${ROTATE_EVERY_HOURS:=24}"
echo "==> Installing service account"
install -m 0644 "${here}/systemd/buh.sysusers.conf" /usr/lib/sysusers.d/buh.conf
systemd-sysusers
echo "==> Creating state tree ${STATE_DIR}"
install -d -m 0700 -o buh -g buh "${STATE_DIR}"
echo "==> Rendering ${CONFIG_DIR}/config.toml"
install -d -m 0755 "${CONFIG_DIR}"
render() {
local out="$1"
sed \
-e "s|{{BIND}}|${BIND}|g" \
-e "s|{{DB_PATH}}|${DB_PATH}|g" \
-e "s|{{LOG_FORMAT}}|${LOG_FORMAT}|g" \
-e "s|{{DEFAULT_TTL_SECONDS}}|${DEFAULT_TTL_SECONDS}|g" \
-e "s|{{MAX_TTL_SECONDS}}|${MAX_TTL_SECONDS}|g" \
-e "s|{{MAX_PAYLOAD_BYTES}}|${MAX_PAYLOAD_BYTES}|g" \
-e "s|{{MAX_PULL_LIMIT}}|${MAX_PULL_LIMIT}|g" \
-e "s|{{MAX_WAIT_SECONDS}}|${MAX_WAIT_SECONDS}|g" \
-e "s|{{BLOB_ENABLED}}|${BLOB_ENABLED}|g" \
-e "s|{{BLOB_BACKEND}}|${BLOB_BACKEND}|g" \
-e "s|{{BLOB_FS_ROOT}}|${BLOB_FS_ROOT}|g" \
-e "s|{{MAX_BLOB_BYTES}}|${MAX_BLOB_BYTES}|g" \
-e "s|{{S3_ENDPOINT}}|${S3_ENDPOINT}|g" \
-e "s|{{S3_REGION}}|${S3_REGION}|g" \
-e "s|{{S3_ACCESS_KEY}}|${S3_ACCESS_KEY}|g" \
-e "s|{{S3_SECRET_KEY}}|${S3_SECRET_KEY}|g" \
-e "s|{{PKI_ENABLED}}|${PKI_ENABLED}|g" \
-e "s|{{PKI_DIR}}|${PKI_DIR}|g" \
-e "s|{{NODE_BIND}}|${NODE_BIND}|g" \
-e "s|{{PKI_SANS}}|${PKI_SANS}|g" \
-e "s|{{LEAF_TTL_HOURS}}|${LEAF_TTL_HOURS}|g" \
-e "s|{{ROTATE_EVERY_HOURS}}|${ROTATE_EVERY_HOURS}|g" \
"${here}/config/config.toml.tmpl" > "${out}"
}
render "${CONFIG_DIR}/config.toml"
chmod 0640 "${CONFIG_DIR}/config.toml"
chgrp buh "${CONFIG_DIR}/config.toml"
echo "==> Installing systemd units"
install -m 0644 "${here}/systemd/buh-node.service" /etc/systemd/system/buh-node.service
install -m 0644 "${here}/systemd/buh-node-sweep.service" /etc/systemd/system/buh-node-sweep.service
install -m 0644 "${here}/systemd/buh-node-sweep.timer" /etc/systemd/system/buh-node-sweep.timer
systemctl daemon-reload
if [[ "${PKI_ENABLED}" == "true" ]]; then
echo "==> Opening BUH_NODE_PORT in firewalld"
install -m 0644 "${here}/firewalld/buh-node.xml" /etc/firewalld/services/buh-node.xml
firewall-cmd --reload
firewall-cmd --permanent --add-service=buh-node
firewall-cmd --reload
echo "==> Initialising the node CA"
runuser -u buh -- "${BIN_DIR}/buh-cli" --db-path "${DB_PATH}" --pki-dir "${PKI_DIR}" ca init
fi
echo "==> Enabling services"
systemctl enable --now buh-node.service
systemctl enable --now buh-node-sweep.timer
echo
echo "buh node deployed."
if [[ "${PKI_ENABLED}" == "true" ]]; then
echo "Share this CA fingerprint so peers/clients can pin you:"
runuser -u buh -- "${BIN_DIR}/buh-cli" --pki-dir "${PKI_DIR}" ca show
echo "Trust a peer with: buh-cli --db-path ${DB_PATH} peer trust <their-ca-fp>"
fi

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>buh-node</short>
<description>buh node PQ-mTLS ingress (BUH_NODE_PORT). This is the single port a node exposes:
the relay/blob API served over X25519MLKEM768 mutual TLS, with peers pinned per-CA. The plain
loopback health port (127.0.0.1:8080) is never opened. Forward this port from the edge
(OPNsense/router) to the node host.</description>
<port protocol="tcp" port="8443"/>
</service>

55
asset/manifest.yml Normal file
View File

@@ -0,0 +1,55 @@
app: buh
# buh nodes run on untrusted, third-party machines — there is no central control plane, no
# shared database, and no central PKI. Each node is its own CA (it self-serves PQ-mTLS and pins
# peers by CA fingerprint) and keeps all state in an embedded Turso datastore. This manifest is
# therefore a per-node deployment descriptor, not a fleet topology: `deploy.sh` renders one
# node's config from the block under the chosen environment/host.
environments:
prod:
components:
node:
# A node opts into roles. Every node runs the relay; this one also runs the blob role.
hosts: [node1.example.internal]
config:
# Plain loopback ingress is OFF in prod: the node is reachable only over PQ-mTLS on
# node_bind (the standardised BUH_NODE_PORT, forwarded from the OPNsense/firewalld edge).
bind: 127.0.0.1:8080 # health/debug on loopback only
db_path: /var/lib/buh/relay.db
log_format: json
relay:
default_ttl_seconds: 604800 # 7 days
max_ttl_seconds: 2592000 # 30 days
max_payload_bytes: 262144 # 256 KiB
max_pull_limit: 100
max_wait_seconds: 30
blob:
enabled: true
backend: fs # filesystem/ZFS; opaque client-encrypted ciphertext only
fs_root: /var/lib/buh/blobs
max_blob_bytes: 67108864 # 64 MiB
pki:
enabled: true
dir: /var/lib/buh/pki # CA key + cert; the node generates these on first start
node_bind: 0.0.0.0:8443 # BUH_NODE_PORT — the only port exposed at the edge
sans: [node1.example.com] # hostnames/IPs the leaf answers to
leaf_ttl_hours: 48
rotate_every_hours: 24 # in-process leaf rotation (no step-ca, no .path units)
dev:
components:
node:
hosts: [localhost]
config:
# Dev/web-demo mode: plain HTTP on loopback, PKI off, so the Vite proxy and the browser
# demo work without certificates. This is the mode the integration tests exercise.
bind: 127.0.0.1:8080
db_path: ./buh-relay.db
log_format: pretty
blob:
enabled: true
backend: fs
fs_root: ./buh-blobs
max_blob_bytes: 67108864
pki:
enabled: false

41
asset/readme.md Normal file
View File

@@ -0,0 +1,41 @@
# buh deployment assets
Ops files for running a buh node on a real host. **Not CI-tested** — kept minimal and honest.
A buh node runs on an untrusted, third-party machine, so these assets assume **no central control
plane, no shared database, and no central PKI**:
- `manifest.yml` — per-node deployment descriptor (relay + optional blob role, PQ-mTLS settings)
for the `prod` and `dev` environments. It is a descriptor, not a fleet topology.
- `config/config.toml.tmpl` — the rendered `buh-api`/`buh-cli` config. No secrets: the datastore
is an embedded Turso file and the node generates its own CA.
- `systemd/buh-node.service` — hardened unit (`generic.md` §8). **No `*.path` cert-reload unit**:
the node is its own CA and rotates its TLS leaf *in process* — that is the decentralised-CA
deviation. Nothing external watches or reloads a certificate.
- `systemd/buh-node-sweep.{service,timer}` — periodic TTL sweep of expired envelopes.
- `systemd/buh.sysusers.conf` — the unprivileged `buh` service account.
- `firewalld/buh-node.xml` — opens **`BUH_NODE_PORT` (8443)**, the single PQ-mTLS ingress port.
The plain loopback health port is never exposed.
- `deploy.sh` — installs the above on the local host, renders the config, has the node generate
its CA, and prints the CA fingerprint to share with peers.
## Quick start (on the target node, as root)
```sh
# build + install the binaries first
cargo build --release --features s3 # drop --features s3 for an fs-only blob node
install -m0755 target/release/buh-api target/release/buh-cli /usr/local/bin/
# then deploy (override any value via the environment — see deploy.sh DEFAULTS)
sudo PKI_SANS='["node1.example.com"]' ./asset/deploy.sh
```
## Trust between nodes
Each node pins peers by CA fingerprint — there is no shared root.
```sh
buh-cli ca show # print my fingerprint to hand to a peer
buh-cli peer trust <their-ca-fp> # accept that peer over PQ-mTLS
buh-cli peer distrust <their-ca-fp> # refuse them on the next handshake
buh-cli peer list # who I currently trust
```

View File

@@ -0,0 +1,25 @@
[Unit]
Description=buh TTL sweep — delete expired envelopes from the relay datastore
[Service]
Type=oneshot
User=buh
Group=buh
ExecStart=/usr/local/bin/buh-cli --db-path /var/lib/buh/relay.db sweep
# Same hardening posture as the node service; only the datastore is writable.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ReadWritePaths=/var/lib/buh
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Periodic buh TTL sweep of expired envelopes
[Timer]
# Envelopes also expire lazily on read; this sweep reclaims space for queues that are never
# pulled again. Hourly is ample for a store-and-forward relay.
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=5m
Unit=buh-node-sweep.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,45 @@
[Unit]
Description=buh node — blind relay/mailbox (+ optional blob role) over PQ-mTLS
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=buh
Group=buh
ExecStart=/usr/local/bin/buh-api --config /etc/buh/config.toml
Restart=on-failure
RestartSec=2
# The node is its own CA and rotates its own TLS leaf IN PROCESS on a timer (see [pki] in
# config.toml). This is the decentralised-CA deviation: there is deliberately NO step-ca and NO
# `.path` unit watching an externally-renewed certificate. Nothing external restarts this service
# for cert rotation.
# Hardening (generic.md §8) — relax an individual knob only if a feature genuinely requires it.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
# Writable state: embedded Turso datastore, the per-node CA/PKI material, and (if the blob role
# is enabled) the local blob store. All live under one tree.
ReadWritePaths=/var/lib/buh
# Network families the service needs (TCP over IPv4/IPv6; AF_UNIX for logging).
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,2 @@
#Type Name ID GECOS Home directory Shell
u buh - "buh node service account" /var/lib/buh /usr/sbin/nologin