mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
refactor(mail): rspamd lib + sbin write through live LXC (ref #153)
Phase 2 deploy on admin.gk2.secubox.in surfaced three brittleness points
that the live-deploy fixes patched ad-hoc:
1. configure_rspamd_milter wrote to ${LXC_BASE}/<container>/rootfs/etc/
rspamd/local.d/ — but on this board the runtime LXC mounts a different
rootfs (/data/lxc/<container>/rootfs/ per lxc.rootfs.path). Files written
to the host-side guess landed in a stale shell the running LXC didn't
see, and the first install's local.d was empty.
2. configure_rspamd_controller had the same path-guess issue plus
hardcoded chown 100110:100110 (had to be patched in commit 637b2221
because _rspamd is uid 107 on this image, not 110).
3. rspamd_keygen called rspamadm on the host PATH — rspamadm only exists
inside the rspamd-installed LXC, so the function errored out on every
install. The Phase 2 deploy worked around it manually.
This refactor makes all three write THROUGH the live LXC via lxc-attach:
- Each local.d template is streamed in with `lxc-attach -- tee` (kernel
resolves the rootfs path; idmap applies to the resulting file uid).
- secrets.inc + worker-controller.inc likewise written via tee, then
chown'd inside the LXC to _rspamd:_rspamd (kernel maps to the correct
outside-LXC subuid regardless of image).
- rspamd_keygen takes a `container` arg, runs `rspamadm dkim_keygen`
inside the LXC, writes the keypair to /etc/rspamd-keys/<domain>/ (the
bind-mounted path; falls back to /var/lib/rspamd/dkim/ if the bind
mount isn't active yet), and mirrors the DNS TXT to the host data dir
for DNS-publishing tooling.
- mailctl's `dkim-keygen` subcommand now delegates to the lib function.
- rspamd-route-sync-patch.sh verifies each write (reads back + asserts
the entry equals expected) and fails loudly on mismatch — the Phase 2
deploy needed a manual second pass for the mitmproxy LXC copy.
Pre-conditions: configure_rspamd_milter and configure_rspamd_controller
now require the LXC to be RUNNING (added an explicit lxc-info guard).
This matches the cmd_rspamd install path which starts the LXC via
install_rspamd before configuring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
637b2221c0
commit
bc7545b1c1
|
|
@ -48,38 +48,60 @@ install_rspamd() {
|
|||
}
|
||||
|
||||
# ─── configure_rspamd_milter ──────────────────────────────────────────────────
|
||||
# Copy 8 of the 9 local.d templates into the LXC (worker-controller.inc is
|
||||
# handled separately because it bind-mounts secrets.inc). Set up data dirs +
|
||||
# bind-mount entries.
|
||||
# Render 8 of the 9 local.d templates inside the live LXC via `lxc-attach`
|
||||
# (worker-controller.inc is handled separately because it bind-mounts
|
||||
# secrets.inc). Set up host-side data dirs + bind-mount entries.
|
||||
#
|
||||
# Phase 2 lesson (commit 637b2221): writing through the host-side rootfs path
|
||||
# `${LXC_BASE}/${container}/rootfs/etc/rspamd/local.d/` is brittle — on this
|
||||
# board the runtime rootfs lives at `/data/lxc/mail/rootfs/` per `lxc.rootfs.
|
||||
# path`, while `/var/lib/lxc/mail/rootfs/` is a stale shell that the running
|
||||
# LXC does not see. Streaming each file through `lxc-attach -- tee` avoids
|
||||
# guessing the rootfs path and lets the kernel resolve idmap on writes.
|
||||
configure_rspamd_milter() {
|
||||
local container="$1"
|
||||
[ -n "$container" ] || { echo "configure_rspamd_milter: container required" >&2; return 1; }
|
||||
local templates="${TEMPLATES_DIR:-/usr/lib/secubox/mail/templates}/rspamd"
|
||||
local rootfs="${LXC_BASE:-/var/lib/lxc}/$container/rootfs"
|
||||
local data="${DATA_PATH:-/data/volumes/mail}"
|
||||
local local_d="$rootfs/etc/rspamd/local.d"
|
||||
|
||||
install -d -m 0755 "$local_d"
|
||||
if ! lxc-info -n "$container" 2>/dev/null | grep -q "State:.*RUNNING"; then
|
||||
echo "configure_rspamd_milter: LXC '$container' is not running" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
lxc-attach -n "$container" -- install -d -m 0755 /etc/rspamd/local.d
|
||||
for f in options.inc worker-proxy.inc worker-normal.inc \
|
||||
dkim_signing.conf arc.conf dmarc.conf \
|
||||
greylist.conf ratelimit.conf; do
|
||||
install -m 0644 "$templates/local.d/$f" "$local_d/$f"
|
||||
[ -f "$templates/local.d/$f" ] || {
|
||||
echo "configure_rspamd_milter: template $templates/local.d/$f missing" >&2
|
||||
return 1
|
||||
}
|
||||
lxc-attach -n "$container" -- tee "/etc/rspamd/local.d/$f" >/dev/null < "$templates/local.d/$f"
|
||||
lxc-attach -n "$container" -- chmod 0644 "/etc/rspamd/local.d/$f"
|
||||
done
|
||||
|
||||
# Persistent data dirs on host — owner 100110:100110 = _rspamd inside unprivileged LXC.
|
||||
# Persistent data dirs on host — chown via lxc-attach so the kernel maps
|
||||
# _rspamd uid (varies per image: 107 in Debian 12 default; 110 in some
|
||||
# rspamd-only minimal builds) to the right outside-LXC subuid.
|
||||
install -d -m 0750 "$data/rspamd/dkim" "$data/rspamd/bayes" \
|
||||
"$data/rspamd/history" "$data/rspamd/settings"
|
||||
chown -R 100110:100110 "$data/rspamd" 2>/dev/null || true
|
||||
|
||||
# Add bind-mount entries to the LXC config (idempotent).
|
||||
# Add bind-mount entries to the LXC config (idempotent). Note: requires
|
||||
# a `lxc-stop && lxc-start` cycle of the container to activate — these
|
||||
# entries are not pickup-on-the-fly. Caller (mailctl) is expected to do
|
||||
# the restart before generating DKIM keys.
|
||||
local lxc_conf="${LXC_BASE:-/var/lib/lxc}/$container/config"
|
||||
if [ -f "$lxc_conf" ] && ! grep -q "/etc/rspamd-keys" "$lxc_conf"; then
|
||||
cat >> "$lxc_conf" <<EOF
|
||||
|
||||
# Phase 2 Rspamd bind mounts (added by configure_rspamd_milter)
|
||||
lxc.mount.entry = $data/rspamd/dkim etc/rspamd-keys none bind,create=dir 0 0
|
||||
lxc.mount.entry = $data/rspamd/bayes var/lib/rspamd/bayes none bind,create=dir 0 0
|
||||
lxc.mount.entry = $data/rspamd/history var/lib/rspamd/history none bind,create=dir 0 0
|
||||
lxc.mount.entry = $data/rspamd/settings var/lib/rspamd/settings none bind,create=dir 0 0
|
||||
EOF
|
||||
echo "[rspamd] bind-mount entries appended to $lxc_conf — restart $container to activate"
|
||||
fi
|
||||
echo "[rspamd] milter config rendered in $container"
|
||||
}
|
||||
|
|
@ -92,25 +114,35 @@ configure_rspamd_controller() {
|
|||
local container="$1"
|
||||
[ -n "$container" ] || { echo "configure_rspamd_controller: container required" >&2; return 1; }
|
||||
local templates="${TEMPLATES_DIR:-/usr/lib/secubox/mail/templates}/rspamd"
|
||||
local rootfs="${LXC_BASE:-/var/lib/lxc}/$container/rootfs"
|
||||
local local_d="$rootfs/etc/rspamd/local.d"
|
||||
local secret_host="/etc/secubox/secrets/rspamd-controller.pw"
|
||||
|
||||
if ! lxc-info -n "$container" 2>/dev/null | grep -q "State:.*RUNNING"; then
|
||||
echo "configure_rspamd_controller: LXC '$container' is not running" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
install -d -m 0700 /etc/secubox/secrets
|
||||
if [ ! -s "$secret_host" ]; then
|
||||
openssl rand -base64 24 > "$secret_host"
|
||||
chmod 0600 "$secret_host"
|
||||
fi
|
||||
install -m 0644 "$templates/local.d/worker-controller.inc" "$local_d/worker-controller.inc"
|
||||
|
||||
[ -f "$templates/local.d/worker-controller.inc" ] || {
|
||||
echo "configure_rspamd_controller: template worker-controller.inc missing" >&2
|
||||
return 1
|
||||
}
|
||||
lxc-attach -n "$container" -- tee /etc/rspamd/local.d/worker-controller.inc >/dev/null \
|
||||
< "$templates/local.d/worker-controller.inc"
|
||||
lxc-attach -n "$container" -- chmod 0644 /etc/rspamd/local.d/worker-controller.inc
|
||||
|
||||
local pw
|
||||
pw=$(tr -d '\n' < "$secret_host")
|
||||
cat > "$local_d/secrets.inc" <<EOF_INC
|
||||
lxc-attach -n "$container" -- tee /etc/rspamd/local.d/secrets.inc >/dev/null <<EOF_INC
|
||||
password = "$pw";
|
||||
enable_password = "$pw";
|
||||
EOF_INC
|
||||
# Resolve _rspamd uid/gid via the LXC itself — the host's 100110 ≠ _rspamd
|
||||
# on all Debian images (varies with packages installed). Run chown from
|
||||
# on all Debian images (107 on the Phase 2 deploy image). Run chown from
|
||||
# inside the container so the kernel applies idmap automatically.
|
||||
lxc-attach -n "$container" -- chown _rspamd:_rspamd /etc/rspamd/local.d/secrets.inc 2>/dev/null || true
|
||||
lxc-attach -n "$container" -- chmod 0640 /etc/rspamd/local.d/secrets.inc 2>/dev/null || true
|
||||
|
|
@ -139,32 +171,67 @@ configure_rspamd_dkim() {
|
|||
local selector="${3:-default}"
|
||||
local data="${DATA_PATH:-/data/volumes/mail}"
|
||||
|
||||
[ -n "$container" ] || { echo "configure_rspamd_dkim: container required" >&2; return 1; }
|
||||
install -d -m 0750 "$data/rspamd/dkim/$domain"
|
||||
chown -R 100110:100110 "$data/rspamd/dkim" 2>/dev/null || true
|
||||
|
||||
if [ ! -f "$data/rspamd/dkim/$domain/$selector.key" ]; then
|
||||
rspamd_keygen "$domain" "$selector"
|
||||
rspamd_keygen "$container" "$domain" "$selector"
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate a 2048-bit DKIM keypair via `rspamadm dkim_keygen` *inside* the
|
||||
# named LXC — rspamadm is shipped by the rspamd package and is only on PATH
|
||||
# inside the container. Phase 2 lesson (commit 637b2221): the previous host-
|
||||
# side variant errored out with "rspamadm not on PATH".
|
||||
rspamd_keygen() {
|
||||
local domain="$1"
|
||||
local selector="${2:-default}"
|
||||
local container="$1"
|
||||
local domain="$2"
|
||||
local selector="${3:-default}"
|
||||
local data="${DATA_PATH:-/data/volumes/mail}"
|
||||
local outdir="$data/rspamd/dkim/$domain"
|
||||
install -d -m 0750 "$outdir"
|
||||
local keyfile="$outdir/$selector.key"
|
||||
local txtfile="$outdir/$selector.txt"
|
||||
|
||||
if ! command -v rspamadm >/dev/null 2>&1; then
|
||||
echo "rspamd_keygen: rspamadm not on PATH (run install_rspamd first or run inside LXC)" >&2
|
||||
[ -n "$container" ] || { echo "rspamd_keygen: container required" >&2; return 1; }
|
||||
[ -n "$domain" ] || { echo "rspamd_keygen: domain required" >&2; return 1; }
|
||||
if ! lxc-info -n "$container" 2>/dev/null | grep -q "State:.*RUNNING"; then
|
||||
echo "rspamd_keygen: LXC '$container' is not running" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
rspamadm dkim_keygen -d "$domain" -s "$selector" -b 2048 -k "$keyfile" > "$txtfile"
|
||||
chmod 0600 "$keyfile"
|
||||
chown 100110:100110 "$keyfile" "$txtfile" 2>/dev/null || true
|
||||
echo "[rspamd] DKIM keypair generated: $keyfile (+ DNS TXT in $txtfile)"
|
||||
# Output dir inside the LXC — relies on the `/etc/rspamd-keys` bind mount
|
||||
# added by configure_rspamd_milter. If that mount isn't active, fall back
|
||||
# to writing into /var/lib/rspamd which is always present in the LXC.
|
||||
local outdir="/etc/rspamd-keys/$domain"
|
||||
if ! lxc-attach -n "$container" -- test -d /etc/rspamd-keys 2>/dev/null; then
|
||||
outdir="/var/lib/rspamd/dkim/$domain"
|
||||
echo "[rspamd] /etc/rspamd-keys bind mount not active — falling back to $outdir" >&2
|
||||
fi
|
||||
|
||||
lxc-attach -n "$container" -- install -d -m 0750 "$outdir"
|
||||
lxc-attach -n "$container" -- chown _rspamd:_rspamd "$outdir" 2>/dev/null || true
|
||||
|
||||
# rspamadm writes the key to -k <path> and prints the DNS TXT to stdout.
|
||||
# Capture the TXT alongside the key.
|
||||
lxc-attach -n "$container" -- bash -c "
|
||||
set -euo pipefail
|
||||
rspamadm dkim_keygen -d '$domain' -s '$selector' -b 2048 \
|
||||
-k '$outdir/$selector.key' > '$outdir/$selector.txt'
|
||||
chown _rspamd:_rspamd '$outdir/$selector.key' '$outdir/$selector.txt'
|
||||
chmod 0600 '$outdir/$selector.key'
|
||||
chmod 0644 '$outdir/$selector.txt'
|
||||
"
|
||||
|
||||
# Mirror the public TXT to the host-side data dir so external tooling
|
||||
# (e.g. DNS-publishing scripts on the orchestrator) can read it without
|
||||
# entering the LXC.
|
||||
local host_dir="$data/rspamd/dkim/$domain"
|
||||
install -d -m 0750 "$host_dir"
|
||||
if [ "$outdir" = "/etc/rspamd-keys/$domain" ]; then
|
||||
# Bind-mount path — already visible at $host_dir on the host.
|
||||
:
|
||||
else
|
||||
# Fallback path inside the LXC rootfs; copy out via lxc-attach.
|
||||
lxc-attach -n "$container" -- cat "$outdir/$selector.txt" > "$host_dir/$selector.txt"
|
||||
fi
|
||||
echo "[rspamd] DKIM keypair generated: $outdir/$selector.key (DNS TXT in $host_dir/$selector.txt)"
|
||||
}
|
||||
|
||||
rspamd_dns_records() {
|
||||
|
|
|
|||
|
|
@ -865,12 +865,10 @@ cmd_rspamd() {
|
|||
dkim-keygen)
|
||||
local domain="${1:-$DOMAIN}"
|
||||
local sel="${2:-default}"
|
||||
LXC_BASE="$LXC_PATH" lxc-attach -n "$CONTAINER" -- rspamadm dkim_keygen \
|
||||
-d "$domain" -s "$sel" -b 2048 \
|
||||
-k "/etc/rspamd-keys/$domain/$sel.key" \
|
||||
> "$DATA_PATH/rspamd/dkim/$domain/$sel.txt"
|
||||
chmod 0600 "$DATA_PATH/rspamd/dkim/$domain/$sel.key" 2>/dev/null || true
|
||||
chown 100110:100110 "$DATA_PATH/rspamd/dkim/$domain/$sel".{key,txt} 2>/dev/null || true
|
||||
# Delegate to the lib function — runs rspamadm inside the LXC and
|
||||
# resolves _rspamd uid via kernel idmap (image-agnostic).
|
||||
LXC_BASE="$LXC_PATH" DATA_PATH="$DATA_PATH" \
|
||||
rspamd_keygen "$CONTAINER" "$domain" "$sel"
|
||||
log "DKIM keypair generated for $domain (selector $sel)"
|
||||
;;
|
||||
dns-records)
|
||||
|
|
|
|||
|
|
@ -25,32 +25,74 @@ else
|
|||
echo "[phase2] $SCRIPT not found — skipping sync-script patch"
|
||||
fi
|
||||
|
||||
# Add rspamd.gk2.secubox.in route in both the host copy and the mitmproxy LXC copy.
|
||||
add_route() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] || { echo "[phase2] $file not present, skipping"; return 0; }
|
||||
python3 - "$file" <<'PY'
|
||||
import sys, json
|
||||
path = sys.argv[1]
|
||||
d = json.load(open(path))
|
||||
d["rspamd.gk2.secubox.in"] = ["10.100.0.10", 11334]
|
||||
# Apply the route in two places (host + mitmproxy LXC) and detect/repair drift.
|
||||
#
|
||||
# Phase 2 lesson (deploy 2026-05-16): the host copy update succeeded but the
|
||||
# LXC copy did not on first run — gate 11 of the smoke caught it. We now
|
||||
# verify each write reads back the expected value and fail loudly if not.
|
||||
|
||||
RSPAMD_HOST="${RSPAMD_HOST:-10.100.0.10}"
|
||||
RSPAMD_CTRL_PORT="${RSPAMD_CTRL_PORT:-11334}"
|
||||
RSPAMD_FQDN="${RSPAMD_FQDN:-rspamd.gk2.secubox.in}"
|
||||
|
||||
apply_route_python() {
|
||||
# stdin: json path
|
||||
# args: fqdn host port
|
||||
python3 - "$@" <<'PY'
|
||||
import json, sys
|
||||
path, fqdn, host, port = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
|
||||
try:
|
||||
d = json.load(open(path))
|
||||
except FileNotFoundError:
|
||||
print(f"[phase2] {path} not present — created with single entry")
|
||||
d = {}
|
||||
expected = [host, port]
|
||||
d[fqdn] = expected
|
||||
json.dump(d, open(path, "w"), indent=2)
|
||||
print(f"[phase2] added rspamd.gk2 → [10.100.0.10, 11334] in {path}")
|
||||
# Read back + verify
|
||||
verify = json.load(open(path))
|
||||
got = verify.get(fqdn)
|
||||
if got != expected:
|
||||
print(f"[phase2] FAILED verify in {path}: got {got!r}, expected {expected!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"[phase2] {path}: {fqdn} → {expected}")
|
||||
PY
|
||||
}
|
||||
|
||||
add_route /srv/mitmproxy/haproxy-routes.json
|
||||
# 1) Host copy.
|
||||
HOST_JSON=/srv/mitmproxy/haproxy-routes.json
|
||||
if [ -d "$(dirname "$HOST_JSON")" ]; then
|
||||
apply_route_python "$HOST_JSON" "$RSPAMD_FQDN" "$RSPAMD_HOST" "$RSPAMD_CTRL_PORT"
|
||||
else
|
||||
echo "[phase2] $(dirname "$HOST_JSON") not present, skipping host copy"
|
||||
fi
|
||||
|
||||
# 2) mitmproxy LXC copy — write via `lxc-attach` so we don't have to guess
|
||||
# where the LXC rootfs is mounted. Also restart mitmproxy to pick up the new
|
||||
# route map (it reads at startup, not live).
|
||||
if command -v lxc-attach >/dev/null 2>&1 && lxc-info -n mitmproxy 2>/dev/null | grep -q RUNNING; then
|
||||
# The LXC has its own copy. Bind the same json into the LXC via attach.
|
||||
lxc-attach -n mitmproxy -- python3 - /srv/mitmproxy/haproxy-routes.json <<'PY'
|
||||
import sys, json
|
||||
path = sys.argv[1]
|
||||
d = json.load(open(path))
|
||||
d["rspamd.gk2.secubox.in"] = ["10.100.0.10", 11334]
|
||||
json.dump(d, open(path, "w"), indent=2)
|
||||
print(f"[phase2] LXC copy updated: {path}")
|
||||
lxc-attach -n mitmproxy -- bash -c "
|
||||
python3 - /srv/mitmproxy/haproxy-routes.json '$RSPAMD_FQDN' '$RSPAMD_HOST' '$RSPAMD_CTRL_PORT' <<'PY'
|
||||
import json, sys
|
||||
path, fqdn, host, port = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
|
||||
try:
|
||||
d = json.load(open(path))
|
||||
except FileNotFoundError:
|
||||
d = {}
|
||||
expected = [host, port]
|
||||
d[fqdn] = expected
|
||||
json.dump(d, open(path, 'w'), indent=2)
|
||||
verify = json.load(open(path))
|
||||
got = verify.get(fqdn)
|
||||
if got != expected:
|
||||
print(f'[phase2] LXC copy verify FAILED: got {got!r}, expected {expected!r}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f'[phase2] LXC copy {path}: {fqdn} → {expected}')
|
||||
PY
|
||||
"
|
||||
lxc-attach -n mitmproxy -- systemctl restart mitmproxy 2>&1 | tail -2 || true
|
||||
else
|
||||
echo "[phase2] mitmproxy LXC not running, skipping LXC copy"
|
||||
fi
|
||||
|
||||
echo "[phase2] route-sync patch complete"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user