Initial completion

This commit is contained in:
Rodger Castle 2026-06-04 19:33:35 -04:00
parent 74ae914886
commit adf4ab5d92
3 changed files with 302 additions and 42 deletions

View File

@ -1,13 +1,81 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Online SQLite backup using the .backup API (safe while the bot is running). # Online SQLite backup using the .backup API.
# TODO: implement; outline below. # 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 set -euo pipefail
# TODO: read DB_PATH from config or env # ── Defaults ─────────────────────────────────────────────────────────────────
# TODO: BACKUP_DIR default /var/backups/mnemosyne, override via $MNEMOSYNE_BACKUP_DIR CONFIG_FILE=""
# TODO: filename: mnemosyne-$(date +%Y%m%d-%H%M%S).db KEEP_DAYS=30
# TODO: sqlite3 "$DB_PATH" ".backup '$BACKUP_DIR/$FILENAME'" BACKUP_DIR="${MNEMOSYNE_BACKUP_DIR:-/var/backups/mnemosyne}"
# (uses the SQLite backup API — safe for concurrent access, no locks held)
# TODO: optionally prune backups older than N days
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

View File

@ -1,44 +1,192 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Mnemosyne install script — idempotent; safe to re-run. # Mnemosyne install script — idempotent; safe to re-run.
# Does NOT clobber an existing database or config file. # 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 set -euo pipefail
# --- Detect distro family --- # ── Defaults ────────────────────────────────────────────────────────────────
# TODO: detect RHEL/AlmaLinux (dnf/yum) vs Debian/Ubuntu (apt) vs other APP_DIR="/opt/mnemosyne"
# TODO: on RHEL family: dnf install -y perl perl-App-cpanminus perl-DBI sqlite CONFIG_DIR="/etc/mnemosyne"
# TODO: on Debian family: apt-get install -y perl cpanminus libdbi-perl sqlite3 DATA_DIR="/var/lib/mnemosyne"
# TODO: document: on either family, run `cpanm --installdeps .` after this step SERVICE_USER="mnemosyne"
SERVICE_GROUP="mnemosyne"
SYSTEMD_DIR="/etc/systemd/system"
CRON_FILE="/etc/cron.d/mnemosyne"
# --- System user --- # ── Argument parsing ─────────────────────────────────────────────────────────
# TODO: create 'mnemosyne' system user/group if not present (useradd -r -s /sbin/nologin) 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 --- # ── Must run as root ─────────────────────────────────────────────────────────
# TODO: /opt/mnemosyne — app files (copy repo here or symlink) if [[ $EUID -ne 0 ]]; then
# TODO: /var/lib/mnemosyne — database directory, owned by mnemosyne user echo "Error: this script must be run as root." >&2
# TODO: /etc/mnemosyne — config directory (skip if already exists to avoid clobbering) exit 1
fi
# --- Config --- # ── Detect source directory (where this script lives) ───────────────────────
# TODO: if /etc/mnemosyne/mnemosyne.conf does not exist: SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# copy config/mnemosyne.conf.example → /etc/mnemosyne/mnemosyne.conf REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# chmod 640, chown root:mnemosyne
# print reminder to edit the file before starting the service
# --- CPAN deps --- echo "==> Mnemosyne installer"
# TODO: cd /opt/mnemosyne && cpanm --installdeps . echo " Source: $REPO_DIR"
echo " App: $APP_DIR"
echo " Config: $CONFIG_DIR"
echo " Data: $DATA_DIR"
echo ""
# --- Systemd --- # ── Detect distro family ─────────────────────────────────────────────────────
# TODO: copy systemd/mnemosyne-bot.service → /etc/systemd/system/ detect_distro() {
# TODO: systemctl daemon-reload if command -v dnf &>/dev/null || command -v yum &>/dev/null; then
# TODO: systemctl enable mnemosyne-bot (but do NOT start — user must configure first) echo "rhel"
elif command -v apt-get &>/dev/null; then
echo "debian"
else
echo "unknown"
fi
}
DISTRO="$(detect_distro)"
# --- Cron for digest --- # ── 1. System packages ───────────────────────────────────────────────────────
# TODO: install a cron entry for mnemosyne user: echo "==> Installing system packages (distro: $DISTRO)"
# */5 * * * * /opt/mnemosyne/bin/mnemosyne-digest --config /etc/mnemosyne/mnemosyne.conf case "$DISTRO" in
# This fires every 5 minutes; the script itself checks against digest_time and exits early. 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 --- # ── 2. CPAN dependencies ─────────────────────────────────────────────────────
# TODO: remind user to copy nginx/mnemosyne.conf.example and adjust, then reload nginx 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" <<CRON
# Mnemosyne morning digest — fires every 5 min; the script checks digest_time itself.
*/5 * * * * $SERVICE_USER $APP_DIR/bin/mnemosyne-digest --config $CONFIG_DIR/mnemosyne.conf 2>&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" <<CRON
# Daily backup at 02:15
15 2 * * * $SERVICE_USER MNEMOSYNE_BACKUP_DIR=$BACKUP_DIR $APP_DIR/scripts/backup.sh --config $CONFIG_DIR/mnemosyne.conf 2>&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)"

View File

@ -1,19 +1,63 @@
[Unit] [Unit]
Description=Mnemosyne Telegram bot (webhook receiver) 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] [Service]
Type=simple Type=simple
User=mnemosyne User=mnemosyne
Group=mnemosyne Group=mnemosyne
WorkingDirectory=/opt/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 Restart=always
RestartSec=5 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 StandardOutput=journal
StandardError=journal StandardError=journal
SyslogIdentifier=mnemosyne-bot 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] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target