diff --git a/scripts/deployment/install-prune-cron.sh b/scripts/deployment/install-prune-cron.sh new file mode 100755 index 0000000..22e934d --- /dev/null +++ b/scripts/deployment/install-prune-cron.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# install-prune-cron.sh — opt-in cron job to prune old deploy backups. +# +# Run ONCE as root (or with sudo) after install.sh to enable daily +# pruning of /var/lib/currencicombo/backups/. The pruner: +# - deletes entries older than 30 days +# - ALWAYS keeps the newest N backups regardless of age (default 5) +# +# No-op on re-run. Opt out by removing /etc/cron.daily/currencicombo-prune-backups. + +set -euo pipefail + +BACKUP_DIR="${CC_BACKUP_DIR:-/var/lib/currencicombo/backups}" +RETAIN_DAYS="${CC_BACKUP_RETAIN_DAYS:-30}" +KEEP_MIN="${CC_BACKUP_KEEP_MIN:-5}" +CRON_FILE="/etc/cron.daily/currencicombo-prune-backups" +DRY_RUN=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + -h|--help) + cat <<'USAGE' +Usage: sudo ./install-prune-cron.sh [--dry-run] + +Env overrides: + CC_BACKUP_DIR (default: /var/lib/currencicombo/backups) + CC_BACKUP_RETAIN_DAYS (default: 30) + CC_BACKUP_KEEP_MIN (default: 5) +USAGE + exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +log() { printf '[install-prune-cron] %s\n' "$*" >&2; } +die() { printf '[install-prune-cron][FATAL] %s\n' "$*" >&2; exit 1; } + +[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)" + +# The pruner script body. Runs daily via cron.daily. +# KEEP_MIN is enforced by listing backups newest-first, skipping the +# first KEEP_MIN, then deleting any remaining entries older than +# RETAIN_DAYS. This means we always keep at least KEEP_MIN (even if +# they're all <30 days old), and never delete one of the newest +# KEEP_MIN (even if it's >30 days old on a dormant host). +read -r -d '' PRUNER_BODY </dev/null | sort -rn | awk '{print \$2}') + +count=\${#all[@]} +if (( count <= KEEP_MIN )); then + logger -t currencicombo-prune "count=\$count <= KEEP_MIN=\$KEEP_MIN; nothing to prune" + exit 0 +fi + +cutoff=\$(date -d "\$RETAIN_DAYS days ago" +%s) +deleted=0 +kept=0 +for i in "\${!all[@]}"; do + p="\${all[\$i]}" + if (( i < KEEP_MIN )); then + kept=\$((kept + 1)) + continue + fi + mtime=\$(stat -c %Y "\$p" 2>/dev/null || echo 0) + if (( mtime < cutoff )); then + rm -rf -- "\$p" + deleted=\$((deleted + 1)) + else + kept=\$((kept + 1)) + fi +done +logger -t currencicombo-prune "deleted=\$deleted kept=\$kept total_before=\$count" +PRUNER + +if [[ "${DRY_RUN}" -eq 1 ]]; then + log "[dry-run] would write ${CRON_FILE} (0755) with pruner targeting ${BACKUP_DIR}, retain ${RETAIN_DAYS}d, keep-min ${KEEP_MIN}" + echo "---" + echo "${PRUNER_BODY}" + echo "---" + exit 0 +fi + +printf '%s\n' "${PRUNER_BODY}" > "${CRON_FILE}" +chmod 0755 "${CRON_FILE}" +chown root:root "${CRON_FILE}" + +log "installed ${CRON_FILE} (backups older than ${RETAIN_DAYS}d, keep-min ${KEEP_MIN}, target ${BACKUP_DIR})" +log "runs daily via /etc/cron.daily/. Opt out: sudo rm ${CRON_FILE}" +log "logs to syslog (tag currencicombo-prune); journalctl -t currencicombo-prune" diff --git a/scripts/deployment/systemd/currencicombo-orchestrator.service b/scripts/deployment/systemd/currencicombo-orchestrator.service new file mode 100644 index 0000000..24e8eff --- /dev/null +++ b/scripts/deployment/systemd/currencicombo-orchestrator.service @@ -0,0 +1,34 @@ +[Unit] +Description=CurrenciCombo orchestrator (Node) +Documentation=https://gitea.d-bis.org/d-bis/CurrenciCombo +After=network-online.target postgresql.service redis-server.service redis.service +Wants=network-online.target + +[Service] +Type=simple +User=currencicombo +Group=currencicombo +WorkingDirectory=/opt/currencicombo/orchestrator +EnvironmentFile=/etc/currencicombo/orchestrator.env +ExecStart=/usr/bin/node /opt/currencicombo/orchestrator/dist/index.js +Restart=on-failure +RestartSec=5 +TimeoutStopSec=20 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=currencicombo-orchestrator + +# Hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/log/currencicombo +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes + +[Install] +WantedBy=multi-user.target diff --git a/scripts/deployment/webapp-nginx.conf b/scripts/deployment/webapp-nginx.conf new file mode 100644 index 0000000..5253af3 --- /dev/null +++ b/scripts/deployment/webapp-nginx.conf @@ -0,0 +1,80 @@ +# Self-contained nginx.conf for the CurrenciCombo Vite SPA. +# Invoked by the `currencicombo-webapp.service` systemd unit and installed +# to /etc/currencicombo/webapp-nginx.conf by scripts/deployment/install.sh. +# +# Listens on :3000 (NPMplus upstream). NPMplus path-routes /api/* to the +# orchestrator on :8080 (with SSE-friendly settings — see README.md); +# everything else lands here. +# This config does NOT proxy /api itself — that's intentional so a wrong +# NPMplus rule fails loudly instead of silently bypassing the orchestrator. + +worker_processes auto; +error_log /var/log/currencicombo/webapp-nginx.error.log warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + access_log /var/log/currencicombo/webapp-nginx.access.log combined; + + sendfile on; + tcp_nopush on; + keepalive_timeout 65; + server_tokens off; + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + # Uploads/bodies: the portal is a static SPA, so any request with a body + # is almost certainly mis-routed. Cap tight. + client_max_body_size 1m; + + server { + listen 3000 default_server; + listen [::]:3000 default_server; + server_name _; + + root /opt/currencicombo/webapp/dist; + index index.html; + + # Security headers are also set by NPMplus, but apply them here too + # so they survive a direct-to-CT curl for debugging. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Immutable asset bundles. + location /assets/ { + access_log off; + expires 1y; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; + } + + # Deny sourcemaps in prod. + location ~ \.map$ { + access_log off; + deny all; + return 404; + } + + # Guard-rail: if NPMplus fails to path-route /api/*, surface it as a + # clean 421 rather than serving index.html and confusing the browser + # with a JSON parse error. The SSE endpoint lives at + # /api/plans/:id/events/stream, which also sits under /api/, so one + # rule covers both. + location /api/ { + return 421 "NPMplus is misconfigured: /api/* must proxy to orchestrator :8080\n"; + add_header Content-Type text/plain always; + } + + # SPA fallback. Must come last. + location / { + try_files $uri $uri/ /index.html; + } + } +}