diff --git a/CLAUDE.md b/CLAUDE.md index f7fba5e..52927bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,6 +172,7 @@ cargo test --workspace # Run all tests (optional but recommende **Required**: `cargo fmt`, `cargo clippy`, and `cargo check` must pass without errors. **Recommended**: Fix all clippy warnings before committing. Run full build and tests when making significant changes. +**IMPORTANT**: UPDATE ANY README FILES RELEVANT TO CHANGES ## Updating Dependencies diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..1b82dae --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,84 @@ +# ndld deployment + +Auto-update script and systemd units for ndld. + +## Install + +```bash +# Clone or copy deploy/ to server, then: +sudo ./setup.sh install + +# Or specify custom install dir: +sudo INSTALL_DIR=/srv/ndld ./setup.sh install +``` + +Create `.env` with required secrets: + +```bash +cat > /opt/ndld/.env << 'EOF' +NDL_CLIENT_ID=your_client_id +NDL_CLIENT_SECRET=your_client_secret +NDLD_PUBLIC_URL=https://ndl.example.com +NDLD_ACME_DOMAIN=ndl.example.com +NDLD_ACME_EMAIL=you@example.com +EOF +chmod 600 /opt/ndld/.env +``` + +## Uninstall + +```bash +sudo ./setup.sh uninstall +``` + +## Check for changes + +```bash +./setup.sh diff # exits 0 if up to date, 1 if changes detected +``` + +## Configuration + +Edit `/opt/ndld/ndld-update.service` for environment: + +| Variable | Default | Description | +|----------|---------|-------------| +| `COMPOSE_DIR` | `/opt/ndld` | Path to docker-compose.yml | +| `HEALTH_URL` | `http://localhost:8080/health` | Health check endpoint | +| `HEALTH_TIMEOUT` | `30` | Seconds to wait for health check | + +Edit `/opt/ndld/ndld-update.timer` for schedule: + +| Setting | Default | Description | +|---------|---------|-------------| +| `OnBootSec` | `5min` | First run after boot | +| `OnUnitActiveSec` | `15min` | Interval between runs | +| `RandomizedDelaySec` | `2min` | Random jitter | + +After changes: `sudo systemctl daemon-reload` + +## Commands + +```bash +# Check timer status +systemctl status ndld-update.timer + +# View logs +journalctl -u ndld-update.service -f + +# Run manually +sudo systemctl start ndld-update.service + +# List recent runs +systemctl list-timers ndld-update.timer +``` + +## Security + +The setup applies these protections: + +- **File permissions**: Scripts owned by root, not world-writable +- **systemd hardening**: `ProtectSystem=strict`, `ProtectHome=yes`, `PrivateTmp=yes`, `NoNewPrivileges=yes` +- **Path validation**: Requires absolute paths, checks compose file exists +- **Health checks**: Verifies service is healthy after restart +- **Rollback**: Attempts to restore previous state if restart fails diff --git a/deploy/ndld-update.service b/deploy/ndld-update.service new file mode 100644 index 0000000..dacda53 --- /dev/null +++ b/deploy/ndld-update.service @@ -0,0 +1,20 @@ +[Unit] +Description=Update ndld Docker container +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/opt/ndld/update.sh +Environment=COMPOSE_DIR=/opt/ndld +Environment=HEALTH_URL=http://localhost:8080/health + +# Security hardening +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +NoNewPrivileges=yes +ReadWritePaths=/opt/ndld + +[Install] +WantedBy=multi-user.target diff --git a/deploy/ndld-update.timer b/deploy/ndld-update.timer new file mode 100644 index 0000000..8556993 --- /dev/null +++ b/deploy/ndld-update.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check for ndld updates + +[Timer] +OnBootSec=5min +OnUnitActiveSec=15min +RandomizedDelaySec=2min + +[Install] +WantedBy=timers.target diff --git a/deploy/setup.sh b/deploy/setup.sh new file mode 100755 index 0000000..52e8bd3 --- /dev/null +++ b/deploy/setup.sh @@ -0,0 +1,133 @@ +#!/bin/sh +set -eu + +INSTALL_DIR="${INSTALL_DIR:-/opt/ndld}" +SYSTEMD_DIR="/etc/systemd/system" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +usage() { + echo "Usage: $0 [install|uninstall|diff]" + exit 1 +} + +require_root() { + if [ "$(id -u)" -ne 0 ]; then + echo "This script must be run as root (or with sudo)" + exit 1 + fi +} + +install() { + require_root + echo "Installing ndld auto-update to $INSTALL_DIR..." + + # Validate source files exist + for f in update.sh ndld-update.service ndld-update.timer; do + [ -f "$SCRIPT_DIR/$f" ] || { echo "Missing: $SCRIPT_DIR/$f"; exit 1; } + done + [ -f "$REPO_ROOT/docker-compose.yml" ] || { echo "Missing: $REPO_ROOT/docker-compose.yml"; exit 1; } + + # Create install dir (root-owned, no world-write) + mkdir -p "$INSTALL_DIR" + chmod 755 "$INSTALL_DIR" + + # Copy files (root:root, not world-writable) + cp "$SCRIPT_DIR/update.sh" "$INSTALL_DIR/" + cp "$SCRIPT_DIR/ndld-update.service" "$INSTALL_DIR/" + cp "$SCRIPT_DIR/ndld-update.timer" "$INSTALL_DIR/" + cp "$REPO_ROOT/docker-compose.yml" "$INSTALL_DIR/" + + chown root:root "$INSTALL_DIR/update.sh" + chown root:root "$INSTALL_DIR/ndld-update.service" + chown root:root "$INSTALL_DIR/ndld-update.timer" + chown root:root "$INSTALL_DIR/docker-compose.yml" + chmod 755 "$INSTALL_DIR/update.sh" + chmod 644 "$INSTALL_DIR/ndld-update.service" + chmod 644 "$INSTALL_DIR/ndld-update.timer" + chmod 644 "$INSTALL_DIR/docker-compose.yml" + + # Remind about .env file + if [ ! -f "$INSTALL_DIR/.env" ]; then + echo "" + echo "NOTE: Create $INSTALL_DIR/.env with required secrets:" + echo " NDL_CLIENT_ID=..." + echo " NDL_CLIENT_SECRET=..." + echo " NDLD_PUBLIC_URL=..." + echo " NDLD_ACME_DOMAIN=..." + echo " NDLD_ACME_EMAIL=..." + echo "" + fi + + # Symlink systemd units + ln -sf "$INSTALL_DIR/ndld-update.service" "$SYSTEMD_DIR/" + ln -sf "$INSTALL_DIR/ndld-update.timer" "$SYSTEMD_DIR/" + + # Enable and start timer + systemctl daemon-reload + systemctl enable --now ndld-update.timer + + echo "Done. Check status with: systemctl status ndld-update.timer" +} + +uninstall() { + require_root + echo "Uninstalling ndld auto-update..." + + # Stop and disable timer + systemctl disable --now ndld-update.timer 2>/dev/null || true + systemctl stop ndld-update.service 2>/dev/null || true + + # Remove symlinks + rm -f "$SYSTEMD_DIR/ndld-update.service" + rm -f "$SYSTEMD_DIR/ndld-update.timer" + + # Remove installed files (but not .env) + rm -f "$INSTALL_DIR/update.sh" + rm -f "$INSTALL_DIR/ndld-update.service" + rm -f "$INSTALL_DIR/ndld-update.timer" + rm -f "$INSTALL_DIR/docker-compose.yml" + + systemctl daemon-reload + + echo "Done. Auto-update disabled." +} + +diff_files() { + echo "Comparing repo -> $INSTALL_DIR" + echo "" + changed=0 + for f in update.sh ndld-update.service ndld-update.timer; do + if [ ! -f "$INSTALL_DIR/$f" ]; then + echo "$f: not installed" + changed=1 + elif ! diff -q "$SCRIPT_DIR/$f" "$INSTALL_DIR/$f" >/dev/null 2>&1; then + echo "$f: differs" + diff --color=auto -u "$INSTALL_DIR/$f" "$SCRIPT_DIR/$f" || true + echo "" + changed=1 + else + echo "$f: up to date" + fi + done + # docker-compose.yml is in repo root + if [ ! -f "$INSTALL_DIR/docker-compose.yml" ]; then + echo "docker-compose.yml: not installed" + changed=1 + elif ! diff -q "$REPO_ROOT/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml" >/dev/null 2>&1; then + echo "docker-compose.yml: differs" + diff --color=auto -u "$INSTALL_DIR/docker-compose.yml" "$REPO_ROOT/docker-compose.yml" || true + echo "" + changed=1 + else + echo "docker-compose.yml: up to date" + fi + exit $changed +} + +case "${1:-}" in + install) install ;; + uninstall) uninstall ;; + diff) diff_files ;; + *) usage ;; +esac diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100755 index 0000000..0710bb5 --- /dev/null +++ b/deploy/update.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +# Configuration +COMPOSE_DIR="${COMPOSE_DIR:-/opt/ndld}" +HEALTH_URL="${HEALTH_URL:-http://localhost:8080/health}" +HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-30}" + +die() { + echo "ERROR: $1" + exit 1 +} + +# Validate COMPOSE_DIR +case "$COMPOSE_DIR" in + /*) ;; # absolute path, ok + *) die "COMPOSE_DIR must be an absolute path: $COMPOSE_DIR" ;; +esac + +[ -d "$COMPOSE_DIR" ] || die "COMPOSE_DIR does not exist: $COMPOSE_DIR" +[ -f "$COMPOSE_DIR/docker-compose.yml" ] || [ -f "$COMPOSE_DIR/compose.yml" ] || \ + die "No compose file found in $COMPOSE_DIR" + +cd "$COMPOSE_DIR" + +echo "Pulling latest images..." +if ! docker compose pull; then + die "Pull failed" +fi + +echo "Updating services..." +if ! docker compose up -d; then + echo "Restart failed, attempting rollback..." + docker compose down 2>/dev/null || true + docker compose up -d 2>/dev/null || die "Rollback failed" + die "Restart failed (rollback attempted)" +fi + +# Health check +echo "Waiting for health check..." +elapsed=0 +while [ $elapsed -lt "$HEALTH_TIMEOUT" ]; do + if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then + echo "Health check passed" + echo "Done" + exit 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) +done + +echo "WARNING: Health check failed after ${HEALTH_TIMEOUT}s (service may still be starting)" +exit 0 diff --git a/ndld/src/routes.rs b/ndld/src/routes.rs index a87a264..dfe7426 100644 --- a/ndld/src/routes.rs +++ b/ndld/src/routes.rs @@ -277,28 +277,28 @@ pub async fn index() -> Markup { h2 { "Install ndl" } p { "Quick install (macOS/Linux):" } div.code-block { - pre { code { "curl -fsSL https://raw.githubusercontent.com/pgray/ndl/main/install.sh | sh" } } - button.copy-btn { "Copy" } + pre { code #cmd1 { "curl -fsSL https://raw.githubusercontent.com/pgray/ndl/main/install.sh | sh" } } + button.copy-btn onclick="copyCode('cmd1', this)" { "Copy" } } p.note { "Or install to a custom directory:" } div.code-block { - pre { code { "curl -fsSL https://raw.githubusercontent.com/pgray/ndl/main/install.sh | INSTALL_DIR=~/.local/bin sh" } } - button.copy-btn { "Copy" } + pre { code #cmd2 { "curl -fsSL https://raw.githubusercontent.com/pgray/ndl/main/install.sh | INSTALL_DIR=~/.local/bin sh" } } + button.copy-btn onclick="copyCode('cmd2', this)" { "Copy" } } p { "Or install with cargo:" } div.code-block { - pre { code { "cargo install ndl" } } - button.copy-btn { "Copy" } + pre { code #cmd3 { "cargo install ndl" } } + button.copy-btn onclick="copyCode('cmd3', this)" { "Copy" } } p { "Or build from source:" } div.code-block { - pre { code { "git clone https://github.com/pgray/ndl\ncd ndl\ncargo install --path ndl" } } - button.copy-btn { "Copy" } + pre { code #cmd4 { "git clone https://github.com/pgray/ndl\ncd ndl\ncargo install --path ndl" } } + button.copy-btn onclick="copyCode('cmd4', this)" { "Copy" } } p { "Then login:" } div.code-block { - pre { code { "ndl login" } } - button.copy-btn { "Copy" } + pre { code #cmd5 { "ndl login" } } + button.copy-btn onclick="copyCode('cmd5', this)" { "Copy" } } } @@ -503,18 +503,23 @@ const LANDING_CSS: &str = r#" "#; const COPY_JS: &str = r#" -document.querySelectorAll('.copy-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const code = btn.parentElement.querySelector('code').textContent; - await navigator.clipboard.writeText(code); - btn.textContent = 'Copied!'; - btn.classList.add('copied'); - setTimeout(() => { - btn.textContent = 'Copy'; - btn.classList.remove('copied'); - }, 2000); - }); -}); +function copyCode(id, btn) { + var text = document.getElementById(id).textContent; + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + btn.textContent = 'Copied!'; + btn.className = 'copy-btn copied'; + setTimeout(function() { + btn.textContent = 'Copy'; + btn.className = 'copy-btn'; + }, 2000); +} "#; /// GET /privacy-policy - Privacy policy page