Merge pull request #56 from outis1one/claude/clever-mayer-FvxF6

Claude/clever mayer fvx f6
This commit is contained in:
Outis
2026-06-07 19:59:30 -04:00
committed by GitHub
34 changed files with 1891 additions and 29 deletions
+1 -1
View File
@@ -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` |
+15 -4
View File
@@ -261,9 +261,20 @@ write_readme() {
}
# ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
# Usage: configure_caddy_for_service "Name" "PORT" "default-subdomain" ["extra"]
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"]
# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"),
# or plain port number for localhost fallback (e.g. "8085").
configure_caddy_for_service() {
local SERVICE_NAME="$1" SERVICE_PORT="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}"
local SERVICE_NAME="$1" SERVICE_UPSTREAM="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}"
# Derive the proxy upstream and a port number for display messages.
# Plain number → localhost:PORT (host-network or legacy services)
# name:port → used as-is (preferred: service on shared caddy_net)
local _UPSTREAM _DISPLAY_PORT
case "$SERVICE_UPSTREAM" in
*:*) _UPSTREAM="$SERVICE_UPSTREAM"; _DISPLAY_PORT="${SERVICE_UPSTREAM##*:}" ;;
*) _UPSTREAM="localhost:$SERVICE_UPSTREAM"; _DISPLAY_PORT="$SERVICE_UPSTREAM" ;;
esac
# Caddy not installed → nothing to do
[ -d "$DOCKER_DIR/caddy" ] || return 0
@@ -280,7 +291,7 @@ configure_caddy_for_service() {
prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY
if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then
echo " Skipping Caddy configuration."
echo " Access $SERVICE_NAME at: http://localhost:$SERVICE_PORT"
echo " Access $SERVICE_NAME at: http://localhost:$_DISPLAY_PORT"
return 0
fi
@@ -320,7 +331,7 @@ configure_caddy_for_service() {
# $SERVICE_NAME
$SERVICE_DOMAIN {
reverse_proxy localhost:$SERVICE_PORT
reverse_proxy $_UPSTREAM
# Security headers
header {
+9 -1
View File
@@ -40,16 +40,24 @@ services:
- ./data:/data
env_file:
- .env
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
AB_COMPOSE
cat > .env << AB_ENV
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
AB_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR"
log_success "Actual Budget configured at $AB_DIR"
configure_caddy_for_service "ActualBudget" "5006" "budget"
configure_caddy_for_service "ActualBudget" "actualbudget:5006" "budget"
write_readme "$AB_DIR" << MD
# Actual Budget
+9 -1
View File
@@ -73,10 +73,18 @@ services:
# Add more optical drives as needed:
# - /dev/sr1:/dev/sr1
privileged: true
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
ARM_COMPOSE
cat > .env << ARM_ENV
ARM_OUTPUT=$ARM_OUTPUT
CADDY_NET=$SITE_CADDY_NET
ARM_ENV
mkdir -p config logs
@@ -85,7 +93,7 @@ ARM_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_OUTPUT"
log_success "A.R.M. configured at $ARM_DIR"
configure_caddy_for_service "A.R.M." "8080" "arm"
configure_caddy_for_service "A.R.M." "arm:8080" "arm"
write_readme "$ARM_DIR" << MD
# A.R.M. (Automatic Ripping Machine)
+9 -1
View File
@@ -50,18 +50,26 @@ services:
- \${PODCASTS_PATH:-./podcasts}:/podcasts
ports:
- "13378:80"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
ABS_COMPOSE
cat > .env << ABS_ENV
AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH
PODCASTS_PATH=./podcasts
CADDY_NET=$SITE_CADDY_NET
ABS_ENV
mkdir -p config metadata podcasts
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR"
log_success "Audiobookshelf configured at $ABS_DIR"
configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks"
configure_caddy_for_service "AudioBookshelf" "audiobookshelf:80" "audiobooks"
write_readme "$ABS_DIR" << MD
# Audiobookshelf
+10 -3
View File
@@ -82,6 +82,13 @@ services:
- ACME_AGREE=true
labels:
- "io.podman.annotations.label/crowdsec.enable=true"
networks:
- caddy_net
networks:
caddy_net:
driver: bridge
name: caddy_net
CADDY_COMPOSE
# Create Caddyfile if it doesn't exist
@@ -114,7 +121,7 @@ CADDY_COMPOSE
# Example:
# myservice.yourdomain.com {
# import authelia
# reverse_proxy localhost:PORT
# reverse_proxy container_name:port
# }
# ActualBudget
@@ -124,7 +131,7 @@ CADDY_COMPOSE
# format json
# level INFO
# }
# reverse_proxy localhost:5006
# reverse_proxy actualbudget:5006
# header {
# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# X-Frame-Options "SAMEORIGIN"
@@ -165,7 +172,7 @@ a web service). You can also edit it by hand:
```
myservice.example.com {
reverse_proxy localhost:1234
reverse_proxy container_name:1234
log {
output file /var/log/caddy/myservice.example.com.log
format json
+9 -1
View File
@@ -59,17 +59,25 @@ services:
# Uncomment for hardware transcoding (Intel/AMD):
# devices:
# - /dev/dri:/dev/dri
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
EMBY_COMPOSE
cat > .env << EMBY_ENV
MEDIA_PATH=$MEDIA_PATH
CADDY_NET=$SITE_CADDY_NET
EMBY_ENV
mkdir -p config
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR"
log_success "Emby configured at $EMBY_DIR"
configure_caddy_for_service "Emby" "8096" "emby"
configure_caddy_for_service "Emby" "emby:8096" "emby"
write_readme "$EMBY_DIR" << MD
# Emby
+21 -3
View File
@@ -26,13 +26,11 @@ name: filebrowser
services:
filebrowser:
image: filebrowser/filebrowser:s6
image: filebrowser/filebrowser:latest
container_name: filebrowser
hostname: filebrowser
restart: unless-stopped
environment:
- PUID=$(id -u "$ACTUAL_USER")
- PGID=$(id -g "$ACTUAL_USER")
- TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
volumes:
- ${FB_PATH}:/srv
@@ -40,10 +38,18 @@ services:
- ./config/settings.json:/config/settings.json
ports:
- "8085:80"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
FB_COMPOSE
cat > .env << FB_ENV
FB_PATH=$FB_PATH
CADDY_NET=$SITE_CADDY_NET
FB_ENV
mkdir -p database config
@@ -61,9 +67,21 @@ FB_SETTINGS
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR"
# Deploy user-management helper script
local _TOOLS_DIR
_TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../tools" 2>/dev/null && pwd)" || true
if [ -f "$_TOOLS_DIR/manage_users.sh" ]; then
cp "$_TOOLS_DIR/manage_users.sh" "$FB_DIR/manage_users.sh"
chmod 750 "$FB_DIR/manage_users.sh"
chown "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR/manage_users.sh"
log_success "manage_users.sh installed at $FB_DIR/manage_users.sh"
fi
echo ""
log_success "Filebrowser configured at $FB_DIR"
configure_caddy_for_service "FileBrowser" "filebrowser:80" "files"
write_readme "$FB_DIR" << MD
# FileBrowser
+9 -1
View File
@@ -44,17 +44,25 @@ services:
- ./data:/fmd/data
ports:
- "8084:8080"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
FMD_COMPOSE
cat > .env << FMD_ENV
FMD_ADMIN_PASSWORD=$FMD_PASS
CADDY_NET=$SITE_CADDY_NET
FMD_ENV
mkdir -p data
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR"
log_success "FindMyDevice configured at $FMD_DIR"
configure_caddy_for_service "FindMyDevice" "8084" "fmd"
configure_caddy_for_service "FindMyDevice" "fmd:8080" "fmd"
write_readme "$FMD_DIR" << MD
# FindMyDevice (FMD)
+9 -1
View File
@@ -169,6 +169,7 @@ FN_FRIGATE__MQTT__PASSWORD=${MQTT_PASS}
FN_FRIGATE__SERVER=http://frigate:5000
FN_FRIGATE__PUBLIC_URL=${FRIGATE_PUBLIC_URL}
FN_ALERTS__NTFY__SERVER=${NTFY_SERVER}
CADDY_NET=$SITE_CADDY_NET
ENVEOF
chmod 600 "$DIR/.env"
log_success ".env written"
@@ -225,6 +226,8 @@ COMPOSEEOF
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
networks:
- caddy_net
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"]
interval: 10s
@@ -257,6 +260,11 @@ COMPOSEEOF
condition: service_healthy
volumes:
- ./frigate-notify/config.yml:/app/config.yml:ro
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
COMPOSEEOF
log_success "docker-compose.yml written"
@@ -506,7 +514,7 @@ FNEOF
# ── Caddy snippet ──────────────────────────────────────────────────────────
if [ -n "$FRIGATE_PUBLIC_URL" ] && [ "$FRIGATE_PUBLIC_URL" != "https://cam.yourdomain.com" ]; then
local _DOM="${FRIGATE_PUBLIC_URL#https://}"
configure_caddy_for_service "Frigate" "8971" "frigate-audio" || true
configure_caddy_for_service "Frigate" "frigate-audio:8971" "frigate-audio" || true
fi
ensure_docker_dir_ownership "$DIR"
+7
View File
@@ -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
+9 -1
View File
@@ -71,10 +71,18 @@ $DEVICE_BLOCK
- "8554:8554"
- "8555:8555/tcp"
- "8555:8555/udp"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
FRIGATE_COMPOSE
cat > .env << FRIGATE_ENV
FRIGATE_MEDIA=$FRIGATE_MEDIA
CADDY_NET=$SITE_CADDY_NET
FRIGATE_ENV
mkdir -p config
@@ -120,7 +128,7 @@ FRIGATE_CONFIG
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true
log_success "Frigate configured at $FRIGATE_DIR"
configure_caddy_for_service "Frigate" "5000" "frigate"
configure_caddy_for_service "Frigate" "frigate:5000" "frigate"
write_readme "$FRIGATE_DIR" << MD
# Frigate NVR
+159
View File
@@ -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 ""
}
+15 -2
View File
@@ -29,13 +29,16 @@ install_homeassistant() {
echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)."
local HA_NETMODE=""
prompt_text " Choose networking mode [1]:" "1" HA_NETMODE
local HA_NET_LINES
local HA_NET_LINES HA_CADDY_NET_LINES
if [ "$HA_NETMODE" = "2" ]; then
HA_NET_LINES=" network_mode: host"
HA_CADDY_NET_LINES=""
echo " → Host networking selected (best device discovery)."
else
HA_NET_LINES=" ports:
- \"8123:8123\""
HA_CADDY_NET_LINES=" networks:
- caddy_net"
echo " → Bridge networking selected (port 8123 published)."
fi
@@ -55,6 +58,12 @@ services:
- ./config:/config
- /run/dbus:/run/dbus:ro
${HA_NET_LINES}
${HA_CADDY_NET_LINES}
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
HOMEASSISTANT_COMPOSE
mkdir -p config
@@ -82,7 +91,11 @@ HA_CONFIG
echo ""
log_success "Home Assistant configured at $HOMEASSISTANT_DIR"
configure_caddy_for_service "Home Assistant" "8123" "home"
if [ "$HA_NETMODE" = "2" ]; then
configure_caddy_for_service "Home Assistant" "8123" "home"
else
configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home"
fi
local START_HA=""
prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA
+17 -1
View File
@@ -137,6 +137,8 @@ services:
restart: always
healthcheck:
disable: false
networks:
- caddy_net
immich-machine-learning:
container_name: immich_machine_learning
@@ -170,6 +172,11 @@ services:
volumes:
model-cache:
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
IMMICH_COMPOSE
else
cat > docker-compose.yml << 'IMMICH_COMPOSE'
@@ -192,6 +199,8 @@ services:
restart: always
healthcheck:
disable: false
networks:
- caddy_net
immich-machine-learning:
container_name: immich_machine_learning
@@ -225,6 +234,11 @@ services:
volumes:
model-cache:
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
IMMICH_COMPOSE
fi
@@ -254,6 +268,7 @@ DB_PASSWORD=$DB_PASS
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
IMMICH_ENV
else
cat > .env << IMMICH_ENV
@@ -271,6 +286,7 @@ DB_PASSWORD=$DB_PASS
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
IMMICH_ENV
fi
@@ -556,7 +572,7 @@ IMPORT_BODY
log_success "Immich configured at $IMMICH_DIR"
configure_caddy_for_service "Immich" "2283" "immich"
configure_caddy_for_service "Immich" "immich-server:2283" "immich"
write_readme "$IMMICH_DIR" << MD
# Immich
+9 -1
View File
@@ -69,17 +69,25 @@ $HWACCEL_BLOCK
- "8096:8096"
- "1900:1900/udp"
- "7359:7359/udp"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
JELLYFIN_COMPOSE
cat > .env << JELLYFIN_ENV
MEDIA_PATH=$MEDIA_PATH
CADDY_NET=$SITE_CADDY_NET
JELLYFIN_ENV
mkdir -p config cache
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR"
log_success "Jellyfin configured at $JELLYFIN_DIR"
configure_caddy_for_service "Jellyfin" "8096" "jellyfin"
configure_caddy_for_service "Jellyfin" "jellyfin:8096" "jellyfin"
write_readme "$JELLYFIN_DIR" << MD
# Jellyfin
+8 -1
View File
@@ -260,6 +260,13 @@ services:
ports:
- "${JS99ER_PORT}:80"
restart: unless-stopped
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
COMPOSE
log_success "Created js99er/docker-compose.yml"
@@ -268,7 +275,7 @@ COMPOSE
log_success "js99er configured at $JS99ER_DIR"
# ── 5. Reverse proxy (no-ops if Caddy isn't installed locally) ───────────
configure_caddy_for_service "js99er" "$JS99ER_PORT" "js99er"
configure_caddy_for_service "js99er" "js99er:80" "js99er"
# ── 6. Build & start ─────────────────────────────────────────────────────
local START_JS99ER=""
+6
View File
@@ -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
+8 -1
View File
@@ -63,6 +63,13 @@ services:
- ./css:/opt/magic_mirror/css
ports:
- "$MM_PORT:8080"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
MM_COMPOSE
mkdir -p config modules css
@@ -125,7 +132,7 @@ MM_COMPOSE
log_success "MagicMirror instance $i configured at $MM_DIR (port $MM_PORT)"
# Offer Caddy only for first instance
[ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "$MM_PORT" "mirror"
[ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror"
local START_MM=""
prompt_yn "Start instance $i now? (y/n):" "y" START_MM
+160
View File
@@ -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 ""
}
+8 -1
View File
@@ -50,13 +50,20 @@ services:
- ./data:/app/data
ports:
- "9925:9000"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: \${CADDY_NET:-caddy_net}
MEALIE_COMPOSE
mkdir -p data
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR"
log_success "Mealie configured at $MEALIE_DIR"
configure_caddy_for_service "Mealie" "9925" "recipes"
configure_caddy_for_service "Mealie" "mealie:9000" "recipes"
write_readme "$MEALIE_DIR" << MD
# Mealie
+9 -1
View File
@@ -54,19 +54,27 @@ services:
ports:
- "4430:443"
- "4433:4433"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
MC_COMPOSE
cat > .env << MC_ENV
MC_HOSTNAME=$MC_HOSTNAME
MC_REVERSE_PROXY=false
MC_TLS_PORT=443
CADDY_NET=$SITE_CADDY_NET
MC_ENV
mkdir -p data files backups
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR"
log_success "MeshCentral configured at $MC_DIR"
configure_caddy_for_service "MeshCentral" "4430" "mesh"
configure_caddy_for_service "MeshCentral" "meshcentral:443" "mesh"
write_readme "$MC_DIR" << MD
# MeshCentral
+10
View File
@@ -35,10 +35,18 @@ services:
- ./config:/etc/ntfy
ports:
- "8090:80"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
NTFY_COMPOSE
cat > .env << NTFY_ENV
TZ=${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}
CADDY_NET=$SITE_CADDY_NET
NTFY_ENV
mkdir -p cache config
@@ -47,6 +55,8 @@ NTFY_ENV
echo ""
log_success "ntfy configured at $NTFY_DIR"
configure_caddy_for_service "ntfy" "ntfy:80" "ntfy"
write_readme "$NTFY_DIR" << MD
# ntfy
+9
View File
@@ -33,6 +33,13 @@ services:
ports:
- "9000:9000"
- "9443:9443"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
PORTAINER_COMPOSE
mkdir -p data
@@ -41,6 +48,8 @@ PORTAINER_COMPOSE
echo ""
log_success "Portainer configured at $PORTAINER_DIR"
configure_caddy_for_service "Portainer" "portainer:9000" "portainer"
write_readme "$PORTAINER_DIR" << MD
# Portainer
+161
View File
@@ -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=<content of ./rustdesk_data/id_ed25519>
# KEY_PUB=<content of ./rustdesk_data/id_ed25519.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: <paste id_ed25519.pub contents>
## 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 ""
}
+8 -1
View File
@@ -42,6 +42,13 @@ services:
- "8082:8082"
- "5000-5150:5000-5150"
- "5000-5150:5000-5150/udp"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
TRACCAR_COMPOSE
mkdir -p logs data config
@@ -63,7 +70,7 @@ TRACCAR_XML
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR"
log_success "Traccar configured at $TRACCAR_DIR"
configure_caddy_for_service "Traccar" "8082" "traccar"
configure_caddy_for_service "Traccar" "traccar:8082" "traccar"
write_readme "$TRACCAR_DIR" << MD
# Traccar
+212
View File
@@ -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://<server-ip>: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 ""
}
+8 -1
View File
@@ -32,6 +32,13 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "3001:3001"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
UPTIME_COMPOSE
mkdir -p data
@@ -68,7 +75,7 @@ docker compose logs -f # logs
MD
# Configure Caddy reverse proxy before starting
configure_caddy_for_service "Uptime Kuma" "3001" "uptime"
configure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime"
local START_UPTIME=""
prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME
+165
View File
@@ -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://<domain>/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 ""
}
+8
View File
@@ -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
+137
View File
@@ -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 <HOST_IP>:$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://<server-ip>:$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 ""
}
+9 -1
View File
@@ -60,18 +60,26 @@ services:
ports:
- "51820:51820/udp"
- "51821:51821/tcp"
networks:
- caddy_net
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
WGEASY_COMPOSE
cat > .env << WGEASY_ENV
WG_HOST=$WG_HOST
WG_PASSWORD=$WG_PASSWORD
CADDY_NET=$SITE_CADDY_NET
WGEASY_ENV
mkdir -p config
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WGEASY_DIR"
log_success "wg-easy configured at $WGEASY_DIR"
configure_caddy_for_service "wg-easy" "51821" "vpn"
configure_caddy_for_service "wg-easy" "wg-easy:51821" "vpn"
write_readme "$WGEASY_DIR" << MD
# wg-easy
+5
View File
@@ -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"
+643
View File
@@ -0,0 +1,643 @@
#!/usr/bin/env bash
# manage_users.sh — FileBrowser user management via the REST API.
#
# Placed in ~/docker/filebrowser/ by the filebrowser installer.
# Requires: curl, jq (sudo apt install curl jq)
#
# Run with no arguments for the interactive menu.
# Pass a command for one-shot use (see --help).
#
# ── Username rules ────────────────────────────────────────────────────────────
# Letters, numbers, hyphens, underscores only. No spaces, dots, or @.
# Examples: alice bob-smith data_user2
#
# ── Password rules ────────────────────────────────────────────────────────────
# Minimum 8 characters. No maximum.
# Must contain at least one letter and one number.
#
# ── Scope (file path) ─────────────────────────────────────────────────────────
# FileBrowser supports ONE scope path per user.
# Scope is an absolute path inside the container, relative to /srv (= FB_PATH).
#
# If FB_PATH=~/drives/data1:
# / → full access (all of ~/drives/data1)
# /alice → alice's own subdir (pair with linked dirs below)
# /music → music subdir only
#
# ── Multi-directory access via linked directories ────────────────────────────
# FileBrowser follows symlinks inside the scope dir.
# Give a user scope=/alice, then link extra folders into /alice:
#
# /alice/music → /srv/music (alice sees "music" in her root)
# /alice/photos → /srv/photos (alice sees "photos" too)
#
# This script creates those symlinks via "docker exec", so they live in
# the bind-mount on the host as well (target is /srv/... so it appears
# broken from the host, but resolves correctly inside the container).
#
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FB_URL="${FB_URL:-http://localhost:8085}"
TOKEN=""
# ── Output helpers ────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
B=$'\e[1m' R=$'\e[0m' GRN=$'\e[32m' RED=$'\e[31m' DIM=$'\e[2m'
else
B="" R="" GRN="" RED="" DIM=""
fi
die() { echo "${RED}ERROR:${R} $*" >&2; exit 1; }
ok() { echo " ${GRN}${R} $*"; }
errmsg() { echo " ${RED}${R} $*" >&2; }
hr() { printf ' %s\n' "────────────────────────────────────────────"; }
banner() { echo; hr; printf " ${B}%-44s${R}\n" "$*"; hr; }
# ── Prerequisites ─────────────────────────────────────────────────────────────
require_cmds() {
for _c in "$@"; do
command -v "$_c" &>/dev/null || die "'$_c' not found — sudo apt install $_c"
done
}
# ── Validation ────────────────────────────────────────────────────────────────
validate_username() {
[[ -n "$1" ]] || { errmsg "Username cannot be empty."; return 1; }
[[ "$1" =~ ^[a-zA-Z0-9_-]+$ ]] || {
errmsg "Invalid username '$1'. Use only letters, numbers, hyphens, underscores."
return 1
}
}
validate_password() {
[[ ${#1} -ge 8 ]] || { errmsg "Password too short (minimum 8 characters)."; return 1; }
[[ "$1" =~ [a-zA-Z] ]] || { errmsg "Password must contain at least one letter."; return 1; }
[[ "$1" =~ [0-9] ]] || { errmsg "Password must contain at least one number."; return 1; }
}
validate_scope() {
[[ "$1" == /* ]] || { errmsg "Scope must start with / (e.g. / or /alice or /music)"; return 1; }
}
# prompt_password VARNAME [label]
# Uses nameref (bash 4.3+) so the caller's local variable is set correctly.
prompt_password() {
local -n _pp_ref="$1"
local _label="${2:-New password}"
local _p1 _p2
while true; do
read -r -s -p " $_label: " _p1; echo
validate_password "$_p1" || continue
read -r -s -p " Confirm: " _p2; echo
[[ "$_p1" == "$_p2" ]] || { errmsg "Passwords do not match. Try again."; continue; }
_pp_ref="$_p1"
break
done
}
# ── Auth — login once, reuse token ────────────────────────────────────────────
ensure_token() {
[[ -n "$TOKEN" ]] && return 0
echo
echo " ${B}FileBrowser login${R} ${DIM}(${FB_URL})${R}"
local _u _p _tok
read -r -p " Admin username [admin]: " _u
_u="${_u:-admin}"
read -r -s -p " Admin password: " _p; echo
_tok=$(curl -s -X POST "$FB_URL/api/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$_u\",\"password\":\"$_p\"}")
[[ "$_tok" == *"."*"."* ]] \
|| die "Login failed. Check credentials and that FileBrowser is running at $FB_URL"
TOKEN="$_tok"
ok "Logged in as $_u"
}
# ── REST wrappers ─────────────────────────────────────────────────────────────
api_get() { curl -sf -X GET "$FB_URL$1" -H "X-Auth: $TOKEN"; }
api_post() { curl -sf -X POST "$FB_URL$1" -H "X-Auth: $TOKEN" \
-H "Content-Type: application/json" -d "$2"; }
api_put() { curl -sf -X PUT "$FB_URL$1" -H "X-Auth: $TOKEN" \
-H "Content-Type: application/json" -d "$2"; }
api_delete() { curl -sf -X DELETE "$FB_URL$1" -H "X-Auth: $TOKEN"; }
find_user() {
api_get "/api/users" | jq -r --arg u "$1" '.[] | select(.username==$u)'
}
get_user_id() {
local _j
_j=$(find_user "$1")
[[ -n "$_j" ]] || { errmsg "User '$1' not found."; return 1; }
echo "$_j" | jq -r '.id'
}
default_perms() {
echo '{"admin":false,"execute":false,"create":true,"rename":true,
"modify":true,"delete":true,"share":false,"download":true}'
}
# ── Docker helpers ─────────────────────────────────────────────────────────────
get_container_name() {
local _compose="$SCRIPT_DIR/docker-compose.yml"
if [[ -f "$_compose" ]]; then
local _name
_name=$(grep 'container_name:' "$_compose" | head -1 | awk '{print $2}')
[[ -n "$_name" ]] && { echo "$_name"; return; }
fi
echo "filebrowser"
}
check_container() {
require_cmds docker
local _running
_running=$(docker inspect --format='{{.State.Running}}' "$1" 2>/dev/null || echo "false")
[[ "$_running" == "true" ]] \
|| { errmsg "Container '$1' is not running. Start it: docker compose up -d"; return 1; }
}
# Print symlinks inside /srv<scope>, one per line. Paths shown as FileBrowser sees them (no /srv).
list_links() {
local _c="$1" _scope="$2"
docker exec "$_c" find "/srv$_scope" -maxdepth 1 -type l \
-exec sh -c '
_name=$(basename "$1")
_target=$(readlink "$1")
_display="${_target#/srv}"
[ -z "$_display" ] && _display="/"
printf " %-24s→ %s\n" "$_name" "$_display"
' _ {} \; 2>/dev/null | sort || true
}
# ── prompt_add_links SCOPE ────────────────────────────────────────────────────
# Loop: user types folder names as they appear in FileBrowser — blank to finish.
# Shared by cmd_add (offered inline) and menu_links (add option).
prompt_add_links() {
local _scope="$1"
local _c
_c=$(get_container_name)
check_container "$_c" || return 1
local _scope_dir="/srv$_scope"
docker exec "$_c" mkdir -p "$_scope_dir" >/dev/null 2>&1 || true
echo
echo " Type a folder name to add — ? to list available folders, blank when done."
echo
while true; do
local _src=""
read -r -p " Folder to add [done]: " _src
[[ -n "$_src" ]] || break
if [[ "$_src" == "?" ]]; then
local _avail
_avail=$(docker exec "$_c" find /srv -maxdepth 1 -mindepth 1 -type d \
-not -name ".*" 2>/dev/null | sed 's|^/srv/||' | sort | xargs echo) || true
echo " Available: ${_avail:-(none found)}"
echo
continue
fi
# Normalise: strip surrounding slashes
_src="${_src#/}"
_src="${_src%/}"
[[ -n "$_src" ]] || continue
local _target="/srv/$_src"
local _link_name
_link_name=$(basename "$_src")
if ! docker exec "$_c" test -e "$_target" 2>/dev/null; then
errmsg "'$_src' not found in FileBrowser — check the path and try again."
continue
fi
local _link_path="$_scope_dir/$_link_name"
if docker exec "$_c" test -e "$_link_path" 2>/dev/null; then
errmsg "'$_link_name' already exists in this user's folder — use Remove to clear it first."
continue
fi
docker exec "$_c" ln -s "$_target" "$_link_path"
if [[ "$_src" == "$_link_name" ]]; then
ok "User can now see '$_link_name'"
else
ok "User can now see '$_link_name' ${DIM}(from $_src)${R}"
fi
done
}
# ── Linked-directory submenu (shown from Modify option 5) ────────────────────
menu_links() {
local _scope="$1"
local _c
_c=$(get_container_name)
check_container "$_c" || return 1
while true; do
banner "Linked directories (scope: $_scope)"
echo " Folders this user can see beyond their scope:"
echo
local _links
_links=$(list_links "$_c" "$_scope")
if [[ -n "$_links" ]]; then
echo "$_links"
else
echo " (none)"
fi
echo
echo " 1 Add folders"
echo " 2 Remove a folder"
echo " 0 Back"
echo
local _ch=""
read -r -p " Choice: " _ch
case "$_ch" in
1) prompt_add_links "$_scope" || true ;;
2)
local _link_name=""
echo
read -r -p " Folder name to remove: " _link_name
[[ -n "$_link_name" ]] || continue
local _link_path="/srv${_scope}/${_link_name}"
if ! docker exec "$_c" test -L "$_link_path" 2>/dev/null; then
errmsg "'$_link_name' is not a linked folder — refusing to delete."
continue
fi
docker exec "$_c" rm "$_link_path"
ok "'$_link_name' removed."
;;
0) break ;;
*) errmsg "Invalid choice." ;;
esac
done
}
# ── cmd: list ─────────────────────────────────────────────────────────────────
cmd_list() {
ensure_token
echo
printf " ${B}%-22s %-5s %s${R}\n" "USERNAME" "ADMIN" "SCOPE"
printf " %-22s %-5s %s\n" "--------" "-----" "-----"
api_get "/api/users" | \
jq -r '.[] | [.username, (if .perm.admin then "yes" else "no" end), .scope] | @tsv' | \
while IFS=$'\t' read -r _u _a _s; do
printf " %-22s %-5s %s\n" "$_u" "$_a" "$_s"
done
echo
}
# ── cmd: add ─────────────────────────────────────────────────────────────────
cmd_add() {
local _username="${1:-}" _scope="${2:-}" _is_admin="false"
[[ "${3:-}" == "--admin" ]] && _is_admin="true"
ensure_token
if [[ -z "$_username" ]]; then
echo
echo " ${B}Username:${R} letters, numbers, hyphens, underscores only. No dots or @."
echo " ${B}Password:${R} min 8 chars, at least 1 letter and 1 number."
echo " ${B}Scope:${R} one path per user — use linked dirs for multi-folder access."
echo
read -r -p " Username: " _username
local _adm=""
read -r -p " Admin? [y/N]: " _adm
[[ "${_adm,,}" == "y" ]] && _is_admin="true"
fi
validate_username "$_username" || return 1
if [[ -z "$_scope" ]]; then
echo
echo " Scope examples:"
echo " / full access (all of FB_PATH)"
echo " $_username user's own private subdir (pair with linked dirs)"
echo " music music subdir only"
echo
read -r -p " Scope for '$_username': " _scope
_scope="/${_scope#/}" # ensure leading /
fi
validate_scope "$_scope" || return 1
local _ex
_ex=$(find_user "$_username")
if [[ -n "$_ex" ]]; then
errmsg "User '$_username' already exists. Use Modify to change it."
return 1
fi
echo
local password=""
prompt_password password "Password for $_username"
local _perms _body
_perms=$(default_perms)
[[ "$_is_admin" == "true" ]] && _perms=$(echo "$_perms" | jq '.admin = true')
_body=$(jq -n \
--arg u "$_username" --arg p "$password" --arg s "$_scope" \
--argjson perms "$_perms" \
'{username:$u, password:$p, scope:$s, locale:"en", viewMode:"list",
singleClick:false, sorting:{by:"name",asc:true}, perm:$perms,
commands:[], lockPassword:false, hideDotfiles:false, dateFormat:false}')
api_post "/api/users" "$_body" >/dev/null
echo
ok "User '$_username' created | scope: $_scope | admin: $_is_admin"
# Offer linked directories (pointless if scope is already /)
if command -v docker &>/dev/null && [[ "$_scope" != "/" ]]; then
local _do_links=""
read -r -p " Add linked directories for '$_username'? [y/N]: " _do_links
if [[ "${_do_links,,}" == "y" ]]; then
prompt_add_links "$_scope" || true
fi
fi
}
# ── cmd: delete ───────────────────────────────────────────────────────────────
cmd_delete() {
local _username="${1:-}"
ensure_token
if [[ -z "$_username" ]]; then
cmd_list
read -r -p " Username to delete: " _username
fi
validate_username "$_username" || return 1
local _uid
_uid=$(get_user_id "$_username") || return 1
local _c=""
read -r -p " Delete '$_username' (id $_uid)? [y/N]: " _c
[[ "${_c,,}" == "y" ]] || { echo " Aborted."; return 0; }
api_delete "/api/users/$_uid" >/dev/null
ok "User '$_username' deleted."
echo " ${DIM}Note: symlinks in their scope dir still exist on disk if you want to reuse them.${R}"
}
# ── cmd: passwd ───────────────────────────────────────────────────────────────
cmd_passwd() {
local _username="${1:-}"
ensure_token
if [[ -z "$_username" ]]; then
cmd_list
read -r -p " Username: " _username
fi
validate_username "$_username" || return 1
local _uid _user
_uid=$(get_user_id "$_username") || return 1
_user=$(find_user "$_username")
echo
echo " ${B}Password rules:${R} min 8 chars, at least 1 letter and 1 number."
echo
local password=""
prompt_password password "New password for $_username"
local _body
_body=$(echo "$_user" | jq --arg p "$password" '. + {password: $p}')
api_put "/api/users/$_uid" "$_body" >/dev/null
echo
ok "Password updated for '$_username'."
}
# ── cmd: scope ────────────────────────────────────────────────────────────────
cmd_scope() {
local _username="${1:-}" _new_scope="${2:-}"
ensure_token
if [[ -z "$_username" ]]; then
cmd_list
read -r -p " Username: " _username
fi
validate_username "$_username" || return 1
local _uid _user _old_scope
_uid=$(get_user_id "$_username") || return 1
_user=$(find_user "$_username")
_old_scope=$(echo "$_user" | jq -r '.scope')
if [[ -z "$_new_scope" ]]; then
echo
echo " Current scope: $_old_scope"
echo " ${DIM}Linked folders in the old scope are not moved automatically.${R}"
echo
read -r -p " New scope: " _new_scope
_new_scope="/${_new_scope#/}"
fi
validate_scope "$_new_scope" || return 1
local _body
_body=$(echo "$_user" | jq --arg s "$_new_scope" '. + {scope: $s}')
api_put "/api/users/$_uid" "$_body" >/dev/null
echo
ok "Scope updated for '$_username': $_old_scope$_new_scope"
# Offer to add links into the new scope
if command -v docker &>/dev/null && [[ "$_new_scope" != "/" ]]; then
local _do_links=""
read -r -p " Add linked directories into '$_new_scope'? [y/N]: " _do_links
if [[ "${_do_links,,}" == "y" ]]; then
prompt_add_links "$_new_scope" || true
fi
fi
}
# ── cmd: rename ───────────────────────────────────────────────────────────────
cmd_rename() {
local _username="${1:-}" _new_username="${2:-}"
ensure_token
if [[ -z "$_username" ]]; then
cmd_list
read -r -p " Username to rename: " _username
fi
validate_username "$_username" || return 1
if [[ -z "$_new_username" ]]; then
read -r -p " New username: " _new_username
fi
validate_username "$_new_username" || return 1
local _uid _user _body
_uid=$(get_user_id "$_username") || return 1
_user=$(find_user "$_username")
_body=$(echo "$_user" | jq --arg u "$_new_username" '. + {username: $u}')
api_put "/api/users/$_uid" "$_body" >/dev/null
ok "Renamed: '$_username' → '$_new_username'"
}
# ── cmd: info ─────────────────────────────────────────────────────────────────
cmd_info() {
local _username="${1:-}"
ensure_token
if [[ -z "$_username" ]]; then
cmd_list
read -r -p " Username: " _username
fi
validate_username "$_username" || return 1
local _user
_user=$(find_user "$_username")
[[ -n "$_user" ]] || { errmsg "User '$_username' not found."; return 1; }
echo
echo "$_user" | jq '{username, scope,
admin: .perm.admin,
create: .perm.create,
modify: .perm.modify,
delete: .perm.delete,
download: .perm.download,
execute: .perm.execute}'
echo
}
# ── Modify submenu ────────────────────────────────────────────────────────────
menu_modify() {
ensure_token
cmd_list
local _cur=""
read -r -p " Username to modify: " _cur
validate_username "$_cur" || return 1
get_user_id "$_cur" >/dev/null || return 1
while true; do
local _user _scope _admin
_user=$(find_user "$_cur") || { errmsg "User '$_cur' no longer exists."; break; }
[[ -n "$_user" ]] || { errmsg "User '$_cur' no longer exists."; break; }
_scope=$(echo "$_user" | jq -r '.scope')
_admin=$(echo "$_user" | jq -r 'if .perm.admin then "yes" else "no" end')
banner "Modify: $_cur"
echo " ${B}Scope:${R} $_scope"
echo " ${B}Admin:${R} $_admin"
echo
echo " 1 Change username"
echo " 2 Change password"
echo " 3 Change scope (file path)"
echo " 4 Toggle admin status"
echo " 5 Linked directories ${DIM}(add/remove multi-folder symlinks)${R}"
echo " 0 Back"
echo
local _ch=""
read -r -p " Choice: " _ch
case "$_ch" in
1)
local _new_u=""
echo
read -r -p " New username: " _new_u
validate_username "$_new_u" || continue
local _uid1 _body1
_uid1=$(get_user_id "$_cur") || continue
_body1=$(echo "$_user" | jq --arg u "$_new_u" '. + {username: $u}')
if api_put "/api/users/$_uid1" "$_body1" >/dev/null; then
ok "Renamed: '$_cur' → '$_new_u'"
_cur="$_new_u"
else
errmsg "Rename failed."
fi
;;
2) cmd_passwd "$_cur" || true ;;
3) cmd_scope "$_cur" || true ;;
4)
local _uid4
_uid4=$(get_user_id "$_cur") || continue
local _toggled
_toggled=$(echo "$_user" | jq '.perm.admin = (.perm.admin | not)')
if api_put "/api/users/$_uid4" "$_toggled" >/dev/null; then
local _new_admin
_new_admin=$(echo "$_toggled" | jq -r 'if .perm.admin then "yes" else "no" end')
ok "Admin for '$_cur' is now: $_new_admin"
else
errmsg "Toggle failed."
fi
;;
5) menu_links "$_scope" || true ;;
0) break ;;
*) errmsg "Invalid choice." ;;
esac
done
}
# ── usage ─────────────────────────────────────────────────────────────────────
usage() {
cat <<'EOF'
FileBrowser user management
Run with no arguments for the interactive menu.
One-shot usage:
manage_users.sh list
manage_users.sh add <username> <scope> [--admin]
manage_users.sh delete <username>
manage_users.sh passwd <username>
manage_users.sh scope <username> <new-scope>
manage_users.sh rename <username> <new-username>
manage_users.sh info <username>
Scope is relative to /srv inside the container (= FB_PATH on the host).
Username: letters, numbers, hyphens, underscores only. No dots or @.
Password: min 8 chars, at least one letter and one number.
Multi-directory access: use the interactive menu — linked directories
are offered automatically when you add a user or change their scope.
Override URL: FB_URL=http://localhost:8085 ./manage_users.sh
EOF
}
# ── Main interactive menu ─────────────────────────────────────────────────────
run_interactive() {
ensure_token
while true; do
banner "FileBrowser User Manager"
echo " ${DIM}${FB_URL}${R}"
echo
echo " 1 List users"
echo " 2 Add user"
echo " 3 Delete user"
echo " 4 Modify user (username / password / scope / admin / links)"
echo " 5 View user details"
echo " 0 Exit"
echo
local _ch=""
read -r -p " Choice: " _ch
case "$_ch" in
1) cmd_list || true ;;
2) cmd_add || true ;;
3) cmd_delete || true ;;
4) menu_modify || true ;;
5) cmd_info || true ;;
0) echo; echo " Goodbye."; echo; exit 0 ;;
*) errmsg "Invalid choice." ;;
esac
done
}
# ── Entry point ───────────────────────────────────────────────────────────────
require_cmds curl jq
_cmd="${1:-}"
shift || true
case "$_cmd" in
"") run_interactive ;;
list) ensure_token; cmd_list ;;
add) ensure_token; cmd_add "$@" ;;
delete|del) ensure_token; cmd_delete "${1:-}" ;;
passwd|pw) ensure_token; cmd_passwd "${1:-}" ;;
scope) ensure_token; cmd_scope "${1:-}" "${2:-}" ;;
rename) ensure_token; cmd_rename "${1:-}" "${2:-}" ;;
info) ensure_token; cmd_info "${1:-}" ;;
help|--help|-h) usage ;;
*) errmsg "Unknown command: $_cmd"; echo; usage; exit 2 ;;
esac