From 56bec03c9b8ae5ce0235515f10007f8a4d70a921 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:02:26 +0000 Subject: [PATCH 1/9] Fix Caddy routing and add caddy_net to all Docker services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/common.sh: fix broken reverse_proxy target — localhost inside Caddy's container is the container's own loopback, not the host; change to host.docker.internal so proxied ports are actually reachable - services/caddy.sh: add extra_hosts host.docker.internal:host-gateway so the above resolves correctly; create caddy_net bridge network in Caddy's own compose so other services can reference it as external; update all Caddyfile template comments and README examples to match - services/filebrowser.sh: update image tag from deprecated :s6 to :latest; remove non-functional PUID/PGID env vars (filebrowser/filebrowser does not honour them); add configure_caddy_for_service call; add caddy_net - services/ntfy.sh: add configure_caddy_for_service call; add caddy_net - services/portainer.sh: add configure_caddy_for_service call; add caddy_net - services/frigate-notify.sh, watchtower.sh: add caddy_net for container-to-container comms (frigate, ntfy) without a Caddy call - All remaining web-facing Docker services: add caddy_net network block to docker-compose and CADDY_NET to .env where applicable; services using network_mode: host (wolf-pair, lyrion) have the top-level block only https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- lib/common.sh | 2 +- services/actualbudget.sh | 8 ++++++++ services/arm.sh | 8 ++++++++ services/audiobookshelf.sh | 8 ++++++++ services/caddy.sh | 15 ++++++++++++--- services/emby.sh | 8 ++++++++ services/filebrowser.sh | 14 +++++++++++--- services/fmd.sh | 8 ++++++++ services/frigate-audio.sh | 8 ++++++++ services/frigate-notify.sh | 7 +++++++ services/frigate.sh | 8 ++++++++ services/homeassistant.sh | 11 ++++++++++- services/immich.sh | 16 ++++++++++++++++ services/jellyfin.sh | 8 ++++++++ services/js99er.sh | 7 +++++++ services/lyrion.sh | 6 ++++++ services/magicmirror.sh | 7 +++++++ services/mealie.sh | 7 +++++++ services/meshcentral.sh | 8 ++++++++ services/ntfy.sh | 10 ++++++++++ services/portainer.sh | 9 +++++++++ services/traccar.sh | 7 +++++++ services/uptimekuma.sh | 7 +++++++ services/watchtower.sh | 8 ++++++++ services/wg-easy.sh | 8 ++++++++ services/wolf-pair.sh | 5 +++++ 26 files changed, 210 insertions(+), 8 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index c8bdb4f..9315e86 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -320,7 +320,7 @@ configure_caddy_for_service() { # $SERVICE_NAME $SERVICE_DOMAIN { - reverse_proxy localhost:$SERVICE_PORT + reverse_proxy host.docker.internal:$SERVICE_PORT # Security headers header { diff --git a/services/actualbudget.sh b/services/actualbudget.sh index 7da0e9c..a6eacee 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -40,10 +40,18 @@ 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" diff --git a/services/arm.sh b/services/arm.sh index 7f5dd19..858309b 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 diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh index c230602..4754beb 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -50,11 +50,19 @@ 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 diff --git a/services/caddy.sh b/services/caddy.sh index 2adeb96..8233122 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -82,6 +82,15 @@ services: - ACME_AGREE=true labels: - "io.podman.annotations.label/crowdsec.enable=true" + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - caddy_net + +networks: + caddy_net: + driver: bridge + name: caddy_net CADDY_COMPOSE # Create Caddyfile if it doesn't exist @@ -114,7 +123,7 @@ CADDY_COMPOSE # Example: # myservice.yourdomain.com { # import authelia -# reverse_proxy localhost:PORT +# reverse_proxy host.docker.internal:PORT # } # ActualBudget @@ -124,7 +133,7 @@ CADDY_COMPOSE # format json # level INFO # } -# reverse_proxy localhost:5006 +# reverse_proxy host.docker.internal:5006 # header { # Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" # X-Frame-Options "SAMEORIGIN" @@ -165,7 +174,7 @@ a web service). You can also edit it by hand: ``` myservice.example.com { - reverse_proxy localhost:1234 + reverse_proxy host.docker.internal: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..ea1bb4e 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -59,10 +59,18 @@ 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 diff --git a/services/filebrowser.sh b/services/filebrowser.sh index 13ef4c3..afaa04f 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 @@ -64,6 +70,8 @@ FB_SETTINGS echo "" log_success "Filebrowser configured at $FB_DIR" + configure_caddy_for_service "FileBrowser" "8085" "files" + write_readme "$FB_DIR" << MD # FileBrowser diff --git a/services/fmd.sh b/services/fmd.sh index d5ba5d5..9d52ff9 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -44,10 +44,18 @@ 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 diff --git a/services/frigate-audio.sh b/services/frigate-audio.sh index 4cad85f..e9b050f 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" 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..6f4bcd1 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 diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 060daf9..bd7488b 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 diff --git a/services/immich.sh b/services/immich.sh index e8d2f7d..09c9561 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 diff --git a/services/jellyfin.sh b/services/jellyfin.sh index a1d4d67..273a3dc 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -69,10 +69,18 @@ $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 diff --git a/services/js99er.sh b/services/js99er.sh index bc47bde..c62645c 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" 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..aa4e256 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 diff --git a/services/mealie.sh b/services/mealie.sh index 7f07d28..e8d6fa9 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -50,6 +50,13 @@ 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 diff --git a/services/meshcentral.sh b/services/meshcentral.sh index 7dfb25e..e249ccb 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -54,12 +54,20 @@ 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 diff --git a/services/ntfy.sh b/services/ntfy.sh index fb004d4..70cd5bc 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" "8090" "ntfy" + write_readme "$NTFY_DIR" << MD # ntfy diff --git a/services/portainer.sh b/services/portainer.sh index 0ad880e..f8a3338 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" "9000" "portainer" + write_readme "$PORTAINER_DIR" << MD # Portainer diff --git a/services/traccar.sh b/services/traccar.sh index 0a03bdd..7e91cbe 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 diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index 3264a3a..907b864 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 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/wg-easy.sh b/services/wg-easy.sh index 422ef03..f2dea62 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -60,11 +60,19 @@ 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 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" From 9c52ac22e1edb56b4b6c20a61801cbf734d7e605 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:12:01 +0000 Subject: [PATCH 2/9] Fix Caddy routing: use container:port via caddy_net (DoTheEvo pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undo the host.docker.internal approach from the previous commit — proper Docker networking routes Caddy to services by container name on the shared caddy_net, not via the host gateway. - lib/common.sh: configure_caddy_for_service now accepts either a plain port number (localhost:PORT fallback) or container:port (preferred). The Caddyfile entry uses the container name for direct Docker DNS routing. - services/caddy.sh: remove extra_hosts hack; update Caddyfile template comments to show container_name:port format - All service files: update configure_caddy_for_service calls to pass container_name:internal_port (e.g. "filebrowser:80", "mealie:9000"). Services using network_mode:host keep plain port numbers. - tools/manage_users.sh: new FileBrowser user-management script (deployed to ~/docker/filebrowser/ during installation). Manages users via the FileBrowser REST API: list, add, delete, passwd, scope, info commands. Documents username format (letters/numbers/hyphens/underscores), password rules (min 8 chars, letter + number required), and scope path convention relative to /srv (= FB_PATH on the host). https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- lib/common.sh | 19 +- services/actualbudget.sh | 2 +- services/arm.sh | 2 +- services/audiobookshelf.sh | 2 +- services/caddy.sh | 8 +- services/emby.sh | 2 +- services/filebrowser.sh | 12 +- services/fmd.sh | 2 +- services/frigate-audio.sh | 2 +- services/frigate.sh | 2 +- services/homeassistant.sh | 6 +- services/immich.sh | 2 +- services/jellyfin.sh | 2 +- services/js99er.sh | 2 +- services/magicmirror.sh | 2 +- services/mealie.sh | 2 +- services/meshcentral.sh | 2 +- services/ntfy.sh | 2 +- services/portainer.sh | 2 +- services/traccar.sh | 2 +- services/uptimekuma.sh | 2 +- services/wg-easy.sh | 2 +- tools/manage_users.sh | 380 +++++++++++++++++++++++++++++++++++++ 23 files changed, 432 insertions(+), 29 deletions(-) create mode 100644 tools/manage_users.sh diff --git a/lib/common.sh b/lib/common.sh index 9315e86..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 host.docker.internal:$SERVICE_PORT + reverse_proxy $_UPSTREAM # Security headers header { diff --git a/services/actualbudget.sh b/services/actualbudget.sh index a6eacee..3cc01a1 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -57,7 +57,7 @@ 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 858309b..6614c4e 100644 --- a/services/arm.sh +++ b/services/arm.sh @@ -93,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 4754beb..f8e2f2d 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -69,7 +69,7 @@ ABS_ENV 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 8233122..f4ee359 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -82,8 +82,6 @@ services: - ACME_AGREE=true labels: - "io.podman.annotations.label/crowdsec.enable=true" - extra_hosts: - - "host.docker.internal:host-gateway" networks: - caddy_net @@ -123,7 +121,7 @@ CADDY_COMPOSE # Example: # myservice.yourdomain.com { # import authelia -# reverse_proxy host.docker.internal:PORT +# reverse_proxy container_name:port # } # ActualBudget @@ -133,7 +131,7 @@ CADDY_COMPOSE # format json # level INFO # } -# reverse_proxy host.docker.internal:5006 +# reverse_proxy actualbudget:5006 # header { # Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" # X-Frame-Options "SAMEORIGIN" @@ -174,7 +172,7 @@ a web service). You can also edit it by hand: ``` myservice.example.com { - reverse_proxy host.docker.internal: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 ea1bb4e..96095da 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -77,7 +77,7 @@ EMBY_ENV 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 afaa04f..5299171 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -67,10 +67,20 @@ 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" "8085" "files" + 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 9d52ff9..db11e5f 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -62,7 +62,7 @@ FMD_ENV 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 e9b050f..3bca1fb 100644 --- a/services/frigate-audio.sh +++ b/services/frigate-audio.sh @@ -514,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.sh b/services/frigate.sh index 6f4bcd1..ef6321a 100644 --- a/services/frigate.sh +++ b/services/frigate.sh @@ -128,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/homeassistant.sh b/services/homeassistant.sh index bd7488b..360c958 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -91,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 09c9561..49d5049 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -572,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 273a3dc..96a4bc8 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -87,7 +87,7 @@ JELLYFIN_ENV 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 c62645c..7cd1e9f 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -275,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/magicmirror.sh b/services/magicmirror.sh index aa4e256..afdff96 100644 --- a/services/magicmirror.sh +++ b/services/magicmirror.sh @@ -132,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/mealie.sh b/services/mealie.sh index e8d6fa9..9dfdcca 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -63,7 +63,7 @@ MEALIE_COMPOSE 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 e249ccb..c9f6a50 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -74,7 +74,7 @@ MC_ENV 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 70cd5bc..060f433 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -55,7 +55,7 @@ NTFY_ENV echo "" log_success "ntfy configured at $NTFY_DIR" - configure_caddy_for_service "ntfy" "8090" "ntfy" + 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 f8a3338..3ee55e2 100644 --- a/services/portainer.sh +++ b/services/portainer.sh @@ -48,7 +48,7 @@ PORTAINER_COMPOSE echo "" log_success "Portainer configured at $PORTAINER_DIR" - configure_caddy_for_service "Portainer" "9000" "portainer" + configure_caddy_for_service "Portainer" "portainer:9000" "portainer" write_readme "$PORTAINER_DIR" << MD # Portainer diff --git a/services/traccar.sh b/services/traccar.sh index 7e91cbe..2c03ec8 100644 --- a/services/traccar.sh +++ b/services/traccar.sh @@ -70,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/uptimekuma.sh b/services/uptimekuma.sh index 907b864..e326e8c 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -75,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/wg-easy.sh b/services/wg-easy.sh index f2dea62..d3ca608 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -79,7 +79,7 @@ WGEASY_ENV 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/tools/manage_users.sh b/tools/manage_users.sh new file mode 100644 index 0000000..d8e3cfa --- /dev/null +++ b/tools/manage_users.sh @@ -0,0 +1,380 @@ +#!/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 (apt install curl jq) +# +# 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 info +# +# ── Username rules ──────────────────────────────────────────────────────────── +# Letters, numbers, hyphens, underscores only. No spaces or dots. +# Examples: alice bob-smith data_user2 +# +# ── Password rules ──────────────────────────────────────────────────────────── +# Minimum 8 characters. No maximum. +# Must contain at least one letter and one number. +# Special characters are allowed. +# +# ── Scope rules ─────────────────────────────────────────────────────────────── +# Scope is a path INSIDE the container, relative to the FileBrowser root (/srv). +# The volume in docker-compose.yml mounts your host path (FB_PATH) as /srv. +# +# If FB_PATH is ~/drives/data1 then: +# / → full access to ~/drives/data1 +# /music → ~/drives/data1/music only +# /docs/bob → ~/drives/data1/docs/bob only +# +# Admin account created on first login gets scope / by default. +# +# ── Examples ───────────────────────────────────────────────────────────────── +# Add admin with full access: +# ./manage_users.sh add admin / +# +# Add alice with access to just the music directory: +# ./manage_users.sh add alice /music +# +# Add bob as an admin with full access: +# ./manage_users.sh add bob / --admin +# +# Change alice's password: +# ./manage_users.sh passwd alice +# +# Restrict alice to a subdirectory: +# ./manage_users.sh scope alice /music/alice +# +# List all users: +# ./manage_users.sh list +# +# Delete bob: +# ./manage_users.sh delete bob +# +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FB_URL="${FB_URL:-http://localhost:8085}" + +# ── Helpers ─────────────────────────────────────────────────────────────────── +die() { echo "ERROR: $*" >&2; exit 1; } +info() { echo " $*"; } + +require_cmd() { + for cmd in "$@"; do + command -v "$cmd" &>/dev/null || die "'$cmd' not found. Install it: sudo apt install $cmd" + done +} + +validate_username() { + local u="$1" + [[ -n "$u" ]] || die "Username cannot be empty." + [[ "$u" =~ ^[a-zA-Z0-9_-]+$ ]] || die "Invalid username '$u'. Only letters, numbers, hyphens, underscores allowed." +} + +validate_password() { + local p="$1" + [[ ${#p} -ge 8 ]] || die "Password too short (minimum 8 characters)." + [[ "$p" =~ [a-zA-Z] ]] || die "Password must contain at least one letter." + [[ "$p" =~ [0-9] ]] || die "Password must contain at least one number." +} + +validate_scope() { + local s="$1" + [[ "$s" == /* ]] || die "Scope must be an absolute path starting with / (e.g. /music or /)" +} + +prompt_password() { + local varname="$1" prompt="${2:-Password}" + local p1 p2 + while true; do + read -r -s -p "$prompt: " p1; echo + read -r -s -p "Confirm: " p2; echo + [[ "$p1" == "$p2" ]] || { echo " Passwords do not match. Try again."; continue; } + validate_password "$p1" + printf -v "$varname" "%s" "$p1" + break + done +} + +# ── Authentication ───────────────────────────────────────────────────────────── +get_token() { + local admin_user admin_pass + read -r -p "FileBrowser admin username [admin]: " admin_user + admin_user="${admin_user:-admin}" + read -r -s -p "FileBrowser admin password: " admin_pass; echo + + local resp + resp=$(curl -s -o /dev/null -w "%{http_code}:%{stderr}" \ + -X POST "$FB_URL/api/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}" 2>/dev/null || true) + + local token + token=$(curl -s -X POST "$FB_URL/api/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}") + + [[ "$token" == *"."*"."* ]] || die "Login failed. Check credentials and that FileBrowser is running." + echo "$token" +} + +# ── API helpers ──────────────────────────────────────────────────────────────── +api_get() { + local token="$1" path="$2" + curl -sf -X GET "$FB_URL$path" -H "X-Auth: $token" +} + +api_post() { + local token="$1" path="$2" body="$3" + curl -sf -X POST "$FB_URL$path" \ + -H "X-Auth: $token" -H "Content-Type: application/json" -d "$body" +} + +api_put() { + local token="$1" path="$2" body="$3" + curl -sf -X PUT "$FB_URL$path" \ + -H "X-Auth: $token" -H "Content-Type: application/json" -d "$body" +} + +api_delete() { + local token="$1" path="$2" + curl -sf -X DELETE "$FB_URL$path" -H "X-Auth: $token" +} + +# Returns user JSON object for the given username, or empty string if not found. +find_user() { + local token="$1" username="$2" + api_get "$token" "/api/users" | jq -r --arg u "$username" '.[] | select(.username==$u)' +} + +get_user_id() { + local token="$1" username="$2" + local user + user=$(find_user "$token" "$username") + [[ -n "$user" ]] || die "User '$username' not found." + echo "$user" | jq -r '.id' +} + +# ── Default permissions for new non-admin users ────────────────────────────── +default_perms() { + cat <<'JSON' +{ + "admin": false, + "execute": false, + "create": true, + "rename": true, + "modify": true, + "delete": true, + "share": false, + "download": true +} +JSON +} + +# ── Commands ────────────────────────────────────────────────────────────────── + +cmd_list() { + local token + token=$(get_token) + echo + printf "%-20s %-5s %-30s\n" "USERNAME" "ADMIN" "SCOPE" + printf "%-20s %-5s %-30s\n" "--------" "-----" "-----" + api_get "$token" "/api/users" | \ + jq -r '.[] | [.username, (if .perm.admin then "yes" else "no" end), .scope] | @tsv' | \ + while IFS=$'\t' read -r uname is_admin scope; do + printf "%-20s %-5s %s\n" "$uname" "$is_admin" "$scope" + done +} + +cmd_add() { + local username="$1" scope="$2" is_admin="${3:-false}" + validate_username "$username" + validate_scope "$scope" + + local password + echo + echo "Setting password for new user '$username'." + echo " Min 8 chars, at least one letter and one number." + echo + prompt_password password "New password for $username" + + local token + token=$(get_token) + + # Check if user already exists + local existing + existing=$(find_user "$token" "$username") + [[ -z "$existing" ]] || die "User '$username' already exists. Use 'passwd' or 'scope' to modify." + + local perms + perms=$(default_perms) + if [[ "$is_admin" == "true" ]]; then + perms=$(echo "$perms" | jq '.admin = true') + fi + + local body + 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 "$token" "/api/users" "$body" >/dev/null + echo + info "User '$username' created." + info " Scope: $scope" + info " Admin: $is_admin" +} + +cmd_delete() { + local username="$1" + validate_username "$username" + + local token + token=$(get_token) + + local uid + uid=$(get_user_id "$token" "$username") + + local confirm + read -r -p "Delete user '$username' (id=$uid)? [y/N]: " confirm + [[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; } + + api_delete "$token" "/api/users/$uid" >/dev/null + echo + info "User '$username' deleted." +} + +cmd_passwd() { + local username="$1" + validate_username "$username" + + local token + token=$(get_token) + + local uid user + uid=$(get_user_id "$token" "$username") + user=$(find_user "$token" "$username") + + echo + echo "Changing password for '$username'." + echo " Min 8 chars, at least one letter and one number." + echo + + local password + prompt_password password "New password for $username" + + local body + body=$(echo "$user" | jq --arg p "$password" '. + {password: $p}') + api_put "$token" "/api/users/$uid" "$body" >/dev/null + echo + info "Password updated for '$username'." +} + +cmd_scope() { + local username="$1" new_scope="$2" + validate_username "$username" + validate_scope "$new_scope" + + local token + token=$(get_token) + + local uid user old_scope + uid=$(get_user_id "$token" "$username") + user=$(find_user "$token" "$username") + old_scope=$(echo "$user" | jq -r '.scope') + + local body + body=$(echo "$user" | jq --arg s "$new_scope" '. + {scope: $s}') + api_put "$token" "/api/users/$uid" "$body" >/dev/null + echo + info "Scope updated for '$username': $old_scope → $new_scope" +} + +cmd_info() { + local username="$1" + validate_username "$username" + + local token + token=$(get_token) + + local user + user=$(find_user "$token" "$username") + [[ -n "$user" ]] || die "User '$username' not found." + + echo + echo "$user" | jq '{ + username, + scope, + admin: .perm.admin, + create: .perm.create, + modify: .perm.modify, + delete: .perm.delete, + download: .perm.download, + execute: .perm.execute + }' +} + +usage() { + cat <<'USAGE' +FileBrowser user management + +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 info + +Scope path is relative to /srv inside the container (= FB_PATH on the host). + / full access to everything under FB_PATH + /music only ~/drives/data1/music (if FB_PATH=~/drives/data1) + /docs/bob only ~/drives/data1/docs/bob + +Username: letters, numbers, hyphens, underscores only (no spaces or dots). +Password: min 8 chars, at least one letter and one number. + +Examples: + ./manage_users.sh add admin / + ./manage_users.sh add alice /music + ./manage_users.sh add bob / --admin + ./manage_users.sh passwd alice + ./manage_users.sh scope alice /music/alice + ./manage_users.sh list + ./manage_users.sh delete bob +USAGE +} + +# ── Dispatch ────────────────────────────────────────────────────────────────── +require_cmd curl jq + +cmd="${1:-help}" +shift || true + +case "$cmd" in + list) cmd_list ;; + add) + [[ $# -ge 2 ]] || die "Usage: manage_users.sh add [--admin]" + is_admin="false" + [[ "${3:-}" == "--admin" ]] && is_admin="true" + cmd_add "$1" "$2" "$is_admin" + ;; + delete) [[ $# -ge 1 ]] || die "Usage: manage_users.sh delete "; cmd_delete "$1" ;; + passwd) [[ $# -ge 1 ]] || die "Usage: manage_users.sh passwd "; cmd_passwd "$1" ;; + scope) + [[ $# -ge 2 ]] || die "Usage: manage_users.sh scope " + cmd_scope "$1" "$2" + ;; + info) [[ $# -ge 1 ]] || die "Usage: manage_users.sh info "; cmd_info "$1" ;; + help|--help|-h) usage ;; + *) echo "Unknown command: $cmd"; echo; usage; exit 2 ;; +esac From 774f6ad561605856c4c8a3e4c6b867a833246eef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:32:44 +0000 Subject: [PATCH 3/9] Add 6 new utility services: gatus, mail-archiver, rustdesk, unifi, vaultwarden, watchyourlan - gatus: status/uptime monitoring page with hot-reloaded config - mail-archiver: IMAP email archive and full-text search (postgres backend) - rustdesk: self-hosted remote desktop relay, cross-VLAN/FQDN docs - unifi: UniFi Network Application with MongoDB init via Docker configs - vaultwarden: Bitwarden-compatible password manager with SMTP prompts - watchyourlan: network device tracker (host networking for ARP scanning) All follow caddy_net container-routing pattern except rustdesk (raw TCP/UDP) and watchyourlan (network_mode: host, incompatible with caddy_net). README.md services table updated with all 6 new entries. https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- README.md | 2 +- services/gatus.sh | 159 ++++++++++++++++++++++++++++ services/mail-archiver.sh | 160 ++++++++++++++++++++++++++++ services/rustdesk.sh | 161 +++++++++++++++++++++++++++++ services/unifi.sh | 212 ++++++++++++++++++++++++++++++++++++++ services/vaultwarden.sh | 165 +++++++++++++++++++++++++++++ services/watchyourlan.sh | 137 ++++++++++++++++++++++++ 7 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 services/gatus.sh create mode 100644 services/mail-archiver.sh create mode 100644 services/rustdesk.sh create mode 100644 services/unifi.sh create mode 100644 services/vaultwarden.sh create mode 100644 services/watchyourlan.sh 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/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/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/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/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/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/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 "" +} From 141c9115a77f2455e098472d47a036138106cb6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:52:42 +0000 Subject: [PATCH 4/9] Rewrite manage_users.sh: add interactive menu, fix password nameref bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Interactive menu when run with no args (login once, reuse token) - Modify submenu: change username, password, scope, or toggle admin - Fix: prompt_password now uses local -n nameref (bash 4.3+) so the caller's local variable is actually set; printf -v was writing to global scope and being shadowed by the caller's local declaration - One-shot commands unchanged: list/add/delete/passwd/scope/rename/info - FileBrowser only supports one scope per user — documented clearly https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- tools/manage_users.sh | 613 ++++++++++++++++++++++++------------------ 1 file changed, 357 insertions(+), 256 deletions(-) diff --git a/tools/manage_users.sh b/tools/manage_users.sh index d8e3cfa..ff43d91 100644 --- a/tools/manage_users.sh +++ b/tools/manage_users.sh @@ -2,379 +2,480 @@ # manage_users.sh — FileBrowser user management via the REST API. # # Placed in ~/docker/filebrowser/ by the filebrowser installer. -# Requires: curl, jq (apt install curl jq) +# Requires: curl, jq (sudo apt install curl jq) # -# 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 info +# 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 or dots. +# 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. -# Special characters are allowed. # -# ── Scope rules ─────────────────────────────────────────────────────────────── -# Scope is a path INSIDE the container, relative to the FileBrowser root (/srv). -# The volume in docker-compose.yml mounts your host path (FB_PATH) as /srv. +# ── 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 is ~/drives/data1 then: -# / → full access to ~/drives/data1 +# If FB_PATH=~/drives/data1: +# / → full access (all of ~/drives/data1) # /music → ~/drives/data1/music only # /docs/bob → ~/drives/data1/docs/bob only # -# Admin account created on first login gets scope / by default. -# -# ── Examples ───────────────────────────────────────────────────────────────── -# Add admin with full access: -# ./manage_users.sh add admin / -# -# Add alice with access to just the music directory: -# ./manage_users.sh add alice /music -# -# Add bob as an admin with full access: -# ./manage_users.sh add bob / --admin -# -# Change alice's password: -# ./manage_users.sh passwd alice -# -# Restrict alice to a subdirectory: -# ./manage_users.sh scope alice /music/alice -# -# List all users: -# ./manage_users.sh list -# -# Delete bob: -# ./manage_users.sh delete bob +# For access to multiple unrelated directories: +# • Set scope to a common parent (e.g. /) +# • Create OS-level symlinks inside the scope dir pointing elsewhere # set -Eeuo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FB_URL="${FB_URL:-http://localhost:8085}" +TOKEN="" -# ── Helpers ─────────────────────────────────────────────────────────────────── -die() { echo "ERROR: $*" >&2; exit 1; } -info() { echo " $*"; } +# ── 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 -require_cmd() { - for cmd in "$@"; do - command -v "$cmd" &>/dev/null || die "'$cmd' not found. Install it: sudo apt install $cmd" +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() { - local u="$1" - [[ -n "$u" ]] || die "Username cannot be empty." - [[ "$u" =~ ^[a-zA-Z0-9_-]+$ ]] || die "Invalid username '$u'. Only letters, numbers, hyphens, underscores allowed." + [[ -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() { - local p="$1" - [[ ${#p} -ge 8 ]] || die "Password too short (minimum 8 characters)." - [[ "$p" =~ [a-zA-Z] ]] || die "Password must contain at least one letter." - [[ "$p" =~ [0-9] ]] || die "Password must contain at least one number." + [[ ${#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() { - local s="$1" - [[ "$s" == /* ]] || die "Scope must be an absolute path starting with / (e.g. /music or /)" + [[ "$1" == /* ]] || { errmsg "Scope must start with / (e.g. / or /music or /docs/bob)"; return 1; } } +# prompt_password VARNAME [label] +# Uses nameref (bash 4.3+) so the caller's local variable is set correctly. prompt_password() { - local varname="$1" prompt="${2:-Password}" - local p1 p2 + local -n _pp_ref="$1" + local _label="${2:-New password}" + local _p1 _p2 while true; do - read -r -s -p "$prompt: " p1; echo - read -r -s -p "Confirm: " p2; echo - [[ "$p1" == "$p2" ]] || { echo " Passwords do not match. Try again."; continue; } - validate_password "$p1" - printf -v "$varname" "%s" "$p1" + 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 } -# ── Authentication ───────────────────────────────────────────────────────────── -get_token() { - local admin_user admin_pass - read -r -p "FileBrowser admin username [admin]: " admin_user - admin_user="${admin_user:-admin}" - read -r -s -p "FileBrowser admin password: " admin_pass; echo - - local resp - resp=$(curl -s -o /dev/null -w "%{http_code}:%{stderr}" \ - -X POST "$FB_URL/api/login" \ +# ── 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\":\"$admin_user\",\"password\":\"$admin_pass\"}" 2>/dev/null || true) - - local token - token=$(curl -s -X POST "$FB_URL/api/login" \ - -H "Content-Type: application/json" \ - -d "{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}") - - [[ "$token" == *"."*"."* ]] || die "Login failed. Check credentials and that FileBrowser is running." - echo "$token" + -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" } -# ── API helpers ──────────────────────────────────────────────────────────────── -api_get() { - local token="$1" path="$2" - curl -sf -X GET "$FB_URL$path" -H "X-Auth: $token" -} +# ── 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"; } -api_post() { - local token="$1" path="$2" body="$3" - curl -sf -X POST "$FB_URL$path" \ - -H "X-Auth: $token" -H "Content-Type: application/json" -d "$body" -} - -api_put() { - local token="$1" path="$2" body="$3" - curl -sf -X PUT "$FB_URL$path" \ - -H "X-Auth: $token" -H "Content-Type: application/json" -d "$body" -} - -api_delete() { - local token="$1" path="$2" - curl -sf -X DELETE "$FB_URL$path" -H "X-Auth: $token" -} - -# Returns user JSON object for the given username, or empty string if not found. find_user() { - local token="$1" username="$2" - api_get "$token" "/api/users" | jq -r --arg u "$username" '.[] | select(.username==$u)' + api_get "/api/users" | jq -r --arg u "$1" '.[] | select(.username==$u)' } get_user_id() { - local token="$1" username="$2" - local user - user=$(find_user "$token" "$username") - [[ -n "$user" ]] || die "User '$username' not found." - echo "$user" | jq -r '.id' + local _j + _j=$(find_user "$1") + [[ -n "$_j" ]] || { errmsg "User '$1' not found."; return 1; } + echo "$_j" | jq -r '.id' } -# ── Default permissions for new non-admin users ────────────────────────────── default_perms() { - cat <<'JSON' -{ - "admin": false, - "execute": false, - "create": true, - "rename": true, - "modify": true, - "delete": true, - "share": false, - "download": true -} -JSON + echo '{"admin":false,"execute":false,"create":true,"rename":true, + "modify":true,"delete":true,"share":false,"download":true}' } -# ── Commands ────────────────────────────────────────────────────────────────── - +# ── cmd: list ───────────────────────────────────────────────────────────────── cmd_list() { - local token - token=$(get_token) + ensure_token echo - printf "%-20s %-5s %-30s\n" "USERNAME" "ADMIN" "SCOPE" - printf "%-20s %-5s %-30s\n" "--------" "-----" "-----" - api_get "$token" "/api/users" | \ + 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 uname is_admin scope; do - printf "%-20s %-5s %s\n" "$uname" "$is_admin" "$scope" + 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="${3:-false}" - validate_username "$username" - validate_scope "$scope" + local _username="${1:-}" _scope="${2:-}" _is_admin="false" + [[ "${3:-}" == "--admin" ]] && _is_admin="true" - local password - echo - echo "Setting password for new user '$username'." - echo " Min 8 chars, at least one letter and one number." - echo - prompt_password password "New password for $username" + ensure_token - local token - token=$(get_token) - - # Check if user already exists - local existing - existing=$(find_user "$token" "$username") - [[ -z "$existing" ]] || die "User '$username' already exists. Use 'passwd' or 'scope' to modify." - - local perms - perms=$(default_perms) - if [[ "$is_admin" == "true" ]]; then - perms=$(echo "$perms" | jq '.admin = true') + 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} FileBrowser supports ONE path per user." + echo " For multi-dir access: use a common parent or OS symlinks." + echo + read -r -p " Username: " _username + local _adm="" + read -r -p " Admin? [y/N]: " _adm + [[ "${_adm,,}" == "y" ]] && _is_admin="true" fi - local body - 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}') + validate_username "$_username" || return 1 + + if [[ -z "$_scope" ]]; then + echo + echo " Scope examples:" + echo " / full access (all of FB_PATH)" + echo " /music music subdir only" + echo " /docs/bob docs/bob subdir only" + echo + read -r -p " Scope for '$_username': " _scope + 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 - api_post "$token" "/api/users" "$body" >/dev/null echo - info "User '$username' created." - info " Scope: $scope" - info " Admin: $is_admin" + 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" } +# ── cmd: delete ─────────────────────────────────────────────────────────────── cmd_delete() { - local username="$1" - validate_username "$username" + local _username="${1:-}" + ensure_token - local token - token=$(get_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 "$token" "$username") + local _uid + _uid=$(get_user_id "$_username") || return 1 - local confirm - read -r -p "Delete user '$username' (id=$uid)? [y/N]: " confirm - [[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; } - - api_delete "$token" "/api/users/$uid" >/dev/null - echo - info "User '$username' deleted." + 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." } +# ── cmd: passwd ─────────────────────────────────────────────────────────────── cmd_passwd() { - local username="$1" - validate_username "$username" + local _username="${1:-}" + ensure_token - local token - token=$(get_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 "$token" "$username") - user=$(find_user "$token" "$username") + local _uid _user + _uid=$(get_user_id "$_username") || return 1 + _user=$(find_user "$_username") echo - echo "Changing password for '$username'." - echo " Min 8 chars, at least one letter and one number." + 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 password - prompt_password password "New password for $username" - - local body - body=$(echo "$user" | jq --arg p "$password" '. + {password: $p}') - api_put "$token" "/api/users/$uid" "$body" >/dev/null + local _body + _body=$(echo "$_user" | jq --arg p "$password" '. + {password: $p}') + api_put "/api/users/$_uid" "$_body" >/dev/null echo - info "Password updated for '$username'." + ok "Password updated for '$_username'." } +# ── cmd: scope ──────────────────────────────────────────────────────────────── cmd_scope() { - local username="$1" new_scope="$2" - validate_username "$username" - validate_scope "$new_scope" + local _username="${1:-}" _new_scope="${2:-}" + ensure_token - local token - token=$(get_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 "$token" "$username") - user=$(find_user "$token" "$username") - old_scope=$(echo "$user" | jq -r '.scope') + local _uid _user _old_scope + _uid=$(get_user_id "$_username") || return 1 + _user=$(find_user "$_username") + _old_scope=$(echo "$_user" | jq -r '.scope') - local body - body=$(echo "$user" | jq --arg s "$new_scope" '. + {scope: $s}') - api_put "$token" "/api/users/$uid" "$body" >/dev/null + if [[ -z "$_new_scope" ]]; then + echo + echo " Current scope: $_old_scope" + echo " Note: FileBrowser supports ONE path per user." + echo " Examples: / /music /docs/bob" + echo + read -r -p " 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 - info "Scope updated for '$username': $old_scope → $new_scope" + ok "Scope updated for '$_username': $_old_scope → $_new_scope" } +# ── 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" - validate_username "$username" + local _username="${1:-}" + ensure_token - local token - token=$(get_token) - - local user - user=$(find_user "$token" "$username") - [[ -n "$user" ]] || die "User '$username' not found." + 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, + echo "$_user" | jq '{username, scope, admin: .perm.admin, create: .perm.create, modify: .perm.modify, delete: .perm.delete, download: .perm.download, - execute: .perm.execute - }' + 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 " 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 + ;; + 0) break ;; + *) errmsg "Invalid choice." ;; + esac + done +} + +# ── usage ───────────────────────────────────────────────────────────────────── usage() { - cat <<'USAGE' + cat <<'EOF' FileBrowser user management -Usage: + 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 path is relative to /srv inside the container (= FB_PATH on the host). +Scope is relative to /srv inside the container (= FB_PATH on the host): / full access to everything under FB_PATH - /music only ~/drives/data1/music (if FB_PATH=~/drives/data1) - /docs/bob only ~/drives/data1/docs/bob + /music ~/drives/data1/music only (if FB_PATH=~/drives/data1) + /docs/bob ~/drives/data1/docs/bob only -Username: letters, numbers, hyphens, underscores only (no spaces or dots). +FileBrowser supports ONE scope path per user. +For multi-directory access: use a common parent, or place OS-level +symlinks inside the scope dir pointing to other locations. + +Username: letters, numbers, hyphens, underscores only. No dots or @. Password: min 8 chars, at least one letter and one number. -Examples: - ./manage_users.sh add admin / - ./manage_users.sh add alice /music - ./manage_users.sh add bob / --admin - ./manage_users.sh passwd alice - ./manage_users.sh scope alice /music/alice - ./manage_users.sh list - ./manage_users.sh delete bob -USAGE +Override URL: FB_URL=http://localhost:8085 ./manage_users.sh +EOF } -# ── Dispatch ────────────────────────────────────────────────────────────────── -require_cmd curl jq +# ── 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)" + 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 +} -cmd="${1:-help}" +# ── Entry point ─────────────────────────────────────────────────────────────── +require_cmds curl jq + +_cmd="${1:-}" shift || true -case "$cmd" in - list) cmd_list ;; - add) - [[ $# -ge 2 ]] || die "Usage: manage_users.sh add [--admin]" - is_admin="false" - [[ "${3:-}" == "--admin" ]] && is_admin="true" - cmd_add "$1" "$2" "$is_admin" - ;; - delete) [[ $# -ge 1 ]] || die "Usage: manage_users.sh delete "; cmd_delete "$1" ;; - passwd) [[ $# -ge 1 ]] || die "Usage: manage_users.sh passwd "; cmd_passwd "$1" ;; - scope) - [[ $# -ge 2 ]] || die "Usage: manage_users.sh scope " - cmd_scope "$1" "$2" - ;; - info) [[ $# -ge 1 ]] || die "Usage: manage_users.sh info "; cmd_info "$1" ;; +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 ;; - *) echo "Unknown command: $cmd"; echo; usage; exit 2 ;; + *) errmsg "Unknown command: $_cmd"; echo; usage; exit 2 ;; esac From b884d79dd20443b0b7f0646147282a7e62a90376 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 23:07:27 +0000 Subject: [PATCH 5/9] manage_users.sh: add linked-directory (symlink) management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New option 5 in the Modify submenu: "Manage linked directories" - Lists existing symlinks inside the user's scope dir (via docker exec) - Add: prompts for source path (/srv/...) and a display name, creates the symlink inside /srv/ → /srv in the container - Remove: lists links, prompts for name, refuses to delete non-symlinks - Warns if source path doesn't exist yet; offers to create anyway - Auto-detects container name from docker-compose.yml next to the script - Checks that the container is running before any docker exec calls This is the recommended workaround for FileBrowser's single-scope limitation: symlinks inside the scope dir appear as normal folders. https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- tools/manage_users.sh | 187 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 173 insertions(+), 14 deletions(-) diff --git a/tools/manage_users.sh b/tools/manage_users.sh index ff43d91..c6e8f83 100644 --- a/tools/manage_users.sh +++ b/tools/manage_users.sh @@ -24,12 +24,18 @@ # /music → ~/drives/data1/music only # /docs/bob → ~/drives/data1/docs/bob only # -# For access to multiple unrelated directories: -# • Set scope to a common parent (e.g. /) -# • Create OS-level symlinks inside the scope dir pointing elsewhere +# ── Multi-directory access via symlinks ─────────────────────────────────────── +# FileBrowser follows symlinks inside a user's scope dir. +# Use "Manage linked directories" in the Modify menu to create symlinks +# inside the user's scope that point to other directories under /srv. +# +# Example: user alice has scope /alice +# link /alice/music → /srv/music (alice sees a "music" folder in her root) +# link /alice/photos → /srv/photos (alice sees a "photos" folder too) # set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FB_URL="${FB_URL:-http://localhost:8085}" TOKEN="" @@ -130,6 +136,155 @@ default_perms() { "modify":true,"delete":true,"share":false,"download":true}' } +# ── Docker helpers for symlink management ───────────────────────────────────── + +# Read container name from docker-compose.yml next to this script, or default. +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() { + local _c="$1" + require_cmds docker + local _running + _running=$(docker inspect --format='{{.State.Running}}' "$_c" 2>/dev/null || echo "false") + [[ "$_running" == "true" ]] \ + || { errmsg "Container '$_c' is not running. Start it: docker compose up -d"; return 1; } +} + +# List symlinks in /srv inside the container. +list_scope_symlinks() { + local _c="$1" _scope="$2" + local _dir="/srv$_scope" + local _out + _out=$(docker exec "$_c" find "$_dir" -maxdepth 1 -type l \ + -exec sh -c 'printf " %-24s→ %s\n" "$(basename "$1")" "$(readlink "$1")"' _ {} \; \ + 2>/dev/null | sort) || true + if [[ -n "$_out" ]]; then + echo "$_out" + else + echo " (none)" + fi +} + +# Interactively add a symlink inside a user's scope dir. +add_scope_symlink() { + local _c="$1" _scope="$2" + local _scope_dir="/srv$_scope" + + echo + echo " Add a directory link inside this user's scope." + echo " Both paths are inside /srv (the FileBrowser root)." + echo + echo " Example: source=/music → user sees a 'music' folder in their root." + echo " source=/shared/reports name=reports" + echo + + local _src="" + read -r -p " Source path in /srv (e.g. /music): " _src + _src="/${_src#/}" # ensure leading / + _src="${_src%/}" # strip trailing / + validate_scope "$_src" || return 1 + + # Check that target actually exists inside the container + if ! docker exec "$_c" test -e "/srv$_src" 2>/dev/null; then + errmsg "/srv$_src does not exist inside the container." + local _anyway="" + read -r -p " Create the link anyway? (it will appear broken until the dir exists) [y/N]: " _anyway + [[ "${_anyway,,}" == "y" ]] || { echo " Aborted."; return 0; } + fi + + local _default_name + _default_name=$(basename "$_src") + local _link_name="" + read -r -p " Name shown to user in their folder [$_default_name]: " _link_name + _link_name="${_link_name:-$_default_name}" + [[ "$_link_name" =~ ^[a-zA-Z0-9._-]+$ ]] \ + || { errmsg "Invalid name '$_link_name'. Use letters, numbers, dots, hyphens, underscores."; return 1; } + + local _link_path="$_scope_dir/$_link_name" + local _target="/srv$_src" + + # Ensure the scope dir exists inside the container + docker exec "$_c" mkdir -p "$_scope_dir" >/dev/null 2>&1 || true + + # Handle existing entry at link path + if docker exec "$_c" test -e "$_link_path" 2>/dev/null; then + local _ov="" + echo + errmsg "'$_link_name' already exists in $_scope_dir." + read -r -p " Overwrite? [y/N]: " _ov + [[ "${_ov,,}" == "y" ]] || { echo " Aborted."; return 0; } + docker exec "$_c" rm -rf "$_link_path" + fi + + echo + echo " Creating: $_link_path → $_target" + docker exec "$_c" ln -s "$_target" "$_link_path" + ok "'$_link_name' linked to $_target" + echo " User will see '$_link_name' as a folder inside their scope." +} + +# Interactively remove a symlink from a user's scope dir. +remove_scope_symlink() { + local _c="$1" _scope="$2" + local _scope_dir="/srv$_scope" + + echo + echo " Current links in $_scope_dir:" + list_scope_symlinks "$_c" "$_scope" + echo + local _link_name="" + read -r -p " Link name to remove (Enter to cancel): " _link_name + [[ -n "$_link_name" ]] || { echo " Cancelled."; return 0; } + + local _link_path="$_scope_dir/$_link_name" + + # Only remove symlinks — refuse to delete regular files/dirs + if ! docker exec "$_c" test -L "$_link_path" 2>/dev/null; then + errmsg "'$_link_name' is not a symlink. Refusing to delete." + return 1 + fi + + docker exec "$_c" rm "$_link_path" + ok "Link '$_link_name' removed from $_scope_dir." +} + +# Submenu for managing symlinks for one user. +menu_symlinks() { + local _username="$1" _scope="$2" + local _c + _c=$(get_container_name) + check_container "$_c" || return 1 + + while true; do + banner "Linked dirs: $_username (scope: $_scope)" + echo " Container: ${DIM}$_c${R} Root: ${DIM}/srv${R}" + echo + echo " Symlinks visible to $_username in /srv$_scope:" + list_scope_symlinks "$_c" "$_scope" + echo + echo " 1 Add a linked directory" + echo " 2 Remove a linked directory" + echo " 0 Back" + echo + local _ch="" + read -r -p " Choice: " _ch + case "$_ch" in + 1) add_scope_symlink "$_c" "$_scope" || true ;; + 2) remove_scope_symlink "$_c" "$_scope" || true ;; + 0) break ;; + *) errmsg "Invalid choice." ;; + esac + done +} + # ── cmd: list ───────────────────────────────────────────────────────────────── cmd_list() { ensure_token @@ -156,7 +311,7 @@ cmd_add() { 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} FileBrowser supports ONE path per user." - echo " For multi-dir access: use a common parent or OS symlinks." + echo " For multi-dir access use Modify → Linked directories." echo read -r -p " Username: " _username local _adm="" @@ -170,8 +325,8 @@ cmd_add() { echo echo " Scope examples:" echo " / full access (all of FB_PATH)" + echo " /alice alice's private subdir" echo " /music music subdir only" - echo " /docs/bob docs/bob subdir only" echo read -r -p " Scope for '$_username': " _scope fi @@ -201,6 +356,7 @@ cmd_add() { api_post "/api/users" "$_body" >/dev/null echo ok "User '$_username' created | scope: $_scope | admin: $_is_admin" + echo " Tip: use Modify → Linked directories to give access to more folders." } # ── cmd: delete ─────────────────────────────────────────────────────────────── @@ -222,6 +378,7 @@ cmd_delete() { [[ "${_c,,}" == "y" ]] || { echo " Aborted."; return 0; } api_delete "/api/users/$_uid" >/dev/null ok "User '$_username' deleted." + echo " Note: any symlinks created in their scope dir still exist on disk." } # ── cmd: passwd ─────────────────────────────────────────────────────────────── @@ -271,8 +428,8 @@ cmd_scope() { if [[ -z "$_new_scope" ]]; then echo echo " Current scope: $_old_scope" - echo " Note: FileBrowser supports ONE path per user." - echo " Examples: / /music /docs/bob" + echo " Note: existing symlinks in the old scope dir are not moved automatically." + echo " Examples: / /music /docs/bob /alice" echo read -r -p " New scope: " _new_scope fi @@ -359,6 +516,7 @@ menu_modify() { echo " 2 Change password" echo " 3 Change scope (file path)" echo " 4 Toggle admin status" + echo " 5 Manage linked directories ${DIM}(symlinks for multi-dir access)${R}" echo " 0 Back" echo local _ch="" @@ -395,6 +553,7 @@ menu_modify() { errmsg "Toggle failed." fi ;; + 5) menu_symlinks "$_cur" "$_scope" || true ;; 0) break ;; *) errmsg "Invalid choice." ;; esac @@ -419,16 +578,16 @@ One-shot usage: Scope is relative to /srv inside the container (= FB_PATH on the host): / full access to everything under FB_PATH - /music ~/drives/data1/music only (if FB_PATH=~/drives/data1) - /docs/bob ~/drives/data1/docs/bob only - -FileBrowser supports ONE scope path per user. -For multi-directory access: use a common parent, or place OS-level -symlinks inside the scope dir pointing to other locations. + /alice alice's own subdir + /music music subdir only 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 → Modify → Linked +directories. This creates symlinks inside the user's scope dir so they +see multiple folders without scope being set to /. + Override URL: FB_URL=http://localhost:8085 ./manage_users.sh EOF } @@ -443,7 +602,7 @@ run_interactive() { echo " 1 List users" echo " 2 Add user" echo " 3 Delete user" - echo " 4 Modify user (username / password / scope / admin)" + echo " 4 Modify user (username / password / scope / admin / links)" echo " 5 View user details" echo " 0 Exit" echo From 5fdeff3f9aec28a32a4d9addabff5c00120af906 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 23:15:12 +0000 Subject: [PATCH 6/9] manage_users.sh: inline link prompts on add/scope-change - prompt_add_links: shared helper loops asking for /srv paths until blank Enter, creates symlinks via docker exec, skips bad paths - cmd_add: offers linked-dir prompt right after user creation - cmd_scope: offers linked-dir prompt after a scope change - menu_links: tighter submenu (list + add loop + remove) replacing the old menu_symlinks; called from Modify option 5 - Scope prompts now show the leading / so the user only types the rest - Note on delete: symlinks on disk survive user deletion (by design) https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- tools/manage_users.sh | 257 ++++++++++++++++++++---------------------- 1 file changed, 125 insertions(+), 132 deletions(-) diff --git a/tools/manage_users.sh b/tools/manage_users.sh index c6e8f83..e6c4697 100644 --- a/tools/manage_users.sh +++ b/tools/manage_users.sh @@ -21,17 +21,19 @@ # # If FB_PATH=~/drives/data1: # / → full access (all of ~/drives/data1) -# /music → ~/drives/data1/music only -# /docs/bob → ~/drives/data1/docs/bob only +# /alice → alice's own subdir (pair with linked dirs below) +# /music → music subdir only # -# ── Multi-directory access via symlinks ─────────────────────────────────────── -# FileBrowser follows symlinks inside a user's scope dir. -# Use "Manage linked directories" in the Modify menu to create symlinks -# inside the user's scope that point to other directories under /srv. +# ── Multi-directory access via linked directories ──────────────────────────── +# FileBrowser follows symlinks inside the scope dir. +# Give a user scope=/alice, then link extra folders into /alice: # -# Example: user alice has scope /alice -# link /alice/music → /srv/music (alice sees a "music" folder in her root) -# link /alice/photos → /srv/photos (alice sees a "photos" folder too) +# /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 @@ -75,7 +77,7 @@ validate_password() { } validate_scope() { - [[ "$1" == /* ]] || { errmsg "Scope must start with / (e.g. / or /music or /docs/bob)"; return 1; } + [[ "$1" == /* ]] || { errmsg "Scope must start with / (e.g. / or /alice or /music)"; return 1; } } # prompt_password VARNAME [label] @@ -136,9 +138,8 @@ default_perms() { "modify":true,"delete":true,"share":false,"download":true}' } -# ── Docker helpers for symlink management ───────────────────────────────────── +# ── Docker helpers ───────────────────────────────────────────────────────────── -# Read container name from docker-compose.yml next to this script, or default. get_container_name() { local _compose="$SCRIPT_DIR/docker-compose.yml" if [[ -f "$_compose" ]]; then @@ -150,135 +151,115 @@ get_container_name() { } check_container() { - local _c="$1" require_cmds docker local _running - _running=$(docker inspect --format='{{.State.Running}}' "$_c" 2>/dev/null || echo "false") + _running=$(docker inspect --format='{{.State.Running}}' "$1" 2>/dev/null || echo "false") [[ "$_running" == "true" ]] \ - || { errmsg "Container '$_c' is not running. Start it: docker compose up -d"; return 1; } + || { errmsg "Container '$1' is not running. Start it: docker compose up -d"; return 1; } } -# List symlinks in /srv inside the container. -list_scope_symlinks() { +# Print symlinks inside /srv, one per line. +list_links() { local _c="$1" _scope="$2" - local _dir="/srv$_scope" - local _out - _out=$(docker exec "$_c" find "$_dir" -maxdepth 1 -type l \ + docker exec "$_c" find "/srv$_scope" -maxdepth 1 -type l \ -exec sh -c 'printf " %-24s→ %s\n" "$(basename "$1")" "$(readlink "$1")"' _ {} \; \ - 2>/dev/null | sort) || true - if [[ -n "$_out" ]]; then - echo "$_out" - else - echo " (none)" - fi + 2>/dev/null | sort || true } -# Interactively add a symlink inside a user's scope dir. -add_scope_symlink() { - local _c="$1" _scope="$2" +# ── prompt_add_links SCOPE ──────────────────────────────────────────────────── +# Loop: ask for /srv source paths, create symlinks, blank line 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" - - echo - echo " Add a directory link inside this user's scope." - echo " Both paths are inside /srv (the FileBrowser root)." - echo - echo " Example: source=/music → user sees a 'music' folder in their root." - echo " source=/shared/reports name=reports" - echo - - local _src="" - read -r -p " Source path in /srv (e.g. /music): " _src - _src="/${_src#/}" # ensure leading / - _src="${_src%/}" # strip trailing / - validate_scope "$_src" || return 1 - - # Check that target actually exists inside the container - if ! docker exec "$_c" test -e "/srv$_src" 2>/dev/null; then - errmsg "/srv$_src does not exist inside the container." - local _anyway="" - read -r -p " Create the link anyway? (it will appear broken until the dir exists) [y/N]: " _anyway - [[ "${_anyway,,}" == "y" ]] || { echo " Aborted."; return 0; } - fi - - local _default_name - _default_name=$(basename "$_src") - local _link_name="" - read -r -p " Name shown to user in their folder [$_default_name]: " _link_name - _link_name="${_link_name:-$_default_name}" - [[ "$_link_name" =~ ^[a-zA-Z0-9._-]+$ ]] \ - || { errmsg "Invalid name '$_link_name'. Use letters, numbers, dots, hyphens, underscores."; return 1; } - - local _link_path="$_scope_dir/$_link_name" - local _target="/srv$_src" - - # Ensure the scope dir exists inside the container docker exec "$_c" mkdir -p "$_scope_dir" >/dev/null 2>&1 || true - # Handle existing entry at link path - if docker exec "$_c" test -e "$_link_path" 2>/dev/null; then - local _ov="" - echo - errmsg "'$_link_name' already exists in $_scope_dir." - read -r -p " Overwrite? [y/N]: " _ov - [[ "${_ov,,}" == "y" ]] || { echo " Aborted."; return 0; } - docker exec "$_c" rm -rf "$_link_path" - fi - echo - echo " Creating: $_link_path → $_target" - docker exec "$_c" ln -s "$_target" "$_link_path" - ok "'$_link_name' linked to $_target" - echo " User will see '$_link_name' as a folder inside their scope." + echo " Link directories into '$_scope_dir'." + echo " Enter a /srv path for each directory to add — blank line when done." + echo + + while true; do + local _src="" + read -r -p " /srv path [done]: /" _src + [[ -n "$_src" ]] || break + + _src="/${_src#/}" # normalise leading slash + _src="${_src%/}" # strip trailing slash + + if ! docker exec "$_c" test -e "/srv$_src" 2>/dev/null; then + errmsg "/srv$_src does not exist inside the container. Skipping." + continue + fi + + local _default_name + _default_name=$(basename "$_src") + local _link_name="" + read -r -p " Name in user's folder [$_default_name]: " _link_name + _link_name="${_link_name:-$_default_name}" + + if ! [[ "$_link_name" =~ ^[a-zA-Z0-9._-]+$ ]]; then + errmsg "Invalid name. Use letters, numbers, dots, hyphens, underscores." + continue + fi + + local _link_path="$_scope_dir/$_link_name" + local _target="/srv$_src" + + if docker exec "$_c" test -e "$_link_path" 2>/dev/null; then + errmsg "'$_link_name' already exists at $_link_path — skipping. Use Remove to clear it first." + continue + fi + + docker exec "$_c" ln -s "$_target" "$_link_path" + ok "'$_link_name' → $_target" + done } -# Interactively remove a symlink from a user's scope dir. -remove_scope_symlink() { - local _c="$1" _scope="$2" - local _scope_dir="/srv$_scope" - - echo - echo " Current links in $_scope_dir:" - list_scope_symlinks "$_c" "$_scope" - echo - local _link_name="" - read -r -p " Link name to remove (Enter to cancel): " _link_name - [[ -n "$_link_name" ]] || { echo " Cancelled."; return 0; } - - local _link_path="$_scope_dir/$_link_name" - - # Only remove symlinks — refuse to delete regular files/dirs - if ! docker exec "$_c" test -L "$_link_path" 2>/dev/null; then - errmsg "'$_link_name' is not a symlink. Refusing to delete." - return 1 - fi - - docker exec "$_c" rm "$_link_path" - ok "Link '$_link_name' removed from $_scope_dir." -} - -# Submenu for managing symlinks for one user. -menu_symlinks() { - local _username="$1" _scope="$2" +# ── 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 dirs: $_username (scope: $_scope)" - echo " Container: ${DIM}$_c${R} Root: ${DIM}/srv${R}" + banner "Linked directories (scope: $_scope)" + echo " ${DIM}Symlinks inside /srv${_scope} — visible as folders in FileBrowser${R}" echo - echo " Symlinks visible to $_username in /srv$_scope:" - list_scope_symlinks "$_c" "$_scope" + local _links + _links=$(list_links "$_c" "$_scope") + if [[ -n "$_links" ]]; then + echo "$_links" + else + echo " (none)" + fi echo - echo " 1 Add a linked directory" + echo " 1 Add linked directories" echo " 2 Remove a linked directory" echo " 0 Back" echo local _ch="" read -r -p " Choice: " _ch case "$_ch" in - 1) add_scope_symlink "$_c" "$_scope" || true ;; - 2) remove_scope_symlink "$_c" "$_scope" || true ;; + 1) prompt_add_links "$_scope" || true ;; + 2) + local _link_name="" + echo + read -r -p " Link 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 symlink — refusing to delete." + continue + fi + docker exec "$_c" rm "$_link_path" + ok "'$_link_name' removed." + ;; 0) break ;; *) errmsg "Invalid choice." ;; esac @@ -310,8 +291,7 @@ cmd_add() { 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} FileBrowser supports ONE path per user." - echo " For multi-dir access use Modify → Linked directories." + echo " ${B}Scope:${R} one path per user — use linked dirs for multi-folder access." echo read -r -p " Username: " _username local _adm="" @@ -325,10 +305,11 @@ cmd_add() { echo echo " Scope examples:" echo " / full access (all of FB_PATH)" - echo " /alice alice's private subdir" + echo " /$_username user's own private subdir (recommended with linked dirs)" echo " /music music subdir only" echo - read -r -p " Scope for '$_username': " _scope + read -r -p " Scope for '$_username': /" _scope + _scope="/${_scope#/}" fi validate_scope "$_scope" || return 1 @@ -356,7 +337,15 @@ cmd_add() { api_post "/api/users" "$_body" >/dev/null echo ok "User '$_username' created | scope: $_scope | admin: $_is_admin" - echo " Tip: use Modify → Linked directories to give access to more folders." + + # Offer to add linked directories inline + if command -v docker &>/dev/null; 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 ─────────────────────────────────────────────────────────────── @@ -378,7 +367,7 @@ cmd_delete() { [[ "${_c,,}" == "y" ]] || { echo " Aborted."; return 0; } api_delete "/api/users/$_uid" >/dev/null ok "User '$_username' deleted." - echo " Note: any symlinks created in their scope dir still exist on disk." + echo " ${DIM}Note: symlinks in their scope dir still exist on disk if you want to reuse them.${R}" } # ── cmd: passwd ─────────────────────────────────────────────────────────────── @@ -428,10 +417,10 @@ cmd_scope() { if [[ -z "$_new_scope" ]]; then echo echo " Current scope: $_old_scope" - echo " Note: existing symlinks in the old scope dir are not moved automatically." - echo " Examples: / /music /docs/bob /alice" + echo " ${DIM}Existing symlinks in the old scope dir are not moved automatically.${R}" echo - read -r -p " New scope: " _new_scope + read -r -p " New scope: /" _new_scope + _new_scope="/${_new_scope#/}" fi validate_scope "$_new_scope" || return 1 @@ -440,6 +429,15 @@ cmd_scope() { 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 ─────────────────────────────────────────────────────────────── @@ -516,7 +514,7 @@ menu_modify() { echo " 2 Change password" echo " 3 Change scope (file path)" echo " 4 Toggle admin status" - echo " 5 Manage linked directories ${DIM}(symlinks for multi-dir access)${R}" + echo " 5 Linked directories ${DIM}(add/remove multi-folder symlinks)${R}" echo " 0 Back" echo local _ch="" @@ -553,7 +551,7 @@ menu_modify() { errmsg "Toggle failed." fi ;; - 5) menu_symlinks "$_cur" "$_scope" || true ;; + 5) menu_links "$_scope" || true ;; 0) break ;; *) errmsg "Invalid choice." ;; esac @@ -576,17 +574,12 @@ One-shot usage: manage_users.sh rename manage_users.sh info -Scope is relative to /srv inside the container (= FB_PATH on the host): - / full access to everything under FB_PATH - /alice alice's own subdir - /music music subdir only - +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 → Modify → Linked -directories. This creates symlinks inside the user's scope dir so they -see multiple folders without scope being set to /. +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 From c584811be4f6d7bf9b1a17bed401a419225e02b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 23:18:50 +0000 Subject: [PATCH 7/9] manage_users.sh: remove /srv from all user-facing prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users think in FileBrowser folder names, not container paths. - prompt_add_links: prompt now "Folder to add [done]:" with examples like "music photos documents/shared" — /srv added internally - list_links: strip /srv prefix from displayed target paths - menu_links: rename options to "Add folders" / "Remove a folder" - scope prompts: remove the leading "/" hint (confusing); normalise internally instead - ok message shows "(from path/subdir)" only when link name differs https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- tools/manage_users.sh | 74 ++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/tools/manage_users.sh b/tools/manage_users.sh index e6c4697..809a28d 100644 --- a/tools/manage_users.sh +++ b/tools/manage_users.sh @@ -158,16 +158,21 @@ check_container() { || { errmsg "Container '$1' is not running. Start it: docker compose up -d"; return 1; } } -# Print symlinks inside /srv, one per line. +# 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 'printf " %-24s→ %s\n" "$(basename "$1")" "$(readlink "$1")"' _ {} \; \ - 2>/dev/null | sort || true + -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: ask for /srv source paths, create symlinks, blank line to finish. +# 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" @@ -179,44 +184,41 @@ prompt_add_links() { docker exec "$_c" mkdir -p "$_scope_dir" >/dev/null 2>&1 || true echo - echo " Link directories into '$_scope_dir'." - echo " Enter a /srv path for each directory to add — blank line when done." + echo " Type folder names as they appear in FileBrowser — blank line when done." + echo " Examples: music photos documents/shared" echo while true; do local _src="" - read -r -p " /srv path [done]: /" _src + read -r -p " Folder to add [done]: " _src [[ -n "$_src" ]] || break - _src="/${_src#/}" # normalise leading slash - _src="${_src%/}" # strip trailing slash + # Normalise: strip surrounding slashes + _src="${_src#/}" + _src="${_src%/}" + [[ -n "$_src" ]] || continue - if ! docker exec "$_c" test -e "/srv$_src" 2>/dev/null; then - errmsg "/srv$_src does not exist inside the container. Skipping." - continue - fi + local _target="/srv/$_src" + local _link_name + _link_name=$(basename "$_src") - local _default_name - _default_name=$(basename "$_src") - local _link_name="" - read -r -p " Name in user's folder [$_default_name]: " _link_name - _link_name="${_link_name:-$_default_name}" - - if ! [[ "$_link_name" =~ ^[a-zA-Z0-9._-]+$ ]]; then - errmsg "Invalid name. Use letters, numbers, dots, hyphens, underscores." + 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" - local _target="/srv$_src" - if docker exec "$_c" test -e "$_link_path" 2>/dev/null; then - errmsg "'$_link_name' already exists at $_link_path — skipping. Use Remove to clear it first." + 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" - ok "'$_link_name' → $_target" + 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 } @@ -229,7 +231,7 @@ menu_links() { while true; do banner "Linked directories (scope: $_scope)" - echo " ${DIM}Symlinks inside /srv${_scope} — visible as folders in FileBrowser${R}" + echo " Folders this user can see beyond their scope:" echo local _links _links=$(list_links "$_c" "$_scope") @@ -239,8 +241,8 @@ menu_links() { echo " (none)" fi echo - echo " 1 Add linked directories" - echo " 2 Remove a linked directory" + echo " 1 Add folders" + echo " 2 Remove a folder" echo " 0 Back" echo local _ch="" @@ -250,11 +252,11 @@ menu_links() { 2) local _link_name="" echo - read -r -p " Link name to remove: " _link_name + 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 symlink — refusing to delete." + errmsg "'$_link_name' is not a linked folder — refusing to delete." continue fi docker exec "$_c" rm "$_link_path" @@ -305,11 +307,11 @@ cmd_add() { echo echo " Scope examples:" echo " / full access (all of FB_PATH)" - echo " /$_username user's own private subdir (recommended with linked dirs)" - echo " /music music subdir only" + 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#/}" + read -r -p " Scope for '$_username': " _scope + _scope="/${_scope#/}" # ensure leading / fi validate_scope "$_scope" || return 1 @@ -417,9 +419,9 @@ cmd_scope() { if [[ -z "$_new_scope" ]]; then echo echo " Current scope: $_old_scope" - echo " ${DIM}Existing symlinks in the old scope dir are not moved automatically.${R}" + echo " ${DIM}Linked folders in the old scope are not moved automatically.${R}" echo - read -r -p " New scope: /" _new_scope + read -r -p " New scope: " _new_scope _new_scope="/${_new_scope#/}" fi validate_scope "$_new_scope" || return 1 From c71c08ac874639498cd94604565584b14350a2d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 23:22:22 +0000 Subject: [PATCH 8/9] manage_users.sh: show available folders before link prompt List top-level directories from /srv so the user knows what to type without having to guess. Subdirs (e.g. documents/shared) still work. Skip link prompt when scope is / (user already has full access). https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- tools/manage_users.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tools/manage_users.sh b/tools/manage_users.sh index 809a28d..f46ee52 100644 --- a/tools/manage_users.sh +++ b/tools/manage_users.sh @@ -184,8 +184,18 @@ prompt_add_links() { docker exec "$_c" mkdir -p "$_scope_dir" >/dev/null 2>&1 || true echo - echo " Type folder names as they appear in FileBrowser — blank line when done." - echo " Examples: music photos documents/shared" + # Show top-level folders so the user knows what's available + 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 + if [[ -n "$_avail" ]]; then + echo " Available folders: $_avail" + echo " Subdirectories also work, e.g.: documents/shared" + else + echo " (No top-level folders found in FileBrowser root)" + fi + echo + echo " Type a folder name to add — blank line when done." echo while true; do @@ -340,8 +350,8 @@ cmd_add() { echo ok "User '$_username' created | scope: $_scope | admin: $_is_admin" - # Offer to add linked directories inline - if command -v docker &>/dev/null; then + # 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 From ea1a9cf3b7eb5e1cae6032d68d74d2402666cf7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 23:27:54 +0000 Subject: [PATCH 9/9] manage_users.sh: list folders on demand with ? instead of auto-display Auto-displaying all top-level dirs could be a wall of text. Typing ? at the folder prompt lists them on demand instead. https://claude.ai/code/session_01UZus2Q9gNTfUdqSMrhuX29 --- tools/manage_users.sh | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tools/manage_users.sh b/tools/manage_users.sh index f46ee52..05a1537 100644 --- a/tools/manage_users.sh +++ b/tools/manage_users.sh @@ -184,18 +184,7 @@ prompt_add_links() { docker exec "$_c" mkdir -p "$_scope_dir" >/dev/null 2>&1 || true echo - # Show top-level folders so the user knows what's available - 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 - if [[ -n "$_avail" ]]; then - echo " Available folders: $_avail" - echo " Subdirectories also work, e.g.: documents/shared" - else - echo " (No top-level folders found in FileBrowser root)" - fi - echo - echo " Type a folder name to add — blank line when done." + echo " Type a folder name to add — ? to list available folders, blank when done." echo while true; do @@ -203,6 +192,15 @@ prompt_add_links() { 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%/}"