From 774f6ad561605856c4c8a3e4c6b867a833246eef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:32:44 +0000 Subject: [PATCH] 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 "" +}