fix(node): run the TTL sweep in-process; drop the broken external sweep timer

Turso locks the datastore exclusively, so a separate `buh-cli sweep` (and the
shipped systemd sweep timer) cannot open the DB while the daemon holds it — they
fail with a file-locking error. Move the sweep into the daemon as a periodic
in-process task (`[relay].sweep_interval_seconds`, default 3600s), running in
both plain and PQ-mTLS modes.

Remove asset/systemd/buh-node-sweep.{service,timer} and update deploy.sh,
manifest.yml, the config template, and the readme accordingly. The readme also
notes the broader constraint: CLI trust/admin commands cannot run against a live
daemon's datastore — a daemon-side admin path is the next step.

cargo check/fmt/clippy(--all-features) clean; in-daemon sweep verified live.

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 16:06:41 +03:00
parent bb39e76326
commit a6a9c2ced8
9 changed files with 50 additions and 48 deletions

View File

@@ -18,6 +18,7 @@ max_ttl_seconds = {{MAX_TTL_SECONDS}}
max_payload_bytes = {{MAX_PAYLOAD_BYTES}}
max_pull_limit = {{MAX_PULL_LIMIT}}
max_wait_seconds = {{MAX_WAIT_SECONDS}}
sweep_interval_seconds = {{SWEEP_INTERVAL_SECONDS}} # in-process TTL sweep (no external timer)
[blob]
# A node opts into the blob role. `fs` stores opaque client-encrypted ciphertext on local disk

View File

@@ -3,7 +3,7 @@
# 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
# a hardened systemd service, 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.
@@ -37,6 +37,7 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
: "${MAX_PAYLOAD_BYTES:=262144}"
: "${MAX_PULL_LIMIT:=100}"
: "${MAX_WAIT_SECONDS:=30}"
: "${SWEEP_INTERVAL_SECONDS:=3600}"
: "${BLOB_ENABLED:=true}"
: "${BLOB_BACKEND:=fs}"
@@ -74,6 +75,7 @@ render() {
-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|{{SWEEP_INTERVAL_SECONDS}}|${SWEEP_INTERVAL_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" \
@@ -94,10 +96,8 @@ render "${CONFIG_DIR}/config.toml"
chmod 0640 "${CONFIG_DIR}/config.toml"
chgrp buh "${CONFIG_DIR}/config.toml"
echo "==> Installing systemd units"
echo "==> Installing systemd unit"
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
@@ -111,9 +111,8 @@ if [[ "${PKI_ENABLED}" == "true" ]]; then
runuser -u buh -- "${BIN_DIR}/buh-cli" --db-path "${DB_PATH}" --pki-dir "${PKI_DIR}" ca init
fi
echo "==> Enabling services"
echo "==> Enabling the node service"
systemctl enable --now buh-node.service
systemctl enable --now buh-node-sweep.timer
echo
echo "buh node deployed."

View File

@@ -24,6 +24,7 @@ environments:
max_payload_bytes: 262144 # 256 KiB
max_pull_limit: 100
max_wait_seconds: 30
sweep_interval_seconds: 3600 # in-process TTL sweep (no external timer)
blob:
enabled: true
backend: fs # filesystem/ZFS; opaque client-encrypted ciphertext only

View File

@@ -10,8 +10,9 @@ plane, no shared database, and no central PKI**:
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.
deviation. Nothing external watches or reloads a certificate. The TTL sweep also runs *in
process* (`[relay].sweep_interval_seconds`), because Turso locks the datastore exclusively — a
separate `buh-cli sweep` cannot open the DB while the daemon holds it.
- `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.
@@ -33,6 +34,12 @@ sudo PKI_SANS='["node1.example.com"]' ./asset/deploy.sh
Each node pins peers by CA fingerprint — there is no shared root.
> **Note (PoC limitation):** Turso locks the datastore exclusively, so these CLI commands cannot
> run while this host's `buh-api` daemon is up. Stop the service to change trust
> (`systemctl stop buh-node` → edit trust → `systemctl start buh-node`), or run them from an
> operator workstation against a copy. A daemon-side admin path that removes this constraint is
> the next planned step.
```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

View File

@@ -1,25 +0,0 @@
[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

@@ -1,13 +0,0 @@
[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

@@ -82,6 +82,10 @@ pub struct RelayConfig {
pub max_pull_limit: i64,
/// Maximum long-poll wait a client may request, in seconds.
pub max_wait_seconds: u64,
/// How often the daemon sweeps expired envelopes, in seconds. The sweep runs **in process**
/// because Turso locks the datastore exclusively — a separate `buh-cli sweep` cannot run while
/// the daemon holds the DB.
pub sweep_interval_seconds: u64,
}
impl Default for AppConfig {
@@ -97,6 +101,7 @@ impl Default for AppConfig {
max_payload_bytes: core.max_payload_bytes,
max_pull_limit: core.max_pull_limit,
max_wait_seconds: 30,
sweep_interval_seconds: 3600,
},
blob: BlobConfig {
enabled: false,

View File

@@ -15,7 +15,7 @@ use tracing_subscriber::EnvFilter;
use buh_api::config::{AppConfig, BlobConfig};
use buh_api::router::router;
use buh_api::serve::{serve_plain, serve_pqmtls};
use buh_api::serve::{serve_plain, serve_pqmtls, spawn_sweeper};
use buh_api::state::AppState;
use buh_api::tls::{NodeTls, TrustStore};
use buh_data::DataStack;
@@ -57,6 +57,12 @@ async fn main() -> anyhow::Result<()> {
)?;
}
// In-process TTL sweep (an external `buh-cli sweep` cannot run while the daemon holds the DB).
spawn_sweeper(
stack.ctx.clone(),
Duration::from_secs(config.relay.sweep_interval_seconds),
);
let state = AppState {
ctx: stack.ctx.clone(),
max_wait: Duration::from_secs(config.relay.max_wait_seconds),

View File

@@ -15,10 +15,31 @@ use hyper_util::service::TowerToHyperService;
use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use buh_core::PeerTrustRegistry;
use buh_core::{Ctx, PeerTrustRegistry, mailbox};
use crate::tls::{NodeTls, TrustStore};
/// Spawn the in-process TTL sweep that deletes expired envelopes every `interval`.
///
/// The sweep runs inside the daemon — not as an external `buh-cli sweep` — because Turso locks the
/// datastore exclusively, so a second process cannot open the DB while the daemon holds it.
pub fn spawn_sweeper(ctx: Ctx, interval: Duration) {
let interval = interval.max(Duration::from_secs(1));
tracing::info!(interval_secs = interval.as_secs(), "TTL sweeper started");
tokio::spawn(async move {
let mut tick = tokio::time::interval(interval);
tick.tick().await; // consume the immediate first tick
loop {
tick.tick().await;
match mailbox::sweep(&ctx).await {
Ok(0) => {}
Ok(n) => tracing::info!(removed = n, "swept expired envelopes"),
Err(e) => tracing::error!(error = %e, "TTL sweep failed"),
}
}
});
}
/// Plain-HTTP ingress: the dev/web-demo loopback mode. No certificates.
pub async fn serve_plain(
app: axum::Router,