diff --git a/scripts/backup.sh b/scripts/backup.sh index bab5b5a..ceb66d8 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,13 +1,81 @@ #!/usr/bin/env bash -# Online SQLite backup using the .backup API (safe while the bot is running). -# TODO: implement; outline below. +# Online SQLite backup using the .backup API. +# Safe to run while the bot is live — SQLite's backup API acquires only a short +# shared lock per page, never blocking the application for the full duration. +# +# Usage: +# backup.sh --config /etc/mnemosyne/mnemosyne.conf [--keep-days N] +# +# Environment overrides: +# MNEMOSYNE_BACKUP_DIR — destination directory (default: /var/backups/mnemosyne) +# MNEMOSYNE_DB_PATH — database path (overrides config) + set -euo pipefail -# TODO: read DB_PATH from config or env -# TODO: BACKUP_DIR default /var/backups/mnemosyne, override via $MNEMOSYNE_BACKUP_DIR -# TODO: filename: mnemosyne-$(date +%Y%m%d-%H%M%S).db -# TODO: sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/$FILENAME'" -# (uses the SQLite backup API — safe for concurrent access, no locks held) -# TODO: optionally prune backups older than N days +# ── Defaults ───────────────────────────────────────────────────────────────── +CONFIG_FILE="" +KEEP_DAYS=30 +BACKUP_DIR="${MNEMOSYNE_BACKUP_DIR:-/var/backups/mnemosyne}" -echo "TODO: backup.sh not yet implemented" +# ── Argument parsing ───────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --config) CONFIG_FILE="$2"; shift 2 ;; + --keep-days) KEEP_DAYS="$2"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done + +# ── Resolve DB path ────────────────────────────────────────────────────────── +# Priority: env var > config file > error +if [[ -n "${MNEMOSYNE_DB_PATH:-}" ]]; then + DB_PATH="$MNEMOSYNE_DB_PATH" +elif [[ -n "$CONFIG_FILE" ]]; then + if [[ ! -f "$CONFIG_FILE" ]]; then + echo "Config file not found: $CONFIG_FILE" >&2 + exit 1 + fi + # Parse db.path from the INI config (section [db], key path) + DB_PATH="$(awk -F'=' '/^\[db\]/{in_db=1; next} /^\[/{in_db=0} in_db && /^[[:space:]]*path[[:space:]]*=/{gsub(/[[:space:]#].*/, "", $2); print $2; exit}' "$CONFIG_FILE")" + DB_PATH="${DB_PATH// /}" # strip spaces + if [[ -z "$DB_PATH" ]]; then + echo "Could not read db.path from $CONFIG_FILE" >&2 + exit 1 + fi +else + echo "Provide --config or set MNEMOSYNE_DB_PATH" >&2 + exit 1 +fi + +if [[ ! -f "$DB_PATH" ]]; then + echo "Database not found: $DB_PATH" >&2 + exit 1 +fi + +# ── Ensure backup directory exists ─────────────────────────────────────────── +mkdir -p "$BACKUP_DIR" + +# ── Run backup ─────────────────────────────────────────────────────────────── +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE="$BACKUP_DIR/mnemosyne-${TIMESTAMP}.db" + +# sqlite3 .backup uses the SQLite Online Backup API — consistent snapshot, +# safe under concurrent writes, no application downtime required. +sqlite3 "$DB_PATH" ".backup '$BACKUP_FILE'" + +# Verify the backup is a valid SQLite database +if ! sqlite3 "$BACKUP_FILE" "PRAGMA integrity_check;" | grep -q "^ok$"; then + echo "Backup integrity check failed: $BACKUP_FILE" >&2 + rm -f "$BACKUP_FILE" + exit 1 +fi + +BYTES="$(wc -c < "$BACKUP_FILE")" +echo "Backup written: $BACKUP_FILE (${BYTES} bytes)" + +# ── Prune old backups ───────────────────────────────────────────────────────── +if [[ "$KEEP_DAYS" -gt 0 ]]; then + PRUNED="$(find "$BACKUP_DIR" -maxdepth 1 -name 'mnemosyne-*.db' \ + -mtime "+${KEEP_DAYS}" -print -delete | wc -l)" + [[ "$PRUNED" -gt 0 ]] && echo "Pruned $PRUNED backup(s) older than ${KEEP_DAYS} days" +fi diff --git a/scripts/install.sh b/scripts/install.sh index 84d1051..e325831 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,44 +1,192 @@ #!/usr/bin/env bash # Mnemosyne install script — idempotent; safe to re-run. # Does NOT clobber an existing database or config file. -# TODO: implement; outline below. +# +# Usage (as root or with sudo): +# bash scripts/install.sh [--app-dir /opt/mnemosyne] [--config-dir /etc/mnemosyne] +# [--data-dir /var/lib/mnemosyne] +# +# After running: +# 1. Edit /etc/mnemosyne/mnemosyne.conf (fill in token, secret, URL, chat ID) +# 2. Copy and adjust nginx/mnemosyne.conf.example → /etc/nginx/conf.d/mnemosyne.conf +# 3. Obtain a Let's Encrypt cert: certbot --nginx -d your.subdomain.com +# 4. Register the Telegram webhook: /opt/mnemosyne/bin/mnemosyne-webhook --config /etc/mnemosyne/mnemosyne.conf +# 5. Start the bot: systemctl start mnemosyne-bot set -euo pipefail -# --- Detect distro family --- -# TODO: detect RHEL/AlmaLinux (dnf/yum) vs Debian/Ubuntu (apt) vs other -# TODO: on RHEL family: dnf install -y perl perl-App-cpanminus perl-DBI sqlite -# TODO: on Debian family: apt-get install -y perl cpanminus libdbi-perl sqlite3 -# TODO: document: on either family, run `cpanm --installdeps .` after this step +# ── Defaults ──────────────────────────────────────────────────────────────── +APP_DIR="/opt/mnemosyne" +CONFIG_DIR="/etc/mnemosyne" +DATA_DIR="/var/lib/mnemosyne" +SERVICE_USER="mnemosyne" +SERVICE_GROUP="mnemosyne" +SYSTEMD_DIR="/etc/systemd/system" +CRON_FILE="/etc/cron.d/mnemosyne" -# --- System user --- -# TODO: create 'mnemosyne' system user/group if not present (useradd -r -s /sbin/nologin) +# ── Argument parsing ───────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --app-dir) APP_DIR="$2"; shift 2 ;; + --config-dir) CONFIG_DIR="$2"; shift 2 ;; + --data-dir) DATA_DIR="$2"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 1 ;; + esac +done -# --- Directories --- -# TODO: /opt/mnemosyne — app files (copy repo here or symlink) -# TODO: /var/lib/mnemosyne — database directory, owned by mnemosyne user -# TODO: /etc/mnemosyne — config directory (skip if already exists to avoid clobbering) +# ── Must run as root ───────────────────────────────────────────────────────── +if [[ $EUID -ne 0 ]]; then + echo "Error: this script must be run as root." >&2 + exit 1 +fi -# --- Config --- -# TODO: if /etc/mnemosyne/mnemosyne.conf does not exist: -# copy config/mnemosyne.conf.example → /etc/mnemosyne/mnemosyne.conf -# chmod 640, chown root:mnemosyne -# print reminder to edit the file before starting the service +# ── Detect source directory (where this script lives) ─────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -# --- CPAN deps --- -# TODO: cd /opt/mnemosyne && cpanm --installdeps . +echo "==> Mnemosyne installer" +echo " Source: $REPO_DIR" +echo " App: $APP_DIR" +echo " Config: $CONFIG_DIR" +echo " Data: $DATA_DIR" +echo "" -# --- Systemd --- -# TODO: copy systemd/mnemosyne-bot.service → /etc/systemd/system/ -# TODO: systemctl daemon-reload -# TODO: systemctl enable mnemosyne-bot (but do NOT start — user must configure first) +# ── Detect distro family ───────────────────────────────────────────────────── +detect_distro() { + if command -v dnf &>/dev/null || command -v yum &>/dev/null; then + echo "rhel" + elif command -v apt-get &>/dev/null; then + echo "debian" + else + echo "unknown" + fi +} +DISTRO="$(detect_distro)" -# --- Cron for digest --- -# TODO: install a cron entry for mnemosyne user: -# */5 * * * * /opt/mnemosyne/bin/mnemosyne-digest --config /etc/mnemosyne/mnemosyne.conf -# This fires every 5 minutes; the script itself checks against digest_time and exits early. +# ── 1. System packages ─────────────────────────────────────────────────────── +echo "==> Installing system packages (distro: $DISTRO)" +case "$DISTRO" in + rhel) + PKG_MGR="dnf" + command -v yum &>/dev/null && ! command -v dnf &>/dev/null && PKG_MGR="yum" + $PKG_MGR install -y perl perl-App-cpanminus perl-DBI sqlite perl-local-lib 2>&1 | grep -E '(Installed|already|Error)' || true + # DBD::SQLite needs C compiler for XS build + $PKG_MGR install -y gcc make perl-devel 2>&1 | grep -E '(Installed|already|Error)' || true + ;; + debian) + apt-get install -y perl cpanminus libdbi-perl sqlite3 gcc make 2>&1 | grep -E '(Setting up|already|Error)' || true + ;; + *) + echo " Warning: unknown distro. Install Perl, cpanminus, DBI, and SQLite manually." >&2 + ;; +esac -# --- Nginx --- -# TODO: remind user to copy nginx/mnemosyne.conf.example and adjust, then reload nginx +# ── 2. CPAN dependencies ───────────────────────────────────────────────────── +echo "==> Installing CPAN dependencies" +# Install into system Perl (running as root) or local::lib if preferred +cpanm --notest --quiet --installdeps "$REPO_DIR" || { + echo " Warning: cpanm reported errors. Check output above." >&2 +} -echo "TODO: install.sh not yet implemented" +# ── 3. System user/group ───────────────────────────────────────────────────── +echo "==> Creating system user: $SERVICE_USER" +if ! getent group "$SERVICE_GROUP" &>/dev/null; then + groupadd --system "$SERVICE_GROUP" + echo " Created group $SERVICE_GROUP" +fi +if ! id "$SERVICE_USER" &>/dev/null; then + useradd --system --gid "$SERVICE_GROUP" --no-create-home \ + --home-dir "$DATA_DIR" --shell /sbin/nologin "$SERVICE_USER" + echo " Created user $SERVICE_USER" +else + echo " User $SERVICE_USER already exists — skipping" +fi + +# ── 4. Directories ─────────────────────────────────────────────────────────── +echo "==> Creating directories" +install -d -m 755 "$APP_DIR" +install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$DATA_DIR" +install -d -m 750 "$CONFIG_DIR" + +# ── 5. Copy application files ──────────────────────────────────────────────── +echo "==> Installing application to $APP_DIR" +# Use rsync if available (preserves structure, skips .git), otherwise cp +if command -v rsync &>/dev/null; then + rsync -a --exclude='.git' --exclude='config/mnemosyne.conf' \ + "$REPO_DIR/" "$APP_DIR/" +else + cp -rp "$REPO_DIR/." "$APP_DIR/" + rm -rf "$APP_DIR/.git" + rm -f "$APP_DIR/config/mnemosyne.conf" +fi +chmod +x "$APP_DIR/bin/"* +chown -R root:"$SERVICE_GROUP" "$APP_DIR" +chmod -R o-rwx "$APP_DIR" + +# ── 6. Config file ─────────────────────────────────────────────────────────── +echo "==> Config" +if [[ -f "$CONFIG_DIR/mnemosyne.conf" ]]; then + echo " $CONFIG_DIR/mnemosyne.conf already exists — not overwriting" +else + install -m 640 -o root -g "$SERVICE_GROUP" \ + "$REPO_DIR/config/mnemosyne.conf.example" \ + "$CONFIG_DIR/mnemosyne.conf" + echo "" + echo " *** ACTION REQUIRED ***" + echo " Edit $CONFIG_DIR/mnemosyne.conf and fill in:" + echo " bot.token — from @BotFather" + echo " bot.webhook_secret — run: openssl rand -hex 32" + echo " bot.webhook_url — your public HTTPS webhook URL" + echo " bot.allowed_chat_ids — your Telegram chat ID (@userinfobot)" + echo " db.path — default: $DATA_DIR/mnemosyne.db" + echo " app.timezone — IANA timezone (e.g. America/New_York)" + echo "" +fi + +# ── 7. Systemd service ─────────────────────────────────────────────────────── +echo "==> Installing systemd service" +# Rewrite paths in the service template +sed "s|/opt/mnemosyne|$APP_DIR|g; s|/etc/mnemosyne|$CONFIG_DIR|g" \ + "$REPO_DIR/systemd/mnemosyne-bot.service" \ + > "$SYSTEMD_DIR/mnemosyne-bot.service" +chmod 644 "$SYSTEMD_DIR/mnemosyne-bot.service" +systemctl daemon-reload +systemctl enable mnemosyne-bot +echo " Enabled mnemosyne-bot.service (not started — configure first)" + +# ── 8. Cron entry for digest ───────────────────────────────────────────────── +echo "==> Installing cron job for digest" +cat > "$CRON_FILE" <&1 | logger -t mnemosyne-digest +CRON +chmod 644 "$CRON_FILE" +echo " Installed $CRON_FILE" + +# ── 9. Backup cron (optional) ──────────────────────────────────────────────── +echo "==> Installing daily backup cron" +BACKUP_DIR="/var/backups/mnemosyne" +install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" "$BACKUP_DIR" +cat >> "$CRON_FILE" <&1 | logger -t mnemosyne-backup +CRON +echo " Backup cron added (target: $BACKUP_DIR)" + +# ── Done ───────────────────────────────────────────────────────────────────── +echo "" +echo "==> Installation complete." +echo "" +echo "Next steps:" +echo " 1. Edit $CONFIG_DIR/mnemosyne.conf" +echo " 2. Copy nginx/mnemosyne.conf.example → /etc/nginx/conf.d/mnemosyne.conf" +echo " and adjust server_name + port" +echo " 3. Obtain TLS cert: certbot --nginx -d your.subdomain.example.com" +echo " 4. Reload nginx: systemctl reload nginx" +echo " 5. Register webhook: $APP_DIR/bin/mnemosyne-webhook --config $CONFIG_DIR/mnemosyne.conf" +echo " 6. Start bot: systemctl start mnemosyne-bot" +echo " 7. Check logs: journalctl -u mnemosyne-bot -f" +echo "" +echo " Note (RHEL/AlmaLinux): if SELinux is enforcing, you may need:" +echo " setsebool -P httpd_can_network_connect 1" +echo " (allows nginx to proxy to the local bot port)" diff --git a/systemd/mnemosyne-bot.service b/systemd/mnemosyne-bot.service index 996e6c6..954c6a8 100644 --- a/systemd/mnemosyne-bot.service +++ b/systemd/mnemosyne-bot.service @@ -1,19 +1,63 @@ [Unit] Description=Mnemosyne Telegram bot (webhook receiver) -After=network.target +Documentation=https://git.castlehollow.com/rodger/mnemosyne +After=network-online.target +Wants=network-online.target [Service] Type=simple User=mnemosyne Group=mnemosyne WorkingDirectory=/opt/mnemosyne -ExecStart=/usr/bin/perl /opt/mnemosyne/bin/mnemosyne-bot --config /etc/mnemosyne/mnemosyne.conf + +ExecStart=/usr/bin/perl /opt/mnemosyne/bin/mnemosyne-bot \ + --config /etc/mnemosyne/mnemosyne.conf + Restart=always RestartSec=5 -# Keep logs in the journal +# Cap restart rate: no more than 5 restarts in 60 seconds +StartLimitBurst=5 +StartLimitIntervalSec=60 + +# Logging StandardOutput=journal StandardError=journal SyslogIdentifier=mnemosyne-bot +# ── Hardening ──────────────────────────────────────────────────────────────── +# Prevent privilege escalation +NoNewPrivileges=true + +# Private /tmp — isolates temp files from other services +PrivateTmp=true + +# Read-only /usr, /boot, /etc (except the config dir we explicitly allow) +ProtectSystem=strict +# The bot needs to write the SQLite database; allow the data dir +ReadWritePaths=/var/lib/mnemosyne + +# No access to home directories +ProtectHome=true + +# Prevent writing to kernel tunables +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true + +# Restrict address families: only IPv4/IPv6 (outbound Telegram API) + Unix sockets +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + +# Syscall filtering — Perl/DBI only needs the basics +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +# No realtime scheduling, no raw I/O, no device access +RestrictRealtime=true +PrivateDevices=true + +# Tighten /proc view +ProtectProc=invisible + [Install] WantedBy=multi-user.target