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:
44
asset/config/config.toml.tmpl
Normal file
44
asset/config/config.toml.tmpl
Normal 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
124
asset/deploy.sh
Executable 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
|
||||
9
asset/firewalld/buh-node.xml
Normal file
9
asset/firewalld/buh-node.xml
Normal 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
55
asset/manifest.yml
Normal 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
41
asset/readme.md
Normal 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
|
||||
```
|
||||
25
asset/systemd/buh-node-sweep.service
Normal file
25
asset/systemd/buh-node-sweep.service
Normal 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
|
||||
13
asset/systemd/buh-node-sweep.timer
Normal file
13
asset/systemd/buh-node-sweep.timer
Normal 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
|
||||
45
asset/systemd/buh-node.service
Normal file
45
asset/systemd/buh-node.service
Normal 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
|
||||
2
asset/systemd/buh.sysusers.conf
Normal file
2
asset/systemd/buh.sysusers.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
#Type Name ID GECOS Home directory Shell
|
||||
u buh - "buh node service account" /var/lib/buh /usr/sbin/nologin
|
||||
Reference in New Issue
Block a user