Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
84 changes: 84 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions deploy/ndld-update.service
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions deploy/ndld-update.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[Unit]
Description=Check for ndld updates

[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
RandomizedDelaySec=2min

[Install]
WantedBy=timers.target
133 changes: 133 additions & 0 deletions deploy/setup.sh
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions deploy/update.sh
Original file line number Diff line number Diff line change
@@ -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
49 changes: 27 additions & 22 deletions ndld/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}

Expand Down Expand Up @@ -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
Expand Down