From e551a01cbf2bcc71b9ca974f6fad3c752f2e8746 Mon Sep 17 00:00:00 2001 From: Jean Date: Wed, 15 Jul 2026 11:45:50 +0000 Subject: [PATCH] Harden for production: fsync writes, deployment + backup tooling The event log is the account/channel authority, so make it durable: persist() now fsyncs every committed event before reporting success, and a serialisation failure is an error instead of a silently-dropped blank line. Add a hardened systemd unit (scripts/echo.service), an atomic gzip backup script with retention (scripts/backup.sh), and a Deployment wiki page (install, durability, backup/restore, upgrade, monitoring). --- README.md | 1 + scripts/backup.sh | 30 ++++++++++++++++++++++++++++++ scripts/echo.service | 43 +++++++++++++++++++++++++++++++++++++++++++ src/engine/db/mod.rs | 9 ++++++++- 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100755 scripts/backup.sh create mode 100644 scripts/echo.service diff --git a/README.md b/README.md index 8291d27..037ac85 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The [wiki](https://git.devtronic.pro/fedserv/echo/wiki) has the detail: - [Configuration](https://git.devtronic.pro/fedserv/echo/wiki/Configuration) — the `config.toml` reference - [Directory API](https://git.devtronic.pro/fedserv/echo/wiki/Directory-API) — the gRPC account and channel directory for websites - [Writing a Module](https://git.devtronic.pro/fedserv/echo/wiki/Writing-a-Module) — build your own service or ircd link +- [Deployment](https://git.devtronic.pro/fedserv/echo/wiki/Deployment) — running Echo in production: install, durability, backups ## Status diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..9bc86f0 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Back up Echo's event log. The log is append-only, and compaction replaces it +# atomically (temp file + rename), so a plain copy is a consistent snapshot; a +# copy taken mid-append has at most a truncated final line, which Echo skips on +# load. Run from cron or a systemd timer, e.g. hourly. +# +# ECHO_DATA_DIR=/opt/echo ECHO_BACKUP_DIR=/opt/echo/backups ./backup.sh +# +# Restore: stop the service, gunzip a backup over the live log, start again: +# systemctl stop echo +# gunzip -c /opt/echo/backups/echo.db..jsonl.gz > /opt/echo/echo.db.jsonl +# systemctl start echo +set -euo pipefail + +DATA_DIR="${ECHO_DATA_DIR:-/opt/echo}" +BACKUP_DIR="${ECHO_BACKUP_DIR:-$DATA_DIR/backups}" +KEEP="${ECHO_BACKUP_KEEP:-48}" +LOG="$DATA_DIR/echo.db.jsonl" + +[ -f "$LOG" ] || { echo "no event log at $LOG" >&2; exit 1; } +mkdir -p "$BACKUP_DIR" + +stamp=$(date -u +%Y%m%dT%H%M%SZ) +dest="$BACKUP_DIR/echo.db.$stamp.jsonl.gz" +gzip -c "$LOG" > "$dest.tmp" +mv "$dest.tmp" "$dest" +echo "backed up $LOG -> $dest ($(du -h "$dest" | cut -f1))" + +# Keep the most recent $KEEP backups. +ls -1t "$BACKUP_DIR"/echo.db.*.jsonl.gz 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -f diff --git a/scripts/echo.service b/scripts/echo.service new file mode 100644 index 0000000..9762910 --- /dev/null +++ b/scripts/echo.service @@ -0,0 +1,43 @@ +# Systemd unit for running Echo in production. Copy to /etc/systemd/system/, +# edit the paths and User, then: systemctl daemon-reload && systemctl enable --now echo +# +# Assumes a dedicated system user and a data directory holding config.toml and +# the event log (echo.db.jsonl, written relative to WorkingDirectory): +# useradd --system --home-dir /opt/echo --shell /usr/sbin/nologin echo +# install -o echo -g echo target/release/echo /opt/echo/echo +# install -o echo -g echo config.toml /opt/echo/config.toml + +[Unit] +Description=Echo IRC services +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=echo +Group=echo +WorkingDirectory=/opt/echo +ExecStart=/opt/echo/echo /opt/echo/config.toml +Restart=on-failure +RestartSec=5s +# Give in-flight work a moment; the store fsyncs every event so a hard stop +# never loses a committed change. +TimeoutStopSec=15s + +# Hardening. Echo needs only the network and its own data directory. +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictRealtime=true +LockPersonality=true +ReadWritePaths=/opt/echo +# If you do NOT run a local sendmail (i.e. [email] uses a network relay like +# msmtp), you can tighten this to ProtectSystem=strict for a read-only root. + +[Install] +WantedBy=multi-user.target diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index f3d7a24..26b5ecb 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -762,8 +762,15 @@ impl EventLog { } fn persist(&self, entry: &LogEntry) -> std::io::Result<()> { + // Serialise first, and treat a failure as an error rather than writing a + // blank line, which would silently drop a committed change. + let line = serde_json::to_string(entry) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?; - writeln!(f, "{}", serde_json::to_string(entry).unwrap_or_default()) + writeln!(f, "{line}")?; + // fsync: a committed account/channel change is on disk before we report + // success, so a crash or power loss can't lose it. + f.sync_all() } // How many entries the log currently holds.