From 4a37b3622d5bf71039cfa6dd761bdd66004c90b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 17:01:14 +0000 Subject: [PATCH 1/3] v0.9.6: README generation + migrate authelia + 5 services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/common.sh: add write_readme helper. Every module now writes a README.md into its ~/docker// folder (self-documenting service folders). - services/authelia.sh: SSO + 2FA portal, ported from the authelia-setup repo + the monolith's working block (secrets + Argon2 hash generation, caddy_net, Caddyfile forward-auth snippet + portal block, README). Guards against clobbering an existing install. - services/{filebrowser,ntfy,uptimekuma,portainer,watchtower}.sh: mechanical migrations from the monolith, each with a README. Ports 8085/8090/3001/9443/—. All pass bash -n; ./setup.sh --list shows them under homelab; dry-run run-one exits 0 for each with real commands guarded. https://claude.ai/code/session_017eA2qqq9jfF2tNtpUYL8vK --- CHANGELOG.md | 25 ++++ VERSION | 2 +- lib/common.sh | 20 +++ services/authelia.sh | 315 ++++++++++++++++++++++++++++++++++++++++ services/filebrowser.sh | 99 +++++++++++++ services/ntfy.sh | 87 +++++++++++ services/portainer.sh | 76 ++++++++++ services/uptimekuma.sh | 81 +++++++++++ services/watchtower.sh | 162 +++++++++++++++++++++ 9 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 services/authelia.sh create mode 100644 services/filebrowser.sh create mode 100644 services/ntfy.sh create mode 100644 services/portainer.sh create mode 100644 services/uptimekuma.sh create mode 100644 services/watchtower.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d59bfb..9447615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this project. Versions follow `MAJOR.MINOR.PATCH`. The project is pre-1.0 while the modular system reaches parity with the monolithic `ubuntu-post-install-*.sh` scripts. +## [0.9.6] - 2026-06-03 + +### Added +- **Per-service README generation.** New `write_readme` helper in + `lib/common.sh`; every module now writes a `README.md` into its + `~/docker//` folder (what it is, access URL, start/stop, data + location, reverse-proxy notes) — so each service folder is self-documenting. +- Migrated 6 services from the monolith into modules (all in the `homelab` + group, each with a README): + - `authelia` — SSO + 2FA portal, ported from the `authelia-setup` repo + the + monolith's working block: prompts for domain/SMTP, generates + jwt/session/storage secrets + the admin Argon2 hash, writes + compose/config/users, creates `caddy_net`, and injects the forward-auth + snippet + portal block into the Caddyfile. Won't clobber an existing install. + - `filebrowser` (8085), `ntfy` (8090), `uptimekuma` (3001), + `portainer` (9443), `watchtower` (no web port). + +### Notes +- `homelab` group now: authelia, filebrowser, homeassistant, ntfy, portainer, + uptimekuma, watchtower. +- Remaining monolith services still to migrate: ActualBudget, ARM, + AudioBookshelf, Caddy, CrowdSec, Emby, FindMyDevice, Frigate, Frigate-Notify, + Immich, Jellyfin, Lyrion, MagicMirror, Mealie, MeshCentral, Traccar, ddclient, + wg-easy. + ## [0.9.5] - 2026-06-03 ### Added diff --git a/VERSION b/VERSION index b0bb878..85b7c69 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.5 +0.9.6 diff --git a/lib/common.sh b/lib/common.sh index e6bd534..9f24c6c 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -121,6 +121,26 @@ prompt_text() { eval "$varname='${response:-$default}'" } +# ── Per-service README generation ──────────────────────────────────────────── +# Write /README.md from stdin (markdown). Every module is encouraged to +# call this so each ~/docker// folder is self-documenting. +# Usage: +# write_readme "$DIR" </dev/null # consume the heredoc so the caller isn't blocked + echo "[DRY-RUN] Would write $dir/README.md" + return 0 + fi + mkdir -p "$dir" + cat > "$dir/README.md" + chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true +} + # ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── # Usage: configure_caddy_for_service "Name" "PORT" "default-subdomain" ["extra"] configure_caddy_for_service() { diff --git a/services/authelia.sh b/services/authelia.sh new file mode 100644 index 0000000..9a57e56 --- /dev/null +++ b/services/authelia.sh @@ -0,0 +1,315 @@ +#!/bin/bash +# services/authelia.sh — Authelia SSO + 2FA portal (forward-auth for Caddy). +# Ported from the authelia-setup repo / the monolith's working block. +# Part of the modular post-install system (sourced by setup.sh). + +register_service authelia homelab "SSO + 2FA auth portal (Authelia)" 9091 + +install_authelia() { + require_docker || return 1 + local AUTHELIA_DIR="$DOCKER_DIR/authelia" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would set up Authelia:" + echo " • Create $AUTHELIA_DIR (config/secrets, data)" + echo " • Generate jwt/session/storage secrets + admin password hash" + echo " • Write docker-compose.yml, configuration.yml, users.yml, README.md" + echo " • Create the caddy_net network and add the forward-auth snippet to the Caddyfile" + return 0 + fi + + # Don't clobber an existing install (it would regenerate secrets and break sessions). + if [ -f "$AUTHELIA_DIR/docker-compose.yml" ]; then + local RECONF="" + echo " ⚠ Authelia already exists at $AUTHELIA_DIR (secrets/users would be regenerated)." + prompt_yn " Reconfigure from scratch? (y/n):" "n" RECONF + if [ "$RECONF" != "y" ] && [ "$RECONF" != "Y" ]; then + echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)" + return 0 + fi + fi + + log_info "Installing Authelia..." + mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data" + + # ── Collect configuration ──────────────────────────────────────────────── + echo "" + echo " Authelia needs a few details to configure." + echo "" + local AUTHELIA_DOMAIN AUTHELIA_ADMIN_USER AUTHELIA_ADMIN_DISPLAY AUTHELIA_ADMIN_EMAIL + local AUTHELIA_SMTP_HOST AUTHELIA_SMTP_PORT AUTHELIA_SMTP_USER AUTHELIA_SMTP_PASS AUTHELIA_TZ + prompt_text " Your domain (e.g., example.com):" "example.com" AUTHELIA_DOMAIN + prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER + prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY + prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL + prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST + prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT + prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER + prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS + prompt_text " Timezone (e.g., America/New_York):" "America/New_York" AUTHELIA_TZ + + # ── Secrets ────────────────────────────────────────────────────────────── + echo "" + echo " Generating secrets..." + echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/jwt_secret" + echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/session_secret" + echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/storage_secret" + echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password" + chmod 600 "$AUTHELIA_DIR/config/secrets/"* + echo " ✓ Secrets generated" + + # ── Admin password hash ────────────────────────────────────────────────── + echo "" + local AUTHELIA_TEMP_PASS AUTHELIA_HASH + prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS + echo " Generating password hash..." + AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \ + authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \ + | grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH") + if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then + log_warning "Could not generate hash automatically. After install run:" + echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'" + echo " then update $AUTHELIA_DIR/config/users.yml" + else + echo " ✓ Password hash generated" + fi + + ensure_docker_dir_ownership "$AUTHELIA_DIR" + cd "$AUTHELIA_DIR" || return 1 + + # ── .env ───────────────────────────────────────────────────────────────── + cat > .env << AUTHELIA_ENV +MY_DOMAIN=${AUTHELIA_DOMAIN} +SMTP_USER=${AUTHELIA_SMTP_USER} +DOCKER_MY_NETWORK=caddy_net +TZ=${AUTHELIA_TZ} +AUTHELIA_ENV + + # ── docker-compose.yml (quoted heredoc: ${SMTP_USER} resolved by compose/.env) ── + cat > docker-compose.yml << 'AUTHELIA_COMPOSE' +name: authelia + +services: + authelia: + image: authelia/authelia:4.39.20 + pull_policy: missing + container_name: authelia + user: "1000:1000" + volumes: + - ./config:/config + - ./data:/data + environment: + - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret + - AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret + - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret + - AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password + - AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER} + - AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}> + expose: + - 9091 + restart: unless-stopped + networks: + - caddy_net + +networks: + caddy_net: + external: true +AUTHELIA_COMPOSE + + # ── configuration.yml ──────────────────────────────────────────────────── + cat > config/configuration.yml << AUTHELIA_CONFIG +--- +# Authelia configuration. Secrets injected via AUTHELIA_* env vars in compose. +theme: dark + +server: + address: tcp://0.0.0.0:9091 + +log: + level: info + file_path: /data/authelia.log + +totp: + period: 30 + skew: 1 + +authentication_backend: + file: + path: /config/users.yml + password: + algorithm: argon2 + argon2: + variant: argon2id + iterations: 3 + memory: 65536 + parallelism: 4 + key_length: 32 + salt_length: 16 + +access_control: + default_policy: deny + rules: + - domain: "*.${AUTHELIA_DOMAIN}" + policy: two_factor + +session: + name: authelia_session + expiration: 12h + inactivity: 2h + remember_me: 7d + cookies: + - domain: ${AUTHELIA_DOMAIN} + authelia_url: https://auth.${AUTHELIA_DOMAIN} + default_redirection_url: https://${AUTHELIA_DOMAIN} + +storage: + local: + path: /data/db.sqlite3 + +notifier: + disable_startup_check: false + smtp: + address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT} + timeout: 10s + identifier: localhost + subject: "[Authelia] {title}" + startup_check_address: ${AUTHELIA_SMTP_USER} + disable_require_tls: false + disable_starttls: false +AUTHELIA_CONFIG + + # ── users.yml ──────────────────────────────────────────────────────────── + cat > config/users.yml << AUTHELIA_USERS +--- +# Authelia users database +# Add users: copy a block, change username/email/displayname, restart authelia. +# Generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword' +# Login with username (not email). Use "Forgot Password" to set a real password. + +users: + ${AUTHELIA_ADMIN_USER}: + displayname: "${AUTHELIA_ADMIN_DISPLAY}" + email: ${AUTHELIA_ADMIN_EMAIL} + password: "${AUTHELIA_HASH}" + groups: + - admins + - users +AUTHELIA_USERS + + chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" + log_success "Authelia configured at $AUTHELIA_DIR" + + # ── caddy_net network ──────────────────────────────────────────────────── + if ! docker network ls --format '{{.Name}}' | grep -q "^caddy_net$"; then + docker network create caddy_net >/dev/null 2>&1 && echo " ✓ Created docker network caddy_net" \ + || echo " ⚠ Failed to create caddy_net" + else + echo " ✓ Docker network caddy_net already exists" + fi + + # ── Caddyfile forward-auth snippet + portal block ──────────────────────── + local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" + if [ -f "$CADDY_FILE" ]; then + echo " Configuring Caddy for Authelia..." + if ! grep -q "(authelia)" "$CADDY_FILE"; then + cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)" + { cat << 'SNIPPET_EOF' +# ── Authelia forward auth snippet ───────────────────────────────────────────── +(authelia) { + forward_auth authelia:9091 { + uri /api/authz/forward-auth + copy_headers Remote-User Remote-Groups Remote-Name Remote-Email + } +} + +SNIPPET_EOF + cat "$CADDY_FILE"; } > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE" + echo " ✓ Authelia snippet added to Caddyfile" + fi + if ! grep -q "auth.${AUTHELIA_DOMAIN}" "$CADDY_FILE"; then + cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK + +# ── Authelia login portal ────────────────────────────────────────────────────── +auth.${AUTHELIA_DOMAIN} { + reverse_proxy authelia:9091 + log { + output file /var/log/caddy/auth.log + } +} +CADDY_AUTH_BLOCK + echo " ✓ Authelia portal block added for auth.${AUTHELIA_DOMAIN}" + fi + docker ps --format '{{.Names}}' | grep -q "^caddy$" && \ + { docker exec -w /etc/caddy caddy caddy reload 2>/dev/null && echo " ✓ Caddy reloaded" || echo " ⚠ Reload manually after checking the Caddyfile"; } + else + echo " ℹ Caddy not installed yet — add the (authelia) snippet + auth.${AUTHELIA_DOMAIN} block to your Caddyfile later (see README)." + fi + + # ── README for the service folder ──────────────────────────────────────── + write_readme "$AUTHELIA_DIR" << README_MD +# Authelia — SSO + 2FA portal + +Single login (with TOTP two-factor) that protects any Caddy subdomain via +forward-auth. Portal: **https://auth.${AUTHELIA_DOMAIN}** + +## Layout +\`\`\` +$AUTHELIA_DIR/ +├── docker-compose.yml +├── .env +├── config/ +│ ├── configuration.yml +│ ├── users.yml +│ └── secrets/ # jwt/session/storage/smtp — never commit +└── data/ # sqlite db + log +\`\`\` + +## Protect a service with Authelia +In that service's Caddy site block, add \`import authelia\`: +\`\`\` +myservice.${AUTHELIA_DOMAIN} { + import authelia + reverse_proxy localhost:PORT +} +\`\`\` +The \`(authelia)\` snippet and the \`auth.${AUTHELIA_DOMAIN}\` portal block were +added to \`$DOCKER_DIR/caddy/Caddyfile\` automatically. + +## Manage +\`\`\` +cd $AUTHELIA_DIR +docker compose up -d # start +docker compose restart authelia +docker compose logs -f authelia +docker compose down # stop +\`\`\` + +## Users +- Login with the **username** (not email). Admin user: \`${AUTHELIA_ADMIN_USER}\`. +- Tell users to click **Forgot Password** on first login to set their own + password (Authelia emails a reset link via SMTP). +- Add a user: copy a block in \`config/users.yml\`, change username/email/ + displayname, generate a hash, then \`docker compose restart authelia\`: +\`\`\` +docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword' +\`\`\` + +## Notes +- Authelia listens on 9091 **internally only** (no published port) and is + reached through Caddy on the shared \`caddy_net\` docker network. +- Two-factor is **required** (\`default_policy: deny\`, rule \`two_factor\` for + \`*.${AUTHELIA_DOMAIN}\`). +README_MD + + local START_AUTHELIA="" + prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA + if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then + docker compose up -d 2>/dev/null && log_success "Authelia started" || log_warning "Failed to start Authelia" + fi + + echo "" + echo " Auth portal: https://auth.${AUTHELIA_DOMAIN}" + echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set a real password)" + echo " README: $AUTHELIA_DIR/README.md" + echo "" +} diff --git a/services/filebrowser.sh b/services/filebrowser.sh new file mode 100644 index 0000000..0145e55 --- /dev/null +++ b/services/filebrowser.sh @@ -0,0 +1,99 @@ +#!/bin/bash +# services/filebrowser.sh — FileBrowser web-based file manager. +# Part of the modular post-install system (sourced by setup.sh). + +register_service filebrowser homelab "Web file manager (FileBrowser)" 8085 + +install_filebrowser() { + require_docker || return 1 + log_info "Installing Filebrowser..." + local FB_DIR="$DOCKER_DIR/filebrowser" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $FB_DIR" + return 0 + fi + + mkdir -p "$FB_DIR" + ensure_docker_dir_ownership "$FB_DIR" + cd "$FB_DIR" || return 1 + + local FB_PATH="" + prompt_text "Path to browse [default: $ACTUAL_HOME]:" "$ACTUAL_HOME" FB_PATH + + cat > docker-compose.yml << FB_COMPOSE +name: filebrowser + +services: + filebrowser: + image: filebrowser/filebrowser:s6 + container_name: filebrowser + hostname: filebrowser + restart: unless-stopped + environment: + - PUID=$(id -u "$ACTUAL_USER") + - PGID=$(id -g "$ACTUAL_USER") + - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") + volumes: + - ${FB_PATH}:/srv + - ./database/filebrowser.db:/database/filebrowser.db + - ./config/settings.json:/config/settings.json + ports: + - "8085:80" +FB_COMPOSE + + cat > .env << FB_ENV +FB_PATH=$FB_PATH +FB_ENV + + mkdir -p database config + touch database/filebrowser.db + cat > config/settings.json << 'FB_SETTINGS' +{ + "port": 80, + "baseURL": "", + "address": "", + "log": "stdout", + "database": "/database/filebrowser.db", + "root": "/srv" +} +FB_SETTINGS + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR" + + echo "" + log_success "Filebrowser configured at $FB_DIR" + + write_readme "$FB_DIR" << MD +# FileBrowser + +Web-based file manager. Browse, upload, and download files through a browser. + +## Access +- URL: http://localhost:8085 +- Default login: admin / admin (change immediately!) + +## Data +- Browsed path: $FB_PATH (mounted to /srv) +- Database: ./database/filebrowser.db +- Settings: ./config/settings.json + +## Manage +\`\`\` +cd $FB_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +\`\`\` +MD + + local START_FB="" + prompt_yn "Start Filebrowser now? (y/n):" "y" START_FB + if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then + docker compose up -d 2>/dev/null && log_success "Filebrowser started" || log_warning "Failed to start" + fi + + echo " Access at: http://localhost:8085" + echo " Default login: admin / admin (change immediately!)" + echo "" +} diff --git a/services/ntfy.sh b/services/ntfy.sh new file mode 100644 index 0000000..a020528 --- /dev/null +++ b/services/ntfy.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# services/ntfy.sh — ntfy self-hosted push notification server. +# Part of the modular post-install system (sourced by setup.sh). + +register_service ntfy homelab "Self-hosted push notifications (ntfy)" 8090 + +install_ntfy() { + require_docker || return 1 + log_info "Installing ntfy..." + local NTFY_DIR="$DOCKER_DIR/ntfy" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $NTFY_DIR" + return 0 + fi + + mkdir -p "$NTFY_DIR" + ensure_docker_dir_ownership "$NTFY_DIR" + cd "$NTFY_DIR" || return 1 + + cat > docker-compose.yml << 'NTFY_COMPOSE' +name: ntfy + +services: + ntfy: + image: binwiederhier/ntfy:latest + container_name: ntfy + hostname: ntfy + restart: unless-stopped + command: serve + environment: + - TZ=${TZ} + volumes: + - ./cache:/var/cache/ntfy + - ./config:/etc/ntfy + ports: + - "8090:80" +NTFY_COMPOSE + + cat > .env << NTFY_ENV +TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") +NTFY_ENV + + mkdir -p cache config + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" + + echo "" + log_success "ntfy configured at $NTFY_DIR" + + write_readme "$NTFY_DIR" << MD +# ntfy + +Self-hosted push notification server. Send notifications from scripts to your +phone or browser. + +## Access +- URL: http://localhost:8090 + +## Usage +- Send a notification: \`curl -d "Hello!" localhost:8090/mytopic\` +- Subscribe on phone: ntfy app -> Add subscription -> localhost:8090/mytopic + +## Data +- Config: ./config (mounted to /etc/ntfy) +- Cache: ./cache (mounted to /var/cache/ntfy) + +## Manage +\`\`\` +cd $NTFY_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +\`\`\` +MD + + local START_NTFY="" + prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY + if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then + docker compose up -d 2>/dev/null && log_success "ntfy started" || log_warning "Failed to start" + fi + + echo " Access at: http://localhost:8090" + echo "" + echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic" + echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic" + echo "" +} diff --git a/services/portainer.sh b/services/portainer.sh new file mode 100644 index 0000000..7d11603 --- /dev/null +++ b/services/portainer.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# services/portainer.sh — Portainer Docker management web UI. +# Part of the modular post-install system (sourced by setup.sh). + +register_service portainer homelab "Docker management UI (Portainer)" 9443 + +install_portainer() { + require_docker || return 1 + log_info "Installing Portainer..." + local PORTAINER_DIR="$DOCKER_DIR/portainer" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $PORTAINER_DIR" + return 0 + fi + + mkdir -p "$PORTAINER_DIR" + ensure_docker_dir_ownership "$PORTAINER_DIR" + cd "$PORTAINER_DIR" || return 1 + + cat > docker-compose.yml << 'PORTAINER_COMPOSE' +name: portainer + +services: + portainer: + image: portainer/portainer-ce:latest + container_name: portainer + hostname: portainer + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data:/data + ports: + - "9000:9000" + - "9443:9443" +PORTAINER_COMPOSE + + mkdir -p data + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR" + + echo "" + log_success "Portainer configured at $PORTAINER_DIR" + + write_readme "$PORTAINER_DIR" << MD +# Portainer + +Web UI for managing Docker — containers, images, volumes, and networks. + +## Access +- HTTPS: https://localhost:9443 +- HTTP: http://localhost:9000 +- Create your admin account on first visit. + +## Data +- App data: ./data (mounted to /data) +- Mounts the Docker socket to manage the host's Docker. + +## Manage +\`\`\` +cd $PORTAINER_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +\`\`\` +MD + + local START_PORTAINER="" + prompt_yn "Start Portainer now? (y/n):" "y" START_PORTAINER + if [ "$START_PORTAINER" = "y" ] || [ "$START_PORTAINER" = "Y" ]; then + docker compose up -d 2>/dev/null && log_success "Portainer started" || log_warning "Failed to start" + fi + + echo " Access at: https://localhost:9443" + echo " Create admin account on first visit" + echo "" +} diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh new file mode 100644 index 0000000..013ab6c --- /dev/null +++ b/services/uptimekuma.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# services/uptimekuma.sh — Uptime Kuma uptime/status monitoring. +# Part of the modular post-install system (sourced by setup.sh). + +register_service uptimekuma homelab "Uptime/status monitoring (Uptime Kuma)" 3001 + +install_uptimekuma() { + require_docker || return 1 + log_info "Installing Uptime Kuma..." + local UPTIME_DIR="$DOCKER_DIR/uptime-kuma" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $UPTIME_DIR" + return 0 + fi + + mkdir -p "$UPTIME_DIR" + ensure_docker_dir_ownership "$UPTIME_DIR" + cd "$UPTIME_DIR" || return 1 + + cat > docker-compose.yml << 'UPTIME_COMPOSE' +name: uptime-kuma + +services: + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: uptime-kuma + hostname: uptime-kuma + restart: unless-stopped + volumes: + - ./data:/app/data + - /var/run/docker.sock:/var/run/docker.sock:ro + ports: + - "3001:3001" +UPTIME_COMPOSE + + mkdir -p data + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPTIME_DIR" + + echo "" + log_success "Uptime Kuma configured at $UPTIME_DIR" + + write_readme "$UPTIME_DIR" << MD +# Uptime Kuma + +Self-hosted uptime/status monitoring dashboard. Monitor websites, servers, and +Docker containers. + +## Access +- URL: http://localhost:3001 +- Create your admin account on first visit. + +## Data +- App data: ./data (mounted to /app/data) +- Mounts the Docker socket (read-only) for container monitoring. + +## Reverse proxy +If Caddy is installed, you can expose this via the prompt during install +(see configure_caddy_for_service). Default subdomain: uptime. + +## Manage +\`\`\` +cd $UPTIME_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +\`\`\` +MD + + # Configure Caddy reverse proxy before starting + configure_caddy_for_service "Uptime Kuma" "3001" "uptime" + + local START_UPTIME="" + prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME + if [ "$START_UPTIME" = "y" ] || [ "$START_UPTIME" = "Y" ]; then + docker compose up -d 2>/dev/null && log_success "Uptime Kuma started" || log_warning "Failed to start" + fi + + echo " Access at: http://localhost:3001" + echo "" +} diff --git a/services/watchtower.sh b/services/watchtower.sh new file mode 100644 index 0000000..d82e247 --- /dev/null +++ b/services/watchtower.sh @@ -0,0 +1,162 @@ +#!/bin/bash +# services/watchtower.sh — Watchtower automatic container update monitoring. +# Part of the modular post-install system (sourced by setup.sh). + +register_service watchtower homelab "Automatic container updates (Watchtower)" + +install_watchtower() { + require_docker || return 1 + log_info "Installing Watchtower..." + local WT_DIR="$DOCKER_DIR/watchtower" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $WT_DIR" + return 0 + fi + + mkdir -p "$WT_DIR" 2>/dev/null || true + ensure_docker_dir_ownership "$WT_DIR" + cd "$WT_DIR" 2>/dev/null || cd "$DOCKER_DIR" || return 1 + + # Ask about mode + echo "" + echo "Watchtower Mode:" + echo " [M] Monitor only - Get notifications about available updates (SAFE)" + echo " [A] Auto-update - Automatically pull and restart containers (RISKY)" + echo "" + echo " ⚠️ Auto-update can break apps like Immich that need DB migrations!" + echo " Recommendation: Use monitor mode, update manually when ready." + echo "" + local WT_MODE="M" + prompt_text "Mode [M/A]:" "M" WT_MODE + WT_MODE=$(echo "$WT_MODE" | tr '[:lower:]' '[:upper:]') + + local MONITOR_ONLY + if [ "$WT_MODE" = "A" ]; then + MONITOR_ONLY="false" + echo " Mode: Auto-update (containers will be updated automatically)" + else + MONITOR_ONLY="true" + echo " Mode: Monitor only (you'll be notified of updates)" + fi + + # Check for ntfy + local NTFY_URL="" + if [ -d "$DOCKER_DIR/ntfy" ]; then + echo " ✓ ntfy detected - configuring notifications" + NTFY_URL="http://ntfy/watchtower" + fi + + cat > docker-compose.yml << WT_COMPOSE +name: watchtower + +services: + watchtower: + image: containrrr/watchtower:latest + container_name: watchtower + hostname: watchtower + restart: unless-stopped + environment: + # Check for updates daily at 4 AM + - WATCHTOWER_SCHEDULE=0 0 4 * * * + # Monitor only - don't auto-update (change to false for auto-update) + - WATCHTOWER_MONITOR_ONLY=${MONITOR_ONLY} + # Cleanup old images after update + - WATCHTOWER_CLEANUP=true + # Include stopped containers + - WATCHTOWER_INCLUDE_STOPPED=true + # Notification URL (ntfy, Discord, Slack, etc.) + - WATCHTOWER_NOTIFICATION_URL=\${NOTIFICATION_URL:-} + # Show debug info + - WATCHTOWER_DEBUG=false + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro +WT_COMPOSE + + # Create .env + cat > .env << WT_ENV +# Watchtower Configuration +# ========================= +# +# Monitor-only mode: Watchtower checks for updates but doesn't apply them. +# This is SAFER because some apps (Immich, Mealie) have database migrations +# that can break if you update without proper procedures. +# +# To update manually: +# cd ~/docker/{app} +# docker compose pull +# docker compose up -d + +# Set to "false" to enable auto-updates (RISKY!) +MONITOR_ONLY=$MONITOR_ONLY + +# Notification URL (optional) +# Examples: +# ntfy: ntfy://ntfy.example.com/watchtower +# Discord: discord://token@id +# Slack: slack://hook-url +# Gotify: gotify://hostname/token +# +# Full list: https://containrrr.dev/shoutrrr/services/overview/ +NOTIFICATION_URL=$NTFY_URL +WT_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WT_DIR" 2>/dev/null || true + + echo "" + log_success "Watchtower installed at $WT_DIR" + + write_readme "$WT_DIR" << MD +# Watchtower + +Monitors running containers for image updates. Defaults to NOTIFY-ONLY mode, +because apps like Immich can have breaking DB migrations on auto-update. + +## No web interface +Watchtower has no web UI/port. It runs in the background and checks for updates +daily at 4 AM. + +## Configuration +- Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update") (set MONITOR_ONLY in .env; "false" = auto-update) +- Notifications: set NOTIFICATION_URL in .env (ntfy, Discord, Slack, Gotify, ...) + See https://containrrr.dev/shoutrrr/services/overview/ + +## Exclude a container +Add this label to any container you want Watchtower to ignore: +\`com.centurylinklabs.watchtower.enable=false\` + +## Update an app manually +\`\`\` +cd ~/docker/ +docker compose pull +docker compose up -d +\`\`\` + +## Manage +\`\`\` +cd $WT_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +\`\`\` +MD + + local START_WATCHTOWER="" + prompt_yn "Start Watchtower now? (y/n):" "y" START_WATCHTOWER + if [ "$START_WATCHTOWER" = "y" ] || [ "$START_WATCHTOWER" = "Y" ]; then + docker compose up -d 2>/dev/null && log_success "Watchtower started" || log_warning "Failed to start" + fi + + echo " Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update")" + echo "" + echo " Checks for updates daily at 4 AM." + if [ -n "$NTFY_URL" ]; then + echo " Notifications: $NTFY_URL" + else + echo " Configure NOTIFICATION_URL in .env for alerts." + fi + echo "" + echo " To exclude a container from Watchtower:" + echo " Add label: com.centurylinklabs.watchtower.enable=false" + echo "" +} From 9dc8c4063d05ddc537eba5c52dae8dc069145e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 17:16:32 +0000 Subject: [PATCH 2/3] v0.9.7: Caddy + CrowdSec modules; category menu with required-gate - services/caddy.sh (homelab): reverse proxy + auto HTTPS, own ~/docker/caddy folder (compose + starter Caddyfile + README). - services/crowdsec.sh (homelab): system-level IPS (agent + firewall bouncer + Caddy acquisition + optional ntfy alerts), README in ~/docker/crowdsec. - setup.sh guided flow redesign: * Prints REQUIRED set (essentials + glow + docker check) with a cancel option. * Offers Caddy first (most services proxy through it). * Category menu LOOP: pick category -> checklist ([installed] marked) -> install -> back to menu, until Done. whiptail + text fallback. - Categories reorganized: base/homelab/utilities/media/cameras/gaming/backup; moved ntfy/filebrowser/portainer/uptimekuma/watchtower to utilities; caddy->crowdsec->authelia ordered first in homelab. Verified: bash -n all; --list groups by category with caddy first; cancel path prints 'Cancelled, nothing changed'; dry-run guided flow runs required + loops menu; run-one still works. https://claude.ai/code/session_017eA2qqq9jfF2tNtpUYL8vK --- CHANGELOG.md | 22 ++++ MODULAR.md | 11 +- VERSION | 2 +- services/caddy.sh | 218 +++++++++++++++++++++++++++++++++++++++ services/crowdsec.sh | 221 +++++++++++++++++++++++++++++++++++++++ services/filebrowser.sh | 2 +- services/ntfy.sh | 2 +- services/portainer.sh | 2 +- services/uptimekuma.sh | 2 +- services/watchtower.sh | 2 +- setup.sh | 222 +++++++++++++++++++++++++++------------- 11 files changed, 624 insertions(+), 82 deletions(-) create mode 100644 services/caddy.sh create mode 100644 services/crowdsec.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 9447615..8c304ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to this project. Versions follow `MAJOR.MINOR.PATCH`. The project is pre-1.0 while the modular system reaches parity with the monolithic `ubuntu-post-install-*.sh` scripts. +## [0.9.7] - 2026-06-03 + +### Added +- `services/caddy.sh` *(homelab)* — Caddy reverse proxy + automatic HTTPS, own + `~/docker/caddy/` folder (compose + starter Caddyfile + README). Services add + their site blocks to its Caddyfile. +- `services/crowdsec.sh` *(homelab)* — system-level intrusion prevention + (agent + firewall bouncer + Caddy log acquisition + optional ntfy ban alerts); + README in `~/docker/crowdsec/`. +- **Guided menu redesign** in `setup.sh`: + - Prints the **required** set (essential packages incl. glow + a Docker check) + up front and lets you **cancel** before anything changes. + - Offers **Caddy first** (most services depend on it). + - **Category menu loop**: pick a category → checklist (already-installed shown + as `[installed]`) → install → back to the menu for the next category, until + you choose Done. whiptail UI with a plain-text fallback. + +### Changed +- **Categories** reorganized: `base · homelab · utilities · media · cameras · + gaming · backup`. Moved ntfy, filebrowser, portainer, uptimekuma, watchtower + to `utilities`. Within `homelab`, Caddy → CrowdSec → Authelia sort first. + ## [0.9.6] - 2026-06-03 ### Added diff --git a/MODULAR.md b/MODULAR.md index e01b166..2ad5719 100644 --- a/MODULAR.md +++ b/MODULAR.md @@ -72,10 +72,13 @@ single shared compose file. ## Groups -`base` · `homelab` · `gaming` · `backup`. The menu and `--list` are grouped by -these. The **gaming** group (Wolf, js99er, Minecraft) makes this script a -sensible base for either a homelab box or a gaming box — install only what that -machine needs. +`base` · `homelab` · `utilities` · `media` · `cameras` · `gaming` · `backup`. +The guided menu (`sudo ./setup.sh`) shows the **required** packages first with a +cancel option, then offers **Caddy** (most services proxy through it), then a +**category menu you loop through** — pick a category, tick services (already +installed ones are marked `[installed]`), install, and you land back on the menu +to pick the next category. Within `homelab`, Caddy → CrowdSec → Authelia are +ordered first. ## Migration status diff --git a/VERSION b/VERSION index 85b7c69..c81aa44 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.6 +0.9.7 diff --git a/services/caddy.sh b/services/caddy.sh new file mode 100644 index 0000000..2adeb96 --- /dev/null +++ b/services/caddy.sh @@ -0,0 +1,218 @@ +#!/bin/bash +# services/caddy.sh — Caddy reverse proxy + automatic HTTPS. +# Part of the modular post-install system (sourced by setup.sh). +# +# Caddy is the front door for the homelab: it terminates TLS (automatic +# Let's Encrypt certificates), reverse-proxies to your other services, and +# writes JSON access logs that CrowdSec reads for intrusion prevention. +# +# Each web service adds its own site block to $CADDY_DIR/Caddyfile (the shared +# configure_caddy_for_service helper does this automatically), then Caddy is +# reloaded without downtime. + +register_service caddy homelab "Reverse proxy + automatic HTTPS (Caddy)" 443 + +install_caddy() { + require_docker || return 1 + log_info "Installing Caddy reverse proxy..." + + local CADDY_DIR="$DOCKER_DIR/caddy" + + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ CADDY - Modern Web Server & Reverse Proxy │" + echo "│ Automatic HTTPS, reverse proxy for all your services │" + echo "│ Port: 80 (HTTP), 443 (HTTPS) │" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + + # ── DRY-RUN: describe the plan and bail before touching anything real ──── + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $CADDY_DIR (data/, config/)" + echo "[DRY-RUN] Would write $CADDY_DIR/docker-compose.yml (ports 80/443 + HTTP/3)" + echo "[DRY-RUN] Would write a starter $CADDY_DIR/Caddyfile (if none exists)" + echo "[DRY-RUN] Would write $CADDY_DIR/README.md" + echo "[DRY-RUN] Would optionally start Caddy (docker compose up -d)" + return 0 + fi + + # Reconfigure guard: warn if Caddy already looks installed. + if [ -f "$CADDY_DIR/Caddyfile" ] || [ -f "$CADDY_DIR/docker-compose.yml" ]; then + echo "" + echo "⚠ Caddy appears to be already installed at $CADDY_DIR" + local RECONFIGURE_CADDY="" + prompt_yn "Do you want to reconfigure it? (y/n):" "n" RECONFIGURE_CADDY + if [ "$RECONFIGURE_CADDY" != "y" ] && [ "$RECONFIGURE_CADDY" != "Y" ]; then + echo " Skipping Caddy installation" + return 0 + fi + fi + + mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config" + ensure_docker_dir_ownership "$CADDY_DIR" + + # Backup existing Caddyfile if it exists + if [ -f "$CADDY_DIR/Caddyfile" ]; then + mkdir -p "$CADDY_DIR/backups" + local BACKUP_FILE="$CADDY_DIR/backups/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)" + cp "$CADDY_DIR/Caddyfile" "$BACKUP_FILE" + echo " ✓ Backed up existing Caddyfile to: $BACKUP_FILE" + fi + + cd "$CADDY_DIR" || return 1 + + cat > docker-compose.yml << 'CADDY_COMPOSE' +name: caddy + +services: + caddy: + image: caddy:latest + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - ./data:/data + - ./config:/config + - /var/log/caddy:/var/log/caddy + environment: + - ACME_AGREE=true + labels: + - "io.podman.annotations.label/crowdsec.enable=true" +CADDY_COMPOSE + + # Create Caddyfile if it doesn't exist + if [ ! -f "Caddyfile" ]; then + cat > Caddyfile << 'CADDYFILE' +{ + # Global options + admin off + # Email for Let's Encrypt notifications + # email admin@yourdomain.com +} + +# Example configuration - edit this for your services +# Uncomment and modify these examples: + +# ── Authelia SSO snippet (auto-added by installer if Authelia is installed) ─── +# (authelia) { +# forward_auth authelia:9091 { +# uri /api/authz/forward-auth +# copy_headers Remote-User Remote-Groups Remote-Name Remote-Email +# } +# } +# +# Authelia login portal +# auth.yourdomain.com { +# reverse_proxy authelia:9091 +# } +# +# To protect any service with Authelia, add: import authelia +# Example: +# myservice.yourdomain.com { +# import authelia +# reverse_proxy localhost:PORT +# } + +# ActualBudget +# budget.yourdomain.com { +# log { +# output file /var/log/caddy/actualbudget-access.log +# format json +# level INFO +# } +# reverse_proxy localhost:5006 +# header { +# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" +# X-Frame-Options "SAMEORIGIN" +# X-Content-Type-Options "nosniff" +# X-XSS-Protection "1; mode=block" +# Referrer-Policy "strict-origin-when-cross-origin" +# } +# } + +# Add more services here... +CADDYFILE + echo " ✓ Created example Caddyfile" + else + echo " ℹ Using existing Caddyfile" + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CADDY_DIR" + echo " ✓ Caddy configured at $CADDY_DIR" + + write_readme "$CADDY_DIR" << 'CADDY_README' +# Caddy — reverse proxy + automatic HTTPS + +Caddy is the front door for this box. It: + +- Reverse-proxies incoming requests to your other services. +- Obtains and renews TLS certificates automatically (Let's Encrypt / ZeroSSL), + so every site is HTTPS with no manual cert wrangling. +- Listens on **80** (HTTP, redirects to HTTPS) and **443** (HTTPS, incl. HTTP/3 + on 443/udp). +- Writes JSON access logs to `/var/log/caddy/` — these are what CrowdSec reads + to detect and ban malicious traffic. + +## Adding services + +Other services add their own **site blocks** to `./Caddyfile` (the installer's +`configure_caddy_for_service` helper appends them automatically when you install +a web service). You can also edit it by hand: + +``` +myservice.example.com { + reverse_proxy localhost:1234 + log { + output file /var/log/caddy/myservice.example.com.log + format json + } +} +``` + +## Reloading after edits + +Apply Caddyfile changes without downtime: + +``` +docker exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +## Start / stop + +From this folder (`~/docker/caddy`): + +``` +docker compose up -d # start +docker compose down # stop +docker compose logs -f # follow logs +``` + +## Where things live + +- Caddyfile: `~/docker/caddy/Caddyfile` (mounted at `/etc/caddy/Caddyfile`) +- Access logs: `/var/log/caddy/*.log` (JSON; consumed by CrowdSec) +- Certs/state: `~/docker/caddy/data` and `~/docker/caddy/config` +- Backups of the Caddyfile: `~/docker/caddy/backups/` +CADDY_README + + local START_CADDY="" + prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY + if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then + docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start Caddy" + fi + + echo "" + echo " Configuration file: $CADDY_DIR/Caddyfile" + echo " Edit Caddyfile to add your domains and services" + echo " Reload config: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + echo "" + echo " ⚠ IMPORTANT: Edit the Caddyfile to configure your domains!" + echo " - Uncomment and modify the example configurations" + echo " - Add your domain names" + echo " - Configure services you want to expose" + echo "" +} diff --git a/services/crowdsec.sh b/services/crowdsec.sh new file mode 100644 index 0000000..f4b384d --- /dev/null +++ b/services/crowdsec.sh @@ -0,0 +1,221 @@ +#!/bin/bash +# services/crowdsec.sh — CrowdSec intrusion prevention (fail2ban successor). +# Part of the modular post-install system (sourced by setup.sh). +# +# CrowdSec is a SYSTEM install (apt repo + agent), NOT a docker-compose service: +# • Installs the CrowdSec agent and the iptables firewall bouncer (enforces bans). +# • Installs detection collections for SSH, Linux, Caddy and base HTTP scenarios. +# • Reads Caddy's JSON access logs (/var/log/caddy/*.log) to spot attacks. +# • Optionally pushes ban alerts to an ntfy topic. +# • Adds community IP reputation + optional geo-enrichment on top. +# +# There is no ~/docker/crowdsec compose; we only create a docs-only folder there +# with a README pointing at the real config under /etc/crowdsec. + +register_service crowdsec homelab "Intrusion prevention: bans + geo + IP reputation (CrowdSec)" + +install_crowdsec() { + log_info "Installing CrowdSec intrusion prevention..." + + local DOCS_DIR="$DOCKER_DIR/crowdsec" + + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ CROWDSEC - Intrusion Prevention (fail2ban successor) │" + echo "│ Bans malicious IPs + geo-blocking + community IP reputation │" + echo "│ Protects SSH, Caddy, and other services │" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + + # ── DRY-RUN: describe the plan and bail before touching anything real ──── + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would install the CrowdSec agent (curl https://install.crowdsec.net | sh; apt install crowdsec)" + echo "[DRY-RUN] Would install the firewall bouncer (crowdsec-firewall-bouncer-iptables)" + echo "[DRY-RUN] Would ensure /var/log/caddy exists for log acquisition" + echo "[DRY-RUN] Would install collections: sshd, linux, caddy, base-http-scenarios" + echo "[DRY-RUN] Would write Caddy acquisition /etc/crowdsec/acquis.d/caddy.yaml" + echo "[DRY-RUN] Would optionally wire ntfy ban alerts into the default profile" + echo "[DRY-RUN] Would enable + restart crowdsec and crowdsec-firewall-bouncer" + echo "[DRY-RUN] Would write $DOCS_DIR/README.md (docs-only folder)" + return 0 + fi + + # ── 1. Install the CrowdSec agent ──────────────────────────────────────── + if command -v cscli &> /dev/null; then + echo " ✓ CrowdSec is already installed" + else + echo " Adding CrowdSec repository and installing agent..." + if curl -s https://install.crowdsec.net | sudo sh && sudo apt install -y crowdsec; then + echo " ✓ CrowdSec installed successfully" + else + echo " ⚠ Failed to install CrowdSec" + echo " See https://docs.crowdsec.net/ for manual installation" + fi + fi + + # ── 2. Firewall bouncer (enforces bans via iptables/nftables) ──────────── + echo " Installing firewall bouncer..." + sudo apt install -y crowdsec-firewall-bouncer-iptables 2>/dev/null || \ + echo " ⚠ Could not install firewall bouncer automatically" + + # ── 3. Create log directory for Caddy ──────────────────────────────────── + if [ ! -d "/var/log/caddy" ]; then + sudo mkdir -p /var/log/caddy + sudo chmod 755 /var/log/caddy + echo " ✓ Created /var/log/caddy directory" + fi + + # ── 4. Detection collections: SSH, Caddy HTTP scenarios, base http ─────── + echo " Installing CrowdSec collections (sshd, caddy, base-http)..." + sudo cscli collections install crowdsecurity/sshd crowdsecurity/linux crowdsecurity/caddy crowdsecurity/base-http-scenarios 2>/dev/null || \ + echo " ⚠ Some collections may already be installed" + + # ── 5. Tell CrowdSec to read Caddy's JSON access logs ──────────────────── + local ACQUIS_FILE="/etc/crowdsec/acquis.d/caddy.yaml" + if [ ! -f "$ACQUIS_FILE" ]; then + echo " Creating Caddy log acquisition for CrowdSec..." + sudo mkdir -p /etc/crowdsec/acquis.d + local ACQUIS_CONTENT='filenames: + - /var/log/caddy/*.log + - /var/log/caddy/*-access.log +labels: + type: caddy' + if echo "$ACQUIS_CONTENT" | sudo tee "$ACQUIS_FILE" > /dev/null; then + echo " ✓ Created Caddy acquisition ($ACQUIS_FILE)" + else + echo " ⚠ Failed to create acquisition - create it manually" + fi + else + echo " ✓ Caddy acquisition already exists" + fi + + # ── 6. Geo-blocking + reputation (the capability fail2ban/Authelia lack) ─ + echo "" + echo " Geo-blocking & IP reputation (optional):" + echo " Enrich events with country/ASN data:" + echo " sudo cscli collections install crowdsecurity/geoip-enrich" + echo " Subscribe to community/3rd-party blocklists at:" + echo " https://app.crowdsec.net/" + + # ── 7. Optional: push ban alerts to ntfy ───────────────────────────────── + local CS_NTFY="" + prompt_yn "Send CrowdSec ban alerts to an ntfy topic? (y/n):" "n" CS_NTFY + if [ "$CS_NTFY" = "y" ] || [ "$CS_NTFY" = "Y" ]; then + local CS_NTFY_URL="" + prompt_text " ntfy topic URL (e.g. https://ntfy.sh/my-crowdsec):" "https://ntfy.sh/crowdsec-alerts" CS_NTFY_URL + sudo mkdir -p /etc/crowdsec/notifications + local NTFY_FILE="/etc/crowdsec/notifications/ntfy.yaml" + local NTFY_CONTENT="type: http +name: ntfy +log_level: info +format: | + {{range . -}} + {{range .Decisions -}} + {{.Value}} banned: {{.Scenario}} for {{.Duration}} + {{end -}} + {{end -}} +url: $CS_NTFY_URL +method: POST +headers: + Title: CrowdSec ban + Priority: high + Tags: rotating_light" + if echo "$NTFY_CONTENT" | sudo tee "$NTFY_FILE" > /dev/null; then + echo " ✓ Created ntfy notification ($NTFY_FILE)" + # Wire the notification into the default profile (only once) + if ! grep -qE "^\s*- ntfy" /etc/crowdsec/profiles.yaml 2>/dev/null; then + sudo awk '1; /^on_success:/ && !d {print "notifications:"; print " - ntfy"; d=1}' \ + /etc/crowdsec/profiles.yaml | sudo tee /etc/crowdsec/profiles.yaml.new > /dev/null \ + && sudo mv /etc/crowdsec/profiles.yaml.new /etc/crowdsec/profiles.yaml + echo " ✓ Enabled ntfy alerts in CrowdSec default profile" + else + echo " ✓ ntfy already referenced in CrowdSec profile" + fi + echo " ℹ Alerts fire when an IP is banned (after repeated failed attempts)," + echo " not on every individual failed login." + else + echo " ⚠ Failed to write ntfy notification config" + fi + fi + + # ── 8. Restart services to apply ───────────────────────────────────────── + local RESTART_CS="" + prompt_yn "Restart CrowdSec to apply changes? (y/n):" "y" RESTART_CS + if [ "$RESTART_CS" = "y" ] || [ "$RESTART_CS" = "Y" ]; then + sudo systemctl enable crowdsec 2>/dev/null || true + if sudo systemctl restart crowdsec; then + echo " ✓ CrowdSec restarted successfully" + sudo systemctl enable crowdsec-firewall-bouncer 2>/dev/null || true + sudo systemctl restart crowdsec-firewall-bouncer 2>/dev/null || true + sleep 2 + sudo cscli metrics 2>/dev/null | head -20 || true + else + echo " ⚠ Failed to restart CrowdSec" + echo " Check logs: sudo journalctl -u crowdsec -n 50" + fi + fi + + # ── 9. Docs-only folder under ~/docker for discoverability ─────────────── + write_readme "$DOCS_DIR" << 'CROWDSEC_README' +# CrowdSec — intrusion prevention + +CrowdSec is a **system service** (installed via apt), not a Docker container, so +there is no `docker-compose.yml` in this folder — it exists only to document the +install. The real configuration lives under `/etc/crowdsec`. + +## What it does + +- Detects malicious behaviour (SSH brute force, web scans, etc.) by parsing logs. +- Bans offending IPs via the **firewall bouncer** (iptables/nftables). +- Pulls **community IP reputation** blocklists so known-bad IPs are blocked + before they ever touch your services. +- Optionally enriches events with **geo/ASN** data for geo-blocking. + +## Key commands + +``` +sudo cscli metrics # parsers/scenarios/acquisition health +sudo cscli decisions list # currently banned IPs +sudo cscli decisions delete --ip # unban an IP +sudo cscli decisions add --ip # manually ban an IP +sudo cscli alerts list # recent alerts +sudo cscli collections list # installed detection collections +``` + +## Where configs live + +- Log acquisition (what to watch): `/etc/crowdsec/acquis.d/` + - Caddy access logs: `/etc/crowdsec/acquis.d/caddy.yaml` + (`/var/log/caddy/*.log` — Caddy writes JSON access logs there) +- Notifications: `/etc/crowdsec/notifications/` + - ntfy ban alerts (if enabled): `/etc/crowdsec/notifications/ntfy.yaml`, + wired into `/etc/crowdsec/profiles.yaml` +- Bouncer config: `/etc/crowdsec/bouncers/` + +## Geo + reputation notes + +- Geo-enrichment (country/ASN tagging) is optional: + `sudo cscli collections install crowdsecurity/geoip-enrich` +- Subscribe to community / 3rd-party blocklists at https://app.crowdsec.net/ +- ntfy alerts fire when an IP is **banned** (after repeated failed attempts), + not on every individual failed login. + +## Service control + +``` +sudo systemctl status crowdsec +sudo systemctl restart crowdsec +sudo systemctl status crowdsec-firewall-bouncer +sudo journalctl -u crowdsec -n 50 +``` +CROWDSEC_README + + echo "" + echo " Useful commands:" + echo " List active bans: sudo cscli decisions list" + echo " List alerts: sudo cscli alerts list" + echo " Manually ban IP: sudo cscli decisions add --ip 1.2.3.4" + echo " Unban IP: sudo cscli decisions delete --ip 1.2.3.4" + echo " Show metrics: sudo cscli metrics" + echo "" +} diff --git a/services/filebrowser.sh b/services/filebrowser.sh index 0145e55..0de8c4a 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -2,7 +2,7 @@ # services/filebrowser.sh — FileBrowser web-based file manager. # Part of the modular post-install system (sourced by setup.sh). -register_service filebrowser homelab "Web file manager (FileBrowser)" 8085 +register_service filebrowser utilities "Web file manager (FileBrowser)" 8085 install_filebrowser() { require_docker || return 1 diff --git a/services/ntfy.sh b/services/ntfy.sh index a020528..e5ecd46 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -2,7 +2,7 @@ # services/ntfy.sh — ntfy self-hosted push notification server. # Part of the modular post-install system (sourced by setup.sh). -register_service ntfy homelab "Self-hosted push notifications (ntfy)" 8090 +register_service ntfy utilities "Self-hosted push notifications (ntfy)" 8090 install_ntfy() { require_docker || return 1 diff --git a/services/portainer.sh b/services/portainer.sh index 7d11603..0ad880e 100644 --- a/services/portainer.sh +++ b/services/portainer.sh @@ -2,7 +2,7 @@ # services/portainer.sh — Portainer Docker management web UI. # Part of the modular post-install system (sourced by setup.sh). -register_service portainer homelab "Docker management UI (Portainer)" 9443 +register_service portainer utilities "Docker management UI (Portainer)" 9443 install_portainer() { require_docker || return 1 diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index 013ab6c..3264a3a 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -2,7 +2,7 @@ # services/uptimekuma.sh — Uptime Kuma uptime/status monitoring. # Part of the modular post-install system (sourced by setup.sh). -register_service uptimekuma homelab "Uptime/status monitoring (Uptime Kuma)" 3001 +register_service uptimekuma utilities "Uptime/status monitoring (Uptime Kuma)" 3001 install_uptimekuma() { require_docker || return 1 diff --git a/services/watchtower.sh b/services/watchtower.sh index d82e247..a0e0f11 100644 --- a/services/watchtower.sh +++ b/services/watchtower.sh @@ -2,7 +2,7 @@ # services/watchtower.sh — Watchtower automatic container update monitoring. # Part of the modular post-install system (sourced by setup.sh). -register_service watchtower homelab "Automatic container updates (Watchtower)" +register_service watchtower utilities "Automatic container updates (Watchtower)" install_watchtower() { require_docker || return 1 diff --git a/setup.sh b/setup.sh index aebb0bc..1ce817d 100755 --- a/setup.sh +++ b/setup.sh @@ -1,26 +1,31 @@ #!/bin/bash # setup.sh — modular post-install dispatcher. # -# One source of truth, two ways to run it: -# sudo ./setup.sh interactive menu (pick any services) +# One source of truth, multiple ways to run it: +# sudo ./setup.sh guided install: required packages, then a +# category menu you loop through # sudo ./setup.sh ... install one or more services directly # ./setup.sh --list list available services (grouped) +# ./setup.sh --version print version # # Flags: # --dry-run preview actions without making changes -# --unattended use defaults, no prompts +# --unattended use defaults, no prompts (pair with explicit service names) # -# Every service lives in services/.sh, registers itself, and defines -# install_. Adding a service = adding one file. Updating a service = -# editing one file. Nothing is duplicated or generated. +# Every service lives in services/.sh, registers itself with +# register_service, and defines install_. Adding a service = adding one +# file; it appears in the menu automatically. Nothing is generated. set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# ── Parse global flags, collect service names ──────────────────────────────── -DRY_RUN=false -UNATTENDED=false -DO_LIST=false +# Category display order (groups not listed here are appended alphabetically). +CATEGORY_ORDER=(base homelab utilities media cameras gaming backup) +# Service ordering hint within a category (lower = earlier). Default 50. +declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 ) + +# ── Parse flags / collect service names ────────────────────────────────────── +DRY_RUN=false; UNATTENDED=false; DO_LIST=false REQUESTED=() for arg in "$@"; do case "$arg" in @@ -28,9 +33,7 @@ for arg in "$@"; do --unattended) UNATTENDED=true ;; --list|-l) DO_LIST=true ;; --version|-V) cat "$HERE/VERSION" 2>/dev/null || echo "unknown"; exit 0 ;; - -h|--help) - sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' - exit 0 ;; + -h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; -*) echo "Unknown flag: $arg" >&2; exit 1 ;; *) REQUESTED+=("$arg") ;; esac @@ -40,90 +43,165 @@ export DRY_RUN UNATTENDED # ── Load helpers + all service modules (they self-register) ────────────────── # shellcheck source=lib/common.sh source "$HERE/lib/common.sh" - shopt -s nullglob -for _mod in "$HERE"/services/*.sh; do - # shellcheck source=/dev/null - source "$_mod" -done +for _mod in "$HERE"/services/*.sh; do source "$_mod"; done shopt -u nullglob -# Ordered list of unique groups, in first-seen order. -groups_in_order() { - local seen=" " g +# ── Helpers over the registry ──────────────────────────────────────────────── +# Groups present, in CATEGORY_ORDER first, then any extras alphabetically. +groups_present() { + local g present=() seen=" " for name in "${SERVICE_ORDER[@]}"; do g="${SERVICE_GROUP[$name]}" - case "$seen" in *" $g "*) : ;; *) echo "$g"; seen="$seen$g " ;; esac + case "$seen" in *" $g "*) : ;; *) present+=("$g"); seen="$seen$g " ;; esac done + local out=() + for g in "${CATEGORY_ORDER[@]}"; do + printf '%s\n' "${present[@]}" | grep -qx "$g" && out+=("$g") + done + for g in "${present[@]}"; do + printf '%s\n' "${CATEGORY_ORDER[@]}" | grep -qx "$g" || out+=("$g") + done + printf '%s\n' "${out[@]}" +} + +# Services in a group, ordered by SERVICE_PRIORITY then name. +services_in_group() { + local group="$1" name + for name in "${SERVICE_ORDER[@]}"; do + [ "${SERVICE_GROUP[$name]}" = "$group" ] && echo "${SERVICE_PRIORITY[$name]:-50} $name" + done | sort -n -k1 | awk '{print $2}' +} + +# Best-effort "is it already installed?" for the [installed] marker. +is_installed() { + case "$1" in + base) command -v ncdu >/dev/null 2>&1 ;; + glow) command -v glow >/dev/null 2>&1 ;; + crowdsec) command -v cscli >/dev/null 2>&1 ;; + *) [ -e "$DOCKER_DIR/$1" ] ;; + esac +} + +run_service() { + local name="$1" + if [ -z "${SERVICE_GROUP[$name]:-}" ]; then log_error "Unknown service: $name (try --list)"; return 1; fi + declare -F "install_${name}" >/dev/null || { log_error "Service '$name' has no install_${name}"; return 1; } + log_info "=== ${name} (${SERVICE_DESC[$name]}) ===" + "install_${name}" } list_services() { local g name while IFS= read -r g; do - echo "" - echo "── ${g^^} ──" - for name in "${SERVICE_ORDER[@]}"; do - [ "${SERVICE_GROUP[$name]}" = "$g" ] || continue + echo ""; echo "── ${g^^} ──" + while IFS= read -r name; do printf " %-16s %s\n" "$name" "${SERVICE_DESC[$name]}" - done - done < <(groups_in_order) + done < <(services_in_group "$g") + done < <(groups_present) echo "" } -run_service() { - local name="$1" - if [ -z "${SERVICE_GROUP[$name]:-}" ]; then - log_error "Unknown service: $name (try: $0 --list)" - return 1 - fi - if ! declare -F "install_${name}" >/dev/null; then - log_error "Service '$name' has no install_${name} function." - return 1 - fi - log_info "=== ${name} (${SERVICE_DESC[$name]}) ===" - "install_${name}" -} - # ── --list ─────────────────────────────────────────────────────────────────── -if [ "$DO_LIST" = true ]; then - list_services - exit 0 -fi +if [ "$DO_LIST" = true ]; then list_services; exit 0; fi -# ── Direct service install: ./setup.sh minecraft homeassistant ───────────── +# ── Direct install: ./setup.sh caddy homeassistant ────────────────────────── if [ "${#REQUESTED[@]}" -gt 0 ]; then require_root - rc=0 - for name in "${REQUESTED[@]}"; do - run_service "$name" || rc=1 - done + rc=0; for name in "${REQUESTED[@]}"; do run_service "$name" || rc=1; done exit "$rc" fi -# ── Interactive menu ───────────────────────────────────────────────────────── +# ── Guided interactive flow ────────────────────────────────────────────────── require_root -SELECTED=() -if command -v whiptail >/dev/null 2>&1; then - _items=() - for name in "${SERVICE_ORDER[@]}"; do - _items+=("$name" "${SERVICE_DESC[$name]}" "OFF") - done - _choice=$(whiptail --title "Ubuntu Post-Install — Services" \ - --checklist "Select services to install (space to toggle):" 25 78 16 \ - "${_items[@]}" 3>&1 1>&2 2>&3) || { echo "Cancelled."; exit 0; } - # whiptail returns space-separated, quoted names - eval "SELECTED=($_choice)" -else - echo "Available services:" - list_services - read -rp "Enter service names to install (space-separated): " -a SELECTED +# 1) Show the REQUIRED set and let the user cancel before anything happens. +echo "" +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ Ubuntu Post-Install · v$(cat "$HERE/VERSION" 2>/dev/null || echo '?')" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" +echo "REQUIRED (installed/verified first):" +echo " • Essential CLI packages: net-tools, git, curl, wget, htop, tree," +echo " ncdu, zip/unzip, jq, rsync, and glow (markdown reader)" +echo " • Docker presence check (needed by all containerized services)" +echo "" +echo "Then you'll get a category menu to pick optional services." +echo "" +PROCEED="" +prompt_yn "Proceed with the required setup? (y/n):" "y" PROCEED +if [ "$PROCEED" != "y" ] && [ "$PROCEED" != "Y" ]; then + echo "Cancelled. Nothing was changed." + exit 0 fi -[ "${#SELECTED[@]}" -eq 0 ] && { echo "Nothing selected."; exit 0; } +# 2) Run required. +run_service base +if ! command -v docker >/dev/null 2>&1; then + log_warning "Docker is not installed. Containerized services need it." + echo " Install with: curl -fsSL https://get.docker.com | sh" +fi -rc=0 -for name in "${SELECTED[@]}"; do - run_service "$name" || rc=1 +# 3) Offer Caddy first (most services proxy through it). +if [ -n "${SERVICE_GROUP[caddy]:-}" ] && ! is_installed caddy; then + echo "" + OFFER_CADDY="" + prompt_yn "Install Caddy now? It's the reverse proxy most services use. (y/n):" "y" OFFER_CADDY + [ "$OFFER_CADDY" = "y" ] || [ "$OFFER_CADDY" = "Y" ] && run_service caddy +fi + +# 4) Category menu loop: pick a category → checklist → install → back to menu. +have_whiptail=false +command -v whiptail >/dev/null 2>&1 && have_whiptail=true + +while true; do + mapfile -t CATS < <(groups_present) + + if [ "$have_whiptail" = true ]; then + cat_items=() + for g in "${CATS[@]}"; do + n=$(services_in_group "$g" | wc -l) + cat_items+=("$g" "$n service(s)") + done + cat_items+=("DONE" "Finish and exit") + CHOSEN_CAT=$(whiptail --title "Service Categories" --menu \ + "Pick a category (services you install come back here):" 22 70 14 \ + "${cat_items[@]}" 3>&1 1>&2 2>&3) || break + else + echo ""; echo "Categories:"; i=1 + for g in "${CATS[@]}"; do echo " $i) $g"; i=$((i+1)); done + echo " d) Done" + read -rp "Pick a category [d]: " pick + [ "$pick" = "d" ] || [ -z "$pick" ] && break + CHOSEN_CAT="${CATS[$((pick-1))]:-}" + [ -z "$CHOSEN_CAT" ] && { echo "Invalid."; continue; } + fi + [ "$CHOSEN_CAT" = "DONE" ] && break + + mapfile -t SVCS < <(services_in_group "$CHOSEN_CAT") + SELECTED=() + if [ "$have_whiptail" = true ]; then + svc_items=() + for name in "${SVCS[@]}"; do + tag="${SERVICE_DESC[$name]}" + is_installed "$name" && tag="$tag [installed]" + svc_items+=("$name" "$tag" "OFF") + done + CHOICE=$(whiptail --title "${CHOSEN_CAT^^}" --checklist \ + "Space to select, Enter to install. Already-installed are marked:" 22 78 14 \ + "${svc_items[@]}" 3>&1 1>&2 2>&3) || continue + eval "SELECTED=($CHOICE)" + else + echo ""; echo "${CHOSEN_CAT^^}:" + for name in "${SVCS[@]}"; do + m=""; is_installed "$name" && m=" [installed]" + printf " %-16s %s%s\n" "$name" "${SERVICE_DESC[$name]}" "$m" + done + read -rp "Enter service names to install (space-separated, blank to go back): " -a SELECTED + fi + + for name in "${SELECTED[@]}"; do run_service "$name"; done done -exit "$rc" + +echo "" +log_success "Done. Re-run 'sudo ./setup.sh' any time to add more." From b619733a616d17b693893166c97df5652c715d6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 17:35:50 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Add=20HANDOFF.md=20=E2=80=94=20migration=20?= =?UTF-8?q?status,=20taxonomy,=20wolf-pair=20open=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status snapshot at v0.9.7 for continuing in a new session: module status by category, final taxonomy, the module contract/conventions, verification steps, and the open wolf-pair item (awaiting server.py + Dockerfile upload). https://claude.ai/code/session_017eA2qqq9jfF2tNtpUYL8vK --- HANDOFF.md | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..a1cf2d2 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,112 @@ +# HANDOFF — modular migration status + +**Version:** 0.9.7 · **Branch:** `claude/happy-volta-RPhbD` +**Read also:** `CHANGELOG.md` (per-version detail), `MODULAR.md` (architecture). + +## Where we are + +We're migrating a giant monolithic installer into a **modular system**: +- `setup.sh` — the one dispatcher (menu + run-one). `lib/common.sh` — shared + helpers + the service registry. `services/.sh` — one file per service. +- Run all: `sudo ./setup.sh` (required gate → Caddy offer → category menu loop). + Run one: `sudo ./setup.sh `. List: `./setup.sh --list`. `--version`. +- Every service installs to its own `~/docker//` with its own + `docker-compose.yml` **and a generated `README.md`** (via `write_readme`). +- Nothing is generated/duplicated: a service = one committed file in `services/`. + +The three monolith tiers still exist, frozen as history: +`ubuntu-post-install-{24.04,26.04}.sh` (original, w/ Keycloak), +`*-no-keycloak.sh`, `*-crowdsec.sh` (current "install everything" + glow). +The `-crowdsec.sh` tier is the migration source of truth. + +## Module status + +**Done (16 modules in `services/`):** +| Group | Modules | +|-------|---------| +| base | base, glow | +| homelab | caddy, crowdsec, authelia, homeassistant | +| utilities | filebrowser, ntfy, portainer, uptimekuma, watchtower | +| gaming | wolf, minecraft, js99er | +| backup | backup | + +**Pending — migrate from `ubuntu-post-install-24.04-crowdsec.sh` (find by `# ---- NAME ----`):** +| Target group | Services to migrate | +|------|---------| +| media | jellyfin, emby, audiobookshelf, immich, arm, lyrion | +| cameras | frigate, frigate-notify | +| utilities | actualbudget, mealie, traccar, findmydevice, magicmirror, wg-easy, ddclient | +| (misc) | meshcentral (remote-mgmt server) | + +Also still monolith-only (Phase-1 / system, not yet modularized): SSH config, +Docker install, Samba, VPNs (Tailscale/NetBird/WireGuard), RustDesk, TeamViewer, +MeshCentral agent, UFW. Decide later whether these become `required`/system +modules. + +## Final taxonomy (categories) + +`base` (required) · `homelab` · `utilities` · `media` · `cameras` · `gaming` · +`backup`. Menu order is set in `setup.sh:CATEGORY_ORDER`. Within `homelab`, +`SERVICE_PRIORITY` puts caddy → crowdsec → authelia first. `media`/`cameras` +won't appear in the menu until they have ≥1 module (no empty categories). + +Note: filebrowser/portainer/uptimekuma/watchtower were placed in `utilities` +(weren't in the original taxonomy list) — move if desired by editing their +`register_service ... ...` line. + +## OPEN ITEM — wolf-pair (action needed from you) + +`wolf-pair` (the FQDN device-pairing page for Wolf/Moonlight) was **dropped** +when Wolf was ported, because its source wasn't available. You said you worked +hard on it and will **upload `wolf-pair/server.py` + `wolf-pair/Dockerfile` +(and anything else it needs) in the next chat.** + +To re-add it: create `services/wolf-pair.sh` (group `gaming`) that builds the +wolf-pair container in `~/docker/wolf-pair/`, wires it to reach Wolf, opens its +port, and offers a Caddy block for the pairing FQDN. Wolf's `manage.sh pin` is +the current stopgap. + +## Module contract (for consistency when adding/migrating) + +```bash +#!/bin/bash +register_service "Description" [port] # one line → appears in menu +install_() { + require_docker || return 1 # docker services only + local DIR="$DOCKER_DIR/" + [ "$DRY_RUN" = true ] && { echo "[DRY-RUN] Would create $DIR ..."; return 0; } + mkdir -p "$DIR"; ensure_docker_dir_ownership "$DIR"; cd "$DIR" || return 1 + cat > docker-compose.yml << 'YAML' + ... +YAML + configure_caddy_for_service "Name" "PORT" "subdomain" # optional + write_readme "$DIR" <.sh`, `./setup.sh --list`, +`sudo ./setup.sh --dry-run --unattended ` (must exit 0). + +## Workflow reminders +- Per-version: bump `VERSION`, add a `CHANGELOG.md` entry, commit, push to + `claude/happy-volta-RPhbD`. +- Your loop: build nice standalone `setup-*.sh` elsewhere → upload here → it gets + "massaged" into a `services/.sh` module (wrap in `install_`, use shared + helpers, per-folder + README, register). + +## Suggested next steps +1. Add `wolf-pair` once you upload its files. +2. Migrate **media** batch (jellyfin, emby, audiobookshelf, immich, arm, lyrion) → v0.9.8. +3. Migrate **cameras** (frigate, frigate-notify) → v0.9.9. +4. Migrate remaining **utilities** (actualbudget, mealie, traccar, findmydevice, + magicmirror, wg-easy, ddclient). +5. Decide how Phase-1/system items (VPNs, Samba, remote-access) fit (required vs + their own category).