diff --git a/README.md b/README.md index e2e003e..89b22c7 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Update them any time with `sudo ./setup.sh configure`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` | -| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `magicmirror`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `traccar`, `uptimekuma`, `watchtower`, `wg-easy` | +| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `rustdesk`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/lib/common.sh b/lib/common.sh index c8bdb4f..45d1f40 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -261,9 +261,20 @@ write_readme() { } # ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── -# Usage: configure_caddy_for_service "Name" "PORT" "default-subdomain" ["extra"] +# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"] +# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"), +# or plain port number for localhost fallback (e.g. "8085"). configure_caddy_for_service() { - local SERVICE_NAME="$1" SERVICE_PORT="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" + local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" + + # Derive the proxy upstream and a port number for display messages. + # Plain number → localhost:PORT (host-network or legacy services) + # name:port → used as-is (preferred: service on shared caddy_net) + local _UPSTREAM _DISPLAY_PORT + case "$SERVICE_UPSTREAM" in + *:*) _UPSTREAM="$SERVICE_UPSTREAM"; _DISPLAY_PORT="${SERVICE_UPSTREAM##*:}" ;; + *) _UPSTREAM="localhost:$SERVICE_UPSTREAM"; _DISPLAY_PORT="$SERVICE_UPSTREAM" ;; + esac # Caddy not installed → nothing to do [ -d "$DOCKER_DIR/caddy" ] || return 0 @@ -280,7 +291,7 @@ configure_caddy_for_service() { prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then echo " Skipping Caddy configuration." - echo " Access $SERVICE_NAME at: http://localhost:$SERVICE_PORT" + echo " Access $SERVICE_NAME at: http://localhost:$_DISPLAY_PORT" return 0 fi @@ -320,7 +331,7 @@ configure_caddy_for_service() { # $SERVICE_NAME $SERVICE_DOMAIN { - reverse_proxy localhost:$SERVICE_PORT + reverse_proxy $_UPSTREAM # Security headers header { diff --git a/services/actualbudget.sh b/services/actualbudget.sh index 7da0e9c..3cc01a1 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -40,16 +40,24 @@ services: - ./data:/data env_file: - .env + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} AB_COMPOSE cat > .env << AB_ENV TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET AB_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR" log_success "Actual Budget configured at $AB_DIR" - configure_caddy_for_service "ActualBudget" "5006" "budget" + configure_caddy_for_service "ActualBudget" "actualbudget:5006" "budget" write_readme "$AB_DIR" << MD # Actual Budget diff --git a/services/arm.sh b/services/arm.sh index 7f5dd19..6614c4e 100644 --- a/services/arm.sh +++ b/services/arm.sh @@ -73,10 +73,18 @@ services: # Add more optical drives as needed: # - /dev/sr1:/dev/sr1 privileged: true + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} ARM_COMPOSE cat > .env << ARM_ENV ARM_OUTPUT=$ARM_OUTPUT +CADDY_NET=$SITE_CADDY_NET ARM_ENV mkdir -p config logs @@ -85,7 +93,7 @@ ARM_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_OUTPUT" log_success "A.R.M. configured at $ARM_DIR" - configure_caddy_for_service "A.R.M." "8080" "arm" + configure_caddy_for_service "A.R.M." "arm:8080" "arm" write_readme "$ARM_DIR" << MD # A.R.M. (Automatic Ripping Machine) diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh index c230602..f8e2f2d 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -50,18 +50,26 @@ services: - \${PODCASTS_PATH:-./podcasts}:/podcasts ports: - "13378:80" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} ABS_COMPOSE cat > .env << ABS_ENV AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH PODCASTS_PATH=./podcasts +CADDY_NET=$SITE_CADDY_NET ABS_ENV mkdir -p config metadata podcasts chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR" log_success "Audiobookshelf configured at $ABS_DIR" - configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks" + configure_caddy_for_service "AudioBookshelf" "audiobookshelf:80" "audiobooks" write_readme "$ABS_DIR" << MD # Audiobookshelf diff --git a/services/caddy.sh b/services/caddy.sh index 2adeb96..f4ee359 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -82,6 +82,13 @@ services: - ACME_AGREE=true labels: - "io.podman.annotations.label/crowdsec.enable=true" + networks: + - caddy_net + +networks: + caddy_net: + driver: bridge + name: caddy_net CADDY_COMPOSE # Create Caddyfile if it doesn't exist @@ -114,7 +121,7 @@ CADDY_COMPOSE # Example: # myservice.yourdomain.com { # import authelia -# reverse_proxy localhost:PORT +# reverse_proxy container_name:port # } # ActualBudget @@ -124,7 +131,7 @@ CADDY_COMPOSE # format json # level INFO # } -# reverse_proxy localhost:5006 +# reverse_proxy actualbudget:5006 # header { # Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" # X-Frame-Options "SAMEORIGIN" @@ -165,7 +172,7 @@ a web service). You can also edit it by hand: ``` myservice.example.com { - reverse_proxy localhost:1234 + reverse_proxy container_name:1234 log { output file /var/log/caddy/myservice.example.com.log format json diff --git a/services/emby.sh b/services/emby.sh index e8edbdc..96095da 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -59,17 +59,25 @@ services: # Uncomment for hardware transcoding (Intel/AMD): # devices: # - /dev/dri:/dev/dri + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} EMBY_COMPOSE cat > .env << EMBY_ENV MEDIA_PATH=$MEDIA_PATH +CADDY_NET=$SITE_CADDY_NET EMBY_ENV mkdir -p config chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR" log_success "Emby configured at $EMBY_DIR" - configure_caddy_for_service "Emby" "8096" "emby" + configure_caddy_for_service "Emby" "emby:8096" "emby" write_readme "$EMBY_DIR" << MD # Emby diff --git a/services/filebrowser.sh b/services/filebrowser.sh index 13ef4c3..5299171 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -26,13 +26,11 @@ name: filebrowser services: filebrowser: - image: filebrowser/filebrowser:s6 + image: filebrowser/filebrowser:latest container_name: filebrowser hostname: filebrowser restart: unless-stopped environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} volumes: - ${FB_PATH}:/srv @@ -40,10 +38,18 @@ services: - ./config/settings.json:/config/settings.json ports: - "8085:80" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} FB_COMPOSE cat > .env << FB_ENV FB_PATH=$FB_PATH +CADDY_NET=$SITE_CADDY_NET FB_ENV mkdir -p database config @@ -61,9 +67,21 @@ FB_SETTINGS chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR" + # Deploy user-management helper script + local _TOOLS_DIR + _TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../tools" 2>/dev/null && pwd)" || true + if [ -f "$_TOOLS_DIR/manage_users.sh" ]; then + cp "$_TOOLS_DIR/manage_users.sh" "$FB_DIR/manage_users.sh" + chmod 750 "$FB_DIR/manage_users.sh" + chown "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR/manage_users.sh" + log_success "manage_users.sh installed at $FB_DIR/manage_users.sh" + fi + echo "" log_success "Filebrowser configured at $FB_DIR" + configure_caddy_for_service "FileBrowser" "filebrowser:80" "files" + write_readme "$FB_DIR" << MD # FileBrowser diff --git a/services/fmd.sh b/services/fmd.sh index d5ba5d5..db11e5f 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -44,17 +44,25 @@ services: - ./data:/fmd/data ports: - "8084:8080" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} FMD_COMPOSE cat > .env << FMD_ENV FMD_ADMIN_PASSWORD=$FMD_PASS +CADDY_NET=$SITE_CADDY_NET FMD_ENV mkdir -p data chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" log_success "FindMyDevice configured at $FMD_DIR" - configure_caddy_for_service "FindMyDevice" "8084" "fmd" + configure_caddy_for_service "FindMyDevice" "fmd:8080" "fmd" write_readme "$FMD_DIR" << MD # FindMyDevice (FMD) diff --git a/services/frigate-audio.sh b/services/frigate-audio.sh index 4cad85f..3bca1fb 100644 --- a/services/frigate-audio.sh +++ b/services/frigate-audio.sh @@ -169,6 +169,7 @@ FN_FRIGATE__MQTT__PASSWORD=${MQTT_PASS} FN_FRIGATE__SERVER=http://frigate:5000 FN_FRIGATE__PUBLIC_URL=${FRIGATE_PUBLIC_URL} FN_ALERTS__NTFY__SERVER=${NTFY_SERVER} +CADDY_NET=$SITE_CADDY_NET ENVEOF chmod 600 "$DIR/.env" log_success ".env written" @@ -225,6 +226,8 @@ COMPOSEEOF - "8554:8554" - "8555:8555/tcp" - "8555:8555/udp" + networks: + - caddy_net healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"] interval: 10s @@ -257,6 +260,11 @@ COMPOSEEOF condition: service_healthy volumes: - ./frigate-notify/config.yml:/app/config.yml:ro + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} COMPOSEEOF log_success "docker-compose.yml written" @@ -506,7 +514,7 @@ FNEOF # ── Caddy snippet ────────────────────────────────────────────────────────── if [ -n "$FRIGATE_PUBLIC_URL" ] && [ "$FRIGATE_PUBLIC_URL" != "https://cam.yourdomain.com" ]; then local _DOM="${FRIGATE_PUBLIC_URL#https://}" - configure_caddy_for_service "Frigate" "8971" "frigate-audio" || true + configure_caddy_for_service "Frigate" "frigate-audio:8971" "frigate-audio" || true fi ensure_docker_dir_ownership "$DIR" diff --git a/services/frigate-notify.sh b/services/frigate-notify.sh index 49c2214..4ffed19 100644 --- a/services/frigate-notify.sh +++ b/services/frigate-notify.sh @@ -37,6 +37,13 @@ services: restart: unless-stopped volumes: - ./config.yml:/app/config.yml:ro + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} FN_COMPOSE # Smart defaults based on what's installed diff --git a/services/frigate.sh b/services/frigate.sh index 98557d9..ef6321a 100644 --- a/services/frigate.sh +++ b/services/frigate.sh @@ -71,10 +71,18 @@ $DEVICE_BLOCK - "8554:8554" - "8555:8555/tcp" - "8555:8555/udp" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} FRIGATE_COMPOSE cat > .env << FRIGATE_ENV FRIGATE_MEDIA=$FRIGATE_MEDIA +CADDY_NET=$SITE_CADDY_NET FRIGATE_ENV mkdir -p config @@ -120,7 +128,7 @@ FRIGATE_CONFIG chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true log_success "Frigate configured at $FRIGATE_DIR" - configure_caddy_for_service "Frigate" "5000" "frigate" + configure_caddy_for_service "Frigate" "frigate:5000" "frigate" write_readme "$FRIGATE_DIR" << MD # Frigate NVR diff --git a/services/gatus.sh b/services/gatus.sh new file mode 100644 index 0000000..4bc9a86 --- /dev/null +++ b/services/gatus.sh @@ -0,0 +1,159 @@ +#!/bin/bash +# services/gatus.sh — Gatus status/uptime monitoring page. +# Part of the modular post-install system (sourced by setup.sh). +# +# Gatus polls endpoints (HTTP, TCP, DNS, ICMP) on a schedule and shows a +# clean status dashboard. Config is hot-reloaded from gatus_config/config.yaml. + +register_service gatus utilities "Status & uptime monitoring page (Gatus)" 8086 + +install_gatus() { + require_docker || return 1 + log_info "Installing Gatus..." + local GATUS_DIR="$DOCKER_DIR/gatus" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $GATUS_DIR (gatus_config/, gatus_data/)" + echo "[DRY-RUN] Would deploy twinproduction/gatus:latest" + echo "[DRY-RUN] Port 8086 published, config at gatus_config/config.yaml" + return 0 + fi + + mkdir -p "$GATUS_DIR/gatus_config" "$GATUS_DIR/gatus_data" + ensure_docker_dir_ownership "$GATUS_DIR" + cd "$GATUS_DIR" || return 1 + + local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + cat > docker-compose.yml << 'GATUS_COMPOSE' +name: gatus + +services: + gatus: + image: twinproduction/gatus:latest + container_name: gatus + hostname: gatus + restart: unless-stopped + env_file: .env + ports: + - "8086:8080" + volumes: + - ./gatus_config:/config + - ./gatus_data:/data + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +GATUS_COMPOSE + + cat > .env << GATUS_ENV +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET +GATUS_ENV + + # Write a sample config if none exists + if [ ! -f gatus_config/config.yaml ]; then + cat > gatus_config/config.yaml << 'GATUS_CFG' +# Gatus configuration — docs: https://github.com/TwiN/gatus +# +# Add or remove endpoints below. Config is hot-reloaded on changes. +# Alert types: ntfy, slack, discord, email, telegram, and more. + +storage: + type: sqlite + path: /data/gatus.db + +ui: + title: "Status" + header: "Services" + +# ── Endpoints ───────────────────────────────────────────────────────────────── +endpoints: + - name: Google DNS + group: external + url: "8.8.8.8" + dns: + query-name: "google.com" + query-type: "A" + interval: 5m + conditions: + - "[DNS_RCODE] == NOERROR" + + - name: Example HTTPS + group: external + url: "https://example.com" + interval: 5m + conditions: + - "[STATUS] == 200" + - "[RESPONSE_TIME] < 3000" + - "[CERTIFICATE_EXPIRATION] > 48h" + + # ── Add your services below ──────────────────────────────────────────────── + # - name: Mealie + # group: homelab + # url: "http://mealie:9000/api/app/about" + # interval: 1m + # conditions: + # - "[STATUS] == 200" + # - "[RESPONSE_TIME] < 500" + # + # - name: Portainer + # group: homelab + # url: "https://portainer:9443" + # interval: 1m + # conditions: + # - "[STATUS] == 200" + # client: + # insecure: true +GATUS_CFG + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$GATUS_DIR" + log_success "Gatus configured at $GATUS_DIR" + + configure_caddy_for_service "Gatus" "gatus:8080" "status" + + write_readme "$GATUS_DIR" << MD +# Gatus — status & uptime monitoring + +Clean, self-hosted status page. Polls HTTP, TCP, DNS, and ICMP endpoints. + +## Access +- URL: http://localhost:8086 + +## Configuration +Edit \`gatus_config/config.yaml\` — changes are **hot-reloaded** without restarting. + +Key concepts: +- \`endpoints:\` — what to check (HTTP, TCP, DNS, ICMP) +- \`interval:\` — how often (e.g. 1m, 5m) +- \`conditions:\` — pass/fail rules ([STATUS], [RESPONSE_TIME], etc.) +- \`alerts:\` — notify via ntfy, Slack, Discord, email, etc. + +Full docs: https://github.com/TwiN/gatus + +## Manage +\`\`\`bash +cd $GATUS_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs (check config errors here) +docker compose pull && docker compose up -d # update +\`\`\` +MD + + local START_GATUS="" + prompt_yn "Start Gatus now? (y/n):" "y" START_GATUS + if [ "$START_GATUS" = "y" ] || [ "$START_GATUS" = "Y" ]; then + docker compose up -d \ + && log_success "Gatus started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:8086" + echo " Config: $GATUS_DIR/gatus_config/config.yaml (hot-reloaded)" + echo "" +} diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 060daf9..360c958 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -29,13 +29,16 @@ install_homeassistant() { echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)." local HA_NETMODE="" prompt_text " Choose networking mode [1]:" "1" HA_NETMODE - local HA_NET_LINES + local HA_NET_LINES HA_CADDY_NET_LINES if [ "$HA_NETMODE" = "2" ]; then HA_NET_LINES=" network_mode: host" + HA_CADDY_NET_LINES="" echo " → Host networking selected (best device discovery)." else HA_NET_LINES=" ports: - \"8123:8123\"" + HA_CADDY_NET_LINES=" networks: + - caddy_net" echo " → Bridge networking selected (port 8123 published)." fi @@ -55,6 +58,12 @@ services: - ./config:/config - /run/dbus:/run/dbus:ro ${HA_NET_LINES} +${HA_CADDY_NET_LINES} + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} HOMEASSISTANT_COMPOSE mkdir -p config @@ -82,7 +91,11 @@ HA_CONFIG echo "" log_success "Home Assistant configured at $HOMEASSISTANT_DIR" - configure_caddy_for_service "Home Assistant" "8123" "home" + if [ "$HA_NETMODE" = "2" ]; then + configure_caddy_for_service "Home Assistant" "8123" "home" + else + configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home" + fi local START_HA="" prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA diff --git a/services/immich.sh b/services/immich.sh index e8d2f7d..49d5049 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -137,6 +137,8 @@ services: restart: always healthcheck: disable: false + networks: + - caddy_net immich-machine-learning: container_name: immich_machine_learning @@ -170,6 +172,11 @@ services: volumes: model-cache: + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} IMMICH_COMPOSE else cat > docker-compose.yml << 'IMMICH_COMPOSE' @@ -192,6 +199,8 @@ services: restart: always healthcheck: disable: false + networks: + - caddy_net immich-machine-learning: container_name: immich_machine_learning @@ -225,6 +234,11 @@ services: volumes: model-cache: + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} IMMICH_COMPOSE fi @@ -254,6 +268,7 @@ DB_PASSWORD=$DB_PASS DB_USERNAME=postgres DB_DATABASE_NAME=immich TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET IMMICH_ENV else cat > .env << IMMICH_ENV @@ -271,6 +286,7 @@ DB_PASSWORD=$DB_PASS DB_USERNAME=postgres DB_DATABASE_NAME=immich TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET IMMICH_ENV fi @@ -556,7 +572,7 @@ IMPORT_BODY log_success "Immich configured at $IMMICH_DIR" - configure_caddy_for_service "Immich" "2283" "immich" + configure_caddy_for_service "Immich" "immich-server:2283" "immich" write_readme "$IMMICH_DIR" << MD # Immich diff --git a/services/jellyfin.sh b/services/jellyfin.sh index a1d4d67..96a4bc8 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -69,17 +69,25 @@ $HWACCEL_BLOCK - "8096:8096" - "1900:1900/udp" - "7359:7359/udp" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} JELLYFIN_COMPOSE cat > .env << JELLYFIN_ENV MEDIA_PATH=$MEDIA_PATH +CADDY_NET=$SITE_CADDY_NET JELLYFIN_ENV mkdir -p config cache chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" log_success "Jellyfin configured at $JELLYFIN_DIR" - configure_caddy_for_service "Jellyfin" "8096" "jellyfin" + configure_caddy_for_service "Jellyfin" "jellyfin:8096" "jellyfin" write_readme "$JELLYFIN_DIR" << MD # Jellyfin diff --git a/services/js99er.sh b/services/js99er.sh index bc47bde..7cd1e9f 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -260,6 +260,13 @@ services: ports: - "${JS99ER_PORT}:80" restart: unless-stopped + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} COMPOSE log_success "Created js99er/docker-compose.yml" @@ -268,7 +275,7 @@ COMPOSE log_success "js99er configured at $JS99ER_DIR" # ── 5. Reverse proxy (no-ops if Caddy isn't installed locally) ─────────── - configure_caddy_for_service "js99er" "$JS99ER_PORT" "js99er" + configure_caddy_for_service "js99er" "js99er:80" "js99er" # ── 6. Build & start ───────────────────────────────────────────────────── local START_JS99ER="" diff --git a/services/lyrion.sh b/services/lyrion.sh index 1844009..0a901ee 100644 --- a/services/lyrion.sh +++ b/services/lyrion.sh @@ -56,10 +56,16 @@ services: - \${MUSIC_PATH}:/music:ro - ./playlists:/playlists:rw - /etc/localtime:/etc/localtime:ro + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} LYRION_COMPOSE cat > .env << LYRION_ENV MUSIC_PATH=$MUSIC_PATH +CADDY_NET=$SITE_CADDY_NET LYRION_ENV mkdir -p config playlists diff --git a/services/magicmirror.sh b/services/magicmirror.sh index e0bb4e5..afdff96 100644 --- a/services/magicmirror.sh +++ b/services/magicmirror.sh @@ -63,6 +63,13 @@ services: - ./css:/opt/magic_mirror/css ports: - "$MM_PORT:8080" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} MM_COMPOSE mkdir -p config modules css @@ -125,7 +132,7 @@ MM_COMPOSE log_success "MagicMirror instance $i configured at $MM_DIR (port $MM_PORT)" # Offer Caddy only for first instance - [ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "$MM_PORT" "mirror" + [ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" local START_MM="" prompt_yn "Start instance $i now? (y/n):" "y" START_MM diff --git a/services/mail-archiver.sh b/services/mail-archiver.sh new file mode 100644 index 0000000..fc845b5 --- /dev/null +++ b/services/mail-archiver.sh @@ -0,0 +1,160 @@ +#!/bin/bash +# services/mail-archiver.sh — Mail Archiver (IMAP email archive & search). +# Part of the modular post-install system (sourced by setup.sh). +# +# Self-hosted email archive — connects to IMAP accounts, indexes messages, +# and provides full-text search. No big-tech email required. +# Image: s1t5/mailarchiver DB: postgres:17-alpine + +register_service mail-archiver utilities "IMAP email archive & search (Mail Archiver)" 5000 + +install_mail-archiver() { + require_docker || return 1 + log_info "Installing Mail Archiver..." + local MA_DIR="$DOCKER_DIR/mail-archiver" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $MA_DIR (mailarchiver_database/)" + echo "[DRY-RUN] Would deploy s1t5/mailarchiver:latest + postgres:17-alpine" + echo "[DRY-RUN] Accessed via Caddy reverse proxy (no direct host port)" + echo "[DRY-RUN] Would generate DB and admin passwords" + return 0 + fi + + mkdir -p "$MA_DIR/mailarchiver_database" + ensure_docker_dir_ownership "$MA_DIR" + cd "$MA_DIR" || return 1 + + local DB_PASS ADMIN_PASS TZ_VAL + DB_PASS=$(generate_password 32) + ADMIN_PASS=$(generate_password 24) + TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + cat > docker-compose.yml << 'MA_COMPOSE' +name: mail-archiver + +services: + mailarchiver-app: + image: s1t5/mailarchiver:latest + container_name: mailarchiver-app + hostname: mailarchiver-app + restart: unless-stopped + env_file: .env + expose: + - "5000" + depends_on: + mailarchiver-db: + condition: service_healthy + networks: + - caddy_net + + mailarchiver-db: + image: postgres:17-alpine + container_name: mailarchiver-db + hostname: mailarchiver-db + restart: unless-stopped + env_file: .env + expose: + - "5432" + volumes: + - ./mailarchiver_database:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mailuser -d MailArchiver"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +MA_COMPOSE + + cat > .env << MA_ENV +# ── General ─────────────────────────────────────────────────────────────────── +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +# ── Database connection (app → postgres) ────────────────────────────────────── +ConnectionStrings__DefaultConnection=Host=mailarchiver-db;Database=MailArchiver;Username=mailuser;Password=$DB_PASS; + +# ── Web authentication ──────────────────────────────────────────────────────── +Authentication__Enabled=true +Authentication__Username=admin +Authentication__Password=$ADMIN_PASS +Authentication__SessionTimeoutMinutes=60 +Authentication__CookieName=MailArchiverAuth + +# ── Mail sync schedule ──────────────────────────────────────────────────────── +MailSync__IntervalMinutes=15 +MailSync__TimeoutMinutes=60 +MailSync__ConnectionTimeoutSeconds=180 +MailSync__CommandTimeoutSeconds=300 + +# ── Batch restore limits ────────────────────────────────────────────────────── +BatchRestore__AsyncThreshold=50 +BatchRestore__MaxSyncEmails=150 +BatchRestore__MaxAsyncEmails=50000 +BatchRestore__SessionTimeoutMinutes=30 +BatchRestore__DefaultBatchSize=50 + +# ── Postgres tuning ─────────────────────────────────────────────────────────── +Npgsql__CommandTimeout=600 + +# ── Postgres container ──────────────────────────────────────────────────────── +POSTGRES_DB=MailArchiver +POSTGRES_USER=mailuser +POSTGRES_PASSWORD=$DB_PASS +MA_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MA_DIR" + log_success "Mail Archiver configured at $MA_DIR" + + configure_caddy_for_service "Mail Archiver" "mailarchiver-app:5000" "mail" + + write_readme "$MA_DIR" << MD +# Mail Archiver + +Self-hosted IMAP email archive and full-text search. +Add your IMAP mail accounts through the web UI — Mail Archiver will pull +and index all messages, then let you search the full archive. + +## Access +- URL: via Caddy reverse proxy (no direct host port) +- Login: admin / (see .env Authentication__Password) + +## Adding mail accounts +1. Open the web UI → Settings → Mail Accounts +2. Add IMAP server, username, and password +3. Mail Archiver syncs every \`MailSync__IntervalMinutes\` minutes (default: 15) + +## Credentials +Stored in \`.env\` (chmod 600): +- Web admin password: \`Authentication__Password\` +- DB password: \`POSTGRES_PASSWORD\` + +## Manage +\`\`\`bash +cd $MA_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + local START_MA="" + prompt_yn "Start Mail Archiver now? (y/n):" "y" START_MA + if [ "$START_MA" = "y" ] || [ "$START_MA" = "Y" ]; then + docker compose up -d \ + && log_success "Mail Archiver started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Admin login: admin / $(grep Authentication__Password .env | cut -d= -f2)" + echo " Add IMAP accounts via the web UI after starting." + echo "" +} diff --git a/services/mealie.sh b/services/mealie.sh index 7f07d28..9dfdcca 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -50,13 +50,20 @@ services: - ./data:/app/data ports: - "9925:9000" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} MEALIE_COMPOSE mkdir -p data chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" log_success "Mealie configured at $MEALIE_DIR" - configure_caddy_for_service "Mealie" "9925" "recipes" + configure_caddy_for_service "Mealie" "mealie:9000" "recipes" write_readme "$MEALIE_DIR" << MD # Mealie diff --git a/services/meshcentral.sh b/services/meshcentral.sh index 7dfb25e..c9f6a50 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -54,19 +54,27 @@ services: ports: - "4430:443" - "4433:4433" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} MC_COMPOSE cat > .env << MC_ENV MC_HOSTNAME=$MC_HOSTNAME MC_REVERSE_PROXY=false MC_TLS_PORT=443 +CADDY_NET=$SITE_CADDY_NET MC_ENV mkdir -p data files backups chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" log_success "MeshCentral configured at $MC_DIR" - configure_caddy_for_service "MeshCentral" "4430" "mesh" + configure_caddy_for_service "MeshCentral" "meshcentral:443" "mesh" write_readme "$MC_DIR" << MD # MeshCentral diff --git a/services/ntfy.sh b/services/ntfy.sh index fb004d4..060f433 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -35,10 +35,18 @@ services: - ./config:/etc/ntfy ports: - "8090:80" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} NTFY_COMPOSE cat > .env << NTFY_ENV TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)} +CADDY_NET=$SITE_CADDY_NET NTFY_ENV mkdir -p cache config @@ -47,6 +55,8 @@ NTFY_ENV echo "" log_success "ntfy configured at $NTFY_DIR" + configure_caddy_for_service "ntfy" "ntfy:80" "ntfy" + write_readme "$NTFY_DIR" << MD # ntfy diff --git a/services/portainer.sh b/services/portainer.sh index 0ad880e..3ee55e2 100644 --- a/services/portainer.sh +++ b/services/portainer.sh @@ -33,6 +33,13 @@ services: ports: - "9000:9000" - "9443:9443" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} PORTAINER_COMPOSE mkdir -p data @@ -41,6 +48,8 @@ PORTAINER_COMPOSE echo "" log_success "Portainer configured at $PORTAINER_DIR" + configure_caddy_for_service "Portainer" "portainer:9000" "portainer" + write_readme "$PORTAINER_DIR" << MD # Portainer diff --git a/services/rustdesk.sh b/services/rustdesk.sh new file mode 100644 index 0000000..f4dd000 --- /dev/null +++ b/services/rustdesk.sh @@ -0,0 +1,161 @@ +#!/bin/bash +# services/rustdesk.sh — RustDesk self-hosted remote desktop relay server. +# Part of the modular post-install system (sourced by setup.sh). +# +# RustDesk is an open-source TeamViewer alternative. This installs the +# SERVER-SIDE relay/rendezvous daemon — clients still need the RustDesk app. +# For cross-VLAN / cross-internet access, point RELAY at this server's FQDN. +# +# Ports that must reach this host (firewall/router): +# 21115 TCP — NAT type test +# 21116 TCP — ID register / heartbeat / relay rendezvous +# 21116 UDP — UDP hole-punching +# 21117 TCP — relay traffic (the "HBBR" relay daemon) +# 21118 TCP — WebSocket (browser client support) +# 21119 TCP — WebSocket HTTPS (browser client support) + +register_service rustdesk utilities "Self-hosted remote desktop relay (RustDesk)" 21117 + +install_rustdesk() { + require_docker || return 1 + log_info "Installing RustDesk server..." + local RD_DIR="$DOCKER_DIR/rustdesk" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $RD_DIR (rustdesk_data/)" + echo "[DRY-RUN] Would deploy rustdesk/rustdesk-server-s6:latest" + echo "[DRY-RUN] Ports: 21115-21119 TCP, 21116 UDP" + echo "[DRY-RUN] Would prompt for server FQDN/IP (RELAY env var)" + return 0 + fi + + mkdir -p "$RD_DIR/rustdesk_data" + ensure_docker_dir_ownership "$RD_DIR" + cd "$RD_DIR" || return 1 + + local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + echo "" + echo " RustDesk needs to know its own public hostname or IP." + echo " Clients will connect to this address for relay traffic." + echo " Use a FQDN if you have one (e.g. rustdesk.example.com)," + echo " or your server's public IP if not." + echo "" + local RELAY_HOST="" + prompt_text "Public hostname or IP for this server:" "" RELAY_HOST + if [ -z "$RELAY_HOST" ]; then + log_warning "No relay host set — you MUST edit RELAY in .env before clients will work." + RELAY_HOST="your-server-fqdn-or-ip" + fi + + local ENCRYPTED_ONLY="1" + local _enc="" + prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc + [ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0" + + cat > docker-compose.yml << 'RD_COMPOSE' +name: rustdesk + +services: + rustdesk: + image: rustdesk/rustdesk-server-s6:latest + container_name: rustdesk + hostname: rustdesk + restart: unless-stopped + env_file: .env + ports: + - "21115:21115" + - "21116:21116" + - "21116:21116/udp" + - "21117:21117" + - "21118:21118" + - "21119:21119" + volumes: + - ./rustdesk_data:/data +RD_COMPOSE + + cat > .env << RD_ENV +# ── General ─────────────────────────────────────────────────────────────────── +TZ=$TZ_VAL + +# ── RustDesk server ─────────────────────────────────────────────────────────── +# RELAY: public FQDN or IP that clients use to reach the relay daemon (HBBR). +# Include the port if it's non-standard: hostname:21117 +RELAY=$RELAY_HOST:21117 + +# ENCRYPTED_ONLY: 1 = only clients with the matching public key can connect. +# After first startup, copy the key from ./rustdesk_data/id_ed25519.pub to +# each client: Settings → Network → Key. +ENCRYPTED_ONLY=$ENCRYPTED_ONLY + +# KEY_PRIV and KEY_PUB — optional: paste key file contents here instead of +# relying on the volume-mounted file. Useful for portability. +# KEY_PRIV= +# KEY_PUB= +RD_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$RD_DIR" + log_success "RustDesk configured at $RD_DIR" + + write_readme "$RD_DIR" << MD +# RustDesk — self-hosted remote desktop relay + +Open-source TeamViewer alternative. This is the server-side relay/rendezvous +daemon. Clients use the RustDesk desktop/mobile app to connect. + +## After starting: get the public key + +\`\`\`bash +cat $RD_DIR/rustdesk_data/id_ed25519.pub +\`\`\` + +Paste this key into each client: +**Settings → Network → ID/Relay Server** +- ID Server: $RELAY_HOST +- Relay Server: $RELAY_HOST +- Key: + +## Firewall / router rules required + +Open these ports to this server's IP: +| Port | Protocol | Purpose | +|------|----------|---------| +| 21115 | TCP | NAT type test | +| 21116 | TCP+UDP | ID register / hole-punching | +| 21117 | TCP | Relay traffic | +| 21118 | TCP | WebSocket | +| 21119 | TCP | WebSocket HTTPS | + +## Cross-VLAN setup +Use the server's FQDN (not LAN IP) in RELAY so clients on any VLAN +or on the internet can reach the relay. DNS must resolve the FQDN to +the server's public IP. + +## Manage +\`\`\`bash +cd $RD_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + local START_RD="" + prompt_yn "Start RustDesk server now? (y/n):" "y" START_RD + if [ "$START_RD" = "y" ] || [ "$START_RD" = "Y" ]; then + docker compose up -d \ + && log_success "RustDesk started" \ + || log_warning "Failed to start — check: docker compose logs" + echo "" + echo " After startup, get the public key:" + echo " cat $RD_DIR/rustdesk_data/id_ed25519.pub" + echo " Paste it into client Settings → Network → Key." + fi + + echo "" + echo " Relay host: $RELAY_HOST" + echo " Ports 21115-21119 must be open in your firewall/router." + echo "" +} diff --git a/services/traccar.sh b/services/traccar.sh index 0a03bdd..2c03ec8 100644 --- a/services/traccar.sh +++ b/services/traccar.sh @@ -42,6 +42,13 @@ services: - "8082:8082" - "5000-5150:5000-5150" - "5000-5150:5000-5150/udp" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} TRACCAR_COMPOSE mkdir -p logs data config @@ -63,7 +70,7 @@ TRACCAR_XML chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR" log_success "Traccar configured at $TRACCAR_DIR" - configure_caddy_for_service "Traccar" "8082" "traccar" + configure_caddy_for_service "Traccar" "traccar:8082" "traccar" write_readme "$TRACCAR_DIR" << MD # Traccar diff --git a/services/unifi.sh b/services/unifi.sh new file mode 100644 index 0000000..6c13b5b --- /dev/null +++ b/services/unifi.sh @@ -0,0 +1,212 @@ +#!/bin/bash +# services/unifi.sh — UniFi Network Application (Ubiquiti controller). +# Part of the modular post-install system (sourced by setup.sh). +# +# Two containers: mongo:4 (DB) + linuxserver unifi-network-application (app). +# Web UI runs on HTTPS port 8443 — no plain HTTP web interface. +# Caddy reverse-proxy wiring uses TLS passthrough or tls_insecure_skip_verify. + +register_service unifi utilities "Ubiquiti network controller (UniFi)" 8443 + +install_unifi() { + require_docker || return 1 + log_info "Installing UniFi Network Application..." + local UNIFI_DIR="$DOCKER_DIR/unifi" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $UNIFI_DIR (mongo_db_data/, unifi_data/)" + echo "[DRY-RUN] Would deploy mongo:4 + linuxserver/unifi-network-application:latest" + echo "[DRY-RUN] Ports: 8443 (HTTPS web UI), 8080 (device inform), 3478/udp (STUN), 10001/udp (discovery)" + echo "[DRY-RUN] Would generate MongoDB credentials" + return 0 + fi + + mkdir -p "$UNIFI_DIR" + ensure_docker_dir_ownership "$UNIFI_DIR" + cd "$UNIFI_DIR" || return 1 + + local MONGO_PASS TZ_VAL UID_VAL GID_VAL + MONGO_PASS=$(generate_password 24) + TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + UID_VAL=$(id -u "$ACTUAL_USER") + GID_VAL=$(id -g "$ACTUAL_USER") + + # Single-quoted heredoc: ${...} left literal for Docker Compose to expand from .env + cat > docker-compose.yml << 'UNIFI_COMPOSE' +name: unifi + +services: + unifi-db: + image: mongo:4 + container_name: unifi-db + hostname: unifi-db + restart: unless-stopped + env_file: .env + volumes: + - ./mongo_db_data:/data/db + expose: + - "27017" + configs: + - source: init-mongo.js + target: /docker-entrypoint-initdb.d/init-mongo.js + + unifi-app: + image: lscr.io/linuxserver/unifi-network-application:latest + container_name: unifi-app + hostname: unifi-app + restart: unless-stopped + env_file: .env + depends_on: + - unifi-db + volumes: + - ./unifi_data:/config + ports: + - "8443:8443" + - "8080:8080" + - "3478:3478/udp" + - "10001:10001/udp" + # Optional — uncomment as needed: + # - "1900:1900/udp" # L2 discovery (may conflict with UPnP) + # - "8843:8843" # guest portal HTTPS + # - "8880:8880" # guest portal HTTP + # - "6789:6789" # mobile speed test + # - "5514:5514/udp" # remote syslog + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} + +# Inline MongoDB init — Docker Compose interpolates vars from .env at startup. +configs: + init-mongo.js: + content: | + db.getSiblingDB("${MONGO_DBNAME}").createUser({user: "${MONGO_USER}", pwd: "${MONGO_PASS}", roles: [{role: "${MONGO_ROLE}", db: "${MONGO_DBNAME}"}]}); + db.getSiblingDB("${MONGO_DBNAME}_stat").createUser({user: "${MONGO_USER}", pwd: "${MONGO_PASS}", roles: [{role: "${MONGO_ROLE}", db: "${MONGO_DBNAME}_stat"}]}); +UNIFI_COMPOSE + + cat > .env << UNIFI_ENV +# ── General ─────────────────────────────────────────────────────────────────── +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +# ── LinuxServer — UniFi app ─────────────────────────────────────────────────── +PUID=$UID_VAL +PGID=$GID_VAL +MEM_LIMIT=1024 +MEM_STARTUP=512 + +# ── MongoDB connection ──────────────────────────────────────────────────────── +MONGO_USER=unifi +MONGO_PASS=$MONGO_PASS +MONGO_HOST=unifi-db +MONGO_PORT=27017 +MONGO_DBNAME=unifi_db +MONGO_ROLE=dbOwner +# MONGO_TLS= # optional +# MONGO_AUTHSOURCE= # optional +UNIFI_ENV + + chmod 600 .env + mkdir -p mongo_db_data unifi_data + ensure_docker_dir_ownership "$UNIFI_DIR" + + log_success "UniFi configured at $UNIFI_DIR" + + # ── Optional Caddy reverse proxy (HTTPS backend requires special config) ── + if [ -d "$DOCKER_DIR/caddy" ]; then + echo "" + echo " UniFi web UI is HTTPS-only (self-signed cert internally)." + echo " Caddy can proxy it, but requires tls_insecure_skip_verify." + echo "" + local CADDY_UNIFI="" + prompt_yn "Configure Caddy reverse proxy for UniFi? (y/n):" "n" CADDY_UNIFI + if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then + local UNIFI_DOMAIN="" + prompt_text "UniFi domain (e.g. unifi.example.com):" "unifi.${SITE_DOMAIN:-example.com}" UNIFI_DOMAIN + if [ -n "$UNIFI_DOMAIN" ]; then + local CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" + cp "$CADDYFILE" "$CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true + cat >> "$CADDYFILE" << CADDY_BLOCK + +# UniFi Network Application +$UNIFI_DOMAIN { + reverse_proxy https://unifi-app:8443 { + transport http { + tls_insecure_skip_verify + } + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/$UNIFI_DOMAIN.log + format json + } +} +CADDY_BLOCK + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \ + && log_success "Caddy configured for $UNIFI_DOMAIN" \ + || log_warning "Caddy reload failed — check: docker logs caddy" + fi + fi + fi + + write_readme "$UNIFI_DIR" << MD +# UniFi Network Application + +Ubiquiti network controller. Manages UniFi APs, switches, and gateways. + +## Access +- Web UI: **https://localhost:8443** (HTTPS, self-signed cert — accept the warning) +- First run: complete the setup wizard and adopt your devices. + +## Device adoption +Make sure devices can reach **http://:8080/inform** as the inform URL. +In the controller: Settings → System → Application Configuration → Override inform host. + +## Ports +| Port | Protocol | Purpose | +|------|----------|---------| +| 8443 | TCP | HTTPS web UI | +| 8080 | TCP | Device inform / HTTP redirect | +| 3478 | UDP | STUN | +| 10001 | UDP | AP discovery | + +## Manage +\`\`\`bash +cd $UNIFI_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update (wait for DB first) +\`\`\` + +## Migration from old UniFi Controller +1. Backup: Settings → System → Backup → Create Backup +2. Down the old container +3. Spin up this stack +4. Restore: Settings → System → Backup → Restore +MD + + local START_UNIFI="" + prompt_yn "Start UniFi now? (y/n):" "y" START_UNIFI + if [ "$START_UNIFI" = "y" ] || [ "$START_UNIFI" = "Y" ]; then + docker compose up -d \ + && log_success "UniFi started (first startup takes ~60 s while DB initializes)" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Web UI: https://localhost:8443 (accept the self-signed cert warning)" + echo " MongoDB credentials saved to: $UNIFI_DIR/.env" + echo "" +} diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index 3264a3a..e326e8c 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -32,6 +32,13 @@ services: - /var/run/docker.sock:/var/run/docker.sock:ro ports: - "3001:3001" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} UPTIME_COMPOSE mkdir -p data @@ -68,7 +75,7 @@ docker compose logs -f # logs MD # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Uptime Kuma" "3001" "uptime" + configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" local START_UPTIME="" prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh new file mode 100644 index 0000000..acd22cb --- /dev/null +++ b/services/vaultwarden.sh @@ -0,0 +1,165 @@ +#!/bin/bash +# services/vaultwarden.sh — Vaultwarden (self-hosted Bitwarden server). +# Part of the modular post-install system (sourced by setup.sh). +# +# Vaultwarden is an unofficial, lightweight Bitwarden-compatible server. +# All official Bitwarden clients (browser extension, desktop, mobile) work with it. +# Requires HTTPS in production — set DOMAIN to your public URL. + +register_service vaultwarden utilities "Bitwarden-compatible password manager (Vaultwarden)" 80 + +install_vaultwarden() { + require_docker || return 1 + log_info "Installing Vaultwarden..." + local VW_DIR="$DOCKER_DIR/vaultwarden" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $VW_DIR (vaultwarden_data/)" + echo "[DRY-RUN] Would deploy vaultwarden/server:latest" + echo "[DRY-RUN] Would generate admin token and prompt for domain" + echo "[DRY-RUN] Signups disabled by default (enable via admin panel)" + return 0 + fi + + mkdir -p "$VW_DIR/vaultwarden_data" + ensure_docker_dir_ownership "$VW_DIR" + cd "$VW_DIR" || return 1 + + local ADMIN_TOKEN TZ_VAL + ADMIN_TOKEN=$(generate_password 48) + TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + echo "" + echo " Vaultwarden needs to know its public HTTPS URL so Bitwarden clients" + echo " can connect and password-reset emails link correctly." + echo "" + local VW_DOMAIN="" + local DEFAULT_DOMAIN="https://vault.${SITE_DOMAIN:-example.com}" + prompt_text "Vaultwarden public URL (e.g. https://vault.example.com):" "$DEFAULT_DOMAIN" VW_DOMAIN + [ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN" + + echo "" + echo " SMTP (optional) — for password-reset and invite emails." + echo " Press Enter to skip each field and configure SMTP later in .env." + echo "" + local SMTP_HOST="" SMTP_FROM="" SMTP_USER="" SMTP_PASS="" SMTP_PORT="587" + prompt_text "SMTP host (e.g. smtp.gmail.com) [skip]:" "" SMTP_HOST + if [ -n "$SMTP_HOST" ]; then + prompt_text "SMTP port [587]:" "587" SMTP_PORT + prompt_text "SMTP from address:" "" SMTP_FROM + prompt_text "SMTP username:" "" SMTP_USER + prompt_text "SMTP password:" "" SMTP_PASS + fi + + cat > docker-compose.yml << 'VW_COMPOSE' +name: vaultwarden + +services: + vaultwarden: + image: vaultwarden/server:latest + container_name: vaultwarden + hostname: vaultwarden + restart: unless-stopped + env_file: .env + volumes: + - ./vaultwarden_data:/data + expose: + - "80" + ports: + - "3012:3012" # WebSocket (legacy — not needed for Vaultwarden v1.29+) + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +VW_COMPOSE + + cat > .env << VW_ENV +# ── General ─────────────────────────────────────────────────────────────────── +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +# ── Vaultwarden ─────────────────────────────────────────────────────────────── +# Public URL — MUST match the URL clients use (affects TOTP, push, reset emails) +DOMAIN=$VW_DOMAIN + +# Admin panel: https:///admin — keep this token secret +# To disable admin panel: delete ADMIN_TOKEN from this file +ADMIN_TOKEN=$ADMIN_TOKEN + +# Signups: false = only the first admin can invite users via admin panel +SIGNUPS_ALLOWED=false +SIGNUPS_VERIFY=false + +# WebSocket notifications (v1.29+: built into port 80, no separate port needed) +WEBSOCKET_ENABLED=true + +# ── SMTP (optional — for password-reset and invite emails) ──────────────────── +SMTP_HOST=$SMTP_HOST +SMTP_PORT=$SMTP_PORT +SMTP_SECURITY=starttls +SMTP_FROM=$SMTP_FROM +SMTP_USERNAME=$SMTP_USER +SMTP_PASSWORD=$SMTP_PASS +VW_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$VW_DIR" + log_success "Vaultwarden configured at $VW_DIR" + + configure_caddy_for_service "Vaultwarden" "vaultwarden:80" "vault" + + write_readme "$VW_DIR" << MD +# Vaultwarden — Bitwarden-compatible password manager + +Lightweight, self-hosted Bitwarden server. Works with all official +Bitwarden clients: browser extension, desktop app, and mobile app. + +## Setup +1. Point your Bitwarden client to: $VW_DOMAIN +2. Create the first account (signups are off after the first user — use admin panel) +3. Admin panel: **$VW_DOMAIN/admin** (use ADMIN_TOKEN from .env) + +## Admin panel +The admin panel lets you manage users, send invites, and configure settings. +URL: \`$VW_DOMAIN/admin\` +Token: see \`ADMIN_TOKEN\` in .env + +**Security:** remove or rotate ADMIN_TOKEN after initial setup if you don't +need ongoing admin access. + +## Inviting users (signups disabled) +Admin panel → Users → Invite User → enter email. +Requires SMTP to be configured for the invite email to arrive. + +## Credentials +- Admin token: stored in .env (chmod 600) +- User vaults: encrypted in vaultwarden_data/ + +## Manage +\`\`\`bash +cd $VW_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + local START_VW="" + prompt_yn "Start Vaultwarden now? (y/n):" "y" START_VW + if [ "$START_VW" = "y" ] || [ "$START_VW" = "Y" ]; then + docker compose up -d \ + && log_success "Vaultwarden started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Domain: $VW_DOMAIN" + echo " Admin panel: $VW_DOMAIN/admin" + echo " Admin token: $ADMIN_TOKEN" + echo " (Token also saved to $VW_DIR/.env)" + echo "" +} diff --git a/services/watchtower.sh b/services/watchtower.sh index a0e0f11..d453872 100644 --- a/services/watchtower.sh +++ b/services/watchtower.sh @@ -71,6 +71,13 @@ services: - WATCHTOWER_DEBUG=false volumes: - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} WT_COMPOSE # Create .env @@ -99,6 +106,7 @@ MONITOR_ONLY=$MONITOR_ONLY # # Full list: https://containrrr.dev/shoutrrr/services/overview/ NOTIFICATION_URL=$NTFY_URL +CADDY_NET=$SITE_CADDY_NET WT_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WT_DIR" 2>/dev/null || true diff --git a/services/watchyourlan.sh b/services/watchyourlan.sh new file mode 100644 index 0000000..5a8193a --- /dev/null +++ b/services/watchyourlan.sh @@ -0,0 +1,137 @@ +#!/bin/bash +# services/watchyourlan.sh — WatchYourLAN network device tracker. +# Part of the modular post-install system (sourced by setup.sh). +# +# Continuously scans the network for connected devices, tracks history, +# and can alert on new/unknown devices. Uses network_mode: host so it +# can see the physical network directly (required for ARP scanning). + +register_service watchyourlan utilities "Network device tracker (WatchYourLAN)" 8840 + +install_watchyourlan() { + require_docker || return 1 + log_info "Installing WatchYourLAN..." + local WYL_DIR="$DOCKER_DIR/watchyourlan" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $WYL_DIR (watchyourlan_data/)" + echo "[DRY-RUN] Would deploy aceberg/watchyourlan:latest (network_mode: host)" + echo "[DRY-RUN] Port 8840 on host, needs network interface name" + return 0 + fi + + mkdir -p "$WYL_DIR/watchyourlan_data" + ensure_docker_dir_ownership "$WYL_DIR" + cd "$WYL_DIR" || return 1 + + local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + # Auto-detect primary network interface + local DEFAULT_IFACE + DEFAULT_IFACE=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}') + [ -z "$DEFAULT_IFACE" ] && DEFAULT_IFACE="eth0" + + echo "" + echo " WatchYourLAN needs to know which network interface to scan." + echo " Your detected primary interface: $DEFAULT_IFACE" + echo "" + ip link show 2>/dev/null | awk -F: '/^[0-9]+: / && !/lo/ {gsub(/ /,"",$2); print " •", $2}' || true + echo "" + local SCAN_IFACE="" + prompt_text "Network interface to scan:" "$DEFAULT_IFACE" SCAN_IFACE + [ -z "$SCAN_IFACE" ] && SCAN_IFACE="$DEFAULT_IFACE" + + local GUI_PORT="8840" + prompt_text "GUI port [8840]:" "8840" GUI_PORT + [ -z "$GUI_PORT" ] && GUI_PORT="8840" + + cat > docker-compose.yml << 'WYL_COMPOSE' +name: watchyourlan + +services: + watchyourlan: + image: aceberg/watchyourlan:latest + container_name: watchyourlan + hostname: watchyourlan + restart: unless-stopped + network_mode: host + env_file: .env + volumes: + - ./watchyourlan_data:/data +WYL_COMPOSE + + cat > .env << WYL_ENV +# ── General ─────────────────────────────────────────────────────────────────── +TZ=$TZ_VAL + +# ── WatchYourLAN ────────────────────────────────────────────────────────────── +# Network interface to scan (ARP scanning requires the physical interface) +IFACE=$SCAN_IFACE + +# GUI bind address and port (network_mode: host — binds directly to the host) +GUIIP=0.0.0.0 +GUIPORT=$GUI_PORT + +# Web UI theme (darkly, cosmo, lumen, sandstone, etc.) +THEME=darkly +WYL_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WYL_DIR" + log_success "WatchYourLAN configured at $WYL_DIR" + + # WatchYourLAN uses network_mode: host, so Caddy container-name routing + # can't reach it via caddy_net. Access is directly on host port $GUI_PORT. + # If behind Caddy on the same host, configure manually with host IP:PORT. + if [ -d "$DOCKER_DIR/caddy" ]; then + echo "" + log_info "Note: WatchYourLAN uses host networking (needed for ARP scanning)." + log_info "It cannot join caddy_net. To put it behind Caddy, add this block manually:" + echo "" + echo " yourdomain.com {" + echo " reverse_proxy :$GUI_PORT" + echo " }" + echo "" + echo " where HOST_IP is this server's IP on the Docker bridge (usually 172.17.0.1)." + fi + + write_readme "$WYL_DIR" << MD +# WatchYourLAN — network device tracker + +Scans the network continuously for connected devices, tracks history, +and alerts on new or unknown devices joining the network. + +## Access +- URL: http://localhost:$GUI_PORT (or http://:$GUI_PORT from LAN) + +## Scanning interface +Configured to scan: **$SCAN_IFACE** +Change \`IFACE\` in .env and restart if you need to scan a different interface. + +## Network mode note +WatchYourLAN uses \`network_mode: host\` to see real ARP traffic. +This means it cannot be added to caddy_net for reverse proxy via container name. +To put it behind Caddy, use the host's IP directly in the Caddyfile: +\`reverse_proxy 172.17.0.1:$GUI_PORT\` (adjust IP to your Docker bridge gateway). + +## Manage +\`\`\`bash +cd $WYL_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + local START_WYL="" + prompt_yn "Start WatchYourLAN now? (y/n):" "y" START_WYL + if [ "$START_WYL" = "y" ] || [ "$START_WYL" = "Y" ]; then + docker compose up -d \ + && log_success "WatchYourLAN started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:$GUI_PORT" + echo " Scanning: interface $SCAN_IFACE" + echo "" +} diff --git a/services/wg-easy.sh b/services/wg-easy.sh index 422ef03..d3ca608 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -60,18 +60,26 @@ services: ports: - "51820:51820/udp" - "51821:51821/tcp" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} WGEASY_COMPOSE cat > .env << WGEASY_ENV WG_HOST=$WG_HOST WG_PASSWORD=$WG_PASSWORD +CADDY_NET=$SITE_CADDY_NET WGEASY_ENV mkdir -p config chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WGEASY_DIR" log_success "wg-easy configured at $WGEASY_DIR" - configure_caddy_for_service "wg-easy" "51821" "vpn" + configure_caddy_for_service "wg-easy" "wg-easy:51821" "vpn" write_readme "$WGEASY_DIR" << MD # wg-easy diff --git a/services/wolf-pair.sh b/services/wolf-pair.sh index c6fc969..508bb03 100644 --- a/services/wolf-pair.sh +++ b/services/wolf-pair.sh @@ -239,6 +239,11 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock:ro restart: unless-stopped + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} COMPOSE log_success "docker-compose.yml written" diff --git a/tools/manage_users.sh b/tools/manage_users.sh new file mode 100644 index 0000000..05a1537 --- /dev/null +++ b/tools/manage_users.sh @@ -0,0 +1,643 @@ +#!/usr/bin/env bash +# manage_users.sh — FileBrowser user management via the REST API. +# +# Placed in ~/docker/filebrowser/ by the filebrowser installer. +# Requires: curl, jq (sudo apt install curl jq) +# +# Run with no arguments for the interactive menu. +# Pass a command for one-shot use (see --help). +# +# ── Username rules ──────────────────────────────────────────────────────────── +# Letters, numbers, hyphens, underscores only. No spaces, dots, or @. +# Examples: alice bob-smith data_user2 +# +# ── Password rules ──────────────────────────────────────────────────────────── +# Minimum 8 characters. No maximum. +# Must contain at least one letter and one number. +# +# ── Scope (file path) ───────────────────────────────────────────────────────── +# FileBrowser supports ONE scope path per user. +# Scope is an absolute path inside the container, relative to /srv (= FB_PATH). +# +# If FB_PATH=~/drives/data1: +# / → full access (all of ~/drives/data1) +# /alice → alice's own subdir (pair with linked dirs below) +# /music → music subdir only +# +# ── Multi-directory access via linked directories ──────────────────────────── +# FileBrowser follows symlinks inside the scope dir. +# Give a user scope=/alice, then link extra folders into /alice: +# +# /alice/music → /srv/music (alice sees "music" in her root) +# /alice/photos → /srv/photos (alice sees "photos" too) +# +# This script creates those symlinks via "docker exec", so they live in +# the bind-mount on the host as well (target is /srv/... so it appears +# broken from the host, but resolves correctly inside the container). +# +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FB_URL="${FB_URL:-http://localhost:8085}" +TOKEN="" + +# ── Output helpers ──────────────────────────────────────────────────────────── +if [[ -t 1 ]]; then + B=$'\e[1m' R=$'\e[0m' GRN=$'\e[32m' RED=$'\e[31m' DIM=$'\e[2m' +else + B="" R="" GRN="" RED="" DIM="" +fi + +die() { echo "${RED}ERROR:${R} $*" >&2; exit 1; } +ok() { echo " ${GRN}✓${R} $*"; } +errmsg() { echo " ${RED}✗${R} $*" >&2; } +hr() { printf ' %s\n' "────────────────────────────────────────────"; } +banner() { echo; hr; printf " ${B}%-44s${R}\n" "$*"; hr; } + +# ── Prerequisites ───────────────────────────────────────────────────────────── +require_cmds() { + for _c in "$@"; do + command -v "$_c" &>/dev/null || die "'$_c' not found — sudo apt install $_c" + done +} + +# ── Validation ──────────────────────────────────────────────────────────────── +validate_username() { + [[ -n "$1" ]] || { errmsg "Username cannot be empty."; return 1; } + [[ "$1" =~ ^[a-zA-Z0-9_-]+$ ]] || { + errmsg "Invalid username '$1'. Use only letters, numbers, hyphens, underscores." + return 1 + } +} + +validate_password() { + [[ ${#1} -ge 8 ]] || { errmsg "Password too short (minimum 8 characters)."; return 1; } + [[ "$1" =~ [a-zA-Z] ]] || { errmsg "Password must contain at least one letter."; return 1; } + [[ "$1" =~ [0-9] ]] || { errmsg "Password must contain at least one number."; return 1; } +} + +validate_scope() { + [[ "$1" == /* ]] || { errmsg "Scope must start with / (e.g. / or /alice or /music)"; return 1; } +} + +# prompt_password VARNAME [label] +# Uses nameref (bash 4.3+) so the caller's local variable is set correctly. +prompt_password() { + local -n _pp_ref="$1" + local _label="${2:-New password}" + local _p1 _p2 + while true; do + read -r -s -p " $_label: " _p1; echo + validate_password "$_p1" || continue + read -r -s -p " Confirm: " _p2; echo + [[ "$_p1" == "$_p2" ]] || { errmsg "Passwords do not match. Try again."; continue; } + _pp_ref="$_p1" + break + done +} + +# ── Auth — login once, reuse token ──────────────────────────────────────────── +ensure_token() { + [[ -n "$TOKEN" ]] && return 0 + echo + echo " ${B}FileBrowser login${R} ${DIM}(${FB_URL})${R}" + local _u _p _tok + read -r -p " Admin username [admin]: " _u + _u="${_u:-admin}" + read -r -s -p " Admin password: " _p; echo + _tok=$(curl -s -X POST "$FB_URL/api/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$_u\",\"password\":\"$_p\"}") + [[ "$_tok" == *"."*"."* ]] \ + || die "Login failed. Check credentials and that FileBrowser is running at $FB_URL" + TOKEN="$_tok" + ok "Logged in as $_u" +} + +# ── REST wrappers ───────────────────────────────────────────────────────────── +api_get() { curl -sf -X GET "$FB_URL$1" -H "X-Auth: $TOKEN"; } +api_post() { curl -sf -X POST "$FB_URL$1" -H "X-Auth: $TOKEN" \ + -H "Content-Type: application/json" -d "$2"; } +api_put() { curl -sf -X PUT "$FB_URL$1" -H "X-Auth: $TOKEN" \ + -H "Content-Type: application/json" -d "$2"; } +api_delete() { curl -sf -X DELETE "$FB_URL$1" -H "X-Auth: $TOKEN"; } + +find_user() { + api_get "/api/users" | jq -r --arg u "$1" '.[] | select(.username==$u)' +} + +get_user_id() { + local _j + _j=$(find_user "$1") + [[ -n "$_j" ]] || { errmsg "User '$1' not found."; return 1; } + echo "$_j" | jq -r '.id' +} + +default_perms() { + echo '{"admin":false,"execute":false,"create":true,"rename":true, + "modify":true,"delete":true,"share":false,"download":true}' +} + +# ── Docker helpers ───────────────────────────────────────────────────────────── + +get_container_name() { + local _compose="$SCRIPT_DIR/docker-compose.yml" + if [[ -f "$_compose" ]]; then + local _name + _name=$(grep 'container_name:' "$_compose" | head -1 | awk '{print $2}') + [[ -n "$_name" ]] && { echo "$_name"; return; } + fi + echo "filebrowser" +} + +check_container() { + require_cmds docker + local _running + _running=$(docker inspect --format='{{.State.Running}}' "$1" 2>/dev/null || echo "false") + [[ "$_running" == "true" ]] \ + || { errmsg "Container '$1' is not running. Start it: docker compose up -d"; return 1; } +} + +# Print symlinks inside /srv, one per line. Paths shown as FileBrowser sees them (no /srv). +list_links() { + local _c="$1" _scope="$2" + docker exec "$_c" find "/srv$_scope" -maxdepth 1 -type l \ + -exec sh -c ' + _name=$(basename "$1") + _target=$(readlink "$1") + _display="${_target#/srv}" + [ -z "$_display" ] && _display="/" + printf " %-24s→ %s\n" "$_name" "$_display" + ' _ {} \; 2>/dev/null | sort || true +} + +# ── prompt_add_links SCOPE ──────────────────────────────────────────────────── +# Loop: user types folder names as they appear in FileBrowser — blank to finish. +# Shared by cmd_add (offered inline) and menu_links (add option). +prompt_add_links() { + local _scope="$1" + local _c + _c=$(get_container_name) + check_container "$_c" || return 1 + + local _scope_dir="/srv$_scope" + docker exec "$_c" mkdir -p "$_scope_dir" >/dev/null 2>&1 || true + + echo + echo " Type a folder name to add — ? to list available folders, blank when done." + echo + + while true; do + local _src="" + read -r -p " Folder to add [done]: " _src + [[ -n "$_src" ]] || break + + if [[ "$_src" == "?" ]]; then + local _avail + _avail=$(docker exec "$_c" find /srv -maxdepth 1 -mindepth 1 -type d \ + -not -name ".*" 2>/dev/null | sed 's|^/srv/||' | sort | xargs echo) || true + echo " Available: ${_avail:-(none found)}" + echo + continue + fi + + # Normalise: strip surrounding slashes + _src="${_src#/}" + _src="${_src%/}" + [[ -n "$_src" ]] || continue + + local _target="/srv/$_src" + local _link_name + _link_name=$(basename "$_src") + + if ! docker exec "$_c" test -e "$_target" 2>/dev/null; then + errmsg "'$_src' not found in FileBrowser — check the path and try again." + continue + fi + + local _link_path="$_scope_dir/$_link_name" + if docker exec "$_c" test -e "$_link_path" 2>/dev/null; then + errmsg "'$_link_name' already exists in this user's folder — use Remove to clear it first." + continue + fi + + docker exec "$_c" ln -s "$_target" "$_link_path" + if [[ "$_src" == "$_link_name" ]]; then + ok "User can now see '$_link_name'" + else + ok "User can now see '$_link_name' ${DIM}(from $_src)${R}" + fi + done +} + +# ── Linked-directory submenu (shown from Modify option 5) ──────────────────── +menu_links() { + local _scope="$1" + local _c + _c=$(get_container_name) + check_container "$_c" || return 1 + + while true; do + banner "Linked directories (scope: $_scope)" + echo " Folders this user can see beyond their scope:" + echo + local _links + _links=$(list_links "$_c" "$_scope") + if [[ -n "$_links" ]]; then + echo "$_links" + else + echo " (none)" + fi + echo + echo " 1 Add folders" + echo " 2 Remove a folder" + echo " 0 Back" + echo + local _ch="" + read -r -p " Choice: " _ch + case "$_ch" in + 1) prompt_add_links "$_scope" || true ;; + 2) + local _link_name="" + echo + read -r -p " Folder name to remove: " _link_name + [[ -n "$_link_name" ]] || continue + local _link_path="/srv${_scope}/${_link_name}" + if ! docker exec "$_c" test -L "$_link_path" 2>/dev/null; then + errmsg "'$_link_name' is not a linked folder — refusing to delete." + continue + fi + docker exec "$_c" rm "$_link_path" + ok "'$_link_name' removed." + ;; + 0) break ;; + *) errmsg "Invalid choice." ;; + esac + done +} + +# ── cmd: list ───────────────────────────────────────────────────────────────── +cmd_list() { + ensure_token + echo + printf " ${B}%-22s %-5s %s${R}\n" "USERNAME" "ADMIN" "SCOPE" + printf " %-22s %-5s %s\n" "--------" "-----" "-----" + api_get "/api/users" | \ + jq -r '.[] | [.username, (if .perm.admin then "yes" else "no" end), .scope] | @tsv' | \ + while IFS=$'\t' read -r _u _a _s; do + printf " %-22s %-5s %s\n" "$_u" "$_a" "$_s" + done + echo +} + +# ── cmd: add ───────────────────────────────────────────────────────────────── +cmd_add() { + local _username="${1:-}" _scope="${2:-}" _is_admin="false" + [[ "${3:-}" == "--admin" ]] && _is_admin="true" + + ensure_token + + if [[ -z "$_username" ]]; then + echo + echo " ${B}Username:${R} letters, numbers, hyphens, underscores only. No dots or @." + echo " ${B}Password:${R} min 8 chars, at least 1 letter and 1 number." + echo " ${B}Scope:${R} one path per user — use linked dirs for multi-folder access." + echo + read -r -p " Username: " _username + local _adm="" + read -r -p " Admin? [y/N]: " _adm + [[ "${_adm,,}" == "y" ]] && _is_admin="true" + fi + + validate_username "$_username" || return 1 + + if [[ -z "$_scope" ]]; then + echo + echo " Scope examples:" + echo " / full access (all of FB_PATH)" + echo " $_username user's own private subdir (pair with linked dirs)" + echo " music music subdir only" + echo + read -r -p " Scope for '$_username': " _scope + _scope="/${_scope#/}" # ensure leading / + fi + + validate_scope "$_scope" || return 1 + + local _ex + _ex=$(find_user "$_username") + if [[ -n "$_ex" ]]; then + errmsg "User '$_username' already exists. Use Modify to change it." + return 1 + fi + + echo + local password="" + prompt_password password "Password for $_username" + + local _perms _body + _perms=$(default_perms) + [[ "$_is_admin" == "true" ]] && _perms=$(echo "$_perms" | jq '.admin = true') + _body=$(jq -n \ + --arg u "$_username" --arg p "$password" --arg s "$_scope" \ + --argjson perms "$_perms" \ + '{username:$u, password:$p, scope:$s, locale:"en", viewMode:"list", + singleClick:false, sorting:{by:"name",asc:true}, perm:$perms, + commands:[], lockPassword:false, hideDotfiles:false, dateFormat:false}') + api_post "/api/users" "$_body" >/dev/null + echo + ok "User '$_username' created | scope: $_scope | admin: $_is_admin" + + # Offer linked directories (pointless if scope is already /) + if command -v docker &>/dev/null && [[ "$_scope" != "/" ]]; then + local _do_links="" + read -r -p " Add linked directories for '$_username'? [y/N]: " _do_links + if [[ "${_do_links,,}" == "y" ]]; then + prompt_add_links "$_scope" || true + fi + fi +} + +# ── cmd: delete ─────────────────────────────────────────────────────────────── +cmd_delete() { + local _username="${1:-}" + ensure_token + + if [[ -z "$_username" ]]; then + cmd_list + read -r -p " Username to delete: " _username + fi + validate_username "$_username" || return 1 + + local _uid + _uid=$(get_user_id "$_username") || return 1 + + local _c="" + read -r -p " Delete '$_username' (id $_uid)? [y/N]: " _c + [[ "${_c,,}" == "y" ]] || { echo " Aborted."; return 0; } + api_delete "/api/users/$_uid" >/dev/null + ok "User '$_username' deleted." + echo " ${DIM}Note: symlinks in their scope dir still exist on disk if you want to reuse them.${R}" +} + +# ── cmd: passwd ─────────────────────────────────────────────────────────────── +cmd_passwd() { + local _username="${1:-}" + ensure_token + + if [[ -z "$_username" ]]; then + cmd_list + read -r -p " Username: " _username + fi + validate_username "$_username" || return 1 + + local _uid _user + _uid=$(get_user_id "$_username") || return 1 + _user=$(find_user "$_username") + + echo + echo " ${B}Password rules:${R} min 8 chars, at least 1 letter and 1 number." + echo + local password="" + prompt_password password "New password for $_username" + + local _body + _body=$(echo "$_user" | jq --arg p "$password" '. + {password: $p}') + api_put "/api/users/$_uid" "$_body" >/dev/null + echo + ok "Password updated for '$_username'." +} + +# ── cmd: scope ──────────────────────────────────────────────────────────────── +cmd_scope() { + local _username="${1:-}" _new_scope="${2:-}" + ensure_token + + if [[ -z "$_username" ]]; then + cmd_list + read -r -p " Username: " _username + fi + validate_username "$_username" || return 1 + + local _uid _user _old_scope + _uid=$(get_user_id "$_username") || return 1 + _user=$(find_user "$_username") + _old_scope=$(echo "$_user" | jq -r '.scope') + + if [[ -z "$_new_scope" ]]; then + echo + echo " Current scope: $_old_scope" + echo " ${DIM}Linked folders in the old scope are not moved automatically.${R}" + echo + read -r -p " New scope: " _new_scope + _new_scope="/${_new_scope#/}" + fi + validate_scope "$_new_scope" || return 1 + + local _body + _body=$(echo "$_user" | jq --arg s "$_new_scope" '. + {scope: $s}') + api_put "/api/users/$_uid" "$_body" >/dev/null + echo + ok "Scope updated for '$_username': $_old_scope → $_new_scope" + + # Offer to add links into the new scope + if command -v docker &>/dev/null && [[ "$_new_scope" != "/" ]]; then + local _do_links="" + read -r -p " Add linked directories into '$_new_scope'? [y/N]: " _do_links + if [[ "${_do_links,,}" == "y" ]]; then + prompt_add_links "$_new_scope" || true + fi + fi +} + +# ── cmd: rename ─────────────────────────────────────────────────────────────── +cmd_rename() { + local _username="${1:-}" _new_username="${2:-}" + ensure_token + + if [[ -z "$_username" ]]; then + cmd_list + read -r -p " Username to rename: " _username + fi + validate_username "$_username" || return 1 + + if [[ -z "$_new_username" ]]; then + read -r -p " New username: " _new_username + fi + validate_username "$_new_username" || return 1 + + local _uid _user _body + _uid=$(get_user_id "$_username") || return 1 + _user=$(find_user "$_username") + _body=$(echo "$_user" | jq --arg u "$_new_username" '. + {username: $u}') + api_put "/api/users/$_uid" "$_body" >/dev/null + ok "Renamed: '$_username' → '$_new_username'" +} + +# ── cmd: info ───────────────────────────────────────────────────────────────── +cmd_info() { + local _username="${1:-}" + ensure_token + + if [[ -z "$_username" ]]; then + cmd_list + read -r -p " Username: " _username + fi + validate_username "$_username" || return 1 + + local _user + _user=$(find_user "$_username") + [[ -n "$_user" ]] || { errmsg "User '$_username' not found."; return 1; } + echo + echo "$_user" | jq '{username, scope, + admin: .perm.admin, + create: .perm.create, + modify: .perm.modify, + delete: .perm.delete, + download: .perm.download, + execute: .perm.execute}' + echo +} + +# ── Modify submenu ──────────────────────────────────────────────────────────── +menu_modify() { + ensure_token + cmd_list + + local _cur="" + read -r -p " Username to modify: " _cur + validate_username "$_cur" || return 1 + get_user_id "$_cur" >/dev/null || return 1 + + while true; do + local _user _scope _admin + _user=$(find_user "$_cur") || { errmsg "User '$_cur' no longer exists."; break; } + [[ -n "$_user" ]] || { errmsg "User '$_cur' no longer exists."; break; } + _scope=$(echo "$_user" | jq -r '.scope') + _admin=$(echo "$_user" | jq -r 'if .perm.admin then "yes" else "no" end') + + banner "Modify: $_cur" + echo " ${B}Scope:${R} $_scope" + echo " ${B}Admin:${R} $_admin" + echo + echo " 1 Change username" + echo " 2 Change password" + echo " 3 Change scope (file path)" + echo " 4 Toggle admin status" + echo " 5 Linked directories ${DIM}(add/remove multi-folder symlinks)${R}" + echo " 0 Back" + echo + local _ch="" + read -r -p " Choice: " _ch + + case "$_ch" in + 1) + local _new_u="" + echo + read -r -p " New username: " _new_u + validate_username "$_new_u" || continue + local _uid1 _body1 + _uid1=$(get_user_id "$_cur") || continue + _body1=$(echo "$_user" | jq --arg u "$_new_u" '. + {username: $u}') + if api_put "/api/users/$_uid1" "$_body1" >/dev/null; then + ok "Renamed: '$_cur' → '$_new_u'" + _cur="$_new_u" + else + errmsg "Rename failed." + fi + ;; + 2) cmd_passwd "$_cur" || true ;; + 3) cmd_scope "$_cur" || true ;; + 4) + local _uid4 + _uid4=$(get_user_id "$_cur") || continue + local _toggled + _toggled=$(echo "$_user" | jq '.perm.admin = (.perm.admin | not)') + if api_put "/api/users/$_uid4" "$_toggled" >/dev/null; then + local _new_admin + _new_admin=$(echo "$_toggled" | jq -r 'if .perm.admin then "yes" else "no" end') + ok "Admin for '$_cur' is now: $_new_admin" + else + errmsg "Toggle failed." + fi + ;; + 5) menu_links "$_scope" || true ;; + 0) break ;; + *) errmsg "Invalid choice." ;; + esac + done +} + +# ── usage ───────────────────────────────────────────────────────────────────── +usage() { + cat <<'EOF' +FileBrowser user management + + Run with no arguments for the interactive menu. + +One-shot usage: + manage_users.sh list + manage_users.sh add [--admin] + manage_users.sh delete + manage_users.sh passwd + manage_users.sh scope + manage_users.sh rename + manage_users.sh info + +Scope is relative to /srv inside the container (= FB_PATH on the host). +Username: letters, numbers, hyphens, underscores only. No dots or @. +Password: min 8 chars, at least one letter and one number. + +Multi-directory access: use the interactive menu — linked directories +are offered automatically when you add a user or change their scope. + +Override URL: FB_URL=http://localhost:8085 ./manage_users.sh +EOF +} + +# ── Main interactive menu ───────────────────────────────────────────────────── +run_interactive() { + ensure_token + while true; do + banner "FileBrowser User Manager" + echo " ${DIM}${FB_URL}${R}" + echo + echo " 1 List users" + echo " 2 Add user" + echo " 3 Delete user" + echo " 4 Modify user (username / password / scope / admin / links)" + echo " 5 View user details" + echo " 0 Exit" + echo + local _ch="" + read -r -p " Choice: " _ch + case "$_ch" in + 1) cmd_list || true ;; + 2) cmd_add || true ;; + 3) cmd_delete || true ;; + 4) menu_modify || true ;; + 5) cmd_info || true ;; + 0) echo; echo " Goodbye."; echo; exit 0 ;; + *) errmsg "Invalid choice." ;; + esac + done +} + +# ── Entry point ─────────────────────────────────────────────────────────────── +require_cmds curl jq + +_cmd="${1:-}" +shift || true + +case "$_cmd" in + "") run_interactive ;; + list) ensure_token; cmd_list ;; + add) ensure_token; cmd_add "$@" ;; + delete|del) ensure_token; cmd_delete "${1:-}" ;; + passwd|pw) ensure_token; cmd_passwd "${1:-}" ;; + scope) ensure_token; cmd_scope "${1:-}" "${2:-}" ;; + rename) ensure_token; cmd_rename "${1:-}" "${2:-}" ;; + info) ensure_token; cmd_info "${1:-}" ;; + help|--help|-h) usage ;; + *) errmsg "Unknown command: $_cmd"; echo; usage; exit 2 ;; +esac