diff --git a/deployments/pocket-id/.env.example b/deployments/pocket-id/.env.example new file mode 100644 index 0000000..00deafb --- /dev/null +++ b/deployments/pocket-id/.env.example @@ -0,0 +1,44 @@ +# Copy to .env and fill in. docker compose picks .env up automatically. +# Never commit the populated .env. + +# ─── Public hostname ──────────────────────────────────────────────────────── +# Bare hostname (no scheme) of the pocket-id deployment. Used by Caddy for +# TLS issuance, by anubis as the cookie domain, and to derive APP_URL. +POCKETID_DOMAIN=id.example.com + +# Email for Let's Encrypt registration / expiry notifications. +ACME_EMAIL=admin@example.com + +# ─── Pocket-ID ────────────────────────────────────────────────────────────── +# APP_URL is set automatically from POCKETID_DOMAIN in compose; no need to +# set it here unless you run pocket-id standalone. + +# Encryption key. Generate once with: openssl rand -base64 32 +# Rotating this re-encrypts data on next start; losing it is unrecoverable. +ENCRYPTION_KEY= + +# Behind Caddy, this MUST be true so pocket-id reads the real client IP and +# scheme from X-Forwarded-* headers. Leave it -- compose overrides anyway. +TRUST_PROXY=true + +# Optional: GeoLite2 license key for IP geolocation in the audit log. +# Get one free at https://www.maxmind.com/en/geolite2/signup +MAXMIND_LICENSE_KEY= + +# UID/GID the pocket-id process runs as inside the container. Match the +# owner of ./data on the host if you bind-mount instead of using the named +# volume. +PUID=1000 +PGID=1000 + +# ─── Anubis ───────────────────────────────────────────────────────────────── +# Ed25519 private key (hex) for the anubis PoW sidecar. Generate with: +# openssl rand -hex 32 +# Only needed while the anubis-pid service is enabled in compose. +ANUBIS_PID_KEY= + +# ─── Image tags ───────────────────────────────────────────────────────────── +# Pin for reproducible deploys. +POCKETID_TAG=v2 +CADDY_TAG=2-alpine +ANUBIS_TAG=latest diff --git a/deployments/pocket-id/Caddyfile b/deployments/pocket-id/Caddyfile new file mode 100644 index 0000000..349aa38 --- /dev/null +++ b/deployments/pocket-id/Caddyfile @@ -0,0 +1,39 @@ +# Caddyfile for pocket-id stack. +# +# Auto-issues a Let's Encrypt cert for $POCKETID_DOMAIN and reverse-proxies +# to anubis-pid (which forwards to pocket-id after the PoW challenge). +# +# To skip anubis, change the reverse_proxy target to `pocket-id:1411`. + +{ + email {$ACME_EMAIL} + # Uncomment for staging certs while testing (avoids LE rate limits): + # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +} + +{$POCKETID_DOMAIN} { + encode zstd gzip + + # Forward through anubis (PoW anti-bot) -> pocket-id. + reverse_proxy anubis-pid:8923 { + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + } + + # Sensible security headers. Adjust CSP if you embed pocket-id elsewhere. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "interest-cohort=()" + -Server + } + + log { + output stdout + format console + } +} diff --git a/deployments/pocket-id/build.sh b/deployments/pocket-id/build.sh new file mode 100644 index 0000000..650fb83 --- /dev/null +++ b/deployments/pocket-id/build.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# build.sh -- (re)embed docker-compose.yml, Caddyfile, .env.example into +# deploy.sh as a base64-encoded tar.gz payload after the __ARCHIVE_BELOW__ +# marker. Idempotent: strips any existing payload first. +# +# Run after editing any of the loose files. The resulting deploy.sh is +# self-contained and can be scp'd to the target box on its own. + +set -euo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SCRIPT="$DIR/deploy.sh" +MARKER="__ARCHIVE_BELOW__" + +[[ -f "$SCRIPT" ]] || { echo "deploy.sh not found at $SCRIPT" >&2; exit 1; } +for f in docker-compose.yml Caddyfile .env.example; do + [[ -f "$DIR/$f" ]] || { echo "Missing $DIR/$f" >&2; exit 1; } +done + +# tar -> gzip -> base64. Files only (no leading ./), wrapped at 76 cols +# so the embedded blob is git-friendly. +PAYLOAD=$(tar -czf - -C "$DIR" docker-compose.yml Caddyfile .env.example | base64) + +# Strip any existing payload (everything from MARKER to EOF), then append a +# fresh one. If MARKER isn't present, sed leaves the file unchanged. +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +sed "/^${MARKER}\$/,\$d" "$SCRIPT" > "$TMP" +{ + echo "$MARKER" + echo "$PAYLOAD" +} >> "$TMP" + +mv "$TMP" "$SCRIPT" +chmod +x "$SCRIPT" +trap - EXIT + +size=$(wc -c < "$SCRIPT") +echo "Built $SCRIPT (${size} bytes)" diff --git a/deployments/pocket-id/deploy.sh b/deployments/pocket-id/deploy.sh new file mode 100644 index 0000000..438f2b5 --- /dev/null +++ b/deployments/pocket-id/deploy.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# +# deploy.sh -- deploy the pocket-id stack (caddy + anubis + pocket-id) on +# Alpine Linux. Single-node, dedicated host: runs everything as root. +# +# What this does: +# 1. Installs docker + docker-cli-compose if missing. +# 2. Lays down docker-compose.yml, Caddyfile, .env.example in $STACK_DIR. +# 3. Generates .env on first run with random ENCRYPTION_KEY and +# ANUBIS_PID_KEY. Existing .env is never overwritten. +# 4. Prompts for POCKETID_DOMAIN and ACME_EMAIL if not preset. +# 5. Enables docker on boot, runs `docker compose pull && up -d`. +# 6. Waits for healthchecks to go green. +# +# Self-contained: docker-compose.yml, Caddyfile, and .env.example are +# embedded at the bottom of this file as a base64-encoded tar.gz. The +# script extracts them at runtime, so this single file is all you need +# on the target box. +# +# To rebuild after editing the loose files: run ./build.sh in this dir. +# +# Idempotent: re-running pulls new images and recreates changed services +# without touching .env or named volumes. +# +# Usage: +# bash deploy.sh # interactive prompts +# POCKETID_DOMAIN=id.example.com ACME_EMAIL=me@example.com \ +# bash deploy.sh +# STACK_DIR=/opt/pocket-id bash deploy.sh +# SKIP_DOCKER_INSTALL=1 bash deploy.sh # docker already installed +# FORCE=1 bash deploy.sh # skip confirmations + +set -euo pipefail + +: "${STACK_DIR:=/srv/pocket-id}" +: "${SKIP_DOCKER_INSTALL:=0}" +: "${FORCE:=0}" +: "${POCKETID_DOMAIN:=}" +: "${ACME_EMAIL:=}" + +log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Run as root." +[[ -f /etc/alpine-release ]] || die "This script targets Alpine Linux." + +# ---------------------------------------------------------------------------- +# Extract embedded archive +# ---------------------------------------------------------------------------- +SCRIPT_DIR=$(mktemp -d -t pocket-id-deploy.XXXXXX) +trap 'rm -rf "$SCRIPT_DIR"' EXIT + +extract_archive() { + grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \ + | tail -n +2 \ + | base64 -d \ + | tar -xz -C "$SCRIPT_DIR" +} + +if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then + log "Extracting embedded deployment files..." + extract_archive +else + die "No embedded archive found. Run build.sh to embed deployment files." +fi + +for f in docker-compose.yml Caddyfile .env.example; do + [[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f" +done + +# ---------------------------------------------------------------------------- +# Prompt for required vars if not set +# ---------------------------------------------------------------------------- +prompt() { + local var="$1" prompt="$2" cur="${!var:-}" + if [[ -z "$cur" ]]; then + read -r -p "$prompt: " cur + [[ -n "$cur" ]] || die "$var required." + printf -v "$var" '%s' "$cur" + fi +} +prompt POCKETID_DOMAIN "Public hostname (e.g. id.example.com)" +prompt ACME_EMAIL "Let's Encrypt email" + +# ---------------------------------------------------------------------------- +# Docker +# ---------------------------------------------------------------------------- +if [[ "$SKIP_DOCKER_INSTALL" != "1" ]]; then + if ! command -v docker >/dev/null 2>&1; then + log "Installing docker + docker-cli-compose..." + apk add -q docker docker-cli-compose openrc + else + log "Docker already installed: $(docker --version)" + fi + rc-update add docker default >/dev/null 2>&1 || true + rc-service docker status >/dev/null 2>&1 || rc-service docker start +fi + +# ---------------------------------------------------------------------------- +# Stack directory + files +# ---------------------------------------------------------------------------- +log "Setting up $STACK_DIR..." +install -d -m 0750 "$STACK_DIR" +install -m 0640 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml" +install -m 0640 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile" + +ENV_FILE="$STACK_DIR/.env" +if [[ ! -f "$ENV_FILE" ]]; then + log "Seeding $ENV_FILE with generated secrets..." + install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE" + sed -i \ + -e "s|^POCKETID_DOMAIN=.*|POCKETID_DOMAIN=${POCKETID_DOMAIN}|" \ + -e "s|^ACME_EMAIL=.*|ACME_EMAIL=${ACME_EMAIL}|" \ + -e "s|^ENCRYPTION_KEY=.*|ENCRYPTION_KEY=$(openssl rand -base64 32)|" \ + -e "s|^ANUBIS_PID_KEY=.*|ANUBIS_PID_KEY=$(openssl rand -hex 32)|" \ + "$ENV_FILE" +else + log ".env exists; leaving secrets alone." +fi + +# Validate required values are present. +missing=() +for var in POCKETID_DOMAIN ACME_EMAIL ENCRYPTION_KEY ANUBIS_PID_KEY; do + grep -E "^${var}=.+$" "$ENV_FILE" >/dev/null || missing+=("$var") +done +(( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}" + +# ---------------------------------------------------------------------------- +# Bring up the stack +# ---------------------------------------------------------------------------- +if [[ "$FORCE" != "1" ]]; then + cat </dev/null || true) + unhealthy=$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}') + if [[ -z "$unhealthy" && -n "$status" ]]; then + log "All services healthy." + break + fi + sleep 5 +done + +echo +log "Stack status:" +docker compose ps +echo +cat < caddy:443 --> anubis-pid:8923 --> pocket-id:1411 +# +# Only caddy publishes ports. pocket-id and anubis are internal-only and +# reachable by service name. To bypass anubis (no PoW challenge), point the +# Caddyfile reverse_proxy at pocket-id:1411 directly and remove the anubis +# block. + +name: pocket-id + +volumes: + pocket-id-data: + caddy-data: + caddy-config: + +services: + # --------------------------------------------------------------------------- + # Caddy — TLS termination + reverse proxy. The only service on 80/443. + # Auto-issues Let's Encrypt cert for ${POCKETID_DOMAIN}. + # --------------------------------------------------------------------------- + caddy: + image: caddy:${CADDY_TAG:-2-alpine} + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + environment: + POCKETID_DOMAIN: "${POCKETID_DOMAIN}" + ACME_EMAIL: "${ACME_EMAIL}" + depends_on: + - pocket-id + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:2019/config/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # --------------------------------------------------------------------------- + # Pocket-ID — OIDC provider. No host port published; Caddy fronts it. + # TRUST_PROXY=true is required so client IPs and scheme are read from + # X-Forwarded-* headers Caddy sets. + # --------------------------------------------------------------------------- + pocket-id: + image: ghcr.io/pocket-id/pocket-id:${POCKETID_TAG:-v2} + container_name: pocket-id + restart: unless-stopped + env_file: .env + environment: + # Override .env defaults so the container always sees the right values + # regardless of how .env is laid out. + APP_URL: "https://${POCKETID_DOMAIN}" + TRUST_PROXY: "true" + volumes: + - pocket-id-data:/app/data + healthcheck: + test: ["CMD", "/app/pocket-id", "healthcheck"] + interval: 90s + timeout: 5s + retries: 2 + start_period: 10s + + # --------------------------------------------------------------------------- + # Anubis — PoW anti-bot sidecar in front of pocket-id. Generate the key + # with `openssl rand -hex 32`. To disable: comment this service out and + # change the Caddyfile reverse_proxy target to `pocket-id:1411`. + # --------------------------------------------------------------------------- + anubis-pid: + image: ghcr.io/techarohq/anubis:${ANUBIS_TAG:-latest} + container_name: anubis-pid + restart: unless-stopped + environment: + BIND: ":8923" + TARGET: "http://pocket-id:1411" + DIFFICULTY: "4" # SHA-256 leading zeros; 4 ≈ 1s client work + COOKIE_DOMAIN: "${POCKETID_DOMAIN}" + METRICS_BIND: ":9090" + ED25519_PRIVATE_KEY_HEX: "${ANUBIS_PID_KEY}" + depends_on: + pocket-id: + condition: service_healthy