diff --git a/CADDY-FAIL2BAN-SETUP.md b/CADDY-FAIL2BAN-SETUP.md deleted file mode 100644 index b179e0f..0000000 --- a/CADDY-FAIL2BAN-SETUP.md +++ /dev/null @@ -1,371 +0,0 @@ -# Caddy with Fail2ban Setup Guide - -This guide helps you integrate new services with an existing Caddy reverse proxy and set up fail2ban protection. - -## Quick Start - -For servers with Caddy already installed: - -```bash -# Run the automated helper script -./caddy-setup-helper.sh -``` - -This script will: -- ✅ Detect your Caddy installation -- ✅ Locate and backup your Caddyfile -- ✅ Check for fail2ban configuration -- ✅ Provide examples for adding new services - -## Manual Setup - -### 1. Backup Your Caddyfile - -**IMPORTANT:** Always backup before making changes! - -```bash -# Find your Caddyfile location -CADDYFILE=~/docker/caddy/Caddyfile # Adjust path as needed - -# Create backup directory -mkdir -p $(dirname "$CADDYFILE")/backups - -# Backup with timestamp -cp "$CADDYFILE" "$(dirname "$CADDYFILE")/backups/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)" -``` - -### 2. Add New Services to Caddy - -Add these blocks to your Caddyfile: - -#### ActualBudget (Personal Finance) - -```caddy -budget.yourdomain.com { - log { - output file /var/log/caddy/actualbudget-access.log - format json - level INFO - } - - reverse_proxy localhost:5006 - - # Security headers - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Frame-Options "SAMEORIGIN" - X-Content-Type-Options "nosniff" - X-XSS-Protection "1; mode=block" - Referrer-Policy "strict-origin-when-cross-origin" - } -} -``` - -#### Keycloak (Identity & Access Management) - -```caddy -auth.yourdomain.com { - log { - output file /var/log/caddy/keycloak-access.log - format json - level INFO - } - - reverse_proxy localhost:8180 - - # Security headers - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Frame-Options "SAMEORIGIN" - X-Content-Type-Options "nosniff" - X-XSS-Protection "1; mode=block" - Referrer-Policy "strict-origin-when-cross-origin" - } -} -``` - -### 3. Reload Caddy Configuration - -After editing the Caddyfile: - -```bash -# Format the Caddyfile (optional but recommended) -docker exec -w /etc/caddy caddy caddy fmt --overwrite - -# Reload Caddy configuration -docker exec -w /etc/caddy caddy caddy reload -``` - -If you get errors, check Caddy logs: -```bash -docker logs caddy -``` - -### 4. Restore from Backup (if needed) - -If something goes wrong: - -```bash -# Find your backup -ls -lah ~/docker/caddy/backups/ - -# Restore the backup -cp ~/docker/caddy/backups/Caddyfile.backup.YYYYMMDD_HHMMSS ~/docker/caddy/Caddyfile - -# Reload Caddy -docker exec -w /etc/caddy caddy caddy reload -docker exec -w /etc/caddy caddy caddy fmt --overwrite -``` - -## Fail2ban Configuration - -### Prerequisites - -1. **Enable JSON logging in Caddy** (shown in examples above) -2. **Install fail2ban** on the host: - ```bash - sudo apt update - sudo apt install fail2ban -y - ``` - -### Installation Steps - -#### Step 1: Install Fail2ban Filter - -```bash -# Copy the filter configuration -sudo cp fail2ban-caddy-filter.conf /etc/fail2ban/filter.d/caddy-auth.conf -``` - -Or create it manually: - -```bash -sudo tee /etc/fail2ban/filter.d/caddy-auth.conf > /dev/null <<'EOF' -[Definition] -failregex = ^.*"remote_ip":"".*"status":(?:401|403|429).*$ - ^.*"remote_addr":".*"status":(?:401|403|429).*$ -ignoreregex = ^.*"remote_ip":"(?:127\.0\.0\.1|::1)".*$ -datepattern = "ts":%%s -EOF -``` - -#### Step 2: Install Fail2ban Jail - -```bash -# Copy the jail configuration -sudo cp fail2ban-caddy-jail.conf /etc/fail2ban/jail.d/caddy.conf -``` - -Or create it manually: - -```bash -sudo tee /etc/fail2ban/jail.d/caddy.conf > /dev/null <<'EOF' -[caddy-auth] -enabled = true -port = http,https -filter = caddy-auth -logpath = /var/log/caddy/access.log - /var/log/caddy/*-access.log -maxretry = 5 -findtime = 600 -bantime = 3600 -action = iptables-multiport[name=CaddyAuth, port="http,https", protocol=tcp] -backend = auto -EOF -``` - -#### Step 3: Create Log Directory - -```bash -# Create log directory if using Docker Caddy -sudo mkdir -p /var/log/caddy -sudo chmod 755 /var/log/caddy - -# If Caddy runs as specific user: -# sudo chown caddy:caddy /var/log/caddy -``` - -#### Step 4: Update Caddy Docker Compose - -Add log volume to your Caddy docker-compose.yml: - -```yaml -services: - caddy: - image: caddy:latest - container_name: caddy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - ./data:/data - - ./config:/config - - /var/log/caddy:/var/log/caddy # Add this line -``` - -Then restart Caddy: -```bash -cd ~/docker/caddy -docker compose down -docker compose up -d -``` - -#### Step 5: Restart Fail2ban - -```bash -sudo systemctl restart fail2ban -sudo systemctl status fail2ban -``` - -### Testing Fail2ban - -```bash -# Check if jail is running -sudo fail2ban-client status caddy-auth - -# Test the filter against your logs -sudo fail2ban-regex /var/log/caddy/access.log /etc/fail2ban/filter.d/caddy-auth.conf - -# View banned IPs -sudo fail2ban-client get caddy-auth banip - -# Manually ban/unban an IP (for testing) -sudo fail2ban-client set caddy-auth banip 1.2.3.4 -sudo fail2ban-client set caddy-auth unbanip 1.2.3.4 -``` - -### Troubleshooting - -#### Fail2ban not detecting attacks - -1. **Check log format:** - ```bash - tail -f /var/log/caddy/access.log - ``` - Ensure it's JSON format with `remote_ip` or `remote_addr` field. - -2. **Test filter manually:** - ```bash - sudo fail2ban-regex /var/log/caddy/access.log /etc/fail2ban/filter.d/caddy-auth.conf --print-all-matched - ``` - -3. **Check fail2ban logs:** - ```bash - sudo tail -f /var/log/fail2ban.log - ``` - -#### Caddy configuration errors - -1. **Validate Caddyfile:** - ```bash - docker exec caddy caddy validate --config /etc/caddy/Caddyfile - ``` - -2. **Check Caddy logs:** - ```bash - docker logs caddy --tail 50 - ``` - -## Advanced Configuration - -### Aggressive Fail2ban Settings - -For tighter security: - -```ini -[caddy-auth] -maxretry = 3 # Ban after 3 attempts (instead of 5) -findtime = 300 # Within 5 minutes (instead of 10) -bantime = 86400 # Ban for 24 hours (instead of 1) -``` - -### Ban Time Increment - -Ban repeat offenders for longer: - -```ini -[caddy-auth] -bantime.increment = true -bantime.factor = 24 -bantime.maxtime = 604800 # Maximum 1 week ban -``` - -### Email Notifications - -Get notified when IPs are banned: - -```ini -[caddy-auth] -action = iptables-multiport[name=CaddyAuth, port="http,https", protocol=tcp] - sendmail-whois[name=CaddyAuth, dest=admin@yourdomain.com] -``` - -### Per-Service Jails - -Create separate jails for different services: - -```ini -[caddy-actualbudget] -enabled = true -port = http,https -filter = caddy-auth -logpath = /var/log/caddy/actualbudget-access.log -maxretry = 3 -bantime = 7200 - -[caddy-keycloak] -enabled = true -port = http,https -filter = caddy-auth -logpath = /var/log/caddy/keycloak-access.log -maxretry = 5 -bantime = 3600 -``` - -## Best Practices - -1. **Always backup before changes** -2. **Test configuration before reloading** (`caddy validate`) -3. **Monitor fail2ban logs** initially to tune settings -4. **Use strong passwords** for admin interfaces -5. **Keep services updated** (`docker compose pull && docker compose up -d`) -6. **Regular backups** of configuration and data -7. **Use HTTPS** via Caddy for all services -8. **Implement rate limiting** in Caddy for API endpoints - -## Quick Reference - -### Common Commands - -```bash -# Caddy -docker exec -w /etc/caddy caddy caddy reload -docker exec -w /etc/caddy caddy caddy fmt --overwrite -docker exec caddy caddy validate --config /etc/caddy/Caddyfile -docker logs caddy --tail 50 - -# Fail2ban -sudo systemctl restart fail2ban -sudo fail2ban-client status caddy-auth -sudo fail2ban-client set caddy-auth unbanip 1.2.3.4 -sudo tail -f /var/log/fail2ban.log - -# Backup -cp ~/docker/caddy/Caddyfile ~/docker/caddy/Caddyfile.backup -``` - -### Service Ports - -- **ActualBudget**: 5006 -- **Keycloak**: 8180 -- **Caddy**: 80 (HTTP), 443 (HTTPS) - -## Support - -For issues: -- Caddy documentation: https://caddyserver.com/docs/ -- Fail2ban manual: https://www.fail2ban.org/wiki/index.php/MANUAL_0_8 -- ActualBudget docs: https://actualbudget.org/docs/ -- Keycloak docs: https://www.keycloak.org/documentation diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 408e1b8..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,224 +0,0 @@ -# Changelog - -All notable changes to this project. Versions follow `MAJOR.MINOR.PATCH`. - -## [0.9.5] - 2026-06-03 - -### Changed -- VERSION reset from 1.0.0 to 0.9.5 — versioning now tracks `setup_v.sh` - snapshot files. Each release creates a new numbered file (old files stay). The - current `setup.sh` is always the live version; `setup_v0.9.5.sh` is the first - named snapshot. - -### Added -- `setup_v0.9.5.sh` — first versioned snapshot of `setup.sh`. Future changes - produce `setup_v0.9.6.sh`, etc. Previous snapshots are never removed. - -## [1.0.0] - 2026-06-03 - -### Milestone: full parity with the monolith - -Every service from `ubuntu-post-install-24.04-crowdsec.sh` is now a module. -The modular system (`setup.sh` + `services/`) is the primary install path. -The monolith is retained as a frozen evolution record. - -### Added -- `services/linux-to-sync.sh` *(extras)* — clones the private - `outis1one/linux-to-sync` repository to `~/linux-to-sync` via SSH key or - GitHub PAT (PAT is stripped from the remote URL after clone for security). - `is_installed` marker checks `~/linux-to-sync/.git`. -- Updated `MODULAR.md` migration table to show the completed module inventory - grouped by category. - -## [0.9.11] - 2026-06-03 - -### Added -- **Utilities batch** — 8 service modules migrated from the monolith: - - `services/mealie.sh` *(utilities)* — Recipe manager & meal planner. PUID/PGID baked; - default creds noted (change immediately). Port 9925 → internal 9000. - - `services/actualbudget.sh` *(utilities)* — Open-source personal finance (Actual Budget). - Minimal container; bank sync via SimpleFIN optional. Port 5006. - - `services/traccar.sh` *(utilities)* — GPS tracking server for phones, vehicles, assets. - Ships a starter `config/traccar.xml` with H2 embedded DB. Port 8082 + 5000-5150 device - protocols (TCP+UDP). - - `services/fmd.sh` *(utilities)* — FindMyDevice server for Android. Generates a random - admin password; mobile app from F-Droid (not Play Store). Port 8084. - - `services/ddclient.sh` *(utilities)* — Dynamic DNS updater; no web UI. Ships a - `config/ddclient.conf` template covering Cloudflare, DuckDNS, No-IP. Default start - prompt is "n" — edit config first. - - `services/wg-easy.sh` *(utilities)* — WireGuard VPN with web UI. Auto-detects public - IP for `WG_HOST`; generates random password; requires `NET_ADMIN` + `SYS_MODULE` caps - and `ip_forward` sysctl. Ports 51820/udp (VPN) + 51821/tcp (web). - - `services/meshcentral.sh` *(utilities)* — Self-hosted remote device management server. - Prompts for hostname (domain/IP for agent connections). Ports 4430 (HTTPS) + 4433 (agent). - - `services/magicmirror.sh` *(utilities)* — Modular smart mirror / info dashboard. - Multi-instance (1-3, ports 8081-8083); each instance in `~/docker/magicmirror//`. - Optionally copies existing `config.js` and auto-clones `MMM-*` third-party modules - from GitHub (tries MichMich → bugsounet → MagicMirrorOrg org order). -- **Cameras batch** — 2 service modules: - - `services/frigate.sh` *(cameras)* — AI-powered NVR with object detection. Auto-enables - `/dev/dri/renderD128` for hardware-accelerated detection when present; ships a starter - `config/config.yml` with camera examples. `privileged: true` + 1 GB tmpfs cache. - Ports 5000 (web), 8554 (RTSP restream), 8555 (WebRTC). Default start prompt is "n" — - edit config first. - - `services/frigate-notify.sh` *(cameras)* — Push notification sidecar for Frigate events. - Auto-detects local Frigate and ntfy installs to pre-fill config defaults. Supports ntfy, - Pushover, Discord, Gotify, Telegram, and more. No web UI. - -## [0.9.10] - 2026-06-03 - -### Added -- **Media batch** — 6 service modules migrated from the monolith: - - `services/jellyfin.sh` *(media)* — Free media server (movies, TV, music). Auto-detects - `/dev/dri/renderD128` and enables VAAPI hardware transcoding with the render GID when - present; falls back to CPU transcoding otherwise. Ports 8096, 1900/udp (DLNA), - 7359/udp (discovery). - - `services/emby.sh` *(media)* — Emby media server. UID/GID baked from the install-time - user; HW transcoding block left commented (uncomment `/dev/dri` once GPU confirmed). - Ports 8096 (web) and 8920 (HTTPS). - - `services/audiobookshelf.sh` *(media)* — Audiobook & podcast server. Separate audiobooks - and podcasts paths; podcasts folder defaults to `./podcasts` inside the service dir. - Port 13378. - - `services/arm.sh` *(media)* — Automatic Ripping Machine for DVDs, Blu-rays, CDs. - Detects optical drives at install time (`/dev/sr*`); runs with `privileged: true`. - Ripped output split into movies/ and music/. Port 8080. - - `services/lyrion.sh` *(media)* — Lyrion Music Server (formerly LMS) for Squeezebox - devices, the Squeezer app, and Chromecast. Uses `network_mode: host` so UDP discovery - works without manual port mapping. Port 9000. - - `services/immich.sh` *(media)* — Self-hosted photo & video backup (like Google Photos). - Full multi-container stack (immich-server, immich-machine-learning, valkey/redis, - postgres). Two library strategies: (1) unified — all photos in one place with an - auto-generated `import-photos.sh` helper that handles admin account creation, API key - generation, storage template config, and CLI upload; (2) external — existing photos - indexed read-only, new uploads separate. Port 2283. - -## [0.9.9] - 2026-06-03 - -### Added -- **New `extras` category** for non-Docker add-ons sourced from other repos — - things that build/install on the host instead of running as a container. - Inserted into `CATEGORY_ORDER` between `gaming` and `backup`. -- `services/silent-send.sh` *(extras)* — installs the **Silent Send** browser - extension (redacts PII before it's sent to AI chatbots). Installs the build - toolchain (git, Node.js ≥18 via NodeSource, npm), clones - `outis1one/silent-send` to `~/silent-send`, runs `npm install` so the Firefox - build/sign tooling (`web-ext`) is ready, optionally builds a signed Firefox - `.xpi` (with Mozilla API creds), and prints load-unpacked / build instructions - per browser. README written to the checkout. No server/container. -- `is_installed` marker for `silent-send` (checks `~/silent-send/.git`). - -## [0.9.8] - 2026-06-03 - -### Added -- `services/wolf-pair.sh` *(gaming)* — browser-based Moonlight pairing UI for - Wolf. Builds a tiny Python HTTP container (`python:3.12-alpine` + `docker-cli`) - that watches `docker logs wolf` for the current pairing secret and serves a - PIN entry form on port 8090. Eliminates the `./manage.sh pin` CLI workflow — - open `http://:8090`, type the 4-digit PIN, done. Runs with - `network_mode: host` so it can reach Wolf's `/pin/` API at `localhost:47989`; - mounts the Docker socket read-only for log access. Optional Caddy subdomain. - -## [0.9.7] - 2026-06-03 - -### Added -- `services/caddy.sh` *(homelab)* — Caddy reverse proxy + automatic HTTPS, own - `~/docker/caddy/` folder (compose + starter Caddyfile + README). Services add - their site blocks to its Caddyfile. -- `services/crowdsec.sh` *(homelab)* — system-level intrusion prevention - (agent + firewall bouncer + Caddy log acquisition + optional ntfy ban alerts); - README in `~/docker/crowdsec/`. -- **Guided menu redesign** in `setup.sh`: - - Prints the **required** set (essential packages incl. glow + a Docker check) - up front and lets you **cancel** before anything changes. - - Offers **Caddy first** (most services depend on it). - - **Category menu loop**: pick a category → checklist (already-installed shown - as `[installed]`) → install → back to the menu for the next category, until - you choose Done. whiptail UI with a plain-text fallback. - -### Changed -- **Categories** reorganized: `base · homelab · utilities · media · cameras · - gaming · backup`. Moved ntfy, filebrowser, portainer, uptimekuma, watchtower - to `utilities`. Within `homelab`, Caddy → CrowdSec → Authelia sort first. - -## [0.9.6] - 2026-06-03 - -### Added -- **Per-service README generation.** New `write_readme` helper in - `lib/common.sh`; every module now writes a `README.md` into its - `~/docker//` folder (what it is, access URL, start/stop, data - location, reverse-proxy notes) — so each service folder is self-documenting. -- Migrated 6 services from the monolith into modules (all in the `homelab` - group, each with a README): - - `authelia` — SSO + 2FA portal, ported from the `authelia-setup` repo + the - monolith's working block: prompts for domain/SMTP, generates - jwt/session/storage secrets + the admin Argon2 hash, writes - compose/config/users, creates `caddy_net`, and injects the forward-auth - snippet + portal block into the Caddyfile. Won't clobber an existing install. - - `filebrowser` (8085), `ntfy` (8090), `uptimekuma` (3001), - `portainer` (9443), `watchtower` (no web port). - -### Notes -- `homelab` group now: authelia, filebrowser, homeassistant, ntfy, portainer, - uptimekuma, watchtower. -- Remaining monolith services still to migrate: ActualBudget, ARM, - AudioBookshelf, Caddy, CrowdSec, Emby, FindMyDevice, Frigate, Frigate-Notify, - Immich, Jellyfin, Lyrion, MagicMirror, Mealie, MeshCentral, Traccar, ddclient, - wg-easy. - -## [0.9.5] - 2026-06-03 - -### Added -- `services/minecraft.sh` *(gaming)* — full port of the standalone - `setupminecraft.sh`, converted to the per-service-folder model. Each server - is its own `~/docker//` with a standalone compose, so multiple - servers run side by side (port auto-bumps 25565→25566…). Preserves all the - niceties: Fabric/Quilt/Paper/Vanilla/Forge flavours, the live Modrinth - version/mod-availability picker, curated mods, Vanilla Tweaks datapacks, - whitelist UUID pre-population, LuckPerms bootstrap, Chunky pre-gen, playit.gg - tunnel, generated MINECRAFT_NETWORKING.md / CLIENT_MODS.md, and the - client-mods download web page (its own folder + compose). - -### Fixed -- Minecraft compose env-block emission (trailing-newline bug from the original - that glued `ports:` onto the last env line — now valid YAML). - -### Notes -- `gaming` group now: `js99er`, `minecraft`, `wolf`. -- Still pending: `whitelist` Minecraft helper; migrating the ~65 monolith - services into `services/`. - -## [0.9.4] - 2026-06-03 - -The first versioned release. Introduces the **modular post-install system** so -you can install the whole box at once *or* run a single service, with one -source of truth (no per-service script duplication, nothing generated). - -### Added -- `setup.sh` dispatcher: interactive menu, run-one (`sudo ./setup.sh `), - `--list`, `--dry-run`, `--unattended`, `--version`. -- `lib/common.sh`: shared helpers (logging, prompts, ownership, Caddy wiring) - and a self-registration service registry — one implementation of each. -- Service modules (each its own `~/docker//` folder + standalone compose): - - `base` — essential CLI packages, now including **glow**. - - `glow` — terminal markdown reader (charmbracelet), standalone too. - - `homeassistant` — bridge/host networking choice, `trusted_proxies` pre-seed. - - `js99er` *(gaming)* — self-hosted TI-99/4A emulator (Selkies launcher tie-in removed). - - `wolf` *(gaming)* — Games-on-Whales Moonlight streaming (wolf-pair dropped; `pin` workflow kept). - - `backup` — Kopia encrypted backups (paths adapted to `~/docker`). -- `MODULAR.md` documenting the architecture, how to add a module, migration status. -- Service groups: `base` / `homelab` / `gaming` / `backup`. -- `glow` also added to the live `-crowdsec` monolith scripts' essential packages. - -### Known gaps / next (0.9.5) -- `minecraft` module (rich, multi-instance port of `setupminecraft.sh`) — not yet - written; the background port hit a session limit. -- `whitelist` Minecraft helper not yet shipped. -- ~65 services still live only in the monolith, to migrate into `services/`. - -### Earlier history (pre-versioning) -- Removed Keycloak; standardized on Authelia for SSO. -- Added `-no-keycloak` and `-crowdsec` script tiers (originals kept as the - evolution record). -- CrowdSec replaces fail2ban in the `-crowdsec` tier (SSH + Caddy, geo + IP - reputation, optional ntfy ban alerts). -- Home Assistant added to the `-crowdsec` tier. diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index a1cf2d2..0000000 --- a/HANDOFF.md +++ /dev/null @@ -1,112 +0,0 @@ -# HANDOFF — modular migration status - -**Version:** 0.9.7 · **Branch:** `claude/happy-volta-RPhbD` -**Read also:** `CHANGELOG.md` (per-version detail), `MODULAR.md` (architecture). - -## Where we are - -We're migrating a giant monolithic installer into a **modular system**: -- `setup.sh` — the one dispatcher (menu + run-one). `lib/common.sh` — shared - helpers + the service registry. `services/.sh` — one file per service. -- Run all: `sudo ./setup.sh` (required gate → Caddy offer → category menu loop). - Run one: `sudo ./setup.sh `. List: `./setup.sh --list`. `--version`. -- Every service installs to its own `~/docker//` with its own - `docker-compose.yml` **and a generated `README.md`** (via `write_readme`). -- Nothing is generated/duplicated: a service = one committed file in `services/`. - -The three monolith tiers still exist, frozen as history: -`ubuntu-post-install-{24.04,26.04}.sh` (original, w/ Keycloak), -`*-no-keycloak.sh`, `*-crowdsec.sh` (current "install everything" + glow). -The `-crowdsec.sh` tier is the migration source of truth. - -## Module status - -**Done (16 modules in `services/`):** -| Group | Modules | -|-------|---------| -| base | base, glow | -| homelab | caddy, crowdsec, authelia, homeassistant | -| utilities | filebrowser, ntfy, portainer, uptimekuma, watchtower | -| gaming | wolf, minecraft, js99er | -| backup | backup | - -**Pending — migrate from `ubuntu-post-install-24.04-crowdsec.sh` (find by `# ---- NAME ----`):** -| Target group | Services to migrate | -|------|---------| -| media | jellyfin, emby, audiobookshelf, immich, arm, lyrion | -| cameras | frigate, frigate-notify | -| utilities | actualbudget, mealie, traccar, findmydevice, magicmirror, wg-easy, ddclient | -| (misc) | meshcentral (remote-mgmt server) | - -Also still monolith-only (Phase-1 / system, not yet modularized): SSH config, -Docker install, Samba, VPNs (Tailscale/NetBird/WireGuard), RustDesk, TeamViewer, -MeshCentral agent, UFW. Decide later whether these become `required`/system -modules. - -## Final taxonomy (categories) - -`base` (required) · `homelab` · `utilities` · `media` · `cameras` · `gaming` · -`backup`. Menu order is set in `setup.sh:CATEGORY_ORDER`. Within `homelab`, -`SERVICE_PRIORITY` puts caddy → crowdsec → authelia first. `media`/`cameras` -won't appear in the menu until they have ≥1 module (no empty categories). - -Note: filebrowser/portainer/uptimekuma/watchtower were placed in `utilities` -(weren't in the original taxonomy list) — move if desired by editing their -`register_service ... ...` line. - -## OPEN ITEM — wolf-pair (action needed from you) - -`wolf-pair` (the FQDN device-pairing page for Wolf/Moonlight) was **dropped** -when Wolf was ported, because its source wasn't available. You said you worked -hard on it and will **upload `wolf-pair/server.py` + `wolf-pair/Dockerfile` -(and anything else it needs) in the next chat.** - -To re-add it: create `services/wolf-pair.sh` (group `gaming`) that builds the -wolf-pair container in `~/docker/wolf-pair/`, wires it to reach Wolf, opens its -port, and offers a Caddy block for the pairing FQDN. Wolf's `manage.sh pin` is -the current stopgap. - -## Module contract (for consistency when adding/migrating) - -```bash -#!/bin/bash -register_service "Description" [port] # one line → appears in menu -install_() { - require_docker || return 1 # docker services only - local DIR="$DOCKER_DIR/" - [ "$DRY_RUN" = true ] && { echo "[DRY-RUN] Would create $DIR ..."; return 0; } - mkdir -p "$DIR"; ensure_docker_dir_ownership "$DIR"; cd "$DIR" || return 1 - cat > docker-compose.yml << 'YAML' - ... -YAML - configure_caddy_for_service "Name" "PORT" "subdomain" # optional - write_readme "$DIR" <.sh`, `./setup.sh --list`, -`sudo ./setup.sh --dry-run --unattended ` (must exit 0). - -## Workflow reminders -- Per-version: bump `VERSION`, add a `CHANGELOG.md` entry, commit, push to - `claude/happy-volta-RPhbD`. -- Your loop: build nice standalone `setup-*.sh` elsewhere → upload here → it gets - "massaged" into a `services/.sh` module (wrap in `install_`, use shared - helpers, per-folder + README, register). - -## Suggested next steps -1. Add `wolf-pair` once you upload its files. -2. Migrate **media** batch (jellyfin, emby, audiobookshelf, immich, arm, lyrion) → v0.9.8. -3. Migrate **cameras** (frigate, frigate-notify) → v0.9.9. -4. Migrate remaining **utilities** (actualbudget, mealie, traccar, findmydevice, - magicmirror, wg-easy, ddclient). -5. Decide how Phase-1/system items (VPNs, Samba, remote-access) fit (required vs - their own category). diff --git a/KEYCLOAK-SETUP-GUIDE.md b/KEYCLOAK-SETUP-GUIDE.md deleted file mode 100644 index 88bef08..0000000 --- a/KEYCLOAK-SETUP-GUIDE.md +++ /dev/null @@ -1,678 +0,0 @@ -# Keycloak Setup Guide -## Complete Manual and Automated Configuration Guide - -This guide explains Keycloak concepts and how to configure it both automatically (via the script) and manually (via the web UI). - ---- - -## Table of Contents -1. [What is Keycloak?](#what-is-keycloak) -2. [Key Concepts](#key-concepts) -3. [Automated Setup (via Script)](#automated-setup) -4. [Manual Setup (via Web UI)](#manual-setup) -5. [Configuring External Services](#configuring-external-services) -6. [Reconfiguration & Adding Realms](#reconfiguration) -7. [Common Use Cases](#common-use-cases) -8. [Troubleshooting](#troubleshooting) - ---- - -## What is Keycloak? - -Keycloak is an **Identity and Access Management (IAM)** system that provides: -- **Single Sign-On (SSO)**: Log in once, access all your services -- **User Management**: Create, manage, and authenticate users in one place -- **OAuth2/OIDC**: Industry-standard authentication for web apps -- **Social Login**: Allow login via Google, GitHub, etc. -- **Multi-Factor Authentication (MFA)**: Add extra security with 2FA/TOTP -- **LDAP/Active Directory Integration**: Connect to existing user directories - -**Think of Keycloak as:** A centralized login system for all your self-hosted services. - ---- - -## Key Concepts - -### 1. **Realm** -A **realm** is an isolated container for users, clients, and configuration. - -**Analogy:** Think of a realm like a "company" or "organization" in Keycloak. - -**Why you need it:** -- The default `master` realm is for Keycloak admin only -- You create a separate realm (e.g., `homelab`) for your actual users and applications -- Realms are completely isolated - users in one realm can't access another - -**Example:** -- `master` realm: Only for Keycloak administrators -- `homelab` realm: For your personal services (ActualBudget, Jellyfin, etc.) -- `family` realm: Separate realm for family members (optional) - -### 2. **OAuth2/OpenID Connect (OIDC) Client** -A **client** is an application that uses Keycloak for authentication. - -**Analogy:** Each service (ActualBudget, Jellyfin, etc.) is a "client" that asks Keycloak "Is this user allowed to log in?" - -**Required information:** -- **Client ID**: Name of the application (e.g., `actualbudget`) -- **Client Secret**: Password for the application (auto-generated, 64-char hex) -- **Redirect URIs**: Where Keycloak sends users after login - - Example: `https://budget.yourdomain.com/*` - - Must match EXACTLY or login will fail - -**Flow:** -1. User clicks "Login" in ActualBudget -2. ActualBudget redirects to Keycloak: `https://auth.yourdomain.com/login` -3. User logs in with username/password -4. Keycloak redirects back to ActualBudget: `https://budget.yourdomain.com/callback` -5. ActualBudget gets user info and logs them in - -### 3. **Users** -A **user** is a person who can log in to your services. - -**User attributes:** -- Username (required, unique) -- Email (optional but recommended) -- First name / Last name (optional) -- Password (set via Credentials tab) -- Email verified (set to true to skip verification) -- Enabled (must be true for user to log in) - -### 4. **Redirect URIs** -**Critical concept:** The redirect URI is where Keycloak sends the user after successful login. - -**Common mistakes:** -- ❌ `http://localhost:5006` (won't work for external services) -- ❌ `https://budget.example.com` (missing wildcard or path) -- ✅ `https://budget.example.com/*` (correct - allows all paths) - -**For external services (like Pikapod):** -- Pikapod gives you a URL like: `https://actualbudget-abc123.pikapod.net` -- Your redirect URI: `https://actualbudget-abc123.pikapod.net/*` -- Your Keycloak URL: `https://auth.yourdomain.com` (must be publicly accessible) - ---- - -## Automated Setup (via Script) - -The script automates everything for you. Here's what it does: - -### Step 1: Install Keycloak -```bash -./ubuntu-post-install.sh -# Select KEYCLOAK in whiptail menu -``` - -Prompts: -- Admin password (for Keycloak admin console) -- Database password (for PostgreSQL) - -### Step 2: Automated Configuration -``` -Configure Keycloak with initial realm and clients? (y/n): y -``` - -This automatically: -1. ✅ Waits for Keycloak to start (health check) -2. ✅ Logs in using admin CLI (`kcadm.sh`) -3. ✅ Creates a realm (e.g., `homelab`) -4. ✅ Creates OAuth client for ActualBudget (if selected) -5. ✅ Creates generic OAuth client template -6. ✅ Saves all credentials to `~/docker/keycloak/*.txt` -7. ✅ Optionally creates initial user - -### Step 3: What Gets Created - -**Realm:** `homelab` (or your custom name) - -**ActualBudget OAuth Client:** -- Client ID: `actualbudget` -- Client Secret: (saved to `actualbudget-oauth.txt`) -- Redirect URIs: - - `http://localhost:5006/*` (local development) - - `http://yourdomain.com:5006/*` (local with domain) - - `https://yourdomain.com/*` (production - any subdomain) - - `https://budget.yourdomain.com/*` (specific subdomain) - -**Generic OAuth Client:** -- Client ID: `generic-app` -- Client Secret: (saved to `generic-oauth.txt`) -- Can be cloned for other services - -**Initial User:** -- Username, email, password you provide -- Immediately active -- Can log in to all services - -### Step 4: Configuration Files - -All credentials saved to: -``` -~/docker/keycloak/actualbudget-oauth.txt -~/docker/keycloak/generic-oauth.txt -``` - -These files contain: -- Client ID -- Client Secret -- Authorization URL -- Token URL -- User Info URL -- Instructions for configuring each service - ---- - -## Manual Setup (via Web UI) - -If you prefer to configure Keycloak manually, or want to add services later: - -### Access Admin Console -``` -URL: http://localhost:8180/admin -Username: admin -Password: [your admin password] -``` - -### Step 1: Create a Realm - -1. **Click dropdown** in top-left corner (shows "Master") -2. **Click "Create Realm"** -3. **Realm name:** `homelab` (or your choice) -4. **Click "Create"** - -**Settings to configure:** -- **Login tab:** - - ✅ User registration: OFF (you create users manually) - - ✅ Forgot password: ON (allows password resets) - - ✅ Remember me: ON (convenience) - - ✅ Login with email: ON (users can use email instead of username) - -- **Email tab:** (optional, for password resets) - - Configure SMTP settings if you want email features - -### Step 2: Create an OAuth2 Client (for ActualBudget) - -1. **Switch to your realm** (`homelab`) via dropdown -2. **Go to Clients** (left menu) -3. **Click "Create client"** - -**General Settings:** -- **Client type:** OpenID Connect -- **Client ID:** `actualbudget` -- **Name:** `ActualBudget` -- **Description:** `Personal Finance Management` -- **Click "Next"** - -**Capability config:** -- ✅ Client authentication: ON (creates a secret) -- ✅ Authorization: OFF (not needed) -- ✅ Standard flow: ON (authorization code flow) -- ✅ Direct access grants: ON (allows username/password) -- ❌ Implicit flow: OFF (deprecated) -- ❌ Service accounts: OFF (not needed for web apps) -- **Click "Next"** - -**Login settings:** - -**Important: Adjust these for your setup!** - -**For local ActualBudget:** -``` -Root URL: http://localhost:5006 -Home URL: http://localhost:5006 -Valid redirect URIs: - http://localhost:5006/* - http://localhost:5006/callback - -Valid post logout redirect URIs: + - -Web origins: - http://localhost:5006 -``` - -**For external ActualBudget (Pikapod, etc.):** -``` -Root URL: https://actualbudget-abc123.pikapod.net -Home URL: https://actualbudget-abc123.pikapod.net -Valid redirect URIs: - https://actualbudget-abc123.pikapod.net/* - https://actualbudget-abc123.pikapod.net/callback - -Valid post logout redirect URIs: + - -Web origins: - https://actualbudget-abc123.pikapod.net -``` - -**For self-hosted with domain:** -``` -Root URL: https://budget.yourdomain.com -Home URL: https://budget.yourdomain.com -Valid redirect URIs: - https://budget.yourdomain.com/* - https://budget.yourdomain.com/callback - -Valid post logout redirect URIs: + - -Web origins: - https://budget.yourdomain.com -``` - -4. **Click "Save"** - -### Step 3: Get Client Secret - -1. **Go to "Credentials" tab** -2. **Copy "Client secret"** (you'll need this for ActualBudget) -3. **Save it somewhere safe!** - -### Step 4: Create a User - -1. **Go to Users** (left menu) -2. **Click "Create user"** - -**User details:** -- **Username:** `john` (required) -- **Email:** `john@example.com` (optional but recommended) -- **Email verified:** ✅ ON (skip email verification) -- **First name:** `John` -- **Last name:** `Doe` -- **Enabled:** ✅ ON (user can log in) -- **Click "Create"** - -**Set password:** -1. **Go to "Credentials" tab** -2. **Click "Set password"** -3. **Enter password** (twice) -4. **Temporary:** ❌ OFF (user won't be forced to change it) -5. **Click "Save"** -6. **Confirm** in popup - -### Step 5: Test Login - -1. **Go to Realm Settings** → **Endpoints** -2. **Click "OpenID Endpoint Configuration"** (opens JSON) -3. **Find:** `authorization_endpoint` -4. **Copy URL** and open in browser -5. **Add:** `?client_id=actualbudget&response_type=code&redirect_uri=http://localhost:5006/callback` -6. **Log in** with your user -7. **You should see:** Redirect to callback URL (may error if ActualBudget not configured, but login works) - ---- - -## Configuring External Services - -### Keycloak MUST be Publicly Accessible - -**Critical:** For external services like Pikapod, your Keycloak must be accessible from the internet. - -### Requirements: -1. ✅ **Domain name** (e.g., `yourdomain.com`) -2. ✅ **DNS A record** pointing to your server -3. ✅ **Caddy reverse proxy** with HTTPS -4. ✅ **Port 80/443 open** in firewall -5. ✅ **Keycloak accessible** at `https://auth.yourdomain.com` - -### Setup Caddy for Keycloak - -**Add to Caddyfile:** -```caddy -auth.yourdomain.com { - log { - output file /var/log/caddy/keycloak-access.log - format json - level INFO - } - - reverse_proxy localhost:8180 - - # Security headers - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Frame-Options "SAMEORIGIN" - X-Content-Type-Options "nosniff" - X-XSS-Protection "1; mode=block" - Referrer-Policy "strict-origin-when-cross-origin" - } -} -``` - -**Reload Caddy:** -```bash -cd ~/docker/caddy -docker exec -w /etc/caddy caddy caddy reload -``` - -**Test:** -``` -https://auth.yourdomain.com/admin -``` - -### Configure DNS - -**Add A record:** -``` -auth.yourdomain.com → [Your Server IP] -``` - -**Or use CNAME:** -``` -auth → yourdomain.com -``` - -### Example: ActualBudget on Pikapod - -**Scenario:** -- Keycloak: `https://auth.yourdomain.com` (your server) -- ActualBudget: `https://actualbudget-abc123.pikapod.net` (Pikapod) - -**In Keycloak:** - -1. **Create client:** `actualbudget-pikapod` -2. **Redirect URIs:** - ``` - https://actualbudget-abc123.pikapod.net/* - https://actualbudget-abc123.pikapod.net/callback - ``` -3. **Web origins:** - ``` - https://actualbudget-abc123.pikapod.net - ``` - -**In ActualBudget (Pikapod):** - -Settings → Authentication: -``` -Client ID: actualbudget-pikapod -Client Secret: [from Keycloak credentials tab] - -Authorization URL: https://auth.yourdomain.com/realms/homelab/protocol/openid-connect/auth -Token URL: https://auth.yourdomain.com/realms/homelab/protocol/openid-connect/token -User Info URL: https://auth.yourdomain.com/realms/homelab/protocol/openid-connect/userinfo -``` - -**Flow:** -1. User visits `https://actualbudget-abc123.pikapod.net` -2. Clicks "Login" -3. Redirects to `https://auth.yourdomain.com/realms/homelab/...` -4. User logs in -5. Redirects back to `https://actualbudget-abc123.pikapod.net/callback` -6. User is logged in! - ---- - -## Reconfiguration & Adding Realms - -You can re-run the script to add more realms or clients! - -### Option 1: Re-run the Script - -```bash -cd ~/docker/keycloak -docker compose down -cd ~ -./ubuntu-post-install.sh -# Select KEYCLOAK again -# Choose "Configure Keycloak..." → Yes -# Enter new realm name: "family" -# Create new users -``` - -**This creates:** -- New realm with new users -- New OAuth clients for that realm -- Separate from your existing realm - -### Option 2: Add Realm Manually - -**Via Web UI:** -1. Go to admin console -2. Click realm dropdown -3. "Create Realm" -4. Name: `family` -5. Repeat client/user creation steps - -### Option 3: Use Script Helper - -The script can be extended to add a helper: - -```bash -cd ~/docker/keycloak - -# Login to admin CLI -docker exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \ - --server http://localhost:8080 \ - --realm master \ - --user admin \ - --password [YOUR_ADMIN_PASSWORD] - -# Create new realm -docker exec keycloak /opt/keycloak/bin/kcadm.sh create realms \ - -s realm=family \ - -s enabled=true - -# Create new client -docker exec keycloak /opt/keycloak/bin/kcadm.sh create clients -r family \ - -s clientId=my-new-service \ - -s enabled=true \ - -s clientAuthenticatorType=client-secret \ - -s secret=$(openssl rand -hex 32) \ - -s 'redirectUris=["https://service.yourdomain.com/*"]' - -# Create new user -docker exec keycloak /opt/keycloak/bin/kcadm.sh create users -r family \ - -s username=alice \ - -s email=alice@example.com \ - -s enabled=true - -# Set password -docker exec keycloak /opt/keycloak/bin/kcadm.sh set-password -r family \ - --username alice \ - --new-password 'AlicePassword123!' -``` - ---- - -## Common Use Cases - -### Use Case 1: All Local Services -**Setup:** -- Keycloak: `http://localhost:8180` -- ActualBudget: `http://localhost:5006` -- Jellyfin: `http://localhost:8096` - -**Configuration:** -- No domain needed -- Use `localhost` URLs everywhere -- Redirect URIs: `http://localhost:PORT/*` - -### Use Case 2: Self-Hosted with Domain -**Setup:** -- Keycloak: `https://auth.yourdomain.com` -- ActualBudget: `https://budget.yourdomain.com` -- Jellyfin: `https://jellyfin.yourdomain.com` - -**Configuration:** -- Requires domain + Caddy -- Use HTTPS URLs -- Redirect URIs: `https://service.yourdomain.com/*` - -### Use Case 3: Mixed (Local + External) -**Setup:** -- Keycloak: `https://auth.yourdomain.com` (self-hosted) -- ActualBudget: `https://actualbudget-abc.pikapod.net` (Pikapod) -- Jellyfin: `https://jellyfin.yourdomain.com` (self-hosted) - -**Configuration:** -- Keycloak MUST be publicly accessible -- Each service gets its own client -- ActualBudget redirect: `https://actualbudget-abc.pikapod.net/*` -- Jellyfin redirect: `https://jellyfin.yourdomain.com/*` - ---- - -## Troubleshooting - -### Issue: "Invalid redirect URI" -**Cause:** Redirect URI in Keycloak doesn't match what the app is using. - -**Fix:** -1. Check error message for actual redirect URI -2. Add EXACT URI to Keycloak client settings -3. Include wildcard: `https://domain.com/*` - -### Issue: "Client not found" -**Cause:** Client ID doesn't match. - -**Fix:** -1. Check client ID in Keycloak -2. Ensure it matches exactly in application -3. Case-sensitive! - -### Issue: "Invalid client secret" -**Cause:** Wrong secret or expired. - -**Fix:** -1. Go to Keycloak → Clients → Credentials -2. Copy secret again (or regenerate) -3. Update in application - -### Issue: External service can't reach Keycloak -**Cause:** Keycloak not publicly accessible. - -**Fix:** -1. Ensure Caddy is running: `docker ps | grep caddy` -2. Check DNS: `dig auth.yourdomain.com` -3. Test URL: `curl https://auth.yourdomain.com` -4. Check firewall: `sudo ufw status` (80/443 open?) - -### Issue: Login succeeds but redirect fails -**Cause:** CORS or redirect URI mismatch. - -**Fix:** -1. Add domain to "Web Origins" in client settings -2. Check redirect URI includes protocol (https://) -3. Check for typos in domain name - -### Issue: Can't login to Keycloak admin console -**Cause:** Container not started or wrong password. - -**Fix:** -```bash -# Check if running -docker ps | grep keycloak - -# Check logs -docker logs keycloak --tail 50 - -# Restart -cd ~/docker/keycloak -docker compose restart - -# Reset admin password (if needed) -docker exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \ - --server http://localhost:8080 \ - --realm master \ - --user admin \ - --password NEW_PASSWORD_HERE -``` - ---- - -## Quick Reference - -### Important URLs - -**Local:** -``` -Admin Console: http://localhost:8180/admin -Realm Endpoints: http://localhost:8180/realms/{realm-name}/.well-known/openid-configuration -``` - -**Production:** -``` -Admin Console: https://auth.yourdomain.com/admin -Realm Endpoints: https://auth.yourdomain.com/realms/{realm-name}/.well-known/openid-configuration -``` - -### OAuth URLs (for realm "homelab") - -**Local:** -``` -Authorization: http://localhost:8180/realms/homelab/protocol/openid-connect/auth -Token: http://localhost:8180/realms/homelab/protocol/openid-connect/token -User Info: http://localhost:8180/realms/homelab/protocol/openid-connect/userinfo -Logout: http://localhost:8180/realms/homelab/protocol/openid-connect/logout -``` - -**Production:** -``` -Authorization: https://auth.yourdomain.com/realms/homelab/protocol/openid-connect/auth -Token: https://auth.yourdomain.com/realms/homelab/protocol/openid-connect/token -User Info: https://auth.yourdomain.com/realms/homelab/protocol/openid-connect/userinfo -Logout: https://auth.yourdomain.com/realms/homelab/protocol/openid-connect/logout -``` - -### Common Commands - -```bash -# Start Keycloak -cd ~/docker/keycloak -docker compose up -d - -# Stop Keycloak -docker compose down - -# View logs -docker logs keycloak -f - -# Access shell -docker exec -it keycloak bash - -# Login to admin CLI -docker exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \ - --server http://localhost:8080 \ - --realm master \ - --user admin \ - --password YOUR_PASSWORD - -# Export realm configuration (backup) -docker exec keycloak /opt/keycloak/bin/kc.sh export \ - --dir /opt/keycloak/data/export \ - --realm homelab - -# Copy export to host -docker cp keycloak:/opt/keycloak/data/export ./backup/ -``` - ---- - -## Summary - -**Keycloak provides:** -- ✅ Single Sign-On for all your services -- ✅ Centralized user management -- ✅ OAuth2/OIDC authentication -- ✅ Works with local and external services -- ✅ Professional-grade security - -**Automated setup does:** -- ✅ Creates realm -- ✅ Creates OAuth clients -- ✅ Creates initial user -- ✅ Saves all credentials -- ✅ Ready to use immediately - -**Manual setup allows:** -- ✅ Full control over configuration -- ✅ Multiple realms (family, work, etc.) -- ✅ Custom client settings -- ✅ Advanced features (LDAP, MFA, etc.) - -**For external services:** -- ✅ Keycloak must be publicly accessible -- ✅ Use Caddy with HTTPS -- ✅ Configure proper redirect URIs -- ✅ Test OAuth flow before production - -For questions or issues, check the Keycloak documentation: https://www.keycloak.org/documentation diff --git a/MODULAR.md b/MODULAR.md deleted file mode 100644 index c1aa1c6..0000000 --- a/MODULAR.md +++ /dev/null @@ -1,101 +0,0 @@ -# Modular Post-Install (`setup.sh` + `lib/` + `services/`) - -This is the new structure that gives you **one source of truth** *and* the -ability to **run just the service you want** — without maintaining a pile of -near-duplicate standalone scripts. - -## Why - -The full `ubuntu-post-install-*.sh` scripts are great as a "run once, set up the -whole box" experience, but to add or update one service you edit a 300 KB file -(in two or three places). The separate `setup-*.sh` scripts are easy to run for -one service, but duplicate logic and drift apart. - -The fix is **not** to generate per-service scripts from the monolith (that just -triples the maintenance surface). It's to have **one implementation per service** -in a module, shared helpers in a library, and a thin dispatcher with two entry -points. - -## Layout - -``` -setup.sh # dispatcher: menu, run-one, --list, --dry-run, --unattended -lib/common.sh # shared helpers: logging, prompts, ownership, Caddy wiring, - # the service registry. THE single source of truth. -services/ - base.sh # essential CLI packages (incl. glow) - homeassistant.sh # Home Assistant - ... # one file per service -``` - -## Usage - -```bash -sudo ./setup.sh # interactive menu (whiptail or text) -sudo ./setup.sh homeassistant # install one service -sudo ./setup.sh base glow # install several -./setup.sh --list # list services, grouped -sudo ./setup.sh --dry-run --unattended minecraft # preview, no prompts -``` - -## Anatomy of a service module - -Each `services/.sh` does exactly two things: **register** itself and -define **install_**. - -```bash -#!/bin/bash -register_service myapp homelab "What it does" 1234 # name group description [port] - -install_myapp() { - require_docker || return 1 - local DIR="$DOCKER_DIR/myapp" - [ "$DRY_RUN" = true ] && { echo "[DRY-RUN] Would create $DIR"; return 0; } - mkdir -p "$DIR"; ensure_docker_dir_ownership "$DIR"; cd "$DIR" || return 1 - cat > docker-compose.yml << 'YAML' - ... -YAML - configure_caddy_for_service "MyApp" "1234" "myapp" # optional reverse proxy - prompt_yn "Start now? (y/n):" "y" START && docker compose up -d -} -``` - -Helpers available from `lib/common.sh`: `log_info/success/warning/error`, -`prompt_yn`, `prompt_text`, `run_cmd`, `ensure_docker_dir_ownership`, -`generate_password`, `validate_password`, `configure_caddy_for_service`, -`require_root`, `require_docker`. Globals: `DOCKER_DIR`, `ACTUAL_USER`, -`ACTUAL_HOME`, `DRY_RUN`, `UNATTENDED`. - -Every service installs to its **own folder** `~/docker//` with its **own -`docker-compose.yml`** (the DoTheEvo `selfhosted-apps-docker` layout) — never a -single shared compose file. - -## Groups - -`base` · `homelab` · `utilities` · `media` · `cameras` · `gaming` · `extras` · -`backup`. `extras` holds non-Docker add-ons pulled from other repos (e.g. the -`silent-send` browser extension) — things that install/build on the host rather -than running as a container, so this script stays a single entry point for them. -The guided menu (`sudo ./setup.sh`) shows the **required** packages first with a -cancel option, then offers **Caddy** (most services proxy through it), then a -**category menu you loop through** — pick a category, tick services (already -installed ones are marked `[installed]`), install, and you land back on the menu -to pick the next category. Within `homelab`, Caddy → CrowdSec → Authelia are -ordered first. - -## Migration status - -Migration is complete. `setup.sh` + `services/` now cover every service -from `ubuntu-post-install-*-crowdsec.sh` plus several extras. The monolith -is retained as a frozen evolution record. - -| Group | Modules | -|-------|---------| -| `base` | `base`, `glow` | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` | -| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `magicmirror`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `traccar`, `uptimekuma`, `watchtower`, `wg-easy` | -| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` | -| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | -| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | -| `extras` | `linux-to-sync`, `silent-send`, `sync-cc` | -| `backup` | `backup` | diff --git a/NEW-SCRIPT-STRUCTURE.md b/NEW-SCRIPT-STRUCTURE.md deleted file mode 100644 index c126012..0000000 --- a/NEW-SCRIPT-STRUCTURE.md +++ /dev/null @@ -1,244 +0,0 @@ -# Ubuntu Post-Install Script - New Structure - -## Current Problems - -1. ❌ Services prompt individually even when not selected in whiptail -2. ❌ Too many things installed BEFORE whiptail menu (Samba, VPNs, fail2ban) -3. ❌ No clear explanation to user about script phases -4. ❌ No uninstall option -5. ❌ Confusing for re-running on existing servers - -## New Structure - -### PHASE 1: Essential System Setup (Before Whiptail) -**Purpose:** Install only what's REQUIRED for everything else to work - -**What stays BEFORE whiptail:** -- ✅ System updates (apt update/upgrade) -- ✅ Essential packages (openssh-server, rsync, curl, wget, git, vim, htop, ncdu) -- ✅ SSH key generation (optional) -- ✅ SSH key import from GitHub/Launchpad (optional) -- ✅ Docker & Docker Compose installation -- ✅ Hard drive mounting/formatting - -**What moves TO whiptail menu:** -- 🔄 Samba file sharing -- 🔄 fail2ban for SSH -- 🔄 VPN services (Tailscale, WireGuard, NetBird) -- 🔄 Remote desktop (RustDesk, TeamViewer) -- 🔄 MeshCentral agent - -### PHASE 2: Service Selection (Whiptail Menu) -**Purpose:** Let user choose ALL optional services - -**New whiptail menu structure:** - -``` -┌─────────────────── Select Services ────────────────────┐ -│ ☐ INSTALL ☑ UNINSTALL │ -│ │ -│ === NETWORK & SECURITY === │ -│ [ ] SAMBA File sharing (SMB/CIFS) │ -│ [ ] FAIL2BAN_SSH Protect SSH from brute-force │ -│ [ ] TAILSCALE Easy VPN mesh network │ -│ [ ] NETBIRD Self-hosted VPN │ -│ [ ] WIREGUARD Manual VPN configuration │ -│ │ -│ === REMOTE ACCESS === │ -│ [ ] RUSTDESK Remote desktop (OSS) │ -│ [ ] TEAMVIEWER Remote desktop (commercial) │ -│ [ ] MESHCENTRAL Remote management agent │ -│ │ -│ === DOCKER SERVICES === │ -│ [ ] ACTUALBUDGET Personal finance │ -│ [ ] KEYCLOAK Identity management │ -│ [ ] CADDY Reverse proxy │ -│ [ ] FAIL2BAN_CADDY Protect Caddy services │ -│ [ ] JELLYFIN Media server │ -│ [ ] IMMICH Photo backup │ -│ [ ] AUDIOBOOKSHELF Audiobook server │ -│ [ ] MEALIE Recipe manager │ -│ [ ] UPTIMEKUMA Service monitoring │ -│ [ ] PORTAINER Docker management UI │ -│ [ ] WATCHTOWER Auto-update containers │ -│ ... (all other services) │ -│ │ -│ === MESHCENTRAL SERVER === │ -│ [ ] MESHCENTRAL_SRV Self-hosted remote mgmt server │ -│ │ -│ │ -└─────────────────────────────────────────────────────────┘ -``` - -### PHASE 3: Installation -**Purpose:** Install/uninstall selected services in correct order - -**Dependency-aware installation order:** -1. Install Caddy first (if selected) -2. Install services that depend on Caddy (Keycloak, etc.) -3. Install fail2ban for Caddy (if selected + Caddy installed) -4. Install independent services in parallel where possible - -### Re-Running The Script - -**Behavior on already-configured server:** - -**Phase 1 (Essential):** -- Detects existing installations -- Shows: "Docker is already installed: (version)" -- Prompts: "Reinstall Docker? (y/n): **n**" (defaults to NO) -- Skips if "n" - -**Phase 2 (Services):** -- Whiptail menu shows ALL services -- Already-installed services could be marked with (installed) -- User can: - - Select new services to add - - Select installed services + click "Uninstall Selected" - - Skip menu to make no changes - -## Implementation Changes - -### 1. Add Intro Text - -```bash -# At beginning of script after argument parsing -if [ "$UNATTENDED" != true ]; then - cat << 'EOF' -╔════════════════════════════════════════════════════════════════╗ -║ Ubuntu Post-Install Setup Script ║ -╚════════════════════════════════════════════════════════════════╝ - -This script installs and configures your Ubuntu server in TWO phases: - -PHASE 1: ESSENTIAL SETUP (Required) - • System updates & core packages - • SSH configuration - • Docker & Docker Compose - • Hard drive mounting - -PHASE 2: SERVICE SELECTION (Optional) - • Interactive menu to select services - • Install OR uninstall services - • Safe to re-run on existing servers - -Press ENTER to begin Phase 1... -EOF - read -p "" -fi -``` - -### 2. Reorganize Script Sections - -**NEW ORDER:** -1. Intro text -2. Essential system setup (Phase 1) -3. Whiptail menu with ALL services (Phase 2) -4. Service installations based on selections - -**REMOVED FROM PRE-WHIPTAIL:** -- Lines 1709-1800: Samba installation → Move to whiptail -- Lines 1596-1625: fail2ban (SSH) → Move to whiptail -- Lines 1841-1888: NetBird → Move to whiptail -- Lines 1889-1945: WireGuard → Move to whiptail -- Lines 1947-1998: Tailscale → Move to whiptail -- Lines 2000-2044: RustDesk → Move to whiptail -- Lines 2046-2095: TeamViewer → Move to whiptail -- Lines 2097-2153: MeshCentral Agent → Move to whiptail - -### 3. Update Whiptail Menu - -**Add these to the checklist:** -```bash -"SAMBA" "File sharing (Windows, Mac, Linux)" OFF \ -"FAIL2BAN_SSH" "Protect SSH from brute-force attacks" OFF \ -"TAILSCALE" "Easy VPN mesh network" OFF \ -"NETBIRD" "Self-hosted VPN alternative" OFF \ -"WIREGUARD" "Manual VPN configuration" OFF \ -"RUSTDESK" "Open-source remote desktop" OFF \ -"TEAMVIEWER" "Commercial remote desktop" OFF \ -"MESHCENTRAL_AGENT" "Remote management agent" OFF \ -``` - -### 4. Add Uninstall Functionality - -**New buttons in whiptail:** -```bash ---extra-button --extra-label "Uninstall" \ ---ok-button "Install" --cancel-button "Skip" -``` - -**Check return code:** -- 0 = Install selected -- 1 = Skip/Cancel -- 3 = Uninstall selected - -**Uninstall logic:** -```bash -if [ $WHIPTAIL_RETURN -eq 3 ]; then - # Uninstall mode - for service in $SELECTED_SERVICES; do - uninstall_service "$service" - done -fi -``` - -### 5. Fix Duplicate Prompts - -**Current issue:** -```bash -if [ -z "$INSTALL_AUDIOBOOKSHELF" ]; then - prompt_yn "Install Audiobookshelf? (y/n):" "n" INSTALL_AUDIOBOOKSHELF -fi -``` - -**Problem:** This runs even if user didn't select it in whiptail! - -**Fix:** Only show prompt if whiptail wasn't used OR variable not set by whiptail - -```bash -# After whiptail, set a flag -WHIPTAIL_USED=true - -# In individual service sections -if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUDIOBOOKSHELF" ]; then - prompt_yn "Install Audiobookshelf? (y/n):" "n" INSTALL_AUDIOBOOKSHELF -fi -``` - -**Or simpler:** Just remove all individual prompts! If whiptail is available, use it. If not, show ALL services as prompts. - -## Benefits - -1. ✅ Clear two-phase structure -2. ✅ All optional services in ONE menu -3. ✅ No duplicate prompts -4. ✅ Uninstall functionality -5. ✅ Safe to re-run -6. ✅ User understands what's happening when -7. ✅ Faster for users who know what they want - -## Migration Path - -**For existing users:** -1. Script still works the same way -2. New intro text explains structure -3. Existing installations detected -4. Can use uninstall to remove unwanted services - -## Testing Scenarios - -1. **Fresh install:** All prompts flow correctly -2. **Re-run:** Detects existing, allows adding new services -3. **Uninstall:** Removes selected services cleanly -4. **Whiptail unavailable:** Falls back to individual prompts -5. **Cancel menu:** Skips all service installations - ---- - -**Implementation Priority:** -1. ✅ Fix duplicate prompts (CRITICAL - doing now) -2. ⏳ Add intro text (HIGH) -3. ⏳ Move services to whiptail (HIGH) -4. ⏳ Add uninstall functionality (MEDIUM) -5. ⏳ Improve re-run detection (LOW - already works) diff --git a/SCRIPT-FLOW-INTRO.txt b/SCRIPT-FLOW-INTRO.txt deleted file mode 100644 index e4aef69..0000000 --- a/SCRIPT-FLOW-INTRO.txt +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -# SCRIPT FLOW EXPLANATION FOR USERS -cat << 'EOF' -╔════════════════════════════════════════════════════════════════╗ -║ Ubuntu Post-Install Setup Script ║ -╚════════════════════════════════════════════════════════════════╝ - -This script is divided into TWO main phases: - -┌────────────────────────────────────────────────────────────────┐ -│ PHASE 1: ESSENTIAL SYSTEM SETUP (Required) │ -├────────────────────────────────────────────────────────────────┤ -│ These are installed/configured FIRST: │ -│ ✓ System updates and essential packages │ -│ ✓ OpenSSH server (remote access) │ -│ ✓ rsync, curl, wget (core utilities) │ -│ ✓ SSH key configuration │ -│ ✓ Docker & Docker Compose (container platform) │ -│ ✓ Hard drive mounting (storage setup) │ -│ │ -│ These are REQUIRED for everything else to work. │ -└────────────────────────────────────────────────────────────────┘ - -┌────────────────────────────────────────────────────────────────┐ -│ PHASE 2: SERVICE SELECTION MENU (Optional) │ -├────────────────────────────────────────────────────────────────┤ -│ After Phase 1, you'll see a CHECKBOX MENU where you can: │ -│ • Select which services to INSTALL │ -│ • Select which services to UNINSTALL │ -│ • Skip services you don't want │ -│ │ -│ Services include: │ -│ • Self-hosted apps (ActualBudget, Keycloak, Jellyfin, etc.) │ -│ • Network services (Samba, VPNs, fail2ban) │ -│ • Monitoring tools (Uptime Kuma, Portainer, Watchtower) │ -│ • And many more... │ -└────────────────────────────────────────────────────────────────┘ - -┌────────────────────────────────────────────────────────────────┐ -│ RE-RUNNING THIS SCRIPT │ -├────────────────────────────────────────────────────────────────┤ -│ You can safely re-run this script on an already configured │ -│ server: │ -│ • Phase 1 will DETECT existing installations and skip them │ -│ • Phase 2 menu will show ALL services (installed and not) │ -│ • Select NEW services to add │ -│ • Or select UNINSTALL to remove services │ -│ │ -│ The script is IDEMPOTENT - safe to run multiple times! │ -└────────────────────────────────────────────────────────────────┘ - -Press ENTER to continue with Phase 1 (Essential Setup)... -EOF - -read -p "" - -# Now continue with the actual script... -EOF diff --git a/SCRIPT-VARIANTS.md b/SCRIPT-VARIANTS.md deleted file mode 100644 index 0f7ec6e..0000000 --- a/SCRIPT-VARIANTS.md +++ /dev/null @@ -1,78 +0,0 @@ -# Install Script Variants - -This repo ships the post-install script in three tiers, for both Ubuntu 24.04 -and 26.04. Pick **one** and run it — they are mutually exclusive (each is a -complete, standalone script). - -| File | Keycloak | SSO | Intrusion prevention | -|------|----------|-----|----------------------| -| `ubuntu-post-install-.sh` | ✅ included | Keycloak **or** Authelia | fail2ban (SSH + Caddy) | -| `ubuntu-post-install--no-keycloak.sh` | ❌ removed | Authelia | fail2ban (SSH + Caddy) | -| `ubuntu-post-install--crowdsec.sh` | ❌ removed | Authelia | **CrowdSec** (replaces fail2ban) | - -`` is `24.04` or `26.04`. - -> New services are added to the **`-crowdsec`** tier only (the current tip of -> the evolution); the original and `-no-keycloak` scripts are frozen as -> historical snapshots. For example, **Home Assistant** (home-automation hub, -> port 8123) is available in the `-crowdsec` variants. It ships with a -> `trusted_proxies` config pre-seeded so it works behind the Caddy reverse -> proxy out of the box, and the installer asks whether to use **bridge** -> networking (port 8123 published — proxy-friendly, isolated) or **host** -> networking (needed for LAN device auto-discovery: Chromecast, HomeKit, -> mDNS, some Zigbee/Z-Wave/Bluetooth). - -## Which one? - -- **Original (`.sh`)** — unchanged baseline, kept for fallback. Still offers - Keycloak in the menu. -- **`-no-keycloak`** — same as original but with Keycloak fully removed. - Authelia is the SSO + 2FA option. Use this if you never got Keycloak running - and have standardized on Authelia. -- **`-crowdsec`** — builds on `-no-keycloak` and swaps fail2ban out for - [CrowdSec](https://www.crowdsec.net/): - - SSH brute-force protection (CrowdSec reads `/var/log/auth.log` via the - `crowdsecurity/sshd` collection) - - Caddy HTTP auth abuse (the `crowdsecurity/caddy` collection + a log - acquisition at `/etc/crowdsec/acquis.d/caddy.yaml`) - - Enforcement via `crowdsec-firewall-bouncer-iptables` - - **Geo-blocking + community IP-reputation blocklists** — the capability - that fail2ban and Authelia both lack - - **Optional ntfy alerts on bans** — when configuring CrowdSec the script - can wire up an [ntfy](https://ntfy.sh/) push notification (via CrowdSec's - HTTP notification plugin). It writes `/etc/crowdsec/notifications/ntfy.yaml` - and references it from the default profile in - `/etc/crowdsec/profiles.yaml`. The same script can also install a - self-hosted **ntfy server** (separate menu option), so alerts can stay on - your own infrastructure. - -### A note on "notification of failed attempts" - -CrowdSec alerts fire on a **ban decision** — i.e. once an IP crosses the -failed-attempt threshold for a scenario (e.g. `crowdsecurity/ssh-bf`), not on -every individual failed login. That gives you one actionable "X banned for Y" -push instead of a flood. To alert on a single failed login you'd lower the -scenario threshold or write a custom scenario, but the ban-level alert is the -recommended default. - -Authelia, by contrast, only sends **email/SMTP** notifications (for password -reset and 2FA device registration) — it has no built-in "failed login" push, -which is why CrowdSec → ntfy is the path used here. - -## Notes on the security layers - -- **Authelia** handles per-account failed-login *regulation* (lockout). It does - **not** do geo-blocking. -- **fail2ban** bans IPs at the firewall based on Caddy log patterns - (401/403/429). No geo-blocking, not credential-aware. -- **CrowdSec** covers SSH + Caddy from a single agent, adds geo/ASN enrichment - and crowd-sourced reputation, and is the modern successor to fail2ban. - -Useful CrowdSec commands after install: - -```bash -sudo cscli metrics # overview / parsing health -sudo cscli decisions list # current bans -sudo cscli alerts list # detections -sudo cscli decisions delete --ip # unban -``` diff --git a/SECURITY-IMPROVEMENTS.md b/SECURITY-IMPROVEMENTS.md deleted file mode 100644 index 7340b81..0000000 --- a/SECURITY-IMPROVEMENTS.md +++ /dev/null @@ -1,409 +0,0 @@ -# Security and Infrastructure Improvements - -## Summary of Changes - -This document describes the comprehensive security and infrastructure improvements made to the Ubuntu post-installation script. - -## Issues Fixed - -### 1. Docker Directory Ownership -**Problem:** Docker directories were being created as root, causing permission issues when running Docker without sudo. - -**Solution:** -- Added `ensure_docker_dir_ownership()` helper function -- Applied to ALL 25+ services (Immich, Keycloak, ActualBudget, Jellyfin, etc.) -- Fixed disaster recovery path (line 309) -- All Docker directories now properly owned by sudo user - -**Impact:** Docker containers can now be managed without requiring root/sudo for every command. - ---- - -### 2. Keycloak Security Overhaul -**Problem:** Weak default passwords, special characters causing issues, development mode in production. - -**Solutions Implemented:** - -#### Password Requirements -- **Minimum length:** 12 characters (16+ recommended) -- **Character set:** Letters and numbers ONLY (no special characters) -- **Auto-generation:** Press ENTER to generate secure passwords automatically -- **Validation:** Real-time password validation with retry loop - -#### Production vs Development Mode -- **Production mode:** Uses `start` command, requires hostname configuration -- **Development mode:** Uses `start-dev` command, relaxed security for testing -- **Hostname support:** Proper `KC_HOSTNAME` configuration for public deployment - -#### Environment Variables -- All credentials moved to `.env` file -- Admin password and database password securely stored -- No more hardcoded passwords in docker-compose.yml - -**Example Keycloak .env file structure:** -```env -# Keycloak Environment Variables -KEYCLOAK_ADMIN=admin -KEYCLOAK_ADMIN_PASSWORD= -POSTGRES_DB=keycloak -POSTGRES_USER=keycloak -POSTGRES_PASSWORD= -KC_PROXY=edge -KC_HTTP_ENABLED=true -KC_HOSTNAME=auth.yourdomain.com # (if production mode) -``` - ---- - -### 3. Environment Variable Management (.env Files) - -**Services Now Using .env Files:** -- ✅ Keycloak (admin + database passwords) -- ✅ ActualBudget (timezone and config) -- ✅ Immich (already had .env) -- ✅ FindMyDevice (already had .env) -- ✅ wg-easy (already had .env) -- ✅ Kopia (already had .env) - -**Benefits:** -- Passwords not visible in docker-compose.yml files -- Easy to backup separately from compose files -- Can be excluded from version control -- Easier credential rotation - ---- - -### 4. Caddy2 Reverse Proxy Integration - -**Existing Integration:** -All services already include Caddy2 reverse proxy configuration via the `configure_caddy_for_service()` function. - -**Features:** -- Automatic HTTPS via Let's Encrypt -- HTTP/2 support -- Security headers (HSTS, X-Frame-Options, etc.) -- JSON logging for fail2ban -- Automatic certificate renewal - -**Example Caddy Configuration:** -``` -photos.yourdomain.com { - reverse_proxy localhost:2283 - - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Frame-Options "SAMEORIGIN" - X-Content-Type-Options "nosniff" - X-XSS-Protection "1; mode=block" - Referrer-Policy "strict-origin-when-cross-origin" - } - - log { - output file /var/log/caddy/photos-access.log - format json - } -} -``` - ---- - -### 5. Fail2ban Integration - -**Existing fail2ban Labels:** -All services already include fail2ban monitoring labels: - -```yaml -labels: - - "io.podman.annotations.label/fail2ban.enable=true" - - "io.podman.annotations.label/fail2ban.filter=caddy-auth" -``` - -**Services with fail2ban monitoring:** -- ActualBudget -- Keycloak -- All other internet-facing services - -**fail2ban Configuration:** -- Filter: `/etc/fail2ban/filter.d/caddy-auth.conf` -- Jail: `/etc/fail2ban/jail.d/caddy.conf` -- Ban after: 5 failed attempts -- Ban duration: 3600 seconds (1 hour) -- Detection window: 600 seconds - -**Detailed Setup:** See `CADDY-FAIL2BAN-SETUP.md` for complete configuration. - ---- - -## Helper Functions Added - -### `ensure_docker_dir_ownership(dir1 [dir2 ...])` -Ensures Docker directories are owned by the actual user (not root). - -**Usage:** -```bash -mkdir -p "$SERVICE_DIR" -ensure_docker_dir_ownership "$SERVICE_DIR" -``` - -### `generate_password([length])` -Generates secure alphanumeric passwords (no special characters). - -**Usage:** -```bash -PASSWORD=$(generate_password 20) # 20-character password -``` - -### `validate_password(password [min_length])` -Validates passwords for Keycloak compatibility. - -**Validation Rules:** -- Minimum length (default: 12 characters) -- Alphanumeric only (a-zA-Z0-9) -- Returns 0 if valid, 1 if invalid - -**Usage:** -```bash -if validate_password "$USER_PASSWORD" 12; then - echo "Password accepted" -fi -``` - ---- - -## Keycloak Setup Guide - -### For ActualBudget on Pikapods - -1. **Install Keycloak with production mode:** - ```bash - sudo bash ubuntu-post-install.sh - # Select Keycloak from menu - # Choose production mode (y) - # Enter hostname: auth.yourdomain.com - # Press ENTER to auto-generate secure passwords - ``` - -2. **Configure Caddy2:** - - Script automatically prompts for Caddy configuration - - Enter your domain (e.g., auth.yourdomain.com) - - Ensure DNS A record points to your server - -3. **Configure DNS:** - ``` - auth.yourdomain.com → Your Server IP - ``` - -4. **Access Keycloak:** - ``` - https://auth.yourdomain.com - ``` - -5. **Set up ActualBudget OAuth:** - - The script automatically creates an OAuth client for ActualBudget - - Client details saved to: `~/docker/keycloak/actualbudget-oauth.txt` - - Use these credentials in your Pikapod ActualBudget instance - -6. **Configure ActualBudget on Pikapods:** - - Go to your ActualBudget settings - - Enable OpenID Connect - - Enter your Keycloak details: - - Issuer: `https://auth.yourdomain.com/realms/homelab` - - Client ID: (from actualbudget-oauth.txt) - - Client Secret: (from actualbudget-oauth.txt) - -### For Other Self-Hosted Services - -The script can create generic OAuth clients for other services. After Keycloak installation, you can: - -1. Access Keycloak admin console -2. Create new OAuth2/OIDC clients -3. Configure redirect URIs for your services -4. Use the client credentials in your service configuration - -**Generic Client Template:** -- Client ID: your-service-name -- Client Type: Confidential -- Standard Flow Enabled: Yes -- Valid Redirect URIs: https://your-service.com/* - ---- - -## Password Requirements Reference - -### Keycloak Passwords -- **Minimum:** 12 characters -- **Recommended:** 16+ characters -- **Format:** Alphanumeric only (a-zA-Z0-9) -- **No special characters:** `!@#$%^&*()` etc. are NOT allowed -- **Generation:** Press ENTER for auto-generated secure passwords - -### Why No Special Characters? -Keycloak has issues with special characters in certain authentication flows and database connection strings. Restricting to alphanumeric ensures compatibility. - -### Password Strength with Alphanumeric Only -- 12 characters: ~62^12 = 3.2 × 10^21 combinations -- 16 characters: ~62^16 = 4.7 × 10^28 combinations -- 20 characters: ~62^20 = 7.0 × 10^35 combinations - -This is cryptographically secure for all practical purposes. - ---- - -## Verification Checklist - -After running the updated script: - -### Docker Ownership -```bash -# Check docker directory ownership -ls -la ~/docker/ -# All directories should be owned by your user, not root - -# Test docker without sudo -docker ps -# Should work without permission errors -``` - -### Keycloak -```bash -# Check .env file exists -cat ~/docker/keycloak/.env -# Should contain KEYCLOAK_ADMIN_PASSWORD and POSTGRES_PASSWORD - -# Check production mode -cat ~/docker/keycloak/docker-compose.yml | grep command -# Should show "start" for production or "start-dev" for development - -# Test access -curl http://localhost:8180/health -# Should return health status -``` - -### Caddy2 -```bash -# Check Caddy is running -docker ps | grep caddy - -# Check logs -docker logs caddy - -# Test HTTPS redirect -curl -I http://yourdomain.com -# Should redirect to HTTPS -``` - -### fail2ban -```bash -# Check fail2ban status -sudo fail2ban-client status caddy-auth - -# Test ban -# (Make 5 failed login attempts) -sudo fail2ban-client status caddy-auth -# Should show banned IP -``` - ---- - -## Migration Guide - -If you have existing services: - -### Existing ActualBudget -1. Backup existing data: `cp -r ~/docker/actualbudget ~/docker/actualbudget.backup` -2. Run updated script and select "Reconfigure" when prompted -3. New .env file will be created -4. Verify ownership: `ls -la ~/docker/actualbudget` -5. Restart container: `cd ~/docker/actualbudget && docker compose restart` - -### Existing Keycloak -1. **IMPORTANT:** Backup your data first! - ```bash - cp -r ~/docker/keycloak ~/docker/keycloak.backup - ``` -2. Stop existing container: - ```bash - cd ~/docker/keycloak && docker compose down - ``` -3. Run updated script and select Keycloak -4. Choose whether to keep existing data or start fresh -5. If keeping data, manually update .env with your existing passwords -6. Restart: `docker compose up -d` - ---- - -## Troubleshooting - -### Permission Denied Errors -```bash -# Fix ownership of all docker directories -sudo chown -R $USER:$USER ~/docker -``` - -### Keycloak Won't Start -```bash -# Check logs -docker logs keycloak - -# Common issues: -# 1. Missing KC_HOSTNAME in production mode -# 2. Database connection failed (check postgres container) -# 3. Port 8180 already in use - -# Fix: Edit .env and docker-compose.yml as needed -``` - -### Caddy Certificate Errors -```bash -# Check Caddy logs -docker logs caddy - -# Common issues: -# 1. DNS not pointing to server -# 2. Ports 80/443 not open -# 3. Firewall blocking Let's Encrypt - -# Test DNS: -dig auth.yourdomain.com - -# Test port accessibility: -sudo ufw status -``` - ---- - -## Security Best Practices - -1. **Change default passwords:** Even with auto-generation, review and update if needed -2. **Use production mode for Keycloak:** Never use development mode for internet-facing deployments -3. **Enable fail2ban:** Monitor and ban malicious IPs -4. **Regular updates:** Keep containers updated (use Watchtower in notify mode) -5. **Backup .env files:** Store securely, separate from compose files -6. **Use HTTPS everywhere:** Configure Caddy2 for all public services -7. **Limit exposed ports:** Only expose necessary ports to the internet -8. **Monitor logs:** Regular review of Caddy and fail2ban logs - ---- - -## Additional Resources - -- **Keycloak Setup Guide:** `KEYCLOAK-SETUP-GUIDE.md` -- **Caddy + fail2ban Setup:** `CADDY-FAIL2BAN-SETUP.md` -- **Main Script:** `ubuntu-post-install.sh` -- **Caddy Helper:** `caddy-setup-helper.sh` - ---- - -## Support - -If you encounter issues: - -1. Check logs: `docker logs ` -2. Verify ownership: `ls -la ~/docker` -3. Review this document for troubleshooting steps -4. Check existing documentation in repository - ---- - -**Last Updated:** 2026-01-13 -**Script Version:** Latest (with security improvements) diff --git a/caddy-setup-helper.sh b/caddy-setup-helper.sh deleted file mode 100755 index efc9dfc..0000000 --- a/caddy-setup-helper.sh +++ /dev/null @@ -1,547 +0,0 @@ -#!/bin/bash - -# Caddy Setup Helper Script -# This script helps manage Caddy configuration, backups, and fail2ban integration -# for dockerized Caddy setups - FULLY AUTOMATED with error handling - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -echo "===============================================" -echo " Caddy Configuration & Fail2ban Setup Helper" -echo "===============================================" -echo "" - -# Function to print colored output -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Function to ask yes/no questions -ask_yn() { - local prompt="$1" - local default="${2:-n}" - local response - - if [ "$default" = "y" ]; then - read -p "$prompt [Y/n]: " response - response=${response:-y} - else - read -p "$prompt [y/N]: " response - response=${response:-n} - fi - - [[ "$response" =~ ^[Yy]$ ]] -} - -# Track if we need to show manual instructions -SHOW_MANUAL=false -ERROR_MESSAGES=() - -# ============================== -# 1. CHECK IF CADDY IS INSTALLED -# ============================== -print_info "Checking for Caddy installation..." - -CADDY_CONTAINER="" -CADDYFILE_PATH="" - -# Check if Docker is available -if ! command -v docker &> /dev/null; then - print_error "Docker is not installed or not in PATH" - exit 1 -fi - -# Try to find Caddy container -if docker ps --format '{{.Names}}' | grep -iq "caddy"; then - CADDY_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i "caddy" | head -1) - print_success "Found running Caddy container: $CADDY_CONTAINER" -else - print_warning "No running Caddy container found" - if ! ask_yn "Is Caddy installed?" "n"; then - print_info "Caddy is not installed. Please install Caddy first." - echo "" - echo "To install Caddy with Docker, see CADDY-FAIL2BAN-SETUP.md" - exit 0 - fi -fi - -# ============================== -# 2. LOCATE CADDYFILE -# ============================== -print_info "Locating Caddyfile..." - -# Common Caddyfile locations -POSSIBLE_PATHS=( - "$HOME/docker/caddy/Caddyfile" - "$HOME/docker/caddy/caddyfile" - "$HOME/docker/caddy/config/Caddyfile" - "/etc/caddy/Caddyfile" -) - -for path in "${POSSIBLE_PATHS[@]}"; do - if [ -f "$path" ]; then - CADDYFILE_PATH="$path" - print_success "Found Caddyfile at: $CADDYFILE_PATH" - break - fi -done - -if [ -z "$CADDYFILE_PATH" ]; then - print_warning "Caddyfile not found in common locations" - read -p "Enter path to Caddyfile: " CUSTOM_PATH - if [ -n "$CUSTOM_PATH" ] && [ -f "$CUSTOM_PATH" ]; then - CADDYFILE_PATH="$CUSTOM_PATH" - print_success "Using Caddyfile at: $CADDYFILE_PATH" - else - print_error "Cannot proceed without Caddyfile location" - exit 1 - fi -fi - -CADDY_DIR=$(dirname "$CADDYFILE_PATH") - -# ============================== -# 3. BACKUP CADDYFILE (ALWAYS FIRST!) -# ============================== -print_info "Creating backup of Caddyfile..." - -BACKUP_DIR="$CADDY_DIR/backups" -mkdir -p "$BACKUP_DIR" - -BACKUP_FILE="$BACKUP_DIR/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)" -cp "$CADDYFILE_PATH" "$BACKUP_FILE" -print_success "Backup created: $BACKUP_FILE" - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " BACKUP RESTORE INSTRUCTIONS" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "To restore this backup:" -echo " cp $BACKUP_FILE $CADDYFILE_PATH" -if [ -n "$CADDY_CONTAINER" ]; then - echo " docker exec -w /etc/caddy $CADDY_CONTAINER caddy reload" - echo " docker exec -w /etc/caddy $CADDY_CONTAINER caddy fmt --overwrite" -fi -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -# ============================== -# 4. CHECK FOR FAIL2BAN INSTALLATION -# ============================== -print_info "Checking for fail2ban installation..." - -FAIL2BAN_INSTALLED=false -if command -v fail2ban-client &> /dev/null; then - print_success "fail2ban is installed" - FAIL2BAN_INSTALLED=true -else - print_warning "fail2ban is not installed" - - if ask_yn "Would you like to install fail2ban now?" "y"; then - print_info "Installing fail2ban..." - - if sudo apt update && sudo apt install -y fail2ban; then - print_success "fail2ban installed successfully" - FAIL2BAN_INSTALLED=true - else - print_error "Failed to install fail2ban" - ERROR_MESSAGES+=("Failed to install fail2ban - you may need to install it manually") - SHOW_MANUAL=true - fi - else - print_warning "Skipping fail2ban installation" - SHOW_MANUAL=true - fi -fi - -# ============================== -# 5. CREATE LOG DIRECTORY -# ============================== -if [ "$FAIL2BAN_INSTALLED" = true ]; then - print_info "Checking Caddy log directory..." - - LOG_DIR="/var/log/caddy" - if [ ! -d "$LOG_DIR" ]; then - if ask_yn "Create $LOG_DIR for Caddy logs?" "y"; then - if sudo mkdir -p "$LOG_DIR" && sudo chmod 755 "$LOG_DIR"; then - print_success "Log directory created: $LOG_DIR" - else - print_error "Failed to create log directory" - ERROR_MESSAGES+=("Failed to create $LOG_DIR - create it manually with: sudo mkdir -p $LOG_DIR && sudo chmod 755 $LOG_DIR") - SHOW_MANUAL=true - fi - fi - else - print_success "Log directory exists: $LOG_DIR" - fi -fi - -# ============================== -# 6. CHECK CADDY DOCKER COMPOSE FOR LOG VOLUME -# ============================== -if [ "$FAIL2BAN_INSTALLED" = true ] && [ -n "$CADDY_CONTAINER" ]; then - print_info "Checking if Caddy container has log volume mounted..." - - # Check if the container has /var/log/caddy mounted - if docker inspect "$CADDY_CONTAINER" 2>/dev/null | grep -q "/var/log/caddy"; then - print_success "Caddy container has log volume mounted" - else - print_warning "Caddy container does not have /var/log/caddy volume mounted" - - # Check if there's a docker-compose.yml - COMPOSE_FILE="" - for file in "$CADDY_DIR/docker-compose.yml" "$CADDY_DIR/docker-compose.yaml"; do - if [ -f "$file" ]; then - COMPOSE_FILE="$file" - break - fi - done - - if [ -n "$COMPOSE_FILE" ]; then - if ask_yn "Add /var/log/caddy volume to docker-compose.yml?" "y"; then - # Backup docker-compose.yml - cp "$COMPOSE_FILE" "$COMPOSE_FILE.backup.$(date +%Y%m%d_%H%M%S)" - - # Check if volumes section exists - if grep -q "volumes:" "$COMPOSE_FILE"; then - # Add to existing volumes - if ! grep -q "/var/log/caddy" "$COMPOSE_FILE"; then - # Find the volumes section and add our volume - sed -i '/volumes:/a\ - /var/log/caddy:/var/log/caddy' "$COMPOSE_FILE" - print_success "Added log volume to docker-compose.yml" - print_warning "You'll need to restart the Caddy container for this to take effect" - - if ask_yn "Restart Caddy container now?" "n"; then - cd "$CADDY_DIR" - if docker compose down && docker compose up -d; then - print_success "Caddy container restarted" - # Update CADDY_CONTAINER name in case it changed - CADDY_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i "caddy" | head -1) - else - print_error "Failed to restart Caddy container" - ERROR_MESSAGES+=("Failed to restart Caddy - restart manually with: cd $CADDY_DIR && docker compose restart") - fi - fi - fi - else - print_warning "Could not automatically add volume - docker-compose.yml format is unexpected" - ERROR_MESSAGES+=("Add this volume manually to your Caddy service: /var/log/caddy:/var/log/caddy") - SHOW_MANUAL=true - fi - fi - else - print_warning "No docker-compose.yml found - you may need to add the volume manually" - ERROR_MESSAGES+=("Add log volume to Caddy container: /var/log/caddy:/var/log/caddy") - SHOW_MANUAL=true - fi - fi -fi - -# ============================== -# 7. CREATE FAIL2BAN FILTER -# ============================== -if [ "$FAIL2BAN_INSTALLED" = true ]; then - print_info "Checking fail2ban filter configuration..." - - FILTER_FILE="/etc/fail2ban/filter.d/caddy-auth.conf" - if [ -f "$FILTER_FILE" ]; then - print_success "fail2ban filter already exists: $FILTER_FILE" - else - if ask_yn "Create fail2ban filter for Caddy?" "y"; then - print_info "Creating fail2ban filter..." - - FILTER_CONTENT='[Definition] -failregex = ^.*"remote_ip":"".*"status":(?:401|403|429).*$ - ^.*"remote_addr":".*"status":(?:401|403|429).*$ -ignoreregex = ^.*"remote_ip":"(?:127\.0\.0\.1|::1)".*$ -datepattern = "ts":%%s' - - if echo "$FILTER_CONTENT" | sudo tee "$FILTER_FILE" > /dev/null; then - print_success "Created fail2ban filter: $FILTER_FILE" - else - print_error "Failed to create fail2ban filter" - ERROR_MESSAGES+=("Failed to create $FILTER_FILE - create it manually (see CADDY-FAIL2BAN-SETUP.md)") - SHOW_MANUAL=true - fi - fi - fi -fi - -# ============================== -# 8. CREATE FAIL2BAN JAIL -# ============================== -if [ "$FAIL2BAN_INSTALLED" = true ]; then - print_info "Checking fail2ban jail configuration..." - - JAIL_FILE="/etc/fail2ban/jail.d/caddy.conf" - if [ -f "$JAIL_FILE" ]; then - print_success "fail2ban jail already exists: $JAIL_FILE" - else - if ask_yn "Create fail2ban jail for Caddy?" "y"; then - print_info "Creating fail2ban jail..." - - # Ask for custom settings - echo "" - print_info "Fail2ban jail settings (press Enter for defaults):" - read -p " Max retries before ban [5]: " MAXRETRY - MAXRETRY=${MAXRETRY:-5} - - read -p " Find time window in seconds [600]: " FINDTIME - FINDTIME=${FINDTIME:-600} - - read -p " Ban duration in seconds [3600]: " BANTIME - BANTIME=${BANTIME:-3600} - - JAIL_CONTENT="[caddy-auth] -enabled = true -port = http,https -filter = caddy-auth -logpath = /var/log/caddy/access.log - /var/log/caddy/*-access.log -maxretry = $MAXRETRY -findtime = $FINDTIME -bantime = $BANTIME -action = iptables-multiport[name=CaddyAuth, port=\"http,https\", protocol=tcp] -backend = auto" - - if echo "$JAIL_CONTENT" | sudo tee "$JAIL_FILE" > /dev/null; then - print_success "Created fail2ban jail: $JAIL_FILE" - else - print_error "Failed to create fail2ban jail" - ERROR_MESSAGES+=("Failed to create $JAIL_FILE - create it manually (see CADDY-FAIL2BAN-SETUP.md)") - SHOW_MANUAL=true - fi - fi - fi -fi - -# ============================== -# 9. TEST FAIL2BAN CONFIGURATION -# ============================== -if [ "$FAIL2BAN_INSTALLED" = true ]; then - print_info "Testing fail2ban configuration..." - - if sudo fail2ban-client -t &> /dev/null; then - print_success "fail2ban configuration is valid" - else - print_error "fail2ban configuration has errors" - ERROR_MESSAGES+=("fail2ban configuration is invalid - check with: sudo fail2ban-client -t") - SHOW_MANUAL=true - fi -fi - -# ============================== -# 10. RESTART FAIL2BAN -# ============================== -if [ "$FAIL2BAN_INSTALLED" = true ]; then - if ask_yn "Restart fail2ban to apply changes?" "y"; then - print_info "Restarting fail2ban..." - - if sudo systemctl restart fail2ban; then - print_success "fail2ban restarted successfully" - - # Wait a moment for fail2ban to start - sleep 2 - - # Check if caddy-auth jail is running - if sudo fail2ban-client status caddy-auth &> /dev/null; then - print_success "caddy-auth jail is active" - echo "" - print_info "Jail status:" - sudo fail2ban-client status caddy-auth - else - print_warning "caddy-auth jail is not active" - ERROR_MESSAGES+=("caddy-auth jail failed to start - check with: sudo fail2ban-client status") - SHOW_MANUAL=true - fi - else - print_error "Failed to restart fail2ban" - ERROR_MESSAGES+=("Failed to restart fail2ban - check logs with: sudo journalctl -u fail2ban -n 50") - SHOW_MANUAL=true - fi - fi -fi - -# ============================== -# 11. ADD SERVICE CONFIGURATIONS TO CADDYFILE -# ============================== -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " ADDING NEW SERVICES TO CADDYFILE" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -print_info "You can now add your services to the Caddyfile" -echo "" -echo "Available services to add:" -echo " - ActualBudget (Personal Finance) - Port 5006" -echo " - Keycloak (Identity & Access Management) - Port 8180" -echo "" - -if ask_yn "Would you like to add ActualBudget to Caddyfile?" "n"; then - read -p "Enter domain for ActualBudget (e.g., budget.yourdomain.com): " AB_DOMAIN - - if [ -n "$AB_DOMAIN" ]; then - AB_CONFIG=" -# ActualBudget - Personal Finance -$AB_DOMAIN { - log { - output file /var/log/caddy/actualbudget-access.log - format json - level INFO - } - - reverse_proxy localhost:5006 - - # Security headers - header { - Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\" - X-Frame-Options \"SAMEORIGIN\" - X-Content-Type-Options \"nosniff\" - X-XSS-Protection \"1; mode=block\" - Referrer-Policy \"strict-origin-when-cross-origin\" - } -} -" - - if echo "$AB_CONFIG" >> "$CADDYFILE_PATH"; then - print_success "Added ActualBudget configuration to Caddyfile" - else - print_error "Failed to add ActualBudget configuration" - ERROR_MESSAGES+=("Add ActualBudget manually - see CADDY-FAIL2BAN-SETUP.md") - fi - fi -fi - -if ask_yn "Would you like to add Keycloak to Caddyfile?" "n"; then - read -p "Enter domain for Keycloak (e.g., auth.yourdomain.com): " KC_DOMAIN - - if [ -n "$KC_DOMAIN" ]; then - KC_CONFIG=" -# Keycloak - Identity & Access Management -$KC_DOMAIN { - log { - output file /var/log/caddy/keycloak-access.log - format json - level INFO - } - - reverse_proxy localhost:8180 - - # Security headers - header { - Strict-Transport-Security \"max-age=31536000; includeSubDomains; preload\" - X-Frame-Options \"SAMEORIGIN\" - X-Content-Type-Options \"nosniff\" - X-XSS-Protection \"1; mode=block\" - Referrer-Policy \"strict-origin-when-cross-origin\" - } -} -" - - if echo "$KC_CONFIG" >> "$CADDYFILE_PATH"; then - print_success "Added Keycloak configuration to Caddyfile" - else - print_error "Failed to add Keycloak configuration" - ERROR_MESSAGES+=("Add Keycloak manually - see CADDY-FAIL2BAN-SETUP.md") - fi - fi -fi - -# ============================== -# 12. VALIDATE AND RELOAD CADDY -# ============================== -if [ -n "$CADDY_CONTAINER" ]; then - echo "" - if ask_yn "Validate and reload Caddy configuration?" "y"; then - print_info "Validating Caddyfile..." - - # Format first - if docker exec -w /etc/caddy "$CADDY_CONTAINER" caddy fmt --overwrite 2>/dev/null; then - print_success "Caddyfile formatted" - fi - - # Validate - if docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile 2>/dev/null; then - print_success "Caddyfile is valid" - - # Reload - print_info "Reloading Caddy configuration..." - if docker exec -w /etc/caddy "$CADDY_CONTAINER" caddy reload 2>/dev/null; then - print_success "Caddy configuration reloaded successfully" - else - print_error "Failed to reload Caddy configuration" - ERROR_MESSAGES+=("Failed to reload Caddy - check logs with: docker logs $CADDY_CONTAINER") - SHOW_MANUAL=true - fi - else - print_error "Caddyfile validation failed" - ERROR_MESSAGES+=("Caddyfile has syntax errors - check with: docker exec $CADDY_CONTAINER caddy validate --config /etc/caddy/Caddyfile") - SHOW_MANUAL=true - fi - fi -fi - -# ============================== -# 13. FINAL SUMMARY -# ============================== -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " SETUP COMPLETE" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -if [ "$SHOW_MANUAL" = true ]; then - print_warning "Some steps could not be completed automatically" - echo "" - echo "Issues encountered:" - for msg in "${ERROR_MESSAGES[@]}"; do - echo " - $msg" - done - echo "" - print_info "See CADDY-FAIL2BAN-SETUP.md for manual setup instructions" - echo "" -fi - -print_success "Caddyfile backed up to: $BACKUP_FILE" - -if [ "$FAIL2BAN_INSTALLED" = true ]; then - print_success "fail2ban is installed and configured" - echo "" - echo "Useful commands:" - echo " Check jail status: sudo fail2ban-client status caddy-auth" - echo " View banned IPs: sudo fail2ban-client get caddy-auth banip" - echo " Unban IP: sudo fail2ban-client set caddy-auth unbanip 1.2.3.4" - echo " Test filter: sudo fail2ban-regex /var/log/caddy/access.log /etc/fail2ban/filter.d/caddy-auth.conf" -fi - -echo "" -echo "Caddyfile location: $CADDYFILE_PATH" -echo "Backup location: $BACKUP_FILE" -if [ -n "$CADDY_CONTAINER" ]; then - echo "Caddy container: $CADDY_CONTAINER" - echo "Reload Caddy: docker exec -w /etc/caddy $CADDY_CONTAINER caddy reload" - echo "View Caddy logs: docker logs $CADDY_CONTAINER --tail 50" -fi -echo "" -print_success "All done!" -echo "" diff --git a/docker-compose-actualbudget.yml b/docker-compose-actualbudget.yml deleted file mode 100644 index b51ccae..0000000 --- a/docker-compose-actualbudget.yml +++ /dev/null @@ -1,60 +0,0 @@ -# ActualBudget - Open-source personal finance management -# https://actualbudget.org/ -# -# DEPLOYMENT INSTRUCTIONS: -# 1. Create directory: mkdir -p ~/docker/actualbudget -# 2. Copy this file: cp docker-compose-actualbudget.yml ~/docker/actualbudget/docker-compose.yml -# 3. Create data directory: mkdir -p ~/docker/actualbudget/data -# 4. Start the service: cd ~/docker/actualbudget && docker compose up -d -# 5. Access at: http://localhost:5006 -# -# REVERSE PROXY SETUP (with Caddy): -# Add to your Caddyfile: -# budget.yourdomain.com { -# reverse_proxy localhost:5006 -# } -# -# BANK ACCOUNT SYNC: -# ActualBudget supports SimpleFIN (https://simplefin.org/) for bank synchronization. -# You'll need a SimpleFIN account to sync with your bank accounts. -# Setup instructions: https://actualbudget.org/docs/advanced/bank-sync - -name: actualbudget - -services: - actualbudget: - image: actualbudget/actual-server:latest - container_name: actualbudget - restart: unless-stopped - ports: - - "5006:5006" - volumes: - - ./data:/data - environment: - # Set your timezone - - TZ=UTC - # Uncomment and set these for HTTPS/production deployment: - # - ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB=20 - # - ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB=50 - # - ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB=20 - labels: - # Fail2ban support - logs HTTP requests - - "io.podman.annotations.label/fail2ban.enable=true" - - "io.podman.annotations.label/fail2ban.filter=caddy-auth" - -# NOTES: -# - Default port: 5006 -# - Data stored in: ./data -# - First run: Create an account at http://localhost:5006 -# - Bank sync requires SimpleFIN: https://simplefin.org/ -# - Official docs: https://actualbudget.org/docs/ -# -# BACKUP YOUR DATA: -# Regular backups are important! ActualBudget stores data in SQLite. -# docker compose down -# cp -r data data-backup-$(date +%Y%m%d) -# docker compose up -d -# -# UPDATES: -# docker compose pull -# docker compose up -d diff --git a/docker-compose-keycloak.yml b/docker-compose-keycloak.yml deleted file mode 100644 index dced564..0000000 --- a/docker-compose-keycloak.yml +++ /dev/null @@ -1,137 +0,0 @@ -# Keycloak - Open-source Identity and Access Management -# https://www.keycloak.org/ -# -# DEPLOYMENT INSTRUCTIONS: -# 1. Create directory: mkdir -p ~/docker/keycloak -# 2. Copy this file: cp docker-compose-keycloak.yml ~/docker/keycloak/docker-compose.yml -# 3. Create .env file with credentials (see .env template below) -# 4. Start the service: cd ~/docker/keycloak && docker compose up -d -# 5. Access at: http://localhost:8180/admin (admin console) -# -# .ENV FILE TEMPLATE: -# Create a file named .env in ~/docker/keycloak/ with: -# KEYCLOAK_ADMIN=admin -# KEYCLOAK_ADMIN_PASSWORD= -# POSTGRES_DB=keycloak -# POSTGRES_USER=keycloak -# POSTGRES_PASSWORD= -# KC_DB=postgres -# KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak -# KC_DB_USERNAME=keycloak -# KC_DB_PASSWORD= -# KC_PROXY_HEADERS=xforwarded -# KC_HTTP_ENABLED=true -# KC_HOSTNAME_STRICT=false -# KC_LOG_LEVEL=INFO -# KC_HEALTH_ENABLED=true -# KC_METRICS_ENABLED=true -# # KC_HOSTNAME=auth.yourdomain.com # Uncomment for production -# -# REVERSE PROXY SETUP (with Caddy): -# Add to your Caddyfile: -# auth.yourdomain.com { -# reverse_proxy localhost:8180 -# } -# -# PRODUCTION DEPLOYMENT: -# For production, you should: -# 1. Use a PostgreSQL database (see postgres service below) -# 2. Enable HTTPS via reverse proxy -# 3. Set KC_HOSTNAME to your domain -# 4. Use strong admin password -# 5. Configure proper realm and clients - -name: keycloak - -services: - # PostgreSQL database for Keycloak (recommended for production) - postgres: - image: postgres:16-alpine - container_name: keycloak-db - restart: unless-stopped - env_file: - - .env - volumes: - - ./postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U keycloak"] - interval: 10s - timeout: 5s - retries: 5 - - keycloak: - image: quay.io/keycloak/keycloak:latest - container_name: keycloak - restart: unless-stopped - command: - - start-dev # Use 'start' for production mode - env_file: - - .env - ports: - - "8180:8080" # HTTP port (use reverse proxy for HTTPS) - # - "8787:8787" # Debug port (uncomment if needed) - volumes: - # Optional: Custom themes - # - ./themes:/opt/keycloak/themes - # Optional: Custom providers/extensions - # - ./providers:/opt/keycloak/providers - - ./data:/opt/keycloak/data - depends_on: - postgres: - condition: service_healthy - labels: - # Fail2ban support - - "io.podman.annotations.label/fail2ban.enable=true" - - "io.podman.annotations.label/fail2ban.filter=caddy-auth" - -# NOTES: -# - Admin console: http://localhost:8180/admin -# - Credentials: Stored in .env file -# - Database: PostgreSQL (persistent data in ./postgres-data) -# - Proxy: Uses KC_PROXY_HEADERS=xforwarded (v2 config, no deprecated warnings) -# -# FIRST-TIME SETUP: -# 1. Create .env file with secure passwords (see template above) -# 2. Start containers: docker compose up -d -# 3. Login to admin console at http://localhost:8180/admin -# 4. Create a realm (e.g., "homelab" or "myrealm") -# 5. Create clients for your applications (OAuth2/OIDC) -# 6. Add users or configure identity providers (LDAP, SAML, Social) -# -# COMMON USE CASES: -# - Single Sign-On (SSO) for multiple applications -# - OAuth2/OIDC provider for custom apps (ActualBudget, etc.) -# - SAML 2.0 identity provider -# - User federation with LDAP/Active Directory -# - Multi-factor authentication (MFA/2FA) -# - Social login (Google, GitHub, Facebook, etc.) -# -# PRODUCTION CHECKLIST: -# [ ] Create .env file with secure passwords (12+ chars, alphanumeric only) -# [ ] Set KC_HOSTNAME in .env to your domain (e.g., auth.yourdomain.com) -# [ ] Use 'start' instead of 'start-dev' command in docker-compose.yml -# [ ] Configure HTTPS via reverse proxy (Caddy/nginx) -# [ ] Set KC_HOSTNAME_STRICT=true in .env for production -# [ ] Configure DNS A record for your hostname -# [ ] Set proper file permissions: chmod 600 .env -# [ ] Configure backup strategy for PostgreSQL -# [ ] Set up monitoring (metrics enabled via KC_METRICS_ENABLED=true) -# -# BACKUP: -# docker compose down -# tar -czf keycloak-backup-$(date +%Y%m%d).tar.gz postgres-data data -# docker compose up -d -# -# RESTORE: -# docker compose down -# tar -xzf keycloak-backup-YYYYMMDD.tar.gz -# docker compose up -d -# -# UPDATES: -# docker compose pull -# docker compose up -d -# -# DOCUMENTATION: -# - Official docs: https://www.keycloak.org/documentation -# - Getting started: https://www.keycloak.org/getting-started/getting-started-docker -# - Server admin: https://www.keycloak.org/docs/latest/server_admin/ diff --git a/fail2ban-caddy-filter.conf b/fail2ban-caddy-filter.conf deleted file mode 100644 index 157fc71..0000000 --- a/fail2ban-caddy-filter.conf +++ /dev/null @@ -1,49 +0,0 @@ -# Fail2ban filter for Caddy web server -# -# INSTALLATION: -# 1. Copy this file to: /etc/fail2ban/filter.d/caddy-auth.conf -# sudo cp fail2ban-caddy-filter.conf /etc/fail2ban/filter.d/caddy-auth.conf -# -# 2. Create jail configuration at: /etc/fail2ban/jail.d/caddy.conf -# (See fail2ban-caddy-jail.conf in this directory) -# -# 3. Ensure Caddy is logging in JSON format to /var/log/caddy/access.log -# (See caddy-setup-helper.sh for configuration examples) -# -# 4. Restart fail2ban: -# sudo systemctl restart fail2ban -# -# 5. Check status: -# sudo fail2ban-client status caddy-auth - -[INCLUDES] -before = common.conf - -[Definition] - -# Match failed authentication attempts and forbidden access -# Caddy JSON log format: {"remote_ip":"1.2.3.4","status":401,...} -failregex = ^.*"remote_ip":"".*"status":(?:401|403|429).*$ - ^.*"remote_addr":".*"status":(?:401|403|429).*$ - ^.*"client_ip":"".*"status":(?:401|403|429).*$ - -# Ignore localhost and common false positives -ignoreregex = ^.*"remote_ip":"(?:127\.0\.0\.1|::1)".*$ - ^.*"remote_addr":"(?:127\.0\.0\.1|::1)".*$ - -# Optional: Date/time pattern for log analysis -# Most Caddy JSON logs include "ts" field with Unix timestamp -datepattern = "ts":%%s - -[Init] -journalmatch = _SYSTEMD_UNIT=caddy.service - -# NOTES: -# - This filter looks for HTTP status codes: -# 401 = Unauthorized (failed authentication) -# 403 = Forbidden (access denied) -# 429 = Too Many Requests (rate limiting) -# -# - Adjust the status codes based on your needs -# - For more aggressive blocking, add: 404|500 -# - Test the filter: fail2ban-regex /var/log/caddy/access.log /etc/fail2ban/filter.d/caddy-auth.conf diff --git a/fail2ban-caddy-jail.conf b/fail2ban-caddy-jail.conf deleted file mode 100644 index d334652..0000000 --- a/fail2ban-caddy-jail.conf +++ /dev/null @@ -1,82 +0,0 @@ -# Fail2ban jail configuration for Caddy web server -# -# INSTALLATION: -# 1. Copy this file to: /etc/fail2ban/jail.d/caddy.conf -# sudo cp fail2ban-caddy-jail.conf /etc/fail2ban/jail.d/caddy.conf -# -# 2. Ensure the filter is installed: -# sudo cp fail2ban-caddy-filter.conf /etc/fail2ban/filter.d/caddy-auth.conf -# -# 3. Create log directory if it doesn't exist: -# sudo mkdir -p /var/log/caddy -# sudo chown caddy:caddy /var/log/caddy # Or appropriate user -# -# 4. Restart fail2ban: -# sudo systemctl restart fail2ban -# -# 5. Check status: -# sudo fail2ban-client status caddy-auth - -[caddy-auth] -# Enable this jail -enabled = true - -# Ports to protect (HTTP and HTTPS) -port = http,https - -# Filter to use (must match filename in /etc/fail2ban/filter.d/) -filter = caddy-auth - -# Log file to monitor -# Adjust this path if your Caddy logs are elsewhere -logpath = /var/log/caddy/access.log - /var/log/caddy/*-access.log - -# For Docker Caddy, you might need to use Docker logs: -# logpath = /var/lib/docker/containers/*-caddy*/*.log - -# Maximum retry before ban -# 5 attempts within findtime period will trigger a ban -maxretry = 5 - -# Time window (seconds) to count failures -# 600 = 10 minutes -findtime = 600 - -# Ban duration (seconds) -# 3600 = 1 hour -# 86400 = 24 hours -bantime = 3600 - -# Action to take when banning -# iptables-multiport: Block on multiple ports -action = iptables-multiport[name=CaddyAuth, port="http,https", protocol=tcp] - # Optional: Send email notification - # sendmail-whois[name=CaddyAuth, dest=admin@yourdomain.com] - -# Backend to use for monitoring log file -# auto = automatically detect (systemd journal or file polling) -backend = auto - -# OPTIONAL SETTINGS: - -# Increase ban time on repeat offenders -# First ban: 1 hour, second: 24 hours, third: 1 week -# bantime.increment = true -# bantime.factor = 24 -# bantime.maxtime = 604800 # 1 week max - -# Find all jails using this ban -# This enables ban synchronization across jails -# banaction_allports = iptables-allports - -# NOTES: -# - Adjust maxretry, findtime, and bantime based on your security needs -# - More aggressive: maxretry=3, findtime=300, bantime=86400 -# - More lenient: maxretry=10, findtime=1200, bantime=1800 -# -# TESTING: -# - Check if jail is running: sudo fail2ban-client status caddy-auth -# - View banned IPs: sudo fail2ban-client get caddy-auth banip -# - Unban an IP: sudo fail2ban-client set caddy-auth unbanip 1.2.3.4 -# - Test filter: fail2ban-regex /var/log/caddy/access.log /etc/fail2ban/filter.d/caddy-auth.conf diff --git a/fix-keycloak-proxy.sh b/fix-keycloak-proxy.sh deleted file mode 100755 index 025e201..0000000 --- a/fix-keycloak-proxy.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/bin/bash -# -# Fix Keycloak proxy configuration -# This updates Keycloak to use v2 proxy headers (KC_PROXY_HEADERS) -# instead of deprecated v1 (KC_PROXY) -# - -set -e - -KC_DIR="$HOME/docker/keycloak" - -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "Keycloak Proxy Configuration Fix" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "This script will:" -echo " 1. Backup your current .env file" -echo " 2. Replace deprecated KC_PROXY with KC_PROXY_HEADERS" -echo " 3. Ensure docker-compose.yml uses env_file" -echo " 4. Restart Keycloak with new configuration" -echo "" - -if [ ! -d "$KC_DIR" ]; then - echo "❌ Error: Keycloak directory not found at $KC_DIR" - exit 1 -fi - -cd "$KC_DIR" - -# Backup existing configuration -BACKUP_DIR="$KC_DIR/backups" -mkdir -p "$BACKUP_DIR" -TIMESTAMP=$(date +%Y%m%d_%H%M%S) - -# Check if .env exists -if [ -f ".env" ]; then - echo "✓ Found existing .env file" - cp .env "$BACKUP_DIR/.env.backup.$TIMESTAMP" - echo "✓ Backed up .env to $BACKUP_DIR/.env.backup.$TIMESTAMP" - - # Check if it has the old KC_PROXY setting - if grep -q "KC_PROXY=" .env 2>/dev/null; then - echo "" - echo "Updating .env file..." - - # Replace KC_PROXY with KC_PROXY_HEADERS - sed -i 's/^KC_PROXY=.*/KC_PROXY_HEADERS=xforwarded/' .env - - # Add KC_PROXY_HEADERS if it doesn't exist and KC_PROXY didn't either - if ! grep -q "KC_PROXY_HEADERS=" .env 2>/dev/null; then - echo "" >> .env - echo "# Proxy settings (v2) - Trust X-Forwarded-* headers from Caddy2" >> .env - echo "KC_PROXY_HEADERS=xforwarded" >> .env - fi - - echo "✓ Updated KC_PROXY to KC_PROXY_HEADERS=xforwarded" - elif grep -q "KC_PROXY_HEADERS=" .env 2>/dev/null; then - echo "✓ Already using KC_PROXY_HEADERS - no changes needed" - else - echo "" - echo "Adding KC_PROXY_HEADERS to .env..." - echo "" >> .env - echo "# Proxy settings (v2) - Trust X-Forwarded-* headers from Caddy2" >> .env - echo "KC_PROXY_HEADERS=xforwarded" >> .env - echo "✓ Added KC_PROXY_HEADERS=xforwarded" - fi -else - echo "⚠ No .env file found" - echo "" - echo "Please create a .env file with your Keycloak credentials." - echo "See SECURITY-IMPROVEMENTS.md for the template." - exit 1 -fi - -# Check docker-compose.yml -if [ -f "docker-compose.yml" ]; then - cp docker-compose.yml "$BACKUP_DIR/docker-compose.yml.backup.$TIMESTAMP" - echo "✓ Backed up docker-compose.yml to $BACKUP_DIR/docker-compose.yml.backup.$TIMESTAMP" - - # Check if docker-compose.yml has hardcoded KC_PROXY - if grep -q "KC_PROXY=" docker-compose.yml 2>/dev/null; then - echo "" - echo "⚠ Found KC_PROXY in docker-compose.yml" - echo " Removing it (should be in .env file instead)..." - - # Remove the KC_PROXY line from docker-compose.yml - sed -i '/KC_PROXY=/d' docker-compose.yml - echo "✓ Removed KC_PROXY from docker-compose.yml" - fi - - # Ensure it uses env_file - if ! grep -q "env_file:" docker-compose.yml 2>/dev/null; then - echo "⚠ docker-compose.yml doesn't use env_file" - echo " You may need to update it manually to use 'env_file: - .env'" - else - echo "✓ docker-compose.yml uses env_file" - fi -fi - -# Show current configuration -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "Current Configuration:" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -if [ -f ".env" ]; then - echo "Proxy Settings:" - grep "KC_PROXY" .env | grep -v "^#" || echo " (none found)" - echo "" - - if grep -q "KC_HOSTNAME=" .env | grep -v "^#" 2>/dev/null; then - echo "Hostname:" - grep "KC_HOSTNAME=" .env | grep -v "^#" - echo "" - fi -fi - -# Ask to restart -echo "" -read -p "Restart Keycloak with new configuration? (y/n): " RESTART - -if [ "$RESTART" = "y" ] || [ "$RESTART" = "Y" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Restarting Keycloak..." - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - docker compose down - echo "✓ Stopped Keycloak" - - echo "" - echo "Starting Keycloak (this may take a minute)..." - docker compose up -d - - # Wait for Keycloak to be ready - echo "" - echo "Waiting for Keycloak to be ready..." - KC_READY=false - for i in {1..60}; do - if docker exec keycloak curl -sf http://localhost:8080/health/ready > /dev/null 2>&1; then - KC_READY=true - echo "" - echo "✓ Keycloak is ready" - break - fi - echo -n "." - sleep 2 - done - echo "" - - if [ "$KC_READY" = true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "✅ Keycloak successfully updated!" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Changes applied:" - echo " • Deprecated KC_PROXY removed" - echo " • New KC_PROXY_HEADERS=xforwarded configured" - echo " • Configuration stored in .env file" - echo "" - echo "The 'Hostname v1 options [proxy]' warnings should be gone." - echo "" - echo "Check the logs:" - echo " docker compose logs -f keycloak" - echo "" - else - echo "" - echo "⚠ Keycloak may still be starting. Check logs:" - echo " docker compose logs -f keycloak" - fi -else - echo "" - echo "Skipping restart. To apply changes later, run:" - echo " cd $KC_DIR && docker compose restart" -fi - -echo "" -echo "Backup location: $BACKUP_DIR/" -echo " - .env.backup.$TIMESTAMP" -echo " - docker-compose.yml.backup.$TIMESTAMP" -echo "" diff --git a/setup_v0.9.5.sh b/setup_v0.9.5.sh deleted file mode 100755 index ed6fa9b..0000000 --- a/setup_v0.9.5.sh +++ /dev/null @@ -1,209 +0,0 @@ -#!/bin/bash -# setup.sh — modular post-install dispatcher. -# -# One source of truth, multiple ways to run it: -# sudo ./setup.sh guided install: required packages, then a -# category menu you loop through -# sudo ./setup.sh ... install one or more services directly -# ./setup.sh --list list available services (grouped) -# ./setup.sh --version print version -# -# Flags: -# --dry-run preview actions without making changes -# --unattended use defaults, no prompts (pair with explicit service names) -# -# Every service lives in services/.sh, registers itself with -# register_service, and defines install_. Adding a service = adding one -# file; it appears in the menu automatically. Nothing is generated. -set -uo pipefail - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Category display order (groups not listed here are appended alphabetically). -CATEGORY_ORDER=(base homelab utilities media cameras gaming extras backup) -# Service ordering hint within a category (lower = earlier). Default 50. -declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 ) - -# ── Parse flags / collect service names ────────────────────────────────────── -DRY_RUN=false; UNATTENDED=false; DO_LIST=false -REQUESTED=() -for arg in "$@"; do - case "$arg" in - --dry-run) DRY_RUN=true ;; - --unattended) UNATTENDED=true ;; - --list|-l) DO_LIST=true ;; - --version|-V) cat "$HERE/VERSION" 2>/dev/null || echo "unknown"; exit 0 ;; - -h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; - -*) echo "Unknown flag: $arg" >&2; exit 1 ;; - *) REQUESTED+=("$arg") ;; - esac -done -export DRY_RUN UNATTENDED - -# ── Load helpers + all service modules (they self-register) ────────────────── -# shellcheck source=lib/common.sh -source "$HERE/lib/common.sh" -shopt -s nullglob -for _mod in "$HERE"/services/*.sh; do source "$_mod"; done -shopt -u nullglob - -# ── Helpers over the registry ──────────────────────────────────────────────── -# Groups present, in CATEGORY_ORDER first, then any extras alphabetically. -groups_present() { - local g present=() seen=" " - for name in "${SERVICE_ORDER[@]}"; do - g="${SERVICE_GROUP[$name]}" - case "$seen" in *" $g "*) : ;; *) present+=("$g"); seen="$seen$g " ;; esac - done - local out=() - for g in "${CATEGORY_ORDER[@]}"; do - printf '%s\n' "${present[@]}" | grep -qx "$g" && out+=("$g") - done - for g in "${present[@]}"; do - printf '%s\n' "${CATEGORY_ORDER[@]}" | grep -qx "$g" || out+=("$g") - done - printf '%s\n' "${out[@]}" -} - -# Services in a group, ordered by SERVICE_PRIORITY then name. -services_in_group() { - local group="$1" name - for name in "${SERVICE_ORDER[@]}"; do - [ "${SERVICE_GROUP[$name]}" = "$group" ] && echo "${SERVICE_PRIORITY[$name]:-50} $name" - done | sort -n -k1 | awk '{print $2}' -} - -# Best-effort "is it already installed?" for the [installed] marker. -is_installed() { - case "$1" in - base) command -v ncdu >/dev/null 2>&1 ;; - glow) command -v glow >/dev/null 2>&1 ;; - crowdsec) command -v cscli >/dev/null 2>&1 ;; - silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;; - linux-to-sync) [ -d "$ACTUAL_HOME/linux-to-sync/.git" ] ;; - *) [ -e "$DOCKER_DIR/$1" ] ;; - esac -} - -run_service() { - local name="$1" - if [ -z "${SERVICE_GROUP[$name]:-}" ]; then log_error "Unknown service: $name (try --list)"; return 1; fi - declare -F "install_${name}" >/dev/null || { log_error "Service '$name' has no install_${name}"; return 1; } - log_info "=== ${name} (${SERVICE_DESC[$name]}) ===" - "install_${name}" -} - -list_services() { - local g name - while IFS= read -r g; do - echo ""; echo "── ${g^^} ──" - while IFS= read -r name; do - printf " %-16s %s\n" "$name" "${SERVICE_DESC[$name]}" - done < <(services_in_group "$g") - done < <(groups_present) - echo "" -} - -# ── --list ─────────────────────────────────────────────────────────────────── -if [ "$DO_LIST" = true ]; then list_services; exit 0; fi - -# ── Direct install: ./setup.sh caddy homeassistant ────────────────────────── -if [ "${#REQUESTED[@]}" -gt 0 ]; then - require_root - rc=0; for name in "${REQUESTED[@]}"; do run_service "$name" || rc=1; done - exit "$rc" -fi - -# ── Guided interactive flow ────────────────────────────────────────────────── -require_root - -# 1) Show the REQUIRED set and let the user cancel before anything happens. -echo "" -echo "╔══════════════════════════════════════════════════════════════╗" -echo "║ Ubuntu Post-Install · v$(cat "$HERE/VERSION" 2>/dev/null || echo '?')" -echo "╚══════════════════════════════════════════════════════════════╝" -echo "" -echo "REQUIRED (installed/verified first):" -echo " • Essential CLI packages: net-tools, git, curl, wget, htop, tree," -echo " ncdu, zip/unzip, jq, rsync, and glow (markdown reader)" -echo " • Docker presence check (needed by all containerized services)" -echo "" -echo "Then you'll get a category menu to pick optional services." -echo "" -PROCEED="" -prompt_yn "Proceed with the required setup? (y/n):" "y" PROCEED -if [ "$PROCEED" != "y" ] && [ "$PROCEED" != "Y" ]; then - echo "Cancelled. Nothing was changed." - exit 0 -fi - -# 2) Run required. -run_service base -if ! command -v docker >/dev/null 2>&1; then - log_warning "Docker is not installed. Containerized services need it." - echo " Install with: curl -fsSL https://get.docker.com | sh" -fi - -# 3) Offer Caddy first (most services proxy through it). -if [ -n "${SERVICE_GROUP[caddy]:-}" ] && ! is_installed caddy; then - echo "" - OFFER_CADDY="" - prompt_yn "Install Caddy now? It's the reverse proxy most services use. (y/n):" "y" OFFER_CADDY - [ "$OFFER_CADDY" = "y" ] || [ "$OFFER_CADDY" = "Y" ] && run_service caddy -fi - -# 4) Category menu loop: pick a category → checklist → install → back to menu. -have_whiptail=false -command -v whiptail >/dev/null 2>&1 && have_whiptail=true - -while true; do - mapfile -t CATS < <(groups_present) - - if [ "$have_whiptail" = true ]; then - cat_items=() - for g in "${CATS[@]}"; do - n=$(services_in_group "$g" | wc -l) - cat_items+=("$g" "$n service(s)") - done - cat_items+=("DONE" "Finish and exit") - CHOSEN_CAT=$(whiptail --title "Service Categories" --menu \ - "Pick a category (services you install come back here):" 22 70 14 \ - "${cat_items[@]}" 3>&1 1>&2 2>&3) || break - else - echo ""; echo "Categories:"; i=1 - for g in "${CATS[@]}"; do echo " $i) $g"; i=$((i+1)); done - echo " d) Done" - read -rp "Pick a category [d]: " pick - [ "$pick" = "d" ] || [ -z "$pick" ] && break - CHOSEN_CAT="${CATS[$((pick-1))]:-}" - [ -z "$CHOSEN_CAT" ] && { echo "Invalid."; continue; } - fi - [ "$CHOSEN_CAT" = "DONE" ] && break - - mapfile -t SVCS < <(services_in_group "$CHOSEN_CAT") - SELECTED=() - if [ "$have_whiptail" = true ]; then - svc_items=() - for name in "${SVCS[@]}"; do - tag="${SERVICE_DESC[$name]}" - is_installed "$name" && tag="$tag [installed]" - svc_items+=("$name" "$tag" "OFF") - done - CHOICE=$(whiptail --title "${CHOSEN_CAT^^}" --checklist \ - "Space to select, Enter to install. Already-installed are marked:" 22 78 14 \ - "${svc_items[@]}" 3>&1 1>&2 2>&3) || continue - eval "SELECTED=($CHOICE)" - else - echo ""; echo "${CHOSEN_CAT^^}:" - for name in "${SVCS[@]}"; do - m=""; is_installed "$name" && m=" [installed]" - printf " %-16s %s%s\n" "$name" "${SERVICE_DESC[$name]}" "$m" - done - read -rp "Enter service names to install (space-separated, blank to go back): " -a SELECTED - fi - - for name in "${SELECTED[@]}"; do run_service "$name"; done -done - -echo "" -log_success "Done. Re-run 'sudo ./setup.sh' any time to add more." diff --git a/ubuntu-post-install-24.04-crowdsec.sh b/ubuntu-post-install-24.04-crowdsec.sh deleted file mode 100644 index 1741b9e..0000000 --- a/ubuntu-post-install-24.04-crowdsec.sh +++ /dev/null @@ -1,7500 +0,0 @@ -#!/bin/bash - -# Ubuntu 24.04 Post-Installation Script -# Run with: sudo bash post-install.sh -# This script is rerunnable - it detects existing installations - -# ============================================================================ -# COMMAND-LINE ARGUMENT PARSING -# ============================================================================ - -DRY_RUN=false -UNATTENDED=false -LOG_FILE="/var/log/post-install.log" -WHIPTAIL_USED=false - -show_help() { - echo "Ubuntu 24.04 Post-Installation Script" - echo "" - echo "Usage: sudo ./post-install.sh [OPTIONS]" - echo "" - echo "Options:" - echo " --dry-run Preview what would be installed without making changes" - echo " --unattended Run with default options (no prompts)" - echo " Defaults: skip key generation, no SSH imports," - echo " install Docker, skip VPNs/Remote Desktop/Backup" - echo " --restore Disaster recovery mode - restore from backup" - echo " --help Show this help message" - echo "" - echo "Examples:" - echo " sudo ./post-install.sh # Interactive mode" - echo " sudo ./post-install.sh --dry-run # Preview installations" - echo " sudo ./post-install.sh --unattended # Automated install" - echo " sudo ./post-install.sh --restore # Disaster recovery" - echo "" - exit 0 -} - -RESTORE_MODE=false - -for arg in "$@"; do - case $arg in - --dry-run) - DRY_RUN=true - ;; - --unattended) - UNATTENDED=true - ;; - --restore) - RESTORE_MODE=true - ;; - --help|-h) - show_help - ;; - *) - echo "Unknown option: $arg" - echo "Use --help for usage information" - exit 1 - ;; - esac -done - -# ============================================================================ -# LOGGING SETUP -# ============================================================================ - -# Create log file and tee all output -exec > >(tee -a "$LOG_FILE") 2>&1 -echo "" -echo "=== Post-Install Log Started: $(date) ===" >> "$LOG_FILE" - -echo "=== Ubuntu 24.04 Post-Installation Script ===" -echo "" - -if [ "$DRY_RUN" = true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRY RUN MODE - No changes will be made" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" -fi - -if [ "$UNATTENDED" = true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "UNATTENDED MODE - Using default options" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" -fi - -# Check if running as root -if [ "$EUID" -ne 0 ]; then - echo "Please run as root (use sudo)" - exit 1 -fi - -# Get the actual user (not root) -ACTUAL_USER="${SUDO_USER:-$USER}" -ACTUAL_HOME=$(getent passwd "$ACTUAL_USER" | cut -d: -f6) -DOCKER_DIR="$ACTUAL_HOME/docker" - -echo "Note: Script will continue even if individual packages fail to install" -echo "Log file: $LOG_FILE" -echo "" - -# ============================================================================ -# INSTALLATION MODE SELECTOR -# ============================================================================ - -INSTALL_MODE="normal" - -# Disaster recovery function -run_disaster_recovery() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DISASTER RECOVERY MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This will restore your system and Docker apps from a Kopia backup." - echo "" - echo "Requirements:" - echo " • Backup drive connected (with kopia-repo folder)" - echo " • Kopia password (saved in .env or you remember it)" - echo "" - - # Step 1: Install core utilities - echo "Step 1: Installing core utilities" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Installing essential packages..." - apt-get update - apt-get install -y \ - net-tools \ - openssh-server \ - git \ - curl \ - wget \ - htop \ - ncdu \ - tree \ - zip \ - unzip \ - whiptail \ - 2>/dev/null || echo " ⚠ Some packages may have failed" - - # Install Kopia for backup management - echo "Installing Kopia..." - if ! command -v kopia &> /dev/null; then - curl -s https://kopia.io/signing-key | gpg --dearmor -o /usr/share/keyrings/kopia-keyring.gpg 2>/dev/null - echo "deb [signed-by=/usr/share/keyrings/kopia-keyring.gpg] https://packages.kopia.io/apt/ stable main" | tee /etc/apt/sources.list.d/kopia.list - apt-get update - apt-get install -y kopia 2>/dev/null || echo " ⚠ Kopia install failed" - fi - - # Start SSH - systemctl enable ssh 2>/dev/null || true - systemctl start ssh 2>/dev/null || true - echo "✓ Core utilities installed" - echo "" - - # Step 2: Find/mount backup drive - echo "Step 2: Locate backup drive" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Available block devices:" - lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null || lsblk - echo "" - - # Check if drives are already mounted - if [ -d "$ACTUAL_HOME/drives" ]; then - echo "Existing mount points in ~/drives/:" - ls -la "$ACTUAL_HOME/drives/" 2>/dev/null || echo " (none)" - echo "" - fi - - read -p "Enter path to backup drive or Kopia repo (e.g., /dev/sdb1 or ~/drives/backup1): " BACKUP_PATH - - # If it's a device, mount it - if [[ "$BACKUP_PATH" == /dev/* ]]; then - MOUNT_POINT="$ACTUAL_HOME/drives/restore-backup" - mkdir -p "$MOUNT_POINT" 2>/dev/null - echo "Mounting $BACKUP_PATH to $MOUNT_POINT..." - mount "$BACKUP_PATH" "$MOUNT_POINT" || { echo "Failed to mount. Check device path."; return 1; } - BACKUP_PATH="$MOUNT_POINT" - fi - - # Expand ~ to home directory - BACKUP_PATH="${BACKUP_PATH/#\~/$ACTUAL_HOME}" - - # Look for kopia-repo - KOPIA_REPO="" - if [ -d "$BACKUP_PATH/kopia-repo" ]; then - KOPIA_REPO="$BACKUP_PATH/kopia-repo" - elif [ -d "$BACKUP_PATH/kopia.repository" ] || [ -f "$BACKUP_PATH/kopia.repository.f" ]; then - KOPIA_REPO="$BACKUP_PATH" - else - echo "" - echo "Looking for Kopia repository..." - FOUND_REPO=$(find "$BACKUP_PATH" -maxdepth 3 -name "kopia.repository*" -type f 2>/dev/null | head -1) - if [ -n "$FOUND_REPO" ]; then - KOPIA_REPO=$(dirname "$FOUND_REPO") - fi - fi - - if [ -z "$KOPIA_REPO" ] || [ ! -d "$KOPIA_REPO" ]; then - echo "❌ Could not find Kopia repository at $BACKUP_PATH" - echo " Look for a folder containing 'kopia.repository' files" - return 1 - fi - - echo "✓ Found Kopia repository at: $KOPIA_REPO" - echo "" - - # Step 3: Get Kopia password - echo "Step 3: Kopia password" - echo "━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Try to find password in backed-up .env - if [ -f "$BACKUP_PATH/kopia/.env" ]; then - KOPIA_PASS=$(grep "KOPIA_PASSWORD" "$BACKUP_PATH/kopia/.env" 2>/dev/null | cut -d= -f2) - if [ -n "$KOPIA_PASS" ]; then - echo "Found password in backup. Use this? (y/n)" - read -p "> " USE_FOUND_PASS - [ "$USE_FOUND_PASS" != "y" ] && KOPIA_PASS="" - fi - fi - - if [ -z "$KOPIA_PASS" ]; then - read -s -p "Enter Kopia repository password: " KOPIA_PASS - echo "" - fi - - # Step 4: Install Docker if needed - echo "" - echo "Step 4: Ensure Docker is installed" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - if ! command -v docker &> /dev/null; then - echo "Installing Docker..." - apt-get update - apt-get install -y ca-certificates curl gnupg - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg - chmod a+r /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - usermod -aG docker "$ACTUAL_USER" - echo "✓ Docker installed" - else - echo "✓ Docker already installed" - fi - echo "" - - # Step 5: List snapshots and let user choose - echo "Step 5: Select snapshot to restore" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Connecting to repository and listing snapshots..." - echo "" - - # Use docker to run kopia - SNAPSHOT_LIST=$(docker run --rm \ - -v "$KOPIA_REPO:/repository" \ - -e KOPIA_PASSWORD="$KOPIA_PASS" \ - kopia/kopia:latest \ - snapshot list --all 2>&1) - - if echo "$SNAPSHOT_LIST" | grep -q "invalid password"; then - echo "❌ Invalid password" - return 1 - fi - - echo "$SNAPSHOT_LIST" - echo "" - read -p "Enter snapshot ID to restore (or 'latest' for most recent): " SNAPSHOT_ID - - if [ "$SNAPSHOT_ID" = "latest" ]; then - SNAPSHOT_ID=$(echo "$SNAPSHOT_LIST" | grep -oE "^[a-f0-9]+" | tail -1) - echo "Using latest snapshot: $SNAPSHOT_ID" - fi - - # Step 6: Restore to temp location - echo "" - echo "Step 6: Restoring from backup" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - RESTORE_TEMP="$ACTUAL_HOME/docker-restore-temp" - mkdir -p "$RESTORE_TEMP" - - echo "Restoring to $RESTORE_TEMP..." - docker run --rm \ - -v "$KOPIA_REPO:/repository" \ - -v "$RESTORE_TEMP:/restore" \ - -e KOPIA_PASSWORD="$KOPIA_PASS" \ - kopia/kopia:latest \ - restore "$SNAPSHOT_ID" /restore - - if [ $? -ne 0 ]; then - echo "❌ Restore failed" - return 1 - fi - echo "✓ Snapshot restored to temp location" - echo "" - - # Step 7: Detect and select services to restore - echo "Step 7: Select services to restore" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - mkdir -p "$DOCKER_DIR" - ensure_docker_dir_ownership "$DOCKER_DIR" - - # Find all docker-compose.yml files in restored backup - declare -A SERVICE_DIRS - SERVICES_FOUND=() - for compose_file in $(find "$RESTORE_TEMP" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null); do - SERVICE_DIR=$(dirname "$compose_file") - SERVICE_NAME=$(basename "$SERVICE_DIR") - SERVICES_FOUND+=("$SERVICE_NAME") - SERVICE_DIRS["$SERVICE_NAME"]="$SERVICE_DIR" - done - - if [ ${#SERVICES_FOUND[@]} -eq 0 ]; then - echo "No Docker services found in backup" - else - echo "Found ${#SERVICES_FOUND[@]} services in backup." - echo "" - - # Check if whiptail is available for nice UI - if command -v whiptail &> /dev/null; then - # Build whiptail checklist arguments - CHECKLIST_ARGS=() - for svc in "${SERVICES_FOUND[@]}"; do - CHECKLIST_ARGS+=("$svc" "" "ON") - done - - # Show checklist - returns selected items - SELECTED=$(whiptail --title "Select Services to Restore" \ - --checklist "Use SPACE to select/deselect, ENTER to confirm:" \ - 20 60 12 \ - "${CHECKLIST_ARGS[@]}" \ - 3>&1 1>&2 2>&3) - - # Parse selected services (whiptail returns quoted strings) - SELECTED_SERVICES=() - for item in $SELECTED; do - # Remove quotes - svc=$(echo "$item" | tr -d '"') - SELECTED_SERVICES+=("$svc") - done - else - # Fallback: text-based selection - echo "Select services to restore:" - echo " [A] All services" - echo " [N] None (skip restore)" - echo " [S] Select individually" - echo "" - read -p "Choice (A/N/S) [A]: " RESTORE_CHOICE - - case "${RESTORE_CHOICE^^}" in - N) - SELECTED_SERVICES=() - ;; - S) - SELECTED_SERVICES=() - for svc in "${SERVICES_FOUND[@]}"; do - read -p " Restore $svc? (y/n) [y]: " RESTORE_SVC - if [ "$RESTORE_SVC" != "n" ] && [ "$RESTORE_SVC" != "N" ]; then - SELECTED_SERVICES+=("$svc") - fi - done - ;; - *) - SELECTED_SERVICES=("${SERVICES_FOUND[@]}") - ;; - esac - fi - - # Restore selected services - echo "" - if [ ${#SELECTED_SERVICES[@]} -eq 0 ]; then - echo "No services selected for restore." - else - echo "Restoring ${#SELECTED_SERVICES[@]} services..." - echo "" - for svc in "${SELECTED_SERVICES[@]}"; do - SERVICE_DIR="${SERVICE_DIRS[$svc]}" - TARGET_DIR="$DOCKER_DIR/$svc" - - echo " Restoring $svc..." - - # Copy entire service directory - cp -r "$SERVICE_DIR" "$TARGET_DIR" 2>/dev/null || true - - # Fix ownership - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TARGET_DIR" 2>/dev/null || true - - echo " ✓ $svc restored to $TARGET_DIR" - done - fi - fi - - # Step 8: Start services - echo "" - echo "Step 8: Starting services" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Count restored services - RESTORED_COUNT=0 - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - ((RESTORED_COUNT++)) - fi - done - - if [ $RESTORED_COUNT -eq 0 ]; then - echo "No services to start." - else - echo "Start restored services?" - echo " [A] All services" - echo " [S] Select which to start" - echo " [N] None (start manually later)" - echo "" - read -p "Choice (A/S/N) [A]: " START_CHOICE - - case "${START_CHOICE^^}" in - N) - echo "Services not started. Start manually with:" - echo " cd ~/docker/{service} && docker compose up -d" - ;; - S) - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - SERVICE_NAME=$(basename "$dir") - read -p " Start $SERVICE_NAME? (y/n) [y]: " START_SVC - if [ "$START_SVC" != "n" ] && [ "$START_SVC" != "N" ]; then - echo " Starting $SERVICE_NAME..." - (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME" - fi - fi - done - ;; - *) - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - SERVICE_NAME=$(basename "$dir") - echo " Starting $SERVICE_NAME..." - (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME" - fi - done - ;; - esac - fi - - # Cleanup - echo "" - read -p "Remove temporary restore files? (y/n): " CLEANUP - if [ "$CLEANUP" = "y" ]; then - rm -rf "$RESTORE_TEMP" - echo "✓ Cleaned up temp files" - fi - - # Step 9: Reconnect Kopia for future backups - echo "" - echo "Step 9: Setting up future backups" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Reconnecting Kopia to backup repository for future backups..." - - if command -v kopia &> /dev/null && [ -n "$KOPIA_REPO" ]; then - # Reconnect to the repository - if kopia repository connect filesystem --path="$KOPIA_REPO" --password="$KOPIA_PASSWORD" 2>/dev/null; then - echo "✓ Kopia reconnected to repository" - echo " Repository: $KOPIA_REPO" - - # Verify backup scripts exist - if [ -f "$DOCKER_DIR/kopia/backup-containers.sh" ]; then - chmod +x "$DOCKER_DIR/kopia/backup-containers.sh" 2>/dev/null || true - echo "✓ Backup script ready: $DOCKER_DIR/kopia/backup-containers.sh" - else - echo " ⚠ Backup script not found - run normal install to set up" - fi - else - echo " ⚠ Could not reconnect to repository" - echo " Run manually: kopia repository connect filesystem --path=\"$KOPIA_REPO\"" - fi - else - echo " ⚠ Kopia not available or repository not found" - echo " Run the normal install script to set up backups" - fi - echo "" - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DISASTER RECOVERY COMPLETE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Services restored to: $DOCKER_DIR" - echo "" - echo "Backups: $(command -v kopia &>/dev/null && kopia repository status &>/dev/null && echo "✓ Connected" || echo "⚠ Not connected")" - echo " Run backups: $DOCKER_DIR/kopia/backup-containers.sh" - echo "" - echo "To check running containers:" - echo " docker ps" - echo "" - echo "To view logs:" - echo " docker compose logs -f" - echo "" - echo "⚠️ Some services may need manual configuration:" - echo " • Frigate: Edit config/config.yml with camera URLs" - echo " • Caddy: Update Caddyfile with your domain" - echo " • ddclient: Edit config/ddclient.conf with DNS credentials" - echo "" - - return 0 -} - -# Migration function - import existing Docker containers -run_migration() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "MIGRATION MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This will migrate existing Docker containers to this script's structure." - echo "Containers will be copied (not moved) and versions preserved." - echo "" - - # Step 1: Get source Docker directory - echo "Step 1: Locate existing containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Try to auto-detect Docker directories - DETECTED_DIRS=() - - # Check common locations - for check_dir in /var/docker /opt/docker "$HOME/docker" /home/*/docker; do - if [ -d "$check_dir" ] && [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then - DETECTED_DIRS+=("$check_dir") - fi - done - - # Check mounted drives: ~/drives/*, /mnt/*, /media/* - for mount_base in "$HOME/drives" "$HOME_DIR/drives" /mnt /media; do - if [ -d "$mount_base" ]; then - for mount_dir in "$mount_base"/*; do - if [ -d "$mount_dir/docker" ]; then - check_dir="$mount_dir/docker" - if [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then - DETECTED_DIRS+=("$check_dir") - fi - fi - done - fi - done - - if [ ${#DETECTED_DIRS[@]} -gt 0 ]; then - echo "Auto-detected Docker directories:" - for i in "${!DETECTED_DIRS[@]}"; do - dir="${DETECTED_DIRS[$i]}" - count=$(find "$dir" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null | wc -l) - echo " [$((i+1))] $dir ($count containers)" - done - echo "" - echo "Enter a number to select, or type a custom path:" - else - echo "No Docker directories auto-detected." - echo "" - echo "Common locations:" - echo " • ~/docker" - echo " • ~/drives/primary/docker" - echo " • /var/docker" - echo " • /opt/docker" - echo " • /mnt/data/docker" - echo "" - echo "Enter the full path to your Docker directory:" - fi - echo "" - read -p "Source directory: " SOURCE_DOCKER_DIR - - # Check if user entered a number (to select from detected list) - if [[ "$SOURCE_DOCKER_DIR" =~ ^[0-9]+$ ]] && [ ${#DETECTED_DIRS[@]} -gt 0 ]; then - idx=$((SOURCE_DOCKER_DIR - 1)) - if [ $idx -ge 0 ] && [ $idx -lt ${#DETECTED_DIRS[@]} ]; then - SOURCE_DOCKER_DIR="${DETECTED_DIRS[$idx]}" - echo "Selected: $SOURCE_DOCKER_DIR" - fi - fi - - # Expand ~ if used - SOURCE_DOCKER_DIR="${SOURCE_DOCKER_DIR/#\~/$HOME}" - - if [ ! -d "$SOURCE_DOCKER_DIR" ]; then - echo "❌ Directory not found: $SOURCE_DOCKER_DIR" - return 1 - fi - - # Step 2: Scan for containers - echo "" - echo "Step 2: Scanning for containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - CONTAINERS_FOUND=() - CONTAINER_PATHS=() - - while IFS= read -r compose_file; do - if [ -n "$compose_file" ]; then - container_dir=$(dirname "$compose_file") - container_name=$(basename "$container_dir") - CONTAINERS_FOUND+=("$container_name") - CONTAINER_PATHS+=("$container_dir") - fi - done < <(find "$SOURCE_DOCKER_DIR" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null) - - if [ ${#CONTAINERS_FOUND[@]} -eq 0 ]; then - echo "❌ No Docker containers found in $SOURCE_DOCKER_DIR" - return 1 - fi - - echo "Found ${#CONTAINERS_FOUND[@]} container(s):" - for i in "${!CONTAINERS_FOUND[@]}"; do - container="${CONTAINERS_FOUND[$i]}" - path="${CONTAINER_PATHS[$i]}" - size=$(du -sh "$path" 2>/dev/null | cut -f1) - echo " [$((i+1))] $container ($size)" - done - echo "" - - # Step 3: Select containers to migrate - echo "Step 3: Select containers to migrate" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - SELECTED_CONTAINERS=() - - if command -v whiptail &> /dev/null && [ ${#CONTAINERS_FOUND[@]} -gt 3 ]; then - # Use whiptail for many containers - CHECKLIST_ARGS=() - for container in "${CONTAINERS_FOUND[@]}"; do - CHECKLIST_ARGS+=("$container" "" "ON") - done - - SELECTED=$(whiptail --title "Select Containers to Migrate" \ - --checklist "Use SPACE to select/deselect, ENTER to confirm:" \ - 20 60 12 \ - "${CHECKLIST_ARGS[@]}" \ - 3>&1 1>&2 2>&3) - - if [ $? -eq 0 ] && [ -n "$SELECTED" ]; then - # Parse whiptail output - for container in $SELECTED; do - # Remove quotes - container="${container//\"/}" - SELECTED_CONTAINERS+=("$container") - done - fi - else - # Text-based selection - echo "Select containers to migrate:" - echo " [A] All containers" - echo " [S] Select individually" - echo " [N] None (cancel)" - echo "" - read -p "Choice (A/S/N) [A]: " MIGRATE_CHOICE - - case "${MIGRATE_CHOICE^^}" in - N) - echo "Migration cancelled." - return 0 - ;; - S) - for container in "${CONTAINERS_FOUND[@]}"; do - read -p " Migrate $container? (y/n) [y]: " MIGRATE_THIS - if [ "$MIGRATE_THIS" != "n" ] && [ "$MIGRATE_THIS" != "N" ]; then - SELECTED_CONTAINERS+=("$container") - fi - done - ;; - *) - SELECTED_CONTAINERS=("${CONTAINERS_FOUND[@]}") - ;; - esac - fi - - if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then - echo "No containers selected." - return 0 - fi - - echo "" - echo "Will migrate ${#SELECTED_CONTAINERS[@]} container(s):" - for container in "${SELECTED_CONTAINERS[@]}"; do - echo " • $container" - done - echo "" - - # Step 4: Stop running containers (optional) - echo "Step 4: Container status" - echo "━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Should running containers be stopped before migration?" - echo " • Stopping ensures clean copy of data/databases" - echo " • Not stopping may result in inconsistent state" - echo "" - read -p "Stop containers before copying? (y/n) [y]: " STOP_CONTAINERS - - if [ "$STOP_CONTAINERS" != "n" ] && [ "$STOP_CONTAINERS" != "N" ]; then - echo "Stopping containers..." - for container in "${SELECTED_CONTAINERS[@]}"; do - for i in "${!CONTAINERS_FOUND[@]}"; do - if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then - container_dir="${CONTAINER_PATHS[$i]}" - if [ -f "$container_dir/docker-compose.yml" ] || [ -f "$container_dir/compose.yml" ]; then - echo " Stopping $container..." - (cd "$container_dir" && docker compose down 2>/dev/null) || true - fi - fi - done - done - echo "" - fi - - # Step 5: Decide migration method - echo "Step 5: Migration method" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Check if source is on a different drive (mounted drive, not home on OS) - SOURCE_ON_EXTERNAL=false - if [[ "$SOURCE_DOCKER_DIR" == /mnt/* ]] || [[ "$SOURCE_DOCKER_DIR" == /media/* ]] || [[ "$SOURCE_DOCKER_DIR" == */drives/* ]]; then - SOURCE_ON_EXTERNAL=true - fi - - MIGRATION_METHOD="copy" - if [ "$SOURCE_ON_EXTERNAL" = true ]; then - echo "Source is on a mounted drive: $SOURCE_DOCKER_DIR" - echo "" - echo "How would you like to migrate?" - echo "" - echo " [C] Copy - Copy containers to ~/docker on OS drive" - echo " Best for: old OS drive mounted temporarily" - echo "" - echo " [S] Symlink - Create ~/docker as symlink to source location" - echo " Best for: data drive you'll keep using" - echo "" - echo " [U] Use in-place - Use source location directly, no copy" - echo " Best for: already on your data drive" - echo "" - read -p "Method (C/S/U) [C]: " MIGRATION_METHOD - - case "${MIGRATION_METHOD^^}" in - S) - MIGRATION_METHOD="symlink" - ;; - U) - MIGRATION_METHOD="use" - ;; - *) - MIGRATION_METHOD="copy" - ;; - esac - else - echo "Source: $SOURCE_DOCKER_DIR" - echo "Target: $DOCKER_DIR" - echo "" - echo "Will copy containers to $DOCKER_DIR" - fi - - echo "" - - # Handle different migration methods - case "$MIGRATION_METHOD" in - symlink) - echo "Creating symlink: $DOCKER_DIR → $SOURCE_DOCKER_DIR" - echo "" - - # Check if target exists - if [ -e "$DOCKER_DIR" ] || [ -L "$DOCKER_DIR" ]; then - echo " ⚠ $DOCKER_DIR already exists" - read -p " Remove and create symlink? (y/n) [n]: " REMOVE_EXISTING - if [ "$REMOVE_EXISTING" = "y" ] || [ "$REMOVE_EXISTING" = "Y" ]; then - rm -rf "$DOCKER_DIR" - else - echo " Cancelled." - return 1 - fi - fi - - ln -s "$SOURCE_DOCKER_DIR" "$DOCKER_DIR" - chown -h "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - - echo "✓ Symlink created" - echo " Containers stay at: $SOURCE_DOCKER_DIR" - echo " Accessible via: $DOCKER_DIR → $SOURCE_DOCKER_DIR" - MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]} - ;; - - use) - echo "Using source location directly" - echo "" - DOCKER_DIR="$SOURCE_DOCKER_DIR" - echo "✓ DOCKER_DIR set to: $DOCKER_DIR" - echo " No files copied - containers remain in place" - MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]} - ;; - - copy|*) - echo "Copying containers to $DOCKER_DIR" - echo "" - - # Create target directory - mkdir -p "$DOCKER_DIR" - chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - - MIGRATED_COUNT=0 - for container in "${SELECTED_CONTAINERS[@]}"; do - for i in "${!CONTAINERS_FOUND[@]}"; do - if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then - source_dir="${CONTAINER_PATHS[$i]}" - target_dir="$DOCKER_DIR/$container" - - # Check if already exists - if [ -d "$target_dir" ]; then - echo " ⚠ $container already exists at $target_dir" - read -p " Overwrite? (y/n) [n]: " OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Skipping $container" - continue - fi - rm -rf "$target_dir" - fi - - echo " Copying $container..." - cp -a "$source_dir" "$target_dir" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$target_dir" - ((MIGRATED_COUNT++)) - echo " ✓ $container copied" - fi - done - done - ;; - esac - - echo "" - echo "✓ Migrated $MIGRATED_COUNT container(s)" - echo "" - - # Step 6: Update volume paths - echo "Step 6: Update volume paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Scanning docker-compose files for volume mounts that may need updating..." - echo "" - - # Find all absolute paths in volume mounts (excluding relative paths like ./ or named volumes) - PATHS_FOUND=() - PATHS_FILES=() - - for container in "${SELECTED_CONTAINERS[@]}"; do - compose_file="$DOCKER_DIR/$container/docker-compose.yml" - [ -f "$compose_file" ] || compose_file="$DOCKER_DIR/$container/compose.yml" - [ -f "$compose_file" ] || continue - - # Extract volume mount paths (lines with : that look like /path:/container/path) - while IFS= read -r line; do - # Match absolute paths in volume mounts (starting with /) - if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*(/[^:]+): ]]; then - host_path="${BASH_REMATCH[1]}" - # Skip if it's a relative path or standard paths - if [[ "$host_path" != "./"* ]] && [[ "$host_path" != "/etc/"* ]] && [[ "$host_path" != "/var/run/"* ]]; then - # Check if this path doesn't exist on current system - if [ ! -e "$host_path" ]; then - # Avoid duplicates - if [[ ! " ${PATHS_FOUND[*]} " =~ " ${host_path} " ]]; then - PATHS_FOUND+=("$host_path") - PATHS_FILES+=("$compose_file") - fi - fi - fi - fi - done < "$compose_file" - done - - if [ ${#PATHS_FOUND[@]} -eq 0 ]; then - echo "✓ No volume paths need updating (all paths exist or are relative)" - echo "" - else - echo "Found ${#PATHS_FOUND[@]} volume path(s) that don't exist on this system:" - echo "" - - # Default new base path - DEFAULT_NEW_BASE="$HOME_DIR/drives/primary" - - for i in "${!PATHS_FOUND[@]}"; do - old_path="${PATHS_FOUND[$i]}" - compose_file="${PATHS_FILES[$i]}" - container_name=$(basename "$(dirname "$compose_file")") - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Container: $container_name" - echo "Old path: $old_path" - - # Suggest a new path based on the old path structure - # Extract the last part of the path for suggestion - path_tail=$(basename "$old_path") - suggested_path="$DEFAULT_NEW_BASE/$path_tail" - - echo "Suggested: $suggested_path" - echo "" - echo "Options:" - echo " [Enter] Accept suggested path" - echo " [S] Skip - keep original path" - echo " [path] Enter custom path" - echo "" - read -p "New path: " NEW_PATH_INPUT - - case "$NEW_PATH_INPUT" in - ""|" ") - new_path="$suggested_path" - ;; - [Ss]) - echo " Skipping - keeping original path" - continue - ;; - *) - new_path="${NEW_PATH_INPUT/#\~/$HOME_DIR}" - ;; - esac - - # Update the compose file - echo " Updating: $old_path → $new_path" - - # Escape paths for sed (handle slashes) - old_escaped=$(printf '%s\n' "$old_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g') - new_escaped=$(printf '%s\n' "$new_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g') - - sed -i "s|$old_path|$new_path|g" "$compose_file" - - # Create directory if it doesn't exist - if [ ! -d "$new_path" ]; then - echo " Creating directory: $new_path" - mkdir -p "$new_path" 2>/dev/null || echo " ⚠ Could not create (may need manual creation)" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$new_path" 2>/dev/null || true - fi - - echo " ✓ Updated" - echo "" - done - - echo "✓ Volume paths updated" - echo "" - fi - - # Step 7: Start migrated containers (optional) - echo "Step 7: Start containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - read -p "Start migrated containers? (y/n) [y]: " START_MIGRATED - - if [ "$START_MIGRATED" != "n" ] && [ "$START_MIGRATED" != "N" ]; then - for container in "${SELECTED_CONTAINERS[@]}"; do - target_dir="$DOCKER_DIR/$container" - if [ -d "$target_dir" ]; then - echo " Starting $container..." - (cd "$target_dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $container" - fi - done - echo "" - fi - - # Step 8: Offer additional services - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "MIGRATION COMPLETE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Containers migrated to: $DOCKER_DIR" - echo "" - echo "Would you like to install additional services not in your migration?" - echo "This will continue with the normal install process where you can" - echo "choose which additional apps to install (Immich, Frigate, etc.)" - echo "" - read -p "Continue to install additional services? (y/n) [y]: " INSTALL_MORE - - if [ "$INSTALL_MORE" = "n" ] || [ "$INSTALL_MORE" = "N" ]; then - echo "" - echo "Migration complete. You can run this script again to install more services." - echo "" - echo "To check running containers:" - echo " docker ps" - echo "" - return 0 - fi - - # Return to continue with normal install - INSTALL_MODE="post-migration" - return 0 -} - -# Check for restore mode flag -if [ "$RESTORE_MODE" = true ]; then - run_disaster_recovery - exit $? -fi - -# Interactive mode selector (if not unattended) -if [ "$UNATTENDED" != true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "INSTALLATION MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo " [N] Normal install - Fresh install or modify existing" - echo " [M] Migration - Import existing Docker containers" - echo " [R] Disaster recovery - Restore from Kopia backup" - echo "" - read -p "Select mode (N/M/R) [N]: " MODE_SELECT - - case "${MODE_SELECT^^}" in - R) - run_disaster_recovery - exit $? - ;; - M) - run_migration - # If migration returns 0 and INSTALL_MODE is post-migration, continue - if [ $? -ne 0 ]; then - exit 1 - fi - if [ "$INSTALL_MODE" != "post-migration" ]; then - exit 0 - fi - echo "" - echo "Continuing with additional service installation..." - echo "" - ;; - *) - INSTALL_MODE="normal" - ;; - esac - echo "" -fi - -# ============================================================================ -# DRIVE SETUP (Runs before everything else) -# ============================================================================ - -setup_drives() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This script uses ~/drives/ for data storage:" - echo " ~/drives/primary - Main data drive (media, photos, etc.)" - echo " ~/drives/backup1 - Backup drive(s)" - echo "" - - # Create base drives directory - mkdir -p "$ACTUAL_HOME/drives" - chown "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - # Check for existing mounts - if mount | grep -q "$ACTUAL_HOME/drives/"; then - echo "Existing mounted drives:" - df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " (" $2 " total, " $4 " free)"}' - echo "" - prompt_yn "Configure additional drives? (y/n):" "n" SETUP_DRIVES - else - echo "No drives currently mounted in ~/drives/" - echo "" - prompt_yn "Set up drives now? (y/n):" "y" SETUP_DRIVES - fi - - if [ "$SETUP_DRIVES" != "y" ] && [ "$SETUP_DRIVES" != "Y" ]; then - echo "Skipping drive setup. You can run this script again later." - return 0 - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "AVAILABLE BLOCK DEVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - - # Detect drives without partition tables (new drives) - echo "Checking for new/unpartitioned drives..." - NEW_DRIVES=() - for disk in /dev/sd? /dev/nvme?n?; do - [ -b "$disk" ] || continue - # Check if disk has no partitions - if ! lsblk -n "$disk" | grep -q "part"; then - # Check if it has no partition table - if ! blkid "$disk" &>/dev/null; then - size=$(lsblk -n -d -o SIZE "$disk" 2>/dev/null) - NEW_DRIVES+=("$disk ($size)") - fi - fi - done - - if [ ${#NEW_DRIVES[@]} -gt 0 ]; then - echo "" - echo "⚠️ Found unpartitioned drives (no filesystem):" - for drive in "${NEW_DRIVES[@]}"; do - echo " • $drive" - done - echo "" - prompt_yn "Would you like to partition and format any of these? (y/n):" "n" FORMAT_DRIVES - - if [ "$FORMAT_DRIVES" = "y" ] || [ "$FORMAT_DRIVES" = "Y" ]; then - for drive_info in "${NEW_DRIVES[@]}"; do - drive=$(echo "$drive_info" | cut -d' ' -f1) - echo "" - prompt_yn "Format $drive_info as ext4? (ALL DATA WILL BE ERASED) (y/n):" "n" FORMAT_THIS - - if [ "$FORMAT_THIS" = "y" ] || [ "$FORMAT_THIS" = "Y" ]; then - echo " Creating partition table on $drive..." - parted -s "$drive" mklabel gpt - parted -s "$drive" mkpart primary ext4 0% 100% - - # Wait for partition to appear - sleep 2 - - # Format the partition - part="${drive}1" - [ -b "${drive}p1" ] && part="${drive}p1" # nvme drives - - if [ -b "$part" ]; then - echo " Formatting $part as ext4..." - mkfs.ext4 -F "$part" - echo " ✓ Formatted $part" - else - echo " ⚠ Could not find partition after creation" - fi - fi - done - echo "" - echo "Updated block devices:" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - fi - fi - - # Configure mount points - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CONFIGURE MOUNT POINTS" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Primary drive - echo "PRIMARY DRIVE (for media, photos, docker data):" - prompt_text " Mount point name [primary]:" "primary" PRIMARY_NAME - PRIMARY_NAME="${PRIMARY_NAME:-primary}" - mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME" - - # Check if already mounted - if mount | grep -q "$ACTUAL_HOME/drives/$PRIMARY_NAME"; then - echo " ✓ Already mounted" - else - echo " Enter device (e.g., /dev/sdb1) or leave blank to skip:" - read -p " Device for $PRIMARY_NAME: " PRIMARY_DEV - - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - # Add to fstab - PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV" 2>/dev/null) - if [ -n "$PRIMARY_UUID" ]; then - # Check if not already in fstab - if ! grep -q "$PRIMARY_UUID" /etc/fstab 2>/dev/null; then - cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true - echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab - echo " ✓ Added to fstab" - fi - fi - fi - fi - - # Backup drives - echo "" - echo "BACKUP DRIVES (optional, for rsync backups):" - prompt_text " How many backup drives? [0-4, default: 0]:" "0" NUM_BACKUPS - NUM_BACKUPS="${NUM_BACKUPS:-0}" - - # Validate number - case $NUM_BACKUPS in - 0|1|2|3|4) ;; - *) NUM_BACKUPS=0 ;; - esac - - declare -a BACKUP_NAMES - declare -a BACKUP_DEVS - for i in $(seq 1 $NUM_BACKUPS); do - echo "" - prompt_text " Backup drive $i name [backup$i]:" "backup$i" "BACKUP_NAME" - BACKUP_NAMES[$i]="${BACKUP_NAME:-backup$i}" - mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - - if ! mount | grep -q "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"; then - read -p " Device for ${BACKUP_NAMES[$i]}: " "BACKUP_DEV" - BACKUP_DEVS[$i]="$BACKUP_DEV" - - if [ -n "$BACKUP_DEV" ] && [ -b "$BACKUP_DEV" ]; then - BACKUP_UUID=$(blkid -s UUID -o value "$BACKUP_DEV" 2>/dev/null) - if [ -n "$BACKUP_UUID" ]; then - if ! grep -q "$BACKUP_UUID" /etc/fstab 2>/dev/null; then - echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab - echo " ✓ Added ${BACKUP_NAMES[$i]} to fstab" - fi - fi - fi - else - echo " ✓ Already mounted" - fi - done - - # Set permissions - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - # Mount all from fstab - echo "" - echo "Mounting drives from fstab..." - mount -a 2>/dev/null || true - - echo "" - echo "Current mounts in ~/drives/:" - df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " - " $2 " total, " $4 " free"}' || echo " (none)" - echo "" - - # Export drive names for later use - export PRIMARY_DRIVE_NAME="$PRIMARY_NAME" - export BACKUP_DRIVE_COUNT="$NUM_BACKUPS" - for i in $(seq 1 $NUM_BACKUPS); do - export "BACKUP_DRIVE_${i}_NAME=${BACKUP_NAMES[$i]}" - done - - echo "✓ Drive setup complete" - echo "" -} - -# Run drive setup for normal installs (not migration/recovery) -if [ "$INSTALL_MODE" = "normal" ] && [ "$UNATTENDED" != true ]; then - setup_drives -fi - -# ============================================================================ -# SOFTWARE DETECTION FUNCTIONS -# ============================================================================ - -is_docker_installed() { - command -v docker &> /dev/null && systemctl is-active --quiet docker 2>/dev/null -} - -is_samba_installed() { - command -v smbd &> /dev/null && systemctl is-active --quiet smbd 2>/dev/null -} - -is_netbird_installed() { - command -v netbird &> /dev/null -} - -is_rustdesk_installed() { - command -v rustdesk &> /dev/null || dpkg -l rustdesk &> /dev/null -} - -is_rclone_installed() { - command -v rclone &> /dev/null -} - -is_rsync_installed() { - command -v rsync &> /dev/null -} - -is_ufw_installed() { - command -v ufw &> /dev/null -} - -is_wireguard_installed() { - command -v wg &> /dev/null -} - -is_tailscale_installed() { - command -v tailscale &> /dev/null -} - -is_teamviewer_installed() { - command -v teamviewer &> /dev/null || dpkg -l teamviewer &> /dev/null 2>&1 -} - -is_meshcentral_installed() { - # MeshCentral agent is typically installed as meshagent - command -v meshagent &> /dev/null || [ -f /usr/local/mesh_services/meshagent/meshagent ] -} - -# ============================================================================ -# DRY-RUN AND UNATTENDED HELPER FUNCTIONS -# ============================================================================ - -# Run a command, but skip it in dry-run mode -run_cmd() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would execute: $*" - return 0 - else - "$@" - fi -} - -# Ensure Docker directories are owned by the actual user (not root) -# Usage: ensure_docker_dir_ownership /path/to/dir [additional_paths...] -ensure_docker_dir_ownership() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER" - return 0 - fi - - for dir in "$@"; do - if [ -d "$dir" ]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true - fi - done -} - -# Generate a secure random password with alphanumeric characters only (no special chars) -# Usage: generate_password [length] -# Default length: 32 -generate_password() { - local length="${1:-32}" - openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" -} - -# Validate password (alphanumeric only, minimum length) -# Usage: validate_password "password" [min_length] -# Returns 0 if valid, 1 if invalid -validate_password() { - local password="$1" - local min_length="${2:-12}" - - # Check length - if [ ${#password} -lt "$min_length" ]; then - echo " ⚠ Password must be at least $min_length characters long" - return 1 - fi - - # Check for special characters (not allowed) - if echo "$password" | grep -q '[^a-zA-Z0-9]'; then - echo " ⚠ Password must contain only letters and numbers (no special characters)" - return 1 - fi - - return 0 -} - -# Prompt for yes/no, with unattended default -# Usage: prompt_yn "Question?" "default" VARNAME -# default can be "y" or "n" -prompt_yn() { - local question="$1" - local default="$2" - local varname="$3" - - if [ "$UNATTENDED" = true ]; then - eval "$varname='$default'" - echo "$question [auto: $default]" - return - fi - - read -p "$question " response - eval "$varname='$response'" -} - -# Prompt for text input, with unattended default -# Usage: prompt_text "Question?" "default" VARNAME -prompt_text() { - local question="$1" - local default="$2" - local varname="$3" - - if [ "$UNATTENDED" = true ]; then - eval "$varname='$default'" - echo "$question [auto: $default]" - return - fi - - read -p "$question " response - eval "$varname='$response'" -} - -# ============================================================================ -# CADDY CONFIGURATION HELPER -# ============================================================================ -configure_caddy_for_service() { - local SERVICE_NAME="$1" - local SERVICE_PORT="$2" - local DEFAULT_SUBDOMAIN="$3" - local EXTRA_CONFIG="${4:-}" # Optional extra Caddy directives - - # Check if Caddy is installed - if [ ! -d "$DOCKER_DIR/caddy" ]; then - return 0 # Caddy not installed, skip configuration - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " CADDY REVERSE PROXY CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Caddy is installed. You can configure reverse proxy for $SERVICE_NAME." - echo "" - - local CONFIGURE_CADDY="" - 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" - return 0 - fi - - # Get domain/subdomain - echo "" - echo "Enter the full domain for $SERVICE_NAME:" - echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" - echo "" - local SERVICE_DOMAIN="" - prompt_text "Domain:" "" SERVICE_DOMAIN - - if [ -z "$SERVICE_DOMAIN" ]; then - echo " ⚠ No domain provided, skipping Caddy configuration." - return 0 - fi - - # Backup Caddyfile - local CADDY_DIR="$DOCKER_DIR/caddy" - local CADDYFILE="$CADDY_DIR/Caddyfile" - local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - - if [ -f "$CADDYFILE" ]; then - echo " Backing up Caddyfile to: $(basename $BACKUP_FILE)" - cp "$CADDYFILE" "$BACKUP_FILE" - else - echo " Creating new Caddyfile" - touch "$CADDYFILE" - fi - - # Check if service already configured - if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then - echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile" - local OVERWRITE="" - prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Keeping existing configuration." - return 0 - fi - # Remove existing configuration - sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE" - fi - - # Add service configuration to Caddyfile - echo " Adding $SERVICE_NAME configuration to Caddyfile..." - - cat >> "$CADDYFILE" << CADDY_BLOCK - -# $SERVICE_NAME -$SERVICE_DOMAIN { - reverse_proxy localhost:$SERVICE_PORT - - # Security headers - 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" - } - - # Logging for CrowdSec (Caddy JSON access logs) - log { - output file /var/log/caddy/${SERVICE_DOMAIN}.log - format json - } -$EXTRA_CONFIG -} -CADDY_BLOCK - - echo " ✓ Configuration added to Caddyfile" - - # Reload Caddy - echo " Reloading Caddy configuration..." - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddy reloaded successfully" - else - echo " ⚠ Failed to reload Caddy (will retry after formatting)" - fi - - # Format Caddyfile - echo " Formatting Caddyfile..." - if docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddyfile formatted" - else - echo " ⚠ Failed to format Caddyfile" - fi - - # Final reload after formatting - echo " Final reload..." - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddy configuration active" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $SERVICE_NAME is now accessible at:" - echo " https://$SERVICE_DOMAIN" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - else - echo " ⚠ Failed final reload. Check Caddy logs:" - echo " docker logs caddy" - echo " You can restore from backup: $BACKUP_FILE" - fi - - echo "" -} - -# ============================================================================ -# CHECK IF SERVICE EXISTS AND ASK USER ACTION -# ============================================================================ -check_service_exists() { - local SERVICE_NAME="$1" - local SERVICE_DIR="$2" - local RETURN_VAR="$3" # Variable name to set (true/false) - - if [ -f "$SERVICE_DIR/docker-compose.yml" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $SERVICE_NAME is already installed at $SERVICE_DIR" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Options:" - echo " 1. Skip (keep existing configuration)" - echo " 2. Reconfigure (will backup existing config)" - echo " 3. Restart containers only" - echo "" - local ACTION="" - prompt_text "Choose option [1/2/3]:" "1" ACTION - - case "$ACTION" in - 2) - echo " Backing up existing configuration..." - local BACKUP_NAME=$(echo "$SERVICE_NAME" | tr '[:upper:] ' '[:lower:]-') - BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$BACKUP_NAME" - mkdir -p "$BACKUP_DIR" - cp -r "$SERVICE_DIR" "$BACKUP_DIR/" - echo " ✓ Backup saved to: $BACKUP_DIR" - eval "$RETURN_VAR=true" - return 0 - ;; - 3) - echo " Restarting $SERVICE_NAME containers..." - cd "$SERVICE_DIR" - docker compose restart 2>/dev/null && echo " ✓ $SERVICE_NAME restarted" || echo " ⚠ Failed to restart" - eval "$RETURN_VAR=false" - return 0 - ;; - *) - echo " Skipping $SERVICE_NAME (already configured)" - eval "$RETURN_VAR=false" - return 0 - ;; - esac - else - # Service doesn't exist, proceed with installation - eval "$RETURN_VAR=true" - return 0 - fi -} - -# ============================================================================ -# DETECT AVAILABLE DRIVES (RUN ONCE) -# ============================================================================ -detect_drives() { - # Global variables set by this function: - # - DRIVES_DETECTED: true/false - # - PRIMARY_DRIVE: name of first drive (e.g., "storage1") - # - PRIMARY_DRIVE_PATH: full path to first drive - # - DRIVES_DIR: base drives directory - # - AVAILABLE_DRIVES_COUNT: number of drives found - - DRIVES_DIR="$ACTUAL_HOME/drives" - DRIVES_DETECTED=false - PRIMARY_DRIVE="" - PRIMARY_DRIVE_PATH="" - AVAILABLE_DRIVES_COUNT=0 - - if [ -d "$DRIVES_DIR" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " DETECTED DRIVES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - for drive in "$DRIVES_DIR"/*; do - if [ -d "$drive" ]; then - drive_name=$(basename "$drive") - drive_size=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $2}') - drive_used=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $3}') - drive_avail=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $4}') - - echo " ✓ $drive_name" - if [ -n "$drive_size" ]; then - echo " Path: $drive" - echo " Size: $drive_size (Used: $drive_used, Available: $drive_avail)" - else - echo " Path: $drive" - fi - - # Set PRIMARY_DRIVE to the first drive found - if [ -z "$PRIMARY_DRIVE" ]; then - PRIMARY_DRIVE="$drive_name" - PRIMARY_DRIVE_PATH="$drive" - fi - - AVAILABLE_DRIVES_COUNT=$((AVAILABLE_DRIVES_COUNT + 1)) - fi - done - - if [ $AVAILABLE_DRIVES_COUNT -gt 0 ]; then - DRIVES_DETECTED=true - echo "" - echo " Using '$PRIMARY_DRIVE' as primary drive for default paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - fi - fi - - # If no drives detected, use home directory - if [ "$DRIVES_DETECTED" = "false" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " No ~/drives directory found" - echo " Using $ACTUAL_HOME for default paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - PRIMARY_DRIVE_PATH="$ACTUAL_HOME" - fi -} - -# ============================================================================ -# SHOW CURRENT INSTALLATION STATUS -# ============================================================================ -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "CURRENT SYSTEM STATUS" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -if is_docker_installed; then - echo " ✓ Docker: Installed ($(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ','))" -else - echo " ○ Docker: Not installed" -fi -if is_samba_installed; then - echo " ✓ Samba: Installed and running" -else - echo " ○ Samba: Not installed" -fi -if is_netbird_installed; then - echo " ✓ NetBird: Installed" -else - echo " ○ NetBird: Not installed" -fi -if is_rustdesk_installed; then - echo " ✓ RustDesk: Installed" -else - echo " ○ RustDesk: Not installed" -fi -if is_teamviewer_installed; then - echo " ✓ TeamViewer: Installed" -else - echo " ○ TeamViewer: Not installed" -fi -if is_meshcentral_installed; then - echo " ✓ MeshCentral Agent: Installed" -else - echo " ○ MeshCentral Agent: Not installed" -fi -if is_wireguard_installed; then - echo " ✓ WireGuard: Installed" -else - echo " ○ WireGuard: Not installed" -fi -if is_tailscale_installed; then - echo " ✓ Tailscale: Installed" -else - echo " ○ Tailscale: Not installed" -fi -if is_rclone_installed; then - echo " ✓ rclone: Installed" -else - echo " ○ rclone: Not installed" -fi -if is_rsync_installed; then - echo " ✓ rsync: Installed" -else - echo " ○ rsync: Not installed" -fi -if is_ufw_installed; then - if ufw status 2>/dev/null | grep -q "Status: active"; then - echo " ✓ UFW Firewall: Enabled" - else - echo " ○ UFW Firewall: Installed but disabled" - fi -else - echo " ○ UFW Firewall: Not installed" -fi -echo "" -echo "This script can reinstall/reconfigure any component." -echo "" - -# Update package list -echo "Updating package lists..." -run_cmd apt update - -# Install basic utilities -echo "" -echo "Installing basic utilities..." -echo " - net-tools: Network configuration tools (ifconfig, netstat, etc.)" -echo " - ncdu: Disk usage analyzer with ncurses interface" -echo " - git: Version control system" -echo " - curl: Command-line tool for transferring data with URLs" -echo " - wget: Network downloader" -echo " - htop: Interactive process viewer" -echo " - tree: Display directory structure in tree format" -echo " - zip/unzip: Archive compression utilities" -echo " - rclone: Rsync for cloud storage and local drives (backup tool)" -echo "" - -run_cmd apt install -y \ - net-tools \ - ncdu \ - git \ - curl \ - wget \ - htop \ - tree \ - zip \ - unzip \ - rclone || echo "Warning: Some utilities failed to install, continuing..." - -# Install glow (terminal markdown reader) from the Charm apt repo -echo "" -echo "Installing glow (terminal markdown reader)..." -if command -v glow >/dev/null 2>&1; then - echo " ✓ glow already installed" -elif [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow" -else - sudo mkdir -p /etc/apt/keyrings - if curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then - echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null - sudo apt update -y && sudo apt install -y glow && echo " ✓ glow installed" || echo " ⚠ glow install failed" - else - echo " ⚠ Could not fetch Charm signing key - skipping glow" - fi -fi - -# Install OpenSSH Server -echo "" -echo "Installing OpenSSH Server..." -echo " - openssh-server: SSH server for remote access" -echo "" - -apt install -y openssh-server || echo "Warning: OpenSSH server installation failed, continuing..." - -# Start and enable SSH service -systemctl start ssh || echo "Warning: Failed to start SSH" -systemctl enable ssh || echo "Warning: Failed to enable SSH" - -# On rerun, skip SSH configuration if already set up -SKIP_SSH_CONFIG=false -if (systemctl is-active ssh &>/dev/null || systemctl is-active sshd &>/dev/null) && \ - { [ -f "$ACTUAL_HOME/.ssh/authorized_keys" ] && [ -s "$ACTUAL_HOME/.ssh/authorized_keys" ]; } || \ - [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then - echo "" - echo "SSH is already configured on this system." - prompt_yn "Reconfigure SSH keys and authentication? (y/n):" "n" RECONFIG_SSH - if [ "$RECONFIG_SSH" != "y" ] && [ "$RECONFIG_SSH" != "Y" ]; then - SKIP_SSH_CONFIG=true - fi -fi - -if [ "$SKIP_SSH_CONFIG" != true ]; then - -# Generate SSH key for this computer -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SSH KEY GENERATION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -prompt_yn "Generate a new SSH key for this computer? (y/n):" "n" GENERATE_KEY - -if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - echo "" - read -p "Enter a label/comment for the key (e.g., email or hostname) [default: $ACTUAL_USER@$(hostname)]: " KEY_COMMENT - - if [ -z "$KEY_COMMENT" ]; then - KEY_COMMENT="$ACTUAL_USER@$(hostname)" - fi - - # Check if key already exists - if [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then - echo "" - echo "⚠️ WARNING: SSH key already exists at $ACTUAL_HOME/.ssh/id_rsa" - read -p "Overwrite existing key? This cannot be undone! (y/n): " OVERWRITE_KEY - - if [ "$OVERWRITE_KEY" != "y" ] && [ "$OVERWRITE_KEY" != "Y" ]; then - echo "Skipping key generation." - GENERATE_KEY="n" - fi - fi - - if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - echo "" - echo "Generating 4096-bit RSA key pair..." - echo "This may take a moment..." - - # Generate key as the actual user, not root - sudo -u "$ACTUAL_USER" ssh-keygen -t rsa -b 4096 -C "$KEY_COMMENT" -f "$ACTUAL_HOME/.ssh/id_rsa" -N "" - - if [ $? -eq 0 ]; then - echo "" - echo "✓ SSH key generated successfully!" - echo "" - echo "Private key: $ACTUAL_HOME/.ssh/id_rsa (keep this secret!)" - echo "Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Your PUBLIC key (safe to share):" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - cat "$ACTUAL_HOME/.ssh/id_rsa.pub" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To use this key:" - echo " • Add to GitHub: Settings → SSH and GPG keys → New SSH key" - echo " • Add to servers: Copy above key to remote ~/.ssh/authorized_keys" - echo " • View anytime: cat ~/.ssh/id_rsa.pub" - echo "" - else - echo "✗ Failed to generate SSH key" - fi - fi -else - echo "Skipping SSH key generation." -fi - -# Import SSH keys from GitHub/Launchpad -echo "" -echo "You can import SSH public keys from GitHub or Launchpad for easier SSH access." -echo "" -prompt_yn "Import SSH keys from GitHub or Launchpad? (y/n):" "n" IMPORT_SSH_KEYS - -KEYS_IMPORTED=false -GITHUB_USER="" -LAUNCHPAD_USER="" - -if [ "$IMPORT_SSH_KEYS" = "y" ] || [ "$IMPORT_SSH_KEYS" = "Y" ]; then - prompt_text "GitHub username (or leave blank to skip):" "" GITHUB_USER - prompt_text "Launchpad username (or leave blank to skip):" "" LAUNCHPAD_USER -fi - -# Create .ssh directory if it doesn't exist -mkdir -p "$ACTUAL_HOME/.ssh" -touch "$ACTUAL_HOME/.ssh/authorized_keys" -chmod 700 "$ACTUAL_HOME/.ssh" -chmod 600 "$ACTUAL_HOME/.ssh/authorized_keys" - -if [ -n "$GITHUB_USER" ]; then - echo "Importing SSH keys from GitHub user: $GITHUB_USER" - if curl -fsSL "https://github.com/$GITHUB_USER.keys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then - echo "✓ GitHub keys imported successfully" - KEYS_IMPORTED=true - else - echo "✗ Failed to import GitHub keys" - fi -fi - -if [ -n "$LAUNCHPAD_USER" ]; then - echo "Importing SSH keys from Launchpad user: $LAUNCHPAD_USER" - if curl -fsSL "https://launchpad.net/~$LAUNCHPAD_USER/+sshkeys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then - echo "✓ Launchpad keys imported successfully" - KEYS_IMPORTED=true - else - echo "✗ Failed to import Launchpad keys" - fi -fi - -# Fix ownership -chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/.ssh" - -# Disable password authentication if keys were imported -if [ "$KEYS_IMPORTED" = true ]; then - echo "" - echo "SSH keys imported. Disabling password authentication..." - - # Backup sshd_config - cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup - - # Disable password authentication - sed -i 's/^#*PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config - sed -i 's/^#*PasswordAuthentication no/PasswordAuthentication no/' /etc/ssh/sshd_config - - # Ensure these settings are also set - grep -q "^PasswordAuthentication" /etc/ssh/sshd_config || echo "PasswordAuthentication no" >> /etc/ssh/sshd_config - - # Restart SSH service to apply changes - systemctl restart ssh - - echo "✓ Password authentication disabled. SSH key authentication required." - echo "✓ Backup saved to /etc/ssh/sshd_config.backup" -else - echo "" - echo "No SSH keys imported. Password authentication remains enabled." - - # Offer CrowdSec since password auth is still enabled - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CROWDSEC (Recommended with password SSH)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Since password authentication is enabled, CrowdSec is recommended" - echo "to protect against brute-force SSH attacks. CrowdSec is the modern" - echo "successor to fail2ban and also adds geo-blocking and a community" - echo "IP-reputation blocklist." - echo "" - - prompt_yn "Install and enable CrowdSec? (y/n):" "y" INSTALL_CROWDSEC - - if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then - echo "" - echo "Installing CrowdSec..." - - if [ "$DRY_RUN" != true ]; then - # Add the CrowdSec APT repository and install the agent - if ! command -v cscli &> /dev/null; then - curl -s https://install.crowdsec.net | sudo sh - sudo apt install -y crowdsec - else - echo "✓ CrowdSec is already installed" - fi - - # Firewall bouncer enforces decisions via iptables/nftables - sudo apt install -y crowdsec-firewall-bouncer-iptables - - # SSH brute-force + base Linux detection scenarios - sudo cscli collections install crowdsecurity/sshd crowdsecurity/linux 2>/dev/null || true - - run_cmd systemctl enable crowdsec - run_cmd systemctl restart crowdsec - run_cmd systemctl enable crowdsec-firewall-bouncer - run_cmd systemctl restart crowdsec-firewall-bouncer - echo "✓ CrowdSec installed and configured" - echo " - Detects SSH brute-force via /var/log/auth.log" - echo " - View active bans: sudo cscli decisions list" - fi - else - echo "Skipping CrowdSec installation." - fi -fi - -INSTALL_CROWDSEC="${INSTALL_CROWDSEC:-n}" - -fi # End SKIP_SSH_CONFIG - -# Docker Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "DOCKER INSTALLATION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -if is_docker_installed; then - echo "Docker is already installed: $(docker --version 2>/dev/null)" - prompt_yn "Reinstall Docker? (y/n):" "n" INSTALL_DOCKER -else - prompt_yn "Install Docker? (y/n):" "y" INSTALL_DOCKER -fi - -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo "" - echo "Installing Docker prerequisites..." - echo " - ca-certificates: SSL/TLS certificates for secure connections" - echo " - gnupg: GNU Privacy Guard for package verification" - echo " - lsb-release: Provides Ubuntu version information" - echo "" - - run_cmd apt install -y \ - ca-certificates \ - gnupg \ - lsb-release || echo "Warning: Some prerequisites failed to install, continuing..." - - echo "" - echo "Installing Docker..." - echo " - Docker Engine: Container runtime platform" - echo " - Docker Compose: Multi-container application orchestration" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would remove old Docker packages" - echo "[DRY-RUN] Would add Docker GPG key and repository" - echo "[DRY-RUN] Would install docker-ce, docker-ce-cli, containerd.io, plugins" - echo "[DRY-RUN] Would start and enable Docker service" - echo "[DRY-RUN] Would add $SUDO_USER to docker group" - else - # Remove old Docker packages if they exist - apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true - - # Add Docker's official GPG key - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc - chmod a+r /etc/apt/keyrings/docker.asc - - # Add Docker repository - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ - tee /etc/apt/sources.list.d/docker.list > /dev/null - - # Update package list with Docker repo - apt update - - # Install Docker Engine, CLI, containerd, and Docker Compose plugin - apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || echo "Warning: Docker installation failed, continuing..." - - # Start and enable Docker service - systemctl start docker || echo "Warning: Failed to start Docker" - systemctl enable docker || echo "Warning: Failed to enable Docker" - - # Add current user to docker group (if not root) - if [ -n "$SUDO_USER" ]; then - usermod -aG docker "$SUDO_USER" - echo "User $SUDO_USER added to docker group" - fi - - # Verify Docker installation - echo "" - echo "Verifying Docker installation..." - docker --version || echo "Warning: Docker verification failed" - docker compose version || echo "Warning: Docker Compose verification failed" - fi -else - echo "Skipping Docker installation." -fi - -# Samba File Sharing (Optional) -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SAMBA FILE SHARING (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Samba allows you to share folders over the network to Windows, Mac, and Linux." -echo "The script will share your primary drive at ~/drives/primary" -echo "" - -if is_samba_installed; then - echo "Samba is already installed and running." - if grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then - echo " Share 'Primary' is configured at: $ACTUAL_HOME/drives/primary" - fi - echo "" - prompt_yn "Reconfigure Samba? (y/n):" "n" INSTALL_SAMBA -else - prompt_yn "Install and configure Samba file sharing? (y/n):" "n" INSTALL_SAMBA -fi - -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo "" - echo "Installing Samba file sharing..." - echo " - Samba: SMB/CIFS file server for network file sharing" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install samba" - echo "[DRY-RUN] Would configure [Primary] share at $ACTUAL_HOME/drives/primary" - echo "[DRY-RUN] Would prompt for Samba password" - echo "[DRY-RUN] Would enable and start smbd/nmbd services" - else - run_cmd apt install -y samba || echo "Warning: Samba installation failed, continuing..." - - # Configure Samba share for primary drive - if command -v smbd &> /dev/null; then - echo "" - echo "Configuring Samba share for primary drive..." - - # Backup existing config - cp /etc/samba/smb.conf /etc/samba/smb.conf.backup-$(date +%Y%m%d-%H%M%S) - - # Add Primary share configuration - if ! grep -q "\[Primary\]" /etc/samba/smb.conf; then - cat >> /etc/samba/smb.conf << SAMBA_CONFIG - -# Primary drive share - added by post-install script -[Primary] - comment = Primary Drive - path = $ACTUAL_HOME/drives/primary - browseable = yes - read only = no - writable = yes - valid users = $ACTUAL_USER - create mask = 0775 - directory mask = 0775 -SAMBA_CONFIG - echo "✓ Added [Primary] share to Samba configuration" - else - echo "Samba [Primary] share already configured, skipping..." - fi - - # Add Samba user (skip in unattended mode - user must set password manually) - if [ "$UNATTENDED" != true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "SAMBA PASSWORD SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Set a password for Samba file sharing access." - echo "Tip: Using the same password as your system login is convenient." - echo "" - smbpasswd -a "$ACTUAL_USER" - else - echo "Skipping Samba password setup (unattended mode)" - echo "Set password later with: sudo smbpasswd -a $ACTUAL_USER" - fi - - # Enable and restart Samba services - systemctl enable smbd nmbd || echo "Warning: Failed to enable Samba services" - systemctl restart smbd nmbd || echo "Warning: Failed to restart Samba services" - - echo "" - echo "✓ Samba configured successfully" - echo " Share name: Primary" - echo " Path: $ACTUAL_HOME/drives/primary" - echo " Access: \\\\$(hostname)\\Primary (Windows) or smb://$(hostname)/Primary (Mac/Linux)" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "ADDING MORE SAMBA SHARES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To add another share, edit the Samba config file:" - echo "" - echo " sudo nano /etc/samba/smb.conf" - echo "" - echo "Add a new section at the end:" - echo "" - echo " [ShareName]" - echo " comment = Description of share" - echo " path = /path/to/folder" - echo " browseable = yes" - echo " read only = no" - echo " writable = yes" - echo " valid users = $ACTUAL_USER" - echo " create mask = 0775" - echo " directory mask = 0775" - echo "" - echo "Save (Ctrl+O, Enter) and exit (Ctrl+X), then restart Samba:" - echo "" - echo " sudo systemctl restart smbd nmbd" - echo "" - echo "Verify the share is active:" - echo "" - echo " testparm -s" - echo "" - else - echo "✗ Samba installation failed, skipping configuration" - fi - fi -else - echo "Skipping Samba installation." -fi - -# On rerun, skip VPN configuration if already set up -SKIP_VPN_CONFIG=false -if is_netbird_installed || is_wireguard_installed || is_tailscale_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "VPN SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "VPN services already configured:" - is_netbird_installed && echo " ✓ NetBird" - is_wireguard_installed && echo " ✓ WireGuard" - is_tailscale_installed && echo " ✓ Tailscale" - echo "" - prompt_yn "Reconfigure VPN services? (y/n):" "n" RECONFIG_VPN - if [ "$RECONFIG_VPN" != "y" ] && [ "$RECONFIG_VPN" != "Y" ]; then - SKIP_VPN_CONFIG=true - fi -fi - -if [ "$SKIP_VPN_CONFIG" != true ]; then - -# NetBird Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "NETBIRD MESH VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "NetBird is a secure mesh VPN for connecting devices across networks." -echo "" - -if is_netbird_installed; then - echo "NetBird is already installed." - netbird status 2>/dev/null || true - echo "" - prompt_yn "Reinstall NetBird? (y/n):" "n" INSTALL_NETBIRD -else - prompt_yn "Install NetBird? (y/n):" "n" INSTALL_NETBIRD -fi - -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo "" - echo "Installing NetBird..." - echo " - NetBird: Secure mesh VPN for connecting devices" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download and run NetBird install script" - echo "[DRY-RUN] Would ensure openssh-server is installed (required for NetBird SSH)" - echo "[DRY-RUN] Would configure netbird systemd service with --allow-server-ssh" - else - # NetBird v0.60.0+ requires openssh-server for SSH access. - # It injects /etc/ssh/sshd_config.d/99-netbird.conf and listens on port 22022. - echo "Ensuring openssh-server is installed (required for NetBird SSH)..." - apt install -y openssh-server 2>/dev/null || echo "Warning: openssh-server install failed, continuing..." - systemctl enable ssh 2>/dev/null || true - systemctl start ssh 2>/dev/null || true - - curl -fsSL https://pkgs.netbird.io/install.sh | sh || echo "Warning: NetBird installation failed, continuing..." - - # Persist --allow-server-ssh so this machine accepts NetBird SSH connections - # without requiring interactive re-authentication on every connection. - echo "Configuring NetBird to allow SSH server (persistent across reboots)..." - mkdir -p /etc/systemd/system/netbird.service.d - cat > /etc/systemd/system/netbird.service.d/ssh-server.conf << 'NETBIRD_OVERRIDE' -[Service] -ExecStart= -ExecStart=/usr/bin/netbird service run --allow-server-ssh -NETBIRD_OVERRIDE - systemctl daemon-reload 2>/dev/null || true - echo " ✓ NetBird will start with --allow-server-ssh on every boot" - - echo "" - echo "NetBird installed. Setup instructions:" - echo " 1. Create account at https://app.netbird.io (or self-host)" - echo " 2. Run 'netbird up' and authenticate via browser" - echo "" - echo "For NetBird SSH functionality (v0.60.0+ method):" - echo " • openssh-server is installed and --allow-server-ssh is persisted" - echo " • Enable SSH per-peer in the NetBird dashboard (Peers > [peer] > SSH)" - echo " • NetBird injects /etc/ssh/sshd_config.d/99-netbird.conf automatically" - echo " • Connect from another NetBird peer: ssh user@" - echo " • Get peer IPs with: netbird status" - echo " • SSH will work without re-authenticating each connection" - echo "" - fi -else - echo "Skipping NetBird installation." -fi - -# WireGuard Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "WIREGUARD VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "WireGuard is a fast, modern VPN protocol." -echo " • Lightweight and high-performance" -echo " • Simple configuration via config files" -echo " • Built into Linux kernel" -echo "" - -if is_wireguard_installed; then - echo "WireGuard is already installed." - echo "" - prompt_yn "Reinstall WireGuard? (y/n):" "n" INSTALL_WIREGUARD -else - prompt_yn "Install WireGuard? (y/n):" "n" INSTALL_WIREGUARD -fi - -if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then - echo "" - echo "Installing WireGuard..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install wireguard wireguard-tools" - else - apt install -y wireguard wireguard-tools || echo "Warning: WireGuard installation failed, continuing..." - - echo "" - echo "WireGuard installed. Setup instructions:" - echo "" - echo "Generate keys:" - echo " wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey" - echo "" - echo "Create config at /etc/wireguard/wg0.conf:" - echo " [Interface]" - echo " PrivateKey = " - echo " Address = 10.0.0.1/24" - echo " ListenPort = 51820" - echo "" - echo " [Peer]" - echo " PublicKey = " - echo " AllowedIPs = 10.0.0.2/32" - echo " Endpoint = peer.example.com:51820" - echo "" - echo "Start WireGuard:" - echo " sudo wg-quick up wg0" - echo " sudo systemctl enable wg-quick@wg0 # Start on boot" - echo "" - fi -else - echo "Skipping WireGuard installation." -fi - -# Tailscale Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "TAILSCALE VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Tailscale is a zero-config mesh VPN built on WireGuard." -echo " • Easy setup - just sign in" -echo " • Built on WireGuard for performance" -echo " • Automatic NAT traversal" -echo " • Built-in SSH (Tailscale SSH)" -echo "" - -if is_tailscale_installed; then - echo "Tailscale is already installed." - tailscale status 2>/dev/null || true - echo "" - prompt_yn "Reinstall Tailscale? (y/n):" "n" INSTALL_TAILSCALE -else - prompt_yn "Install Tailscale? (y/n):" "n" INSTALL_TAILSCALE -fi - -if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then - echo "" - echo "Installing Tailscale..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would add Tailscale apt repository" - echo "[DRY-RUN] Would install tailscale" - else - # Add Tailscale's package signing key and repository - curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null - curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list - - apt update - apt install -y tailscale || echo "Warning: Tailscale installation failed, continuing..." - - echo "" - echo "Tailscale installed. Setup instructions:" - echo "" - echo "Connect to Tailscale network:" - echo " sudo tailscale up" - echo "" - echo "This opens a browser to authenticate. After that:" - echo " tailscale status # View connected devices" - echo " tailscale ip # Show your Tailscale IP" - echo "" - echo "Tailscale SSH (optional - enable in admin console):" - echo " • Enable 'SSH' in Tailscale admin console for this machine" - echo " • Connect with: ssh user@device-name (uses Tailscale identity)" - echo " • No SSH keys needed - Tailscale handles authentication" - echo "" - fi -else - echo "Skipping Tailscale installation." -fi - -fi # End SKIP_VPN_CONFIG - -# On rerun, skip remote desktop configuration if already set up -SKIP_RD_CONFIG=false -if is_rustdesk_installed || is_teamviewer_installed || is_meshcentral_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "REMOTE DESKTOP SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Remote desktop services already configured:" - is_rustdesk_installed && echo " ✓ RustDesk" - is_teamviewer_installed && echo " ✓ TeamViewer" - is_meshcentral_installed && echo " ✓ MeshCentral Agent" - echo "" - prompt_yn "Reconfigure remote desktop services? (y/n):" "n" RECONFIG_RD - if [ "$RECONFIG_RD" != "y" ] && [ "$RECONFIG_RD" != "Y" ]; then - SKIP_RD_CONFIG=true - fi -fi - -if [ "$SKIP_RD_CONFIG" != true ]; then - -# RustDesk Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "RUSTDESK REMOTE DESKTOP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "RustDesk is an open-source remote desktop software." -echo "" - -if is_rustdesk_installed; then - echo "RustDesk is already installed." - echo "" - prompt_yn "Reinstall RustDesk? (y/n):" "n" INSTALL_RUSTDESK -else - prompt_yn "Install RustDesk? (y/n):" "n" INSTALL_RUSTDESK -fi - -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo "" - echo "Installing RustDesk..." - echo " - RustDesk: Open-source remote desktop software" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download latest RustDesk from GitHub" - echo "[DRY-RUN] Would install rustdesk .deb package" - else - # Download latest RustDesk .deb package - RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")') - RUSTDESK_URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/rustdesk-${RUSTDESK_VERSION}-x86_64.deb" - - wget -O /tmp/rustdesk.deb "$RUSTDESK_URL" || echo "Warning: RustDesk download failed, continuing..." - - if [ -f /tmp/rustdesk.deb ]; then - apt install -y /tmp/rustdesk.deb || echo "Warning: RustDesk installation failed, continuing..." - rm /tmp/rustdesk.deb - echo "✓ RustDesk installed" - fi - fi -else - echo "Skipping RustDesk installation." -fi - -# TeamViewer Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "TEAMVIEWER REMOTE DESKTOP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "TeamViewer is commercial remote desktop software with a free tier." -echo " • Cross-platform (Windows, Mac, Linux, mobile)" -echo " • Easy to use - no port forwarding needed" -echo " • Requires TeamViewer account for unattended access" -echo "" - -if is_teamviewer_installed; then - echo "TeamViewer is already installed." - echo "" - prompt_yn "Reinstall TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER -else - prompt_yn "Install TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER -fi - -if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then - echo "" - echo "Installing TeamViewer..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download TeamViewer .deb package" - echo "[DRY-RUN] Would install teamviewer" - else - # Download TeamViewer .deb package - wget -O /tmp/teamviewer.deb "https://download.teamviewer.com/download/linux/teamviewer_amd64.deb" || echo "Warning: TeamViewer download failed, continuing..." - - if [ -f /tmp/teamviewer.deb ]; then - apt install -y /tmp/teamviewer.deb || echo "Warning: TeamViewer installation failed, continuing..." - rm /tmp/teamviewer.deb - - echo "" - echo "TeamViewer installed. Setup instructions:" - echo "" - echo "Start TeamViewer:" - echo " teamviewer" - echo "" - echo "For unattended access:" - echo " 1. Open TeamViewer" - echo " 2. Go to Extras → Options → Security" - echo " 3. Set a personal password for unattended access" - echo " 4. Note your TeamViewer ID (shown in main window)" - echo "" - echo "✓ TeamViewer installed" - fi - fi -else - echo "Skipping TeamViewer installation." -fi - -# MeshCentral Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "MESHCENTRAL AGENT (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "MeshCentral is an open-source remote management solution." -echo " • Self-hosted or use public servers" -echo " • Web-based remote desktop (no client software needed)" -echo " • Terminal, file transfer, and remote desktop" -echo " • Requires a MeshCentral server to connect to" -echo "" - -if is_meshcentral_installed; then - echo "MeshCentral Agent is already installed." - echo "" - prompt_yn "Reinstall MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL -else - prompt_yn "Install MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL -fi - -if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then - echo "" - echo "MeshCentral requires a server URL to connect to." - echo "" - echo "If you have a MeshCentral server, the agent install is typically done by:" - echo " 1. Log into your MeshCentral web interface" - echo " 2. Go to 'My Devices' → 'Add Agent'" - echo " 3. Download and run the Linux agent installer" - echo "" - echo "Example (replace with your server's URL):" - echo " wget -O meshagent https://your-meshcentral-server/meshagents?id=XXXXX" - echo " chmod +x meshagent" - echo " sudo ./meshagent -install" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would prompt for MeshCentral server URL" - echo "[DRY-RUN] Would download and install meshagent" - else - prompt_text "Enter MeshCentral agent URL (or leave blank to skip):" "" MESHCENTRAL_URL - - if [ -n "$MESHCENTRAL_URL" ]; then - wget -O /tmp/meshagent "$MESHCENTRAL_URL" || echo "Warning: MeshCentral agent download failed" - - if [ -f /tmp/meshagent ]; then - chmod +x /tmp/meshagent - /tmp/meshagent -install || echo "Warning: MeshCentral agent installation failed" - rm /tmp/meshagent - echo "" - echo "✓ MeshCentral Agent installed" - echo " Check your MeshCentral server - this device should appear shortly." - fi - else - echo "" - echo "No URL provided. Skipping MeshCentral agent installation." - echo "You can install later by downloading the agent from your MeshCentral server." - fi - fi -else - echo "Skipping MeshCentral installation." -fi - -fi # End SKIP_RD_CONFIG - -# ============================================================================ -# SELF-HOSTED DOCKER APPLICATIONS (Optional) -# ============================================================================ -# These applications run in Docker containers using docker-compose -# Each app is installed to ~/docker/{appname}/ - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SELF-HOSTED DOCKER APPLICATIONS (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Install self-hosted applications using Docker Compose." -echo "Each app will be installed to ~/docker/{appname}/" -echo "" -echo "Note: Docker must be installed for these applications." -echo "" - -if ! is_docker_installed && [ "$INSTALL_DOCKER" != "y" ] && [ "$INSTALL_DOCKER" != "Y" ]; then - echo "Docker is not installed. Skipping self-hosted applications." - echo "Install Docker first, then rerun this script." -else - # Create docker apps directory - DOCKER_DIR="$ACTUAL_HOME/docker" - if [ "$DRY_RUN" != true ]; then - mkdir -p "$DOCKER_DIR" - chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - fi - - # ============================================================================ - # DETECT DRIVES (ONCE FOR ALL SERVICES) - # ============================================================================ - detect_drives - - # ============================================================================ - # SERVICE SELECTION MENU - # ============================================================================ - - # Use whiptail for service selection if available - if command -v whiptail &> /dev/null; then - # Detect existing services - echo "Detecting existing services..." - declare -A EXISTING_SERVICES - [ -d "$DOCKER_DIR/immich" ] && EXISTING_SERVICES[IMMICH]="ON" - [ -d "$DOCKER_DIR/audiobookshelf" ] && EXISTING_SERVICES[AUDIOBOOKSHELF]="ON" - [ -d "$DOCKER_DIR/emby" ] && EXISTING_SERVICES[EMBY]="ON" - [ -d "$DOCKER_DIR/arm" ] && EXISTING_SERVICES[ARM]="ON" - [ -d "$DOCKER_DIR/filebrowser" ] && EXISTING_SERVICES[FILEBROWSER]="ON" - [ -d "$DOCKER_DIR/magicmirror" ] && EXISTING_SERVICES[MAGICMIRROR]="ON" - [ -d "$DOCKER_DIR/actualbudget" ] && EXISTING_SERVICES[ACTUALBUDGET]="ON" - [ -d "$DOCKER_DIR/authelia" ] && EXISTING_SERVICES[AUTHELIA]="ON" - [ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON" - [ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON" - [ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON" - [ -d "$DOCKER_DIR/homeassistant" ] && EXISTING_SERVICES[HOMEASSISTANT]="ON" - [ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON" - [ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON" - [ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON" - [ -d "$DOCKER_DIR/ntfy" ] && EXISTING_SERVICES[NTFY]="ON" - [ -d "$DOCKER_DIR/uptime-kuma" ] && EXISTING_SERVICES[UPTIMEKUMA]="ON" - [ -d "$DOCKER_DIR/wg-easy" ] && EXISTING_SERVICES[WGEASY]="ON" - [ -d "$DOCKER_DIR/traccar" ] && EXISTING_SERVICES[TRACCAR]="ON" - [ -d "$DOCKER_DIR/portainer" ] && EXISTING_SERVICES[PORTAINER]="ON" - [ -d "$DOCKER_DIR/meshcentral" ] && EXISTING_SERVICES[MESHCENTRAL]="ON" - [ -d "$DOCKER_DIR/fmd" ] && EXISTING_SERVICES[FINDMYDEVICE]="ON" - [ -d "$DOCKER_DIR/frigate-notify" ] && EXISTING_SERVICES[FRIGATE_NOTIFY]="ON" - [ -d "$DOCKER_DIR/watchtower" ] && EXISTING_SERVICES[WATCHTOWER]="ON" - command -v cscli &> /dev/null && EXISTING_SERVICES[CROWDSEC]="ON" - - # Ask user what action to perform - ACTION=$(whiptail --title "Service Management" --menu \ - "Choose an action:" 15 60 3 \ - "1" "Install new services" \ - "2" "Uninstall existing services" \ - "3" "Cancel and skip" \ - 3>&1 1>&2 2>&3) - - case "$ACTION" in - 1) - # INSTALL MODE - MENU_MODE="INSTALL" - # Build checklist - mark existing services as ON - SELECTED_SERVICES=$(whiptail --title "Select Docker Services to Install" \ - --checklist "Use SPACE to select, ENTER to confirm. [*] = already installed" 25 78 17 \ - "IMMICH" "Photo & video backup (like Google Photos)" ${EXISTING_SERVICES[IMMICH]:-OFF} \ - "AUDIOBOOKSHELF" "Audiobook & podcast server" ${EXISTING_SERVICES[AUDIOBOOKSHELF]:-OFF} \ - "EMBY" "Media server for movies, TV, music" ${EXISTING_SERVICES[EMBY]:-OFF} \ - "ARM" "Automatic Ripping Machine for DVDs/Blu-rays" ${EXISTING_SERVICES[ARM]:-OFF} \ - "FILEBROWSER" "Web-based file manager" ${EXISTING_SERVICES[FILEBROWSER]:-OFF} \ - "MAGICMIRROR" "Smart mirror / dashboard display" ${EXISTING_SERVICES[MAGICMIRROR]:-OFF} \ - "ACTUALBUDGET" "Personal finance management with bank sync" ${EXISTING_SERVICES[ACTUALBUDGET]:-OFF} \ - "AUTHELIA" "SSO + 2FA auth portal for Caddy" ${EXISTING_SERVICES[AUTHELIA]:-OFF} \ - "CADDY" "Reverse proxy with automatic HTTPS" ${EXISTING_SERVICES[CADDY]:-OFF} \ - "CROWDSEC" "Intrusion prevention (CrowdSec: bans + geo + reputation)" ${EXISTING_SERVICES[CROWDSEC]:-OFF} \ - "LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \ - "MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \ - "HOMEASSISTANT" "Home automation hub (Home Assistant)" ${EXISTING_SERVICES[HOMEASSISTANT]:-OFF} \ - "MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \ - "JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \ - "FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \ - "NTFY" "Push notifications server" ${EXISTING_SERVICES[NTFY]:-OFF} \ - "UPTIMEKUMA" "Service monitoring dashboard" ${EXISTING_SERVICES[UPTIMEKUMA]:-OFF} \ - "WGEASY" "WireGuard VPN with web UI" ${EXISTING_SERVICES[WGEASY]:-OFF} \ - "TRACCAR" "GPS tracking server" ${EXISTING_SERVICES[TRACCAR]:-OFF} \ - "PORTAINER" "Docker management web UI" ${EXISTING_SERVICES[PORTAINER]:-OFF} \ - "MESHCENTRAL" "Remote management server" ${EXISTING_SERVICES[MESHCENTRAL]:-OFF} \ - "FINDMYDEVICE" "Device tracking (like Find My)" ${EXISTING_SERVICES[FINDMYDEVICE]:-OFF} \ - "FRIGATE_NOTIFY" "Push notifications for Frigate" ${EXISTING_SERVICES[FRIGATE_NOTIFY]:-OFF} \ - "WATCHTOWER" "Automatic container updates" ${EXISTING_SERVICES[WATCHTOWER]:-OFF} \ - 3>&1 1>&2 2>&3) - ;; - 2) - # UNINSTALL MODE - MENU_MODE="UNINSTALL" - # Only show services that exist - UNINSTALL_OPTIONS="" - [ -n "${EXISTING_SERVICES[IMMICH]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS IMMICH \"Photo & video backup\" ON" - [ -n "${EXISTING_SERVICES[AUDIOBOOKSHELF]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUDIOBOOKSHELF \"Audiobook server\" ON" - [ -n "${EXISTING_SERVICES[EMBY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS EMBY \"Media server\" ON" - [ -n "${EXISTING_SERVICES[ARM]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ARM \"Automatic Ripping Machine\" ON" - [ -n "${EXISTING_SERVICES[FILEBROWSER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FILEBROWSER \"Web file manager\" ON" - [ -n "${EXISTING_SERVICES[MAGICMIRROR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MAGICMIRROR \"Smart mirror\" ON" - [ -n "${EXISTING_SERVICES[ACTUALBUDGET]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ACTUALBUDGET \"Personal finance\" ON" - [ -n "${EXISTING_SERVICES[AUTHELIA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUTHELIA \"SSO + 2FA auth portal\" ON" - [ -n "${EXISTING_SERVICES[CADDY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CADDY \"Reverse proxy\" ON" - [ -n "${EXISTING_SERVICES[CROWDSEC]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CROWDSEC \"Intrusion prevention\" ON" - [ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON" - [ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON" - [ -n "${EXISTING_SERVICES[HOMEASSISTANT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS HOMEASSISTANT \"Home automation\" ON" - [ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON" - [ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON" - [ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON" - [ -n "${EXISTING_SERVICES[NTFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS NTFY \"Push notifications\" ON" - [ -n "${EXISTING_SERVICES[UPTIMEKUMA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS UPTIMEKUMA \"Service monitoring\" ON" - [ -n "${EXISTING_SERVICES[WGEASY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WGEASY \"WireGuard VPN\" ON" - [ -n "${EXISTING_SERVICES[TRACCAR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS TRACCAR \"GPS tracking\" ON" - [ -n "${EXISTING_SERVICES[PORTAINER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS PORTAINER \"Docker management\" ON" - [ -n "${EXISTING_SERVICES[MESHCENTRAL]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MESHCENTRAL \"Remote management\" ON" - [ -n "${EXISTING_SERVICES[FINDMYDEVICE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FINDMYDEVICE \"Device tracking\" ON" - [ -n "${EXISTING_SERVICES[FRIGATE_NOTIFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE_NOTIFY \"Frigate notifications\" ON" - [ -n "${EXISTING_SERVICES[WATCHTOWER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WATCHTOWER \"Container updates\" ON" - - if [ -z "$UNINSTALL_OPTIONS" ]; then - whiptail --title "No Services Found" --msgbox "No services detected to uninstall." 8 50 - SELECTED_SERVICES="" - else - SELECTED_SERVICES=$(eval "whiptail --title 'Select Services to Uninstall' \ - --checklist 'WARNING: Data will be backed up but services will be removed' 25 78 17 \ - $UNINSTALL_OPTIONS \ - 3>&1 1>&2 2>&3") - fi - ;; - 3|*) - # Cancel - SELECTED_SERVICES="" - MENU_MODE="CANCEL" - ;; - esac - - # Check if user cancelled - if [ $? -ne 0 ]; then - echo "Service selection cancelled. Skipping Docker applications." - SELECTED_SERVICES="" - fi - - # Parse selections (whiptail returns quoted strings) - # Only set to "n" if not already set (preserve any earlier choices) - : ${INSTALL_IMMICH:="n"} - : ${INSTALL_AUDIOBOOKSHELF:="n"} - : ${INSTALL_EMBY:="n"} - : ${INSTALL_ARM:="n"} - : ${INSTALL_FILEBROWSER:="n"} - : ${INSTALL_MAGICMIRROR:="n"} - : ${INSTALL_ACTUALBUDGET:="n"} - : ${INSTALL_AUTHELIA:="n"} - : ${INSTALL_CADDY:="n"} - : ${INSTALL_CROWDSEC:="n"} - : ${INSTALL_LMS:="n"} - : ${INSTALL_MEALIE:="n"} - : ${INSTALL_HOMEASSISTANT:="n"} - : ${INSTALL_MINECRAFT:="n"} - : ${INSTALL_JELLYFIN:="n"} - : ${INSTALL_FRIGATE:="n"} - : ${INSTALL_NTFY:="n"} - : ${INSTALL_UPTIMEKUMA:="n"} - : ${INSTALL_WGEASY:="n"} - : ${INSTALL_TRACCAR:="n"} - : ${INSTALL_PORTAINER:="n"} - : ${INSTALL_MESHCENTRAL_SERVER:="n"} - : ${INSTALL_FMD:="n"} - : ${INSTALL_FRIGATE_NOTIFY:="n"} - : ${INSTALL_WATCHTOWER:="n"} - - # Set installation flags based on selections - if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then INSTALL_IMMICH="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then INSTALL_AUDIOBOOKSHELF="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then INSTALL_EMBY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "ARM"; then INSTALL_ARM="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then INSTALL_FILEBROWSER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then INSTALL_MAGICMIRROR="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then INSTALL_ACTUALBUDGET="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUTHELIA"; then INSTALL_AUTHELIA="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then INSTALL_CADDY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then INSTALL_CROWDSEC="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then INSTALL_HOMEASSISTANT="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then INSTALL_NTFY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then INSTALL_UPTIMEKUMA="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then INSTALL_WGEASY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then INSTALL_TRACCAR="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then INSTALL_PORTAINER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then INSTALL_MESHCENTRAL_SERVER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then INSTALL_FMD="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then INSTALL_FRIGATE_NOTIFY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then INSTALL_WATCHTOWER="y"; fi - - if [ "$MENU_MODE" = "UNINSTALL" ]; then - echo "" - echo "Services selected for uninstallation:" - echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /' - echo "" - - # Process uninstalls - if [ -n "$SELECTED_SERVICES" ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " UNINSTALLING SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Define uninstall function - uninstall_service() { - local SERVICE_NAME=$1 - local SERVICE_DIR=$2 - local CONTAINER_NAME=$3 - - echo "Uninstalling $SERVICE_NAME..." - - if [ -d "$SERVICE_DIR" ]; then - cd "$SERVICE_DIR" 2>/dev/null || return - - # Stop and remove containers - if [ -f "docker-compose.yml" ]; then - echo " Stopping containers..." - docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true - fi - - # Backup data - BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$SERVICE_NAME" - mkdir -p "$BACKUP_DIR" - echo " Backing up to $BACKUP_DIR..." - cp -r "$SERVICE_DIR" "$BACKUP_DIR/" 2>/dev/null || true - - # Remove service directory - echo " Removing $SERVICE_DIR..." - rm -rf "$SERVICE_DIR" - - echo " ✓ $SERVICE_NAME uninstalled (backup: $BACKUP_DIR)" - else - echo " ⚠ $SERVICE_NAME directory not found, skipping" - fi - echo "" - } - - # Uninstall selected services - if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then uninstall_service "Immich" "$DOCKER_DIR/immich" "immich"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then uninstall_service "AudioBookshelf" "$DOCKER_DIR/audiobookshelf" "audiobookshelf"; fi - if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then uninstall_service "Emby" "$DOCKER_DIR/emby" "emby"; fi - if echo "$SELECTED_SERVICES" | grep -q "ARM"; then uninstall_service "ARM" "$DOCKER_DIR/arm" "arm"; fi - if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then uninstall_service "FileBrowser" "$DOCKER_DIR/filebrowser" "filebrowser"; fi - if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then uninstall_service "MagicMirror" "$DOCKER_DIR/magicmirror" "magicmirror"; fi - if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then uninstall_service "ActualBudget" "$DOCKER_DIR/actualbudget" "actualbudget"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUTHELIA"; then uninstall_service "Authelia" "$DOCKER_DIR/authelia" "authelia"; fi - if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi - if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi - if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi - if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then uninstall_service "Home Assistant" "$DOCKER_DIR/homeassistant" "homeassistant"; fi - if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi - if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi - if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then uninstall_service "ntfy" "$DOCKER_DIR/ntfy" "ntfy"; fi - if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then uninstall_service "Uptime Kuma" "$DOCKER_DIR/uptime-kuma" "uptime-kuma"; fi - if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then uninstall_service "wg-easy" "$DOCKER_DIR/wg-easy" "wg-easy"; fi - if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then uninstall_service "Traccar" "$DOCKER_DIR/traccar" "traccar"; fi - if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then uninstall_service "Portainer" "$DOCKER_DIR/portainer" "portainer"; fi - if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then uninstall_service "MeshCentral" "$DOCKER_DIR/meshcentral" "meshcentral"; fi - if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then uninstall_service "FindMyDevice" "$DOCKER_DIR/fmd" "fmd"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then uninstall_service "Frigate-Notify" "$DOCKER_DIR/frigate-notify" "frigate-notify"; fi - if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then uninstall_service "Watchtower" "$DOCKER_DIR/watchtower" "watchtower"; fi - - # Special handling for CrowdSec (system packages) - if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then - echo "Uninstalling CrowdSec..." - echo " Stopping CrowdSec services..." - systemctl stop crowdsec 2>/dev/null || true - systemctl stop crowdsec-firewall-bouncer 2>/dev/null || true - systemctl disable crowdsec 2>/dev/null || true - systemctl disable crowdsec-firewall-bouncer 2>/dev/null || true - echo " Removing CrowdSec packages..." - apt-get remove --purge -y crowdsec crowdsec-firewall-bouncer-iptables 2>/dev/null || true - echo " ✓ CrowdSec uninstalled" - echo "" - fi - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " Uninstallation complete!" - echo " Backups saved to: $DOCKER_DIR/backups/" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - fi - - # After uninstall, exit the Docker apps section - WHIPTAIL_USED=true - - elif [ "$MENU_MODE" = "INSTALL" ]; then - echo "" - echo "Selected services:" - echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /' - echo "" - - # Mark that whiptail was used - skip all individual prompts - WHIPTAIL_USED=true - else - # Cancel or other mode - WHIPTAIL_USED=false - fi - else - # Whiptail not available - will use individual prompts - WHIPTAIL_USED=false - MENU_MODE="INSTALL" - fi - - # Skip installation section if we just did uninstalls - if [ "$MENU_MODE" = "UNINSTALL" ]; then - echo "Skipping installation section (uninstall mode was selected)." - # Jump to end of Docker section by setting a flag - SKIP_DOCKER_INSTALLS=true - else - SKIP_DOCKER_INSTALLS=false - fi - - # ============================================================================ - # DOCKER SERVICE INSTALLATIONS - # ============================================================================ - - if [ "$SKIP_DOCKER_INSTALLS" != true ]; then - # ---- IMMICH ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_IMMICH" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ IMMICH - Self-hosted photo & video backup │" - echo "│ Like Google Photos but private. Mobile app auto-uploads. │" - echo "│ Port: 2283 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Immich? (y/n):" "n" INSTALL_IMMICH - fi - - if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then - IMMICH_DIR="$DOCKER_DIR/immich" - - # Check if already installed and ask what to do - check_service_exists "Immich" "$IMMICH_DIR" IMMICH_RECONFIGURE - - if [ "$IMMICH_RECONFIGURE" = "true" ]; then - echo "Installing Immich..." - - # ── Photo library setup ───────────────────────────────────── - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ PHOTO LIBRARY SETUP │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - - DEFAULT_PHOTOS_DIR="$PRIMARY_DRIVE_PATH/photos" - IMMICH_STRATEGY="" - EXTERNAL_LIBRARY="" - EXISTING_PHOTOS_SOURCE="" - - prompt_yn " Do you have existing photos to include? (y/n):" "n" HAS_EXISTING_PHOTOS - - if [ "$HAS_EXISTING_PHOTOS" = "y" ] || [ "$HAS_EXISTING_PHOTOS" = "Y" ]; then - echo "" - echo " How should Immich handle your existing photos?" - echo "" - echo " [1] Import everything into Immich (recommended)" - echo " Immich manages all photos in one unified library." - echo " Dates preserved via EXIF metadata. Organized by date." - echo " Your original folder names are NOT kept on disk" - echo " (use Immich albums to organize instead)." - echo "" - echo " [2] Keep existing photos in place (read-only external library)" - echo " Immich indexes your existing photos without moving them." - echo " New uploads go to a separate folder." - echo " Your folder structure stays intact." - echo "" - - if [ "$UNATTENDED" = true ]; then - IMMICH_STRATEGY="1" - echo " Strategy: [auto: 1]" - else - read -p " Choose [1/2]: " IMMICH_STRATEGY - IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}" - fi - - if [ "$IMMICH_STRATEGY" = "2" ]; then - # Strategy 2: external library - ask where existing photos are - echo "" - echo " Where are your existing photos?" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Existing photos path:" "$DEFAULT_PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$DEFAULT_PHOTOS_DIR" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" - - UPLOAD_LOCATION="$PRIMARY_DRIVE_PATH/immich-uploads" - EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE" - - echo "" - echo " Setup:" - echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)" - echo " New uploads: $UPLOAD_LOCATION" - else - # Strategy 1 with existing photos: ask for library path, will import into it - echo "" - echo " Where should Immich store your photo library?" - echo " (Existing photos will be imported here)" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR" - PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}" - PHOTOS_DIR="${PHOTOS_DIR%/}" - - echo "" - echo " Where are your existing photos now?" - echo " Default: $PHOTOS_DIR" - prompt_text " Existing photos path:" "$PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$PHOTOS_DIR" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" - - UPLOAD_LOCATION="$PHOTOS_DIR" - EXTERNAL_LIBRARY="" - - echo "" - echo " All photos (existing + new) will live in:" - echo " $PHOTOS_DIR" - echo " └── 2026/01/filename.jpg organized by date" - fi - else - # No existing photos: simplest path - just ask where to store - IMMICH_STRATEGY="1" - echo "" - echo " Where should Immich store your photo library?" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR" - PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}" - PHOTOS_DIR="${PHOTOS_DIR%/}" - - UPLOAD_LOCATION="$PHOTOS_DIR" - EXTERNAL_LIBRARY="" - - echo "" - echo " All photos will be stored in:" - echo " $PHOTOS_DIR" - echo " └── 2026/01/filename.jpg organized by date" - fi - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $IMMICH_DIR" - echo "[DRY-RUN] Would store uploads at $UPLOAD_LOCATION" - [ -n "$EXTERNAL_LIBRARY" ] && echo "[DRY-RUN] Would mount external library at $EXTERNAL_LIBRARY" - echo "[DRY-RUN] Would create docker-compose.yml and .env" - else - mkdir -p "$IMMICH_DIR" 2>/dev/null || true - mkdir -p "$UPLOAD_LOCATION" 2>/dev/null || true - [ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" 2>/dev/null || true - - # Create required subdirectories with .immich marker files - # Immich checks these on startup and fails if they're missing - for subdir in thumbs upload backups library profile encoded-video; do - mkdir -p "$UPLOAD_LOCATION/$subdir" 2>/dev/null || true - touch "$UPLOAD_LOCATION/$subdir/.immich" 2>/dev/null || true - done - cd "$IMMICH_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Create docker-compose.yml - if [ -n "$EXTERNAL_LIBRARY" ]; then - # Strategy 2: external library mount included - cat > docker-compose.yml << 'IMMICH_COMPOSE' -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - ${EXTERNAL_LIBRARY}:/usr/src/app/external:ro - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false - - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_DB: ${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -IMMICH_COMPOSE - else - # Strategy 1: unified library, no external mount - cat > docker-compose.yml << 'IMMICH_COMPOSE' -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false - - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_DB: ${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -IMMICH_COMPOSE - fi - - # Generate random password - DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) - - # Create .env file with strategy-appropriate comments - if [ "$IMMICH_STRATEGY" = "2" ]; then - cat > .env << IMMICH_ENV -# ============================================================ -# IMMICH CONFIGURATION — External Library Mode -# ============================================================ -# -# STORAGE TEMPLATE (configure in Immich web UI): -# Admin → Settings → Storage Template → Enable -# Template: {{y}}/{{MM}}/{{filename}} -# This organizes new uploads into: immich-uploads/2026/01/filename.jpg -# -# EXTERNAL LIBRARY SETUP: -# Admin → External Libraries → Create Library -# Import path: /usr/src/app/external -# Click "Scan" to index your existing photos. -# -# ============================================================ - -# New uploads from phone/web -UPLOAD_LOCATION=$UPLOAD_LOCATION - -# Existing photos (read-only, indexed by Immich) -# Mounted at /usr/src/app/external inside the container -EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY - -# Database location (keep on fast storage) -DB_DATA_LOCATION=./postgres - -IMMICH_VERSION=release - -DB_PASSWORD=$DB_PASS -DB_USERNAME=postgres -DB_DATABASE_NAME=immich - -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -IMMICH_ENV - else - cat > .env << IMMICH_ENV -# ============================================================ -# IMMICH CONFIGURATION — Unified Library -# ============================================================ -# -# All photos (imported + new uploads) are stored in one location. -# Storage template organizes files by date automatically. -# -# To import existing photos, run: -# $IMMICH_DIR/import-photos.sh -# -# The import script handles storage template configuration, -# CLI installation, and uploads with EXIF date preservation. -# -# ============================================================ - -# All photos stored here (uploads + imported) -UPLOAD_LOCATION=$UPLOAD_LOCATION - -# Database location (keep on fast storage) -DB_DATA_LOCATION=./postgres - -IMMICH_VERSION=release - -DB_PASSWORD=$DB_PASS -DB_USERNAME=postgres -DB_DATABASE_NAME=immich - -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -IMMICH_ENV - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" 2>/dev/null || true - [ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true - - # Create import helper script for Strategy 1 with existing photos - if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then - cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_HEAD' -#!/bin/bash -################################################################################ -# Immich Photo Import Script -# Generated by ubuntu-post-install -# -# This script imports your existing photo collection into Immich with proper -# EXIF date preservation. Photos are uploaded through the API, which triggers -# Immich's metadata extraction pipeline — dates, GPS, camera info are all -# read from the original files. -# -# What this script does: -# 1. Creates admin account (if first run) or logs in -# 2. Generates an API key automatically -# 3. Configures the storage template (date-based organization) -# 4. Installs the Immich CLI (if needed) -# 5. Uploads all photos with EXIF metadata preserved -# -# Usage: -# ./import-photos.sh # interactive (prompts for everything) -# ./import-photos.sh # skip account setup, use existing key -################################################################################ - -IMPORT_SCRIPT_HEAD - - cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_SCRIPT_VARS -IMMICH_URL="http://localhost:2283" -SOURCE_DIR="$EXISTING_PHOTOS_SOURCE" -IMMICH_DIR="$IMMICH_DIR" -IMPORT_SCRIPT_VARS - - cat >> "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_BODY' - -echo "" -echo "┌─────────────────────────────────────────────────────────────────┐" -echo "│ IMMICH PHOTO IMPORT │" -echo "└─────────────────────────────────────────────────────────────────┘" -echo "" - -# ── Preflight checks ──────────────────────────────────────────── -echo "Checking Immich server..." -if ! curl -s "$IMMICH_URL/api/server/ping" > /dev/null 2>&1; then - echo "" - echo " ✗ Immich is not running at $IMMICH_URL" - echo " Start it with: cd $IMMICH_DIR && docker compose up -d" - echo "" - exit 1 -fi -echo " ✓ Immich is running" - -# Check source directory -if [ ! -d "$SOURCE_DIR" ]; then - echo "" - echo " ✗ Source directory not found: $SOURCE_DIR" - echo " Update SOURCE_DIR in this script if your photos moved." - echo "" - exit 1 -fi -echo " ✓ Source directory: $SOURCE_DIR" - -echo -n " Scanning for photos/videos (this may take a while)..." -PHOTO_COUNT=$(find "$SOURCE_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" -o -iname "*.heif" -o -iname "*.webp" -o -iname "*.gif" -o -iname "*.tiff" -o -iname "*.bmp" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.webm" \) 2>/dev/null | wc -l) -echo " done" -echo " ✓ Found ~$PHOTO_COUNT photos/videos" - -# ── Get or create API key ─────────────────────────────────────── -API_KEY="${1:-}" - -if [ -z "$API_KEY" ]; then - echo "" - # Check if server needs initial setup - SERVER_CONFIG=$(curl -s "$IMMICH_URL/api/server/config" 2>/dev/null) - IS_INITIALIZED=$(echo "$SERVER_CONFIG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('isInitialized', True))" 2>/dev/null) - - if [ "$IS_INITIALIZED" = "False" ]; then - # ── First-time setup: create admin account ────────────── - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FIRST-TIME SETUP — Creating admin account │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - echo " Immich needs an admin account. Enter your details:" - echo "" - read -p " Admin email: " ADMIN_EMAIL - while [ -z "$ADMIN_EMAIL" ]; do - read -p " Admin email (required): " ADMIN_EMAIL - done - read -sp " Admin password: " ADMIN_PASS - echo "" - while [ ${#ADMIN_PASS} -lt 8 ]; do - echo " Password must be at least 8 characters." - read -sp " Admin password: " ADMIN_PASS - echo "" - done - read -p " Your name [Admin]: " ADMIN_NAME - ADMIN_NAME="${ADMIN_NAME:-Admin}" - - echo "" - echo " Creating admin account..." - SIGNUP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/auth/admin-sign-up" \ - -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"name\":\"$ADMIN_NAME\"}" 2>/dev/null) - SIGNUP_CODE=$(echo "$SIGNUP_RESPONSE" | tail -1) - SIGNUP_BODY=$(echo "$SIGNUP_RESPONSE" | sed '$d') - - if [ "$SIGNUP_CODE" = "201" ]; then - echo " ✓ Admin account created" - else - echo " ✗ Failed to create admin account (HTTP $SIGNUP_CODE)" - echo " Response: $SIGNUP_BODY" - echo "" - echo " Create your account manually at $IMMICH_URL" - echo " Then re-run this script with your API key:" - echo " $0 " - exit 1 - fi - else - # Server already initialized, need credentials to log in - echo " Immich is already set up. Log in to generate an API key." - echo "" - read -p " Admin email: " ADMIN_EMAIL - while [ -z "$ADMIN_EMAIL" ]; do - read -p " Admin email (required): " ADMIN_EMAIL - done - read -sp " Admin password: " ADMIN_PASS - echo "" - fi - - # ── Login to get bearer token ─────────────────────────────── - echo " Logging in..." - LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/auth/login" \ - -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" 2>/dev/null) - LOGIN_CODE=$(echo "$LOGIN_RESPONSE" | tail -1) - LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | sed '$d') - - if [ "$LOGIN_CODE" != "201" ]; then - echo " ✗ Login failed (HTTP $LOGIN_CODE)" - echo "" - echo " Check your email/password and try again, or pass an API key:" - echo " $0 " - exit 1 - fi - - ACCESS_TOKEN=$(echo "$LOGIN_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])" 2>/dev/null) - if [ -z "$ACCESS_TOKEN" ]; then - echo " ✗ Could not extract access token from login response" - exit 1 - fi - echo " ✓ Logged in" - - # ── Create API key ────────────────────────────────────────── - echo " Creating API key..." - APIKEY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - "$IMMICH_URL/api/api-keys" \ - -d '{"name":"import-photos-script"}' 2>/dev/null) - APIKEY_CODE=$(echo "$APIKEY_RESPONSE" | tail -1) - APIKEY_BODY=$(echo "$APIKEY_RESPONSE" | sed '$d') - - if [ "$APIKEY_CODE" = "201" ]; then - API_KEY=$(echo "$APIKEY_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['secret'])" 2>/dev/null) - if [ -n "$API_KEY" ]; then - echo " ✓ API key created" - else - echo " ✗ Could not extract API key from response" - echo " Create one manually: $IMMICH_URL → Account Settings → API Keys" - echo " Then re-run: $0 " - exit 1 - fi - else - echo " ✗ Failed to create API key (HTTP $APIKEY_CODE)" - echo " Create one manually: $IMMICH_URL → Account Settings → API Keys" - echo " Then re-run: $0 " - exit 1 - fi -else - # API key provided as argument — verify it - echo "" - echo " Verifying API key..." - VERIFY_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "x-api-key: $API_KEY" "$IMMICH_URL/api/users/me" 2>/dev/null) - if [ "$VERIFY_CODE" != "200" ]; then - echo " ✗ Invalid API key (HTTP $VERIFY_CODE)" - echo " Double-check the key and try again." - exit 1 - fi - echo " ✓ API key valid" -fi - -# ── Configure storage template via API ────────────────────────── -echo "" -echo " Configuring storage template ({{y}}/{{MM}}/{{filename}})..." -CURRENT_CONFIG=$(curl -s -H "x-api-key: $API_KEY" "$IMMICH_URL/api/system-config" 2>/dev/null) -if [ -n "$CURRENT_CONFIG" ] && command -v python3 &> /dev/null; then - UPDATED_CONFIG=$(echo "$CURRENT_CONFIG" | python3 -c " -import sys, json -config = json.load(sys.stdin) -config['storageTemplate']['enabled'] = True -config['storageTemplate']['template'] = '{{y}}/{{MM}}/{{filename}}' -json.dump(config, sys.stdout) -" 2>/dev/null) - if [ -n "$UPDATED_CONFIG" ]; then - RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ - -H "x-api-key: $API_KEY" \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/system-config" \ - -d "$UPDATED_CONFIG" 2>/dev/null) - if [ "$RESULT" = "200" ]; then - echo " ✓ Storage template configured" - else - echo " ⚠ Could not set storage template (HTTP $RESULT)" - echo " Set manually: Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - fi - else - echo " ⚠ Could not parse config. Set storage template manually:" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - fi -else - echo " ⚠ python3 not found. Set storage template manually:" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" -fi - -# ── Install immich-cli if needed ──────────────────────────────── -echo "" -IMMICH_CMD="" - -# Immich CLI requires Node.js >= 20 (File global class) -NODE_OK=false -if command -v node &> /dev/null; then - NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then - NODE_OK=true - fi -fi - -if [ "$NODE_OK" = false ]; then - echo " Immich CLI requires Node.js >= 20 (found: $(node -v 2>/dev/null || echo 'none'))." - read -p " Install Node.js 22 LTS now? (y/n): " INSTALL_NODE_YN - if [ "$INSTALL_NODE_YN" = "y" ] || [ "$INSTALL_NODE_YN" = "Y" ]; then - echo " Installing Node.js 22 LTS from NodeSource..." - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - 2>/dev/null - sudo apt-get install -y -qq nodejs 2>/dev/null - NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then - NODE_OK=true - echo " ✓ Node.js $(node -v) installed" - else - echo " ✗ Installation failed." - echo " Install Node.js 20+ manually: https://nodejs.org/" - echo " Then re-run: $0 $API_KEY" - exit 1 - fi - else - echo "" - echo " Install Node.js 20+ later and re-run this script:" - echo " curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -" - echo " sudo apt-get install -y nodejs" - echo " $0 $API_KEY" - exit 0 - fi -fi - -if command -v immich &> /dev/null; then - IMMICH_CMD="immich" - echo " ✓ Immich CLI found" -elif command -v npx &> /dev/null; then - echo " Immich CLI not installed. Will use npx (downloads temporarily)." - IMMICH_CMD="npx --yes @immich/cli" -elif command -v npm &> /dev/null; then - echo " Installing Immich CLI globally..." - if npm install -g @immich/cli 2>/dev/null; then - IMMICH_CMD="immich" - echo " ✓ Immich CLI installed" - else - echo " ✗ npm install failed. Trying npx..." - IMMICH_CMD="npx --yes @immich/cli" - fi -fi - -if [ -z "$IMMICH_CMD" ]; then - echo " ✗ Could not find npm or npx despite Node.js being installed." - echo " Install manually: npm install -g @immich/cli" - echo " Then re-run: $0 $API_KEY" - exit 1 -fi - -# ── Run the import ────────────────────────────────────────────── -echo "" -echo " Authenticating CLI with Immich..." -if ! $IMMICH_CMD login "$IMMICH_URL/api" "$API_KEY"; then - echo " ✗ CLI login failed" - exit 1 -fi - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " Starting import from: $SOURCE_DIR" -echo " Importing ~$PHOTO_COUNT files. This may take a while." -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -$IMMICH_CMD upload --recursive "$SOURCE_DIR" - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " Import complete!" -echo " View your photos at: $IMMICH_URL" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -IMPORT_SCRIPT_BODY - - chmod +x "$IMMICH_DIR/import-photos.sh" - chown "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR/import-photos.sh" - fi - - echo "" - echo "✓ Immich configured at $IMMICH_DIR" - if [ -n "$EXTERNAL_LIBRARY" ]; then - echo " Existing photos: $EXTERNAL_LIBRARY (read-only)" - echo " New uploads: $UPLOAD_LOCATION" - else - echo " Photo library: $UPLOAD_LOCATION" - fi - echo "" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Immich" "2283" "photos" - - # Ask to start container - prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH - if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then - echo " Starting Immich..." - docker compose up -d 2>/dev/null && echo " ✓ Immich started" || echo " ⚠ Failed to start" - fi - - # ── Post-setup instructions ───────────────────────────── - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ IMMICH SETUP STEPS │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - - if [ -n "$EXISTING_PHOTOS_SOURCE" ] && [ "$IMMICH_STRATEGY" != "2" ]; then - echo " Import your existing photos AFTER this script finishes:" - echo " $IMMICH_DIR/import-photos.sh" - echo "" - echo " The script handles everything automatically:" - echo " creates your admin account, generates an API key," - echo " configures the storage template, installs the CLI," - echo " and imports all photos from $EXISTING_PHOTOS_SOURCE" - echo " with EXIF dates preserved." - echo "" - echo " NOTE: Run import-photos.sh separately after this setup" - echo " script has completed. It may take a while for large" - echo " photo collections." - echo "" - echo " After import, open http://localhost:2283 to browse your photos." - elif [ "$IMMICH_STRATEGY" = "2" ]; then - echo " 1. Open http://localhost:2283 and create your admin account" - echo "" - echo " 2. Enable storage template (keeps new uploads organized):" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - echo "" - echo " 3. Set up your existing photo library:" - echo " Admin → External Libraries → Create Library" - echo " Import path: /usr/src/app/external" - echo " Click Scan to index your photos" - echo "" - echo " New uploads from the mobile app or web UI are stored" - echo " separately in: $UPLOAD_LOCATION" - else - echo " 1. Open http://localhost:2283 and create your admin account" - echo "" - echo " 2. Enable storage template (keeps files organized by date):" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - echo "" - echo " That's it. Upload photos from the mobile app or web UI." - echo " All photos are stored in: $PHOTOS_DIR" - fi - echo "" - fi - fi # End IMMICH_RECONFIGURE check - fi # End INSTALL_IMMICH check - - # ---- AUDIOBOOKSHELF ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUDIOBOOKSHELF" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ AUDIOBOOKSHELF - Audiobook & podcast server │" - echo "│ Stream audiobooks with progress sync across devices. │" - echo "│ Port: 13378 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Audiobookshelf? (y/n):" "n" INSTALL_AUDIOBOOKSHELF - fi - - if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then - ABS_DIR="$DOCKER_DIR/audiobookshelf" - - # Check if already installed and ask what to do - check_service_exists "AudioBookshelf" "$ABS_DIR" ABS_RECONFIGURE - - if [ "$ABS_RECONFIGURE" = "true" ]; then - echo "Installing Audiobookshelf..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $ABS_DIR" - else - mkdir -p "$ABS_DIR" - ensure_docker_dir_ownership "$ABS_DIR" - cd "$ABS_DIR" - - prompt_text "Path to audiobooks folder [default: $PRIMARY_DRIVE_PATH/audiobooks]:" "$PRIMARY_DRIVE_PATH/audiobooks" AUDIOBOOKS_PATH - - cat > docker-compose.yml << ABS_COMPOSE -name: audiobookshelf - -services: - audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf:latest - container_name: audiobookshelf - hostname: audiobookshelf - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - ./metadata:/metadata - - ${AUDIOBOOKS_PATH}:/audiobooks - - ${PODCASTS_PATH:-./podcasts}:/podcasts - ports: - - "13378:80" -ABS_COMPOSE - - cat > .env << ABS_ENV -AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH -PODCASTS_PATH=./podcasts -ABS_ENV - - mkdir -p config metadata podcasts - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR" - - echo "" - echo "✓ Audiobookshelf configured at $ABS_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks" - - prompt_yn "Start Audiobookshelf now? (y/n):" "y" START_ABS - if [ "$START_ABS" = "y" ] || [ "$START_ABS" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Audiobookshelf started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:13378" - echo "" - fi - fi # End ABS_RECONFIGURE check - fi # End INSTALL_AUDIOBOOKSHELF check - - # ---- EMBY ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_EMBY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ EMBY - Media server for movies, TV, music │" - echo "│ Stream your media library to any device. │" - echo "│ Port: 8096 (web), 8920 (https) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Emby? (y/n):" "n" INSTALL_EMBY - fi - - if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then - EMBY_DIR="$DOCKER_DIR/emby" - - # Check if already installed and ask what to do - check_service_exists "Emby" "$EMBY_DIR" EMBY_RECONFIGURE - - if [ "$EMBY_RECONFIGURE" = "true" ]; then - echo "Installing Emby..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $EMBY_DIR" - else - mkdir -p "$EMBY_DIR" - ensure_docker_dir_ownership "$EMBY_DIR" - cd "$EMBY_DIR" - - prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH - - cat > docker-compose.yml << EMBY_COMPOSE -name: emby - -services: - emby: - image: emby/embyserver:latest - container_name: emby - hostname: emby - restart: unless-stopped - environment: - - UID=$(id -u "$ACTUAL_USER") - - GID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - ${MEDIA_PATH}:/media - ports: - - "8096:8096" - - "8920:8920" - # Uncomment for hardware transcoding (Intel/AMD) - # devices: - # - /dev/dri:/dev/dri -EMBY_COMPOSE - - cat > .env << EMBY_ENV -MEDIA_PATH=$MEDIA_PATH -EMBY_ENV - - mkdir -p config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR" - - echo "" - echo "✓ Emby configured at $EMBY_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Emby" "8096" "emby" - - prompt_yn "Start Emby now? (y/n):" "y" START_EMBY - if [ "$START_EMBY" = "y" ] || [ "$START_EMBY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Emby started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8096" - echo "" - fi - fi # End EMBY_RECONFIGURE check - fi # End INSTALL_EMBY check - - # ---- A.R.M. (Automatic Ripping Machine) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ARM" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ A.R.M. - Automatic Ripping Machine │" - echo "│ Automatically rip DVDs, Blu-rays, and CDs. │" - echo "│ Port: 8080 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install A.R.M.? (y/n):" "n" INSTALL_ARM - fi - - if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then - ARM_DIR="$DOCKER_DIR/arm" - check_service_exists "A.R.M." "$ARM_DIR" ARM_RECONFIGURE - - if [ "$ARM_RECONFIGURE" = "true" ]; then - echo "Installing A.R.M...." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $ARM_DIR" - else - mkdir -p "$ARM_DIR" - ensure_docker_dir_ownership "$ARM_DIR" - cd "$ARM_DIR" - - prompt_text "Path for ripped media output [default: $PRIMARY_DRIVE_PATH/ripped]:" "$PRIMARY_DRIVE_PATH/ripped" ARM_OUTPUT - - # Detect optical drives - echo "" - echo "Detecting optical drives..." - OPTICAL_DRIVES=$(ls /dev/sr* 2>/dev/null || echo "") - if [ -n "$OPTICAL_DRIVES" ]; then - echo "Found: $OPTICAL_DRIVES" - else - echo "No optical drives detected. You can add them later." - OPTICAL_DRIVES="/dev/sr0" - fi - - cat > docker-compose.yml << ARM_COMPOSE -name: arm - -services: - automatic-ripping-machine: - image: automaticrippingmachine/automatic-ripping-machine:latest - container_name: arm - hostname: arm - restart: unless-stopped - environment: - - ARM_UID=$(id -u "$ACTUAL_USER") - - ARM_GID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/etc/arm/config - - ./logs:/home/arm/logs - - ${ARM_OUTPUT}/movies:/home/arm/media/completed - - ${ARM_OUTPUT}/music:/home/arm/music - ports: - - "8080:8080" - devices: - - /dev/sr0:/dev/sr0 - # Add more drives as needed: - # - /dev/sr1:/dev/sr1 - privileged: true -ARM_COMPOSE - - cat > .env << ARM_ENV -ARM_OUTPUT=$ARM_OUTPUT -ARM_ENV - - mkdir -p config logs - mkdir -p "$ARM_OUTPUT/movies" "$ARM_OUTPUT/music" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_DIR" - - echo "" - echo "✓ A.R.M. configured at $ARM_DIR" - - prompt_yn "Start A.R.M. now? (y/n):" "y" START_ARM - if [ "$START_ARM" = "y" ] || [ "$START_ARM" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ A.R.M. started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8080" - echo " ⚠️ Complete setup in browser on first visit!" - echo " Note: Edit docker-compose.yml to add more optical drives" - echo "" - fi - fi # End ARM_RECONFIGURE check - fi # End INSTALL_ARM check - - # ---- FILEBROWSER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FILEBROWSER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FILEBROWSER - Web-based file manager │" - echo "│ Browse, upload, download files via web interface. │" - echo "│ Port: 8085 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Filebrowser? (y/n):" "n" INSTALL_FILEBROWSER - fi - - if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then - FB_DIR="$DOCKER_DIR/filebrowser" - check_service_exists "FileBrowser" "$FB_DIR" FB_RECONFIGURE - - if [ "$FB_RECONFIGURE" = "true" ]; then - echo "Installing Filebrowser..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FB_DIR" - else - mkdir -p "$FB_DIR" - ensure_docker_dir_ownership "$FB_DIR" - cd "$FB_DIR" - - prompt_text "Path to browse [default: $PRIMARY_DRIVE_PATH]:" "$PRIMARY_DRIVE_PATH" FB_PATH - - cat > docker-compose.yml << FB_COMPOSE -name: filebrowser - -services: - filebrowser: - image: filebrowser/filebrowser:s6 - container_name: filebrowser - hostname: filebrowser - restart: unless-stopped - environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ${FB_PATH}:/srv - - ./database/filebrowser.db:/database/filebrowser.db - - ./config/settings.json:/config/settings.json - ports: - - "8085:80" -FB_COMPOSE - - cat > .env << FB_ENV -FB_PATH=$FB_PATH -FB_ENV - - mkdir -p database config - touch database/filebrowser.db - cat > config/settings.json << 'FB_SETTINGS' -{ - "port": 80, - "baseURL": "", - "address": "", - "log": "stdout", - "database": "/database/filebrowser.db", - "root": "/srv" -} -FB_SETTINGS - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR" - - echo "" - echo "✓ Filebrowser configured at $FB_DIR" - - prompt_yn "Start Filebrowser now? (y/n):" "y" START_FB - if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Filebrowser started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8085" - echo " Default login: admin / admin (change immediately!)" - echo "" - fi - fi # End FB_RECONFIGURE check - fi # End INSTALL_FILEBROWSER check - - # ---- MAGIC MIRROR ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MAGICMIRROR" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MAGIC MIRROR - Smart mirror / dashboard display │" - echo "│ Modular smart mirror platform. Run up to 3 instances. │" - echo "│ Ports: 8081, 8082, 8083 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Magic Mirror? (y/n):" "n" INSTALL_MAGICMIRROR - fi - - if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then - echo "" - prompt_text "How many Magic Mirror instances? [1-3, default: 1]:" "1" MM_COUNT - MM_COUNT=${MM_COUNT:-1} - if [ "$MM_COUNT" -gt 3 ]; then MM_COUNT=3; fi - if [ "$MM_COUNT" -lt 1 ]; then MM_COUNT=1; fi - - echo "Installing $MM_COUNT Magic Mirror instance(s)..." - - for i in $(seq 1 $MM_COUNT); do - MM_PORT=$((8080 + i)) - MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MM_DIR (port $MM_PORT)" - else - mkdir -p "$MM_DIR" - ensure_docker_dir_ownership "$MM_DIR" - cd "$MM_DIR" - - cat > docker-compose.yml << MM_COMPOSE -name: mm-$MM_PORT - -services: - magicmirror: - image: karsten13/magicmirror:latest - container_name: magicmirror-$MM_PORT - hostname: magicmirror-$MM_PORT - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/opt/magic_mirror/config - - ./modules:/opt/magic_mirror/modules - - ./css:/opt/magic_mirror/css - ports: - - "$MM_PORT:8080" -MM_COMPOSE - - mkdir -p config modules css - - # Ask if user has existing config to copy - echo "" - echo " Config options:" - echo " [1] Use default config (basic modules)" - echo " [2] Copy existing config from path" - read -p " Choose [1]: " MM_CONFIG_CHOICE - MM_CONFIG_CHOICE=${MM_CONFIG_CHOICE:-1} - - if [ "$MM_CONFIG_CHOICE" = "2" ]; then - read -p " Path to config.js: " MM_CONFIG_PATH - if [ -f "$MM_CONFIG_PATH" ]; then - cp "$MM_CONFIG_PATH" config/config.js - echo " ✓ Copied config from $MM_CONFIG_PATH" - - # Also copy custom.css if exists in same directory - MM_CSS_PATH="${MM_CONFIG_PATH%/*}/custom.css" - if [ -f "$MM_CSS_PATH" ]; then - cp "$MM_CSS_PATH" css/custom.css - echo " ✓ Copied custom.css" - fi - - # Parse config for third-party modules and offer to download - echo "" - echo " Scanning for third-party modules..." - - # Extract module names starting with MMM- (third-party convention) - THIRD_PARTY_MODULES=$(grep -oP 'module:\s*["\x27]MMM-[^"\x27]+["\x27]' config/config.js 2>/dev/null | sed "s/module:\s*[\"']//g" | sed "s/[\"']//g" | sort -u) - - if [ -n "$THIRD_PARTY_MODULES" ]; then - echo " Found third-party modules:" - echo "$THIRD_PARTY_MODULES" | while read mod; do - echo " - $mod" - done - echo "" - prompt_yn " Download these modules from GitHub? (y/n):" "y" MM_DOWNLOAD_MODS - if [ "$MM_DOWNLOAD_MODS" = "y" ] || [ "$MM_DOWNLOAD_MODS" = "Y" ]; then - cd modules - echo "$THIRD_PARTY_MODULES" | while read mod; do - if [ -n "$mod" ] && [ ! -d "$mod" ]; then - echo " Downloading $mod..." - # Try common GitHub patterns - git clone --depth 1 "https://github.com/MichMich/$mod.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/bugsounet/$mod.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/MagicMirrorOrg/$mod.git" 2>/dev/null || \ - echo " ⚠ Could not find $mod - search at https://github.com/topics/magicmirror" - fi - done - cd .. - - # Run npm install for each downloaded module - # Note: This runs AFTER container is started, inside the container - echo "" - echo " Note: Module dependencies will be installed when container starts" - echo " If you need to manually install module dependencies, run:" - echo " docker exec magicmirror-$MM_PORT sh -c 'cd /opt/magic_mirror/modules/ && npm install --production'" - echo "" - fi - else - echo " No third-party modules (MMM-*) found in config" - fi - else - echo " ⚠ File not found: $MM_CONFIG_PATH" - echo " Using default config..." - MM_CONFIG_CHOICE="1" - fi - fi - - # Create default config if not copying - if [ "$MM_CONFIG_CHOICE" != "2" ]; then - # Create basic config.js - cat > config/config.js << 'MM_CONFIG' -let config = { - address: "0.0.0.0", - port: 8080, - ipWhitelist: [], - language: "en", - timeFormat: 12, - units: "imperial", - modules: [ - { - module: "alert", - }, - { - module: "clock", - position: "top_left" - }, - { - module: "calendar", - header: "Calendar", - position: "top_left", - config: { - calendars: [ - { - symbol: "calendar-check", - url: "webcal://www.calendarlabs.com/ical-calendar/ics/76/US_Holidays.ics" - } - ] - } - }, - { - module: "weather", - position: "top_right", - config: { - weatherProvider: "openmeteo", - type: "current", - lat: 40.7128, - lon: -74.0060 - } - }, - { - module: "weather", - position: "top_right", - header: "Weather Forecast", - config: { - weatherProvider: "openmeteo", - type: "forecast", - lat: 40.7128, - lon: -74.0060 - } - }, - { - module: "newsfeed", - position: "bottom_bar", - config: { - feeds: [ - { - title: "BBC", - url: "https://feeds.bbci.co.uk/news/rss.xml" - } - ], - showSourceTitle: true, - showPublishDate: true, - broadcastNewsFeeds: true, - broadcastNewsUpdates: true - } - }, - ] -}; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") {module.exports = config;} -MM_CONFIG - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MM_DIR" - - echo " ✓ Magic Mirror #$i configured at $MM_DIR (port $MM_PORT)" - fi - done - - if [ "$DRY_RUN" != true ]; then - echo "" - prompt_yn "Start Magic Mirror instance(s) now? (y/n):" "y" START_MM - if [ "$START_MM" = "y" ] || [ "$START_MM" = "Y" ]; then - for i in $(seq 1 $MM_COUNT); do - MM_PORT=$((8080 + i)) - MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT" - (cd "$MM_DIR" && docker compose up -d 2>/dev/null) && echo " ✓ Magic Mirror #$i started (port $MM_PORT)" || echo " ⚠ Failed to start Magic Mirror #$i" - - # Install npm dependencies for third-party modules inside container - if [ -d "$MM_DIR/modules" ]; then - echo " Installing module dependencies inside container..." - sleep 3 # Wait for container to fully start - for mod_dir in "$MM_DIR/modules"/MMM-*/; do - if [ -d "$mod_dir" ] && [ -f "$mod_dir/package.json" ]; then - mod_name=$(basename "$mod_dir") - echo " Installing $mod_name dependencies..." - docker exec magicmirror-$MM_PORT sh -c "cd /opt/magic_mirror/modules/$mod_name && npm install --production" 2>/dev/null && \ - echo " ✓ $mod_name dependencies installed" || \ - echo " ⚠ $mod_name - npm install failed (container may need restart)" - fi - done - fi - done - fi - - echo " Access at: http://localhost:808X" - echo " Edit config: ~/docker/magicmirror-808X/config/config.js" - echo "" - fi - fi - - # ---- ACTUALBUDGET ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ACTUALBUDGET" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ ACTUALBUDGET - Open-source Personal Finance Management │" - echo "│ Budget tracking with bank account synchronization via SimpleFIN│" - echo "│ Port: 5006 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ActualBudget? (y/n):" "n" INSTALL_ACTUALBUDGET - fi - - if [ "$INSTALL_ACTUALBUDGET" = "y" ] || [ "$INSTALL_ACTUALBUDGET" = "Y" ]; then - AB_DIR="$DOCKER_DIR/actualbudget" - check_service_exists "ActualBudget" "$AB_DIR" AB_RECONFIGURE - - if [ "$AB_RECONFIGURE" = "true" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $AB_DIR" - else - echo "Installing ActualBudget..." - mkdir -p "$AB_DIR/data" - ensure_docker_dir_ownership "$AB_DIR" - cd "$AB_DIR" - - # Create .env file for environment variables - cat > .env << 'AB_ENV' -# ActualBudget Environment Variables -TZ=UTC -AB_ENV - - cat > docker-compose.yml << 'AB_COMPOSE' -name: actualbudget - -services: - actualbudget: - image: actualbudget/actual-server:latest - container_name: actualbudget - restart: unless-stopped - ports: - - "5006:5006" - volumes: - - ./data:/data - env_file: - - .env - labels: - - "io.podman.annotations.label/crowdsec.enable=true" -AB_COMPOSE - - echo " ✓ ActualBudget configured at $AB_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "ActualBudget" "5006" "budget" - - prompt_yn "Start ActualBudget now? (y/n):" "y" START_AB - if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ActualBudget started" || echo " ⚠ Failed to start ActualBudget" - fi - - echo "" - echo " Access at: http://localhost:5006" - echo " Bank sync: https://simplefin.org/ (SimpleFIN account required)" - echo " Data dir: $AB_DIR/data" - echo "" - fi - fi # End AB_RECONFIGURE check - fi # End INSTALL_ACTUALBUDGET check - - # ---- CADDY WEB SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_CADDY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CADDY - Modern Web Server & Reverse Proxy │" - echo "│ Automatic HTTPS, reverse proxy for all your services │" - echo "│ Port: 80 (HTTP), 443 (HTTPS) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - CADDY_DIR="$DOCKER_DIR/caddy" - - # Check if Caddy is already installed - if [ -f "$CADDY_DIR/Caddyfile" ] || [ -f "$CADDY_DIR/docker-compose.yml" ]; then - echo "" - echo "⚠ Caddy appears to be already installed at $CADDY_DIR" - prompt_yn "Do you want to reconfigure it? (y/n):" "n" RECONFIGURE_CADDY - if [ "$RECONFIGURE_CADDY" != "y" ] && [ "$RECONFIGURE_CADDY" != "Y" ]; then - echo " Skipping Caddy installation" - INSTALL_CADDY="n" - fi - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $CADDY_DIR" - else - echo "Installing Caddy..." - mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config" - ensure_docker_dir_ownership "$CADDY_DIR" - - # Backup existing Caddyfile if it exists - if [ -f "$CADDY_DIR/Caddyfile" ]; then - mkdir -p "$CADDY_DIR/backups" - BACKUP_FILE="$CADDY_DIR/backups/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)" - cp "$CADDY_DIR/Caddyfile" "$BACKUP_FILE" - echo " ✓ Backed up existing Caddyfile to: $BACKUP_FILE" - fi - - cd "$CADDY_DIR" - - cat > docker-compose.yml << 'CADDY_COMPOSE' -name: caddy - -services: - caddy: - image: caddy:latest - container_name: caddy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - - "443:443/udp" # HTTP/3 - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - ./data:/data - - ./config:/config - - /var/log/caddy:/var/log/caddy - environment: - - ACME_AGREE=true - labels: - - "io.podman.annotations.label/crowdsec.enable=true" -CADDY_COMPOSE - - # Create Caddyfile if it doesn't exist - if [ ! -f "Caddyfile" ]; then - cat > Caddyfile << 'CADDYFILE' -{ - # Global options - admin off - # Email for Let's Encrypt notifications - # email admin@yourdomain.com -} - -# Example configuration - edit this for your services -# Uncomment and modify these examples: - -# ── Authelia SSO snippet (auto-added by installer if Authelia is installed) ─── -# (authelia) { -# forward_auth authelia:9091 { -# uri /api/authz/forward-auth -# copy_headers Remote-User Remote-Groups Remote-Name Remote-Email -# } -# } -# -# Authelia login portal -# auth.yourdomain.com { -# reverse_proxy authelia:9091 -# } -# -# To protect any service with Authelia, add: import authelia -# Example: -# myservice.yourdomain.com { -# import authelia -# reverse_proxy localhost:PORT -# } - -# ActualBudget -# budget.yourdomain.com { -# log { -# output file /var/log/caddy/actualbudget-access.log -# format json -# level INFO -# } -# reverse_proxy localhost:5006 -# header { -# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" -# X-Frame-Options "SAMEORIGIN" -# X-Content-Type-Options "nosniff" -# X-XSS-Protection "1; mode=block" -# Referrer-Policy "strict-origin-when-cross-origin" -# } -# } - -# Add more services here... -CADDYFILE - echo " ✓ Created example Caddyfile" - else - echo " ℹ Using existing Caddyfile" - fi - - echo " ✓ Caddy configured at $CADDY_DIR" - - prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY - if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start Caddy" - fi - - echo "" - echo " Configuration file: $CADDY_DIR/Caddyfile" - echo " Edit Caddyfile to add your domains and services" - echo " Reload config: cd $CADDY_DIR && docker exec -w /etc/caddy caddy caddy reload" - echo "" - echo " ⚠ IMPORTANT: Edit the Caddyfile to configure your domains!" - echo " - Uncomment and modify the example configurations" - echo " - Add your domain names" - echo " - Configure services you want to expose" - echo "" - fi - fi - fi - - # ---- AUTHELIA ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUTHELIA" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ AUTHELIA - SSO & Two-Factor Authentication Portal │" - echo "│ Protects any Caddy subdomain with a single login + TOTP │" - echo "│ Port: 9091 (internal only, accessed via Caddy) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Authelia? (y/n):" "n" INSTALL_AUTHELIA - fi - - if [ "$INSTALL_AUTHELIA" = "y" ] || [ "$INSTALL_AUTHELIA" = "Y" ]; then - AUTHELIA_DIR="$DOCKER_DIR/authelia" - check_service_exists "Authelia" "$AUTHELIA_DIR" AUTHELIA_RECONFIGURE - - if [ "$AUTHELIA_RECONFIGURE" = "true" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $AUTHELIA_DIR" - else - echo "Installing Authelia..." - mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data" - - # Collect configuration from user - echo "" - echo " Authelia needs a few details to configure." - echo "" - prompt_text " Your domain (e.g., example.com):" "example.com" AUTHELIA_DOMAIN - prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER - prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY - prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL - prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST - prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT - prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER - prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS - prompt_text " Timezone (e.g., America/New_York):" "America/New_York" AUTHELIA_TZ - - # Generate secrets - echo "" - echo " Generating secrets..." - JWT_SECRET=$(openssl rand -hex 32) - SESSION_SECRET=$(openssl rand -hex 32) - STORAGE_SECRET=$(openssl rand -hex 32) - - echo "$JWT_SECRET" > "$AUTHELIA_DIR/config/secrets/jwt_secret" - echo "$SESSION_SECRET" > "$AUTHELIA_DIR/config/secrets/session_secret" - echo "$STORAGE_SECRET" > "$AUTHELIA_DIR/config/secrets/storage_secret" - echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password" - chmod 600 "$AUTHELIA_DIR/config/secrets/"* - echo " ✓ Secrets generated" - - # Generate password hash for admin user - echo "" - echo " Generating password hash for admin user..." - prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS - AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \ - authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \ - | grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH") - if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then - echo " ⚠ Could not generate hash automatically. Run this after install:" - echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'" - echo " Then update $AUTHELIA_DIR/config/users.yml" - else - echo " ✓ Password hash generated" - fi - - ensure_docker_dir_ownership "$AUTHELIA_DIR" - # Authelia requires its config/data owned by uid 1000 - chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" - - cd "$AUTHELIA_DIR" - - # .env file - cat > .env << AUTHELIA_ENV -MY_DOMAIN=${AUTHELIA_DOMAIN} -SMTP_USER=${AUTHELIA_SMTP_USER} -DOCKER_MY_NETWORK=caddy_net -TZ=${AUTHELIA_TZ} -AUTHELIA_ENV - - # docker-compose.yml - cat > docker-compose.yml << 'AUTHELIA_COMPOSE' -name: authelia - -services: - authelia: - image: authelia/authelia:4.39.20 - pull_policy: missing - container_name: authelia - user: "1000:1000" - volumes: - - ./config:/config - - ./data:/data - environment: - - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret - - AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret - - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret - - AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password - - AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER} - - AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}> - expose: - - 9091 - restart: unless-stopped - networks: - - caddy_net - -networks: - caddy_net: - external: true -AUTHELIA_COMPOSE - - # configuration.yml - cat > config/configuration.yml << AUTHELIA_CONFIG ---- -# Authelia configuration -# Secrets are injected via AUTHELIA_* environment variables in docker-compose.yml - -theme: dark - -server: - address: tcp://0.0.0.0:9091 - -log: - level: info - file_path: /data/authelia.log - -totp: - period: 30 - skew: 1 - -authentication_backend: - file: - path: /config/users.yml - password: - algorithm: argon2 - argon2: - variant: argon2id - iterations: 3 - memory: 65536 - parallelism: 4 - key_length: 32 - salt_length: 16 - -access_control: - default_policy: deny - rules: - - domain: "*.${AUTHELIA_DOMAIN}" - policy: two_factor - -session: - name: authelia_session - expiration: 12h - inactivity: 2h - remember_me: 7d - cookies: - - domain: ${AUTHELIA_DOMAIN} - authelia_url: https://auth.${AUTHELIA_DOMAIN} - default_redirection_url: https://${AUTHELIA_DOMAIN} - -storage: - local: - path: /data/db.sqlite3 - -notifier: - disable_startup_check: false - smtp: - address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT} - timeout: 10s - identifier: localhost - subject: "[Authelia] {title}" - startup_check_address: ${AUTHELIA_SMTP_USER} - disable_require_tls: false - disable_starttls: false -AUTHELIA_CONFIG - - # users.yml - cat > config/users.yml << AUTHELIA_USERS ---- -# Authelia users database -# To add users: copy a block, update username/email/displayname, restart authelia -# To generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword' -# Login with username (not email). Tell users to use "Forgot Password" to set their own password. - -users: - ${AUTHELIA_ADMIN_USER}: - displayname: "${AUTHELIA_ADMIN_DISPLAY}" - email: ${AUTHELIA_ADMIN_EMAIL} - password: "${AUTHELIA_HASH}" - groups: - - admins - - users -AUTHELIA_USERS - - chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" - - echo " ✓ Authelia configured at $AUTHELIA_DIR" - echo "" - - # Ensure caddy_net Docker network exists - if ! docker network ls --format '{{.Name}}' | grep -q "^caddy_net$"; then - echo " Creating Docker network caddy_net..." - docker network create caddy_net && echo " ✓ caddy_net created" || echo " ⚠ Failed to create caddy_net" - else - echo " ✓ Docker network caddy_net already exists" - fi - - # Inject Authelia snippet into Caddyfile if Caddy is installed - CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" - if [ -f "$CADDY_FILE" ]; then - echo "" - echo " Configuring Caddy for Authelia..." - - # Add (authelia) snippet at top if not already present - if ! grep -q "(authelia)" "$CADDY_FILE"; then - cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)" - AUTHELIA_SNIPPET=$(cat << 'SNIPPET_EOF' -# ── Authelia forward auth snippet ───────────────────────────────────────────── -(authelia) { - forward_auth authelia:9091 { - uri /api/authz/forward-auth - copy_headers Remote-User Remote-Groups Remote-Name Remote-Email - } -} - -SNIPPET_EOF -) - # Prepend snippet before existing content - echo "$AUTHELIA_SNIPPET" | cat - "$CADDY_FILE" > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE" - echo " ✓ Authelia snippet added to Caddyfile" - else - echo " ✓ Authelia snippet already in Caddyfile" - fi - - # Add auth portal site block if not already present - AUTH_DOMAIN="auth.${AUTHELIA_DOMAIN}" - if ! grep -q "$AUTH_DOMAIN" "$CADDY_FILE"; then - cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK - -# ── Authelia login portal ────────────────────────────────────────────────────── -${AUTH_DOMAIN} { - reverse_proxy authelia:9091 - log { - output file /var/log/caddy/auth.log - } -} -CADDY_AUTH_BLOCK - echo " ✓ Authelia portal block added for ${AUTH_DOMAIN}" - fi - - # Reload Caddy if it's running - if docker ps --format '{{.Names}}' | grep -q "^caddy$"; then - docker exec -w /etc/caddy caddy caddy reload 2>/dev/null \ - && echo " ✓ Caddy reloaded" \ - || echo " ⚠ Caddy reload failed — reload manually after fixing Caddyfile" - fi - else - echo " ℹ Caddy not yet installed. Install Caddy and add this to your Caddyfile:" - echo "" - echo ' (authelia) {' - echo ' forward_auth authelia:9091 {' - echo ' uri /api/authz/forward-auth' - echo ' copy_headers Remote-User Remote-Groups Remote-Name Remote-Email' - echo ' }' - echo ' }' - echo "" - echo " auth.${AUTHELIA_DOMAIN} {" - echo " reverse_proxy authelia:9091" - echo " }" - echo "" - fi - - prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA - if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Authelia started" || echo " ⚠ Failed to start Authelia" - sleep 2 - docker compose logs --tail=20 authelia 2>/dev/null || true - fi - - echo "" - echo " Auth portal: https://auth.${AUTHELIA_DOMAIN}" - echo " Config dir: $AUTHELIA_DIR/config" - echo " Users file: $AUTHELIA_DIR/config/users.yml" - echo " Data dir: $AUTHELIA_DIR/data" - echo "" - echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set real password)" - echo "" - echo " To protect a Caddy site, add 'import authelia' to its block:" - echo " myservice.${AUTHELIA_DOMAIN} {" - echo " import authelia" - echo " reverse_proxy localhost:PORT" - echo " }" - echo "" - echo " To add users:" - echo " Edit $AUTHELIA_DIR/config/users.yml then:" - echo " cd $AUTHELIA_DIR && docker compose restart authelia" - echo "" - fi - fi # End AUTHELIA_RECONFIGURE check - fi # End INSTALL_AUTHELIA check - - # ---- CROWDSEC ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_CROWDSEC" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CROWDSEC - Intrusion Prevention (fail2ban successor) │" - echo "│ Bans malicious IPs + geo-blocking + community IP reputation │" - echo "│ Protects SSH, Caddy, and other services │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install and configure CrowdSec? (y/n):" "n" INSTALL_CROWDSEC - fi - - if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install CrowdSec + Caddy acquisition + firewall bouncer" - else - echo "Installing CrowdSec..." - - # Install the CrowdSec agent if not already present - if command -v cscli &> /dev/null; then - echo " ✓ CrowdSec is already installed" - else - echo " Adding CrowdSec repository and installing agent..." - if curl -s https://install.crowdsec.net | sudo sh && sudo apt install -y crowdsec; then - echo " ✓ CrowdSec installed successfully" - else - echo " ⚠ Failed to install CrowdSec" - echo " See https://docs.crowdsec.net/ for manual installation" - fi - fi - - # Firewall bouncer (enforces bans via iptables/nftables) - echo " Installing firewall bouncer..." - sudo apt install -y crowdsec-firewall-bouncer-iptables 2>/dev/null || \ - echo " ⚠ Could not install firewall bouncer automatically" - - # Create log directory for Caddy - if [ ! -d "/var/log/caddy" ]; then - sudo mkdir -p /var/log/caddy - sudo chmod 755 /var/log/caddy - echo " ✓ Created /var/log/caddy directory" - fi - - # Detection collections: SSH, Caddy HTTP scenarios, base http - echo " Installing CrowdSec collections (sshd, caddy, base-http)..." - sudo cscli collections install crowdsecurity/sshd crowdsecurity/linux crowdsecurity/caddy crowdsecurity/base-http-scenarios 2>/dev/null || \ - echo " ⚠ Some collections may already be installed" - - # Tell CrowdSec to read Caddy's JSON access logs - ACQUIS_FILE="/etc/crowdsec/acquis.d/caddy.yaml" - if [ ! -f "$ACQUIS_FILE" ]; then - echo " Creating Caddy log acquisition for CrowdSec..." - sudo mkdir -p /etc/crowdsec/acquis.d - ACQUIS_CONTENT='filenames: - - /var/log/caddy/*.log - - /var/log/caddy/*-access.log -labels: - type: caddy' - if echo "$ACQUIS_CONTENT" | sudo tee "$ACQUIS_FILE" > /dev/null; then - echo " ✓ Created Caddy acquisition ($ACQUIS_FILE)" - else - echo " ⚠ Failed to create acquisition - create it manually" - fi - else - echo " ✓ Caddy acquisition already exists" - fi - - # Geo-blocking + reputation (the capability fail2ban/Authelia lack) - echo "" - echo " Geo-blocking & IP reputation (optional):" - echo " Enrich events with country/ASN data:" - echo " sudo cscli collections install crowdsecurity/geoip-enrich" - echo " Subscribe to community/3rd-party blocklists at:" - echo " https://app.crowdsec.net/" - - # Optional: push ban alerts to ntfy - prompt_yn "Send CrowdSec ban alerts to an ntfy topic? (y/n):" "n" CS_NTFY - if [ "$CS_NTFY" = "y" ] || [ "$CS_NTFY" = "Y" ]; then - prompt_text " ntfy topic URL (e.g. https://ntfy.sh/my-crowdsec):" "https://ntfy.sh/crowdsec-alerts" CS_NTFY_URL - sudo mkdir -p /etc/crowdsec/notifications - NTFY_FILE="/etc/crowdsec/notifications/ntfy.yaml" - NTFY_CONTENT="type: http -name: ntfy -log_level: info -format: | - {{range . -}} - {{range .Decisions -}} - {{.Value}} banned: {{.Scenario}} for {{.Duration}} - {{end -}} - {{end -}} -url: $CS_NTFY_URL -method: POST -headers: - Title: CrowdSec ban - Priority: high - Tags: rotating_light" - if echo "$NTFY_CONTENT" | sudo tee "$NTFY_FILE" > /dev/null; then - echo " ✓ Created ntfy notification ($NTFY_FILE)" - # Wire the notification into the default profile (only once) - if ! grep -qE "^\s*- ntfy" /etc/crowdsec/profiles.yaml 2>/dev/null; then - sudo awk '1; /^on_success:/ && !d {print "notifications:"; print " - ntfy"; d=1}' \ - /etc/crowdsec/profiles.yaml | sudo tee /etc/crowdsec/profiles.yaml.new > /dev/null \ - && sudo mv /etc/crowdsec/profiles.yaml.new /etc/crowdsec/profiles.yaml - echo " ✓ Enabled ntfy alerts in CrowdSec default profile" - else - echo " ✓ ntfy already referenced in CrowdSec profile" - fi - echo " ℹ Alerts fire when an IP is banned (after repeated failed attempts)," - echo " not on every individual failed login." - else - echo " ⚠ Failed to write ntfy notification config" - fi - fi - - # Restart services to apply - prompt_yn "Restart CrowdSec to apply changes? (y/n):" "y" RESTART_CS - if [ "$RESTART_CS" = "y" ] || [ "$RESTART_CS" = "Y" ]; then - sudo systemctl enable crowdsec 2>/dev/null || true - if sudo systemctl restart crowdsec; then - echo " ✓ CrowdSec restarted successfully" - sudo systemctl enable crowdsec-firewall-bouncer 2>/dev/null || true - sudo systemctl restart crowdsec-firewall-bouncer 2>/dev/null || true - sleep 2 - sudo cscli metrics 2>/dev/null | head -20 || true - else - echo " ⚠ Failed to restart CrowdSec" - echo " Check logs: sudo journalctl -u crowdsec -n 50" - fi - fi - - echo "" - echo " Useful commands:" - echo " List active bans: sudo cscli decisions list" - echo " List alerts: sudo cscli alerts list" - echo " Manually ban IP: sudo cscli decisions add --ip 1.2.3.4" - echo " Unban IP: sudo cscli decisions delete --ip 1.2.3.4" - echo " Show metrics: sudo cscli metrics" - echo "" - fi - fi - - # ---- LYRION MUSIC SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_LMS" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ LYRION MUSIC SERVER (LMS) - Music streaming server │" - echo "│ Stream music to Squeezebox devices, apps, and Chromecast. │" - echo "│ Port: 9000 (web), 9090 (CLI), 3483 (players) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Lyrion Music Server? (y/n):" "n" INSTALL_LMS - fi - - if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then - echo "Installing Lyrion Music Server..." - LMS_DIR="$DOCKER_DIR/lyrion" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $LMS_DIR" - else - mkdir -p "$LMS_DIR" - ensure_docker_dir_ownership "$LMS_DIR" - cd "$LMS_DIR" - - prompt_text "Path to music folder [default: $PRIMARY_DRIVE_PATH/music]:" "$PRIMARY_DRIVE_PATH/music" MUSIC_PATH - - cat > docker-compose.yml << LMS_COMPOSE -name: lyrion - -services: - lyrion: - image: lmscommunity/lyrionmusicserver:stable - container_name: lyrion - hostname: lyrion - restart: unless-stopped - network_mode: host - environment: - - HTTP_PORT=9000 - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config:rw - - ${MUSIC_PATH}:/music:ro - - ./playlists:/playlists:rw - - /etc/localtime:/etc/localtime:ro -LMS_COMPOSE - - cat > .env << LMS_ENV -MUSIC_PATH=$MUSIC_PATH -LMS_ENV - - mkdir -p config playlists - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LMS_DIR" - - echo "" - echo "✓ Lyrion Music Server configured at $LMS_DIR" - - prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS - if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Lyrion Music Server started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:9000" - echo " Note: Uses host networking for Chromecast support" - echo "" - fi - fi - - # ---- MEALIE ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MEALIE" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MEALIE - Recipe manager & meal planner │" - echo "│ Save recipes, plan meals, generate shopping lists. │" - echo "│ Port: 9925 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Mealie? (y/n):" "n" INSTALL_MEALIE - fi - - if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then - echo "Installing Mealie..." - MEALIE_DIR="$DOCKER_DIR/mealie" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MEALIE_DIR" - else - mkdir -p "$MEALIE_DIR" - ensure_docker_dir_ownership "$MEALIE_DIR" - cd "$MEALIE_DIR" - - cat > docker-compose.yml << MEALIE_COMPOSE -name: mealie - -services: - mealie: - image: ghcr.io/mealie-recipes/mealie:latest - container_name: mealie - hostname: mealie - restart: unless-stopped - environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - ALLOW_SIGNUP=true - - MAX_WORKERS=1 - - WEB_CONCURRENCY=1 - - BASE_URL=http://localhost:9925 - volumes: - - ./data:/app/data - ports: - - "9925:9000" -MEALIE_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" - - echo "" - echo "✓ Mealie configured at $MEALIE_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Mealie" "9925" "recipes" - - prompt_yn "Start Mealie now? (y/n):" "y" START_MEALIE - if [ "$START_MEALIE" = "y" ] || [ "$START_MEALIE" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Mealie started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:9925" - echo " Default: changeme@email.com / MyPassword" - echo "" - fi - fi - - # ---- HOME ASSISTANT ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_HOMEASSISTANT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ HOME ASSISTANT - Open-source home automation hub │" - echo "│ Smart-home control, automations, dashboards. │" - echo "│ Port: 8123 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Home Assistant? (y/n):" "n" INSTALL_HOMEASSISTANT - fi - - if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then - echo "Installing Home Assistant..." - HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR" - else - mkdir -p "$HOMEASSISTANT_DIR" - ensure_docker_dir_ownership "$HOMEASSISTANT_DIR" - cd "$HOMEASSISTANT_DIR" - - # Networking mode: bridge (published port) vs host networking. - echo "" - echo " Home Assistant networking mode:" - echo " 1) Bridge - container gets its own network; port 8123 is published" - echo " to the host. Works behind the Caddy reverse proxy and" - echo " keeps HA isolated. Recommended for most setups." - echo " 2) Host - HA shares the host's network directly. Needed for" - echo " auto-discovery of devices on your LAN (Chromecast/Cast," - echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)." - prompt_text " Choose networking mode [1]:" "1" HA_NETMODE - if [ "$HA_NETMODE" = "2" ]; then - HA_NET_LINES=" network_mode: host" - echo " → Host networking selected (best device discovery)." - else - HA_NET_LINES=" ports: - - \"8123:8123\"" - echo " → Bridge networking selected (port 8123 published)." - fi - - cat > docker-compose.yml << HOMEASSISTANT_COMPOSE -name: homeassistant - -services: - homeassistant: - image: ghcr.io/home-assistant/home-assistant:stable - container_name: homeassistant - hostname: homeassistant - restart: unless-stopped - privileged: true - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - /run/dbus:/run/dbus:ro -${HA_NET_LINES} -HOMEASSISTANT_COMPOSE - - mkdir -p config - - # Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy. - # Only written on a fresh install (never clobber an existing config). - if [ ! -f config/configuration.yaml ]; then - cat > config/configuration.yaml << 'HA_CONFIG' -# Loads default set of integrations. Do not remove. -default_config: - -# Allow access through a reverse proxy (e.g. Caddy) -http: - use_x_forwarded_for: true - trusted_proxies: - - 172.16.0.0/12 - - 192.168.0.0/16 - - 10.0.0.0/8 - - 127.0.0.1 - - ::1 -HA_CONFIG - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR" - - echo "" - echo "✓ Home Assistant configured at $HOMEASSISTANT_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Home Assistant" "8123" "home" - - prompt_yn "Start Home Assistant now? (y/n):" "y" START_HOMEASSISTANT - if [ "$START_HOMEASSISTANT" = "y" ] || [ "$START_HOMEASSISTANT" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Home Assistant started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8123" - echo " First run: open the URL and create your admin account (onboarding)." - echo " Note: first startup can take a minute while HA initializes." - echo "" - fi - fi - - # ---- MINECRAFT SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MINECRAFT SERVER - Game server with RAM limit │" - echo "│ Fabric server with configurable memory allocation. │" - echo "│ Port: 25565 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Minecraft Server? (y/n):" "n" INSTALL_MINECRAFT - fi - - if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then - echo "Installing Minecraft Server..." - MC_DIR="$DOCKER_DIR/minecraft" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MC_DIR" - else - mkdir -p "$MC_DIR" - ensure_docker_dir_ownership "$MC_DIR" - cd "$MC_DIR" - - echo "" - prompt_text "Maximum RAM for Minecraft (e.g., 2G, 4G) [default: 2G]:" "2G" MC_RAM - MC_RAM=${MC_RAM:-2G} - - cat > docker-compose.yml << MC_COMPOSE -name: minecraft - -services: - minecraft: - image: itzg/minecraft-server:latest - container_name: minecraft - hostname: minecraft - restart: unless-stopped - tty: true - stdin_open: true - environment: - - EULA=TRUE - - TYPE=FABRIC - - VERSION=LATEST - - MEMORY=${MC_RAM} - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - OPS= - - MOTD=A Minecraft Server - - DIFFICULTY=normal - - MODE=survival - volumes: - - ./data:/data - ports: - - "25565:25565" - deploy: - resources: - limits: - memory: ${MC_RAM} -MC_COMPOSE - - cat > .env << MC_ENV -MC_RAM=$MC_RAM -MC_ENV - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" - - echo "" - echo "✓ Minecraft Server configured at $MC_DIR" - - prompt_yn "Start Minecraft Server now? (y/n):" "y" START_MC - if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Minecraft Server started" || echo " ⚠ Failed to start" - fi - - echo " Connect: localhost:25565" - echo " RAM limit: $MC_RAM" - echo " Console: docker attach minecraft (Ctrl+P, Ctrl+Q to detach)" - echo "" - fi - fi - - # ---- LINUX-TO-SYNC (Private Repo) ---- - if [ "$WHIPTAIL_USED" != true ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ LINUX-TO-SYNC - Private sync repository │" - echo "│ Clone and set up your private linux-to-sync repository. │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - echo "Note: This requires access to github.com/outis1one/linux-to-sync" - echo "" - echo "To grant access, you need ONE of these:" - echo " 1. SSH key already added to your GitHub account" - echo " 2. GitHub Personal Access Token (PAT)" - echo " 3. GitHub CLI (gh) authenticated" - echo "" - prompt_yn "Set up linux-to-sync? (y/n):" "n" INSTALL_LINUXTOSYNC - - if [ "$INSTALL_LINUXTOSYNC" = "y" ] || [ "$INSTALL_LINUXTOSYNC" = "Y" ]; then - SYNC_DIR="$DOCKER_DIR/linux-to-sync" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would clone linux-to-sync to $SYNC_DIR" - else - echo "" - echo "Choose authentication method:" - echo " [1] SSH (if you have SSH key added to GitHub)" - echo " [2] HTTPS with token (requires Personal Access Token)" - echo "" - prompt_text "Enter 1 or 2 [default: 1]:" "1" AUTH_METHOD - - if [ "$AUTH_METHOD" = "2" ]; then - echo "" - echo "Create a Personal Access Token at:" - echo " https://github.com/settings/tokens/new" - echo " - Select 'repo' scope for full repository access" - echo "" - prompt_text "Enter your GitHub Personal Access Token:" "" GH_TOKEN - - if [ -n "$GH_TOKEN" ]; then - git clone "https://$GH_TOKEN@github.com/outis1one/linux-to-sync.git" "$SYNC_DIR" 2>/dev/null - if [ $? -eq 0 ]; then - # Remove token from remote URL for security - cd "$SYNC_DIR" - git remote set-url origin "https://github.com/outis1one/linux-to-sync.git" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" - echo "" - echo "✓ linux-to-sync cloned to $SYNC_DIR" - echo " Note: You'll need to enter token again for push/pull" - echo " Or set up: git config credential.helper store" - else - echo "✗ Clone failed. Check your token and try again." - fi - else - echo "No token provided. Skipping." - fi - else - echo "" - echo "Attempting SSH clone..." - echo "(Make sure your SSH key is added to GitHub)" - echo "" - git clone git@github.com:outis1one/linux-to-sync.git "$SYNC_DIR" 2>/dev/null - if [ $? -eq 0 ]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" - echo "" - echo "✓ linux-to-sync cloned to $SYNC_DIR" - else - echo "" - echo "✗ SSH clone failed." - echo "" - echo "To add your SSH key to GitHub:" - echo " 1. Copy your public key: cat ~/.ssh/id_rsa.pub" - echo " 2. Go to: https://github.com/settings/keys" - echo " 3. Click 'New SSH key' and paste your key" - echo "" - echo "Then retry this script or manually clone:" - echo " git clone git@github.com:outis1one/linux-to-sync.git ~/docker/linux-to-sync" - fi - fi - fi - fi - fi # End WHIPTAIL_USED check for linux-to-sync - - # ---- JELLYFIN (Alternative to Emby) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_JELLYFIN" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ JELLYFIN - Free media server (alternative to Emby) │" - echo "│ Stream movies, TV, music. No premium features locked. │" - echo "│ Port: 8096 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Jellyfin? (y/n):" "n" INSTALL_JELLYFIN - fi - - if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then - echo "Installing Jellyfin..." - JELLYFIN_DIR="$DOCKER_DIR/jellyfin" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $JELLYFIN_DIR" - else - mkdir -p "$JELLYFIN_DIR" - ensure_docker_dir_ownership "$JELLYFIN_DIR" - cd "$JELLYFIN_DIR" - - prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH - - # Get render group ID for hardware acceleration - RENDER_GID=$(getent group render | cut -d: -f3 2>/dev/null || echo "989") - - cat > docker-compose.yml << JELLYFIN_COMPOSE -name: jellyfin - -services: - jellyfin: - image: jellyfin/jellyfin:latest - container_name: jellyfin - hostname: jellyfin - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - devices: - - /dev/dri/renderD128:/dev/dri/renderD128 - group_add: - - "$RENDER_GID" - volumes: - - ./config:/config - - ./cache:/cache - - \${MEDIA_PATH}:/media:ro - ports: - - "8096:8096" - - "1900:1900/udp" - - "7359:7359/udp" -JELLYFIN_COMPOSE - - cat > .env << JELLYFIN_ENV -MEDIA_PATH=$MEDIA_PATH -JELLYFIN_ENV - - mkdir -p config cache - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" - - echo "" - echo "✓ Jellyfin configured at $JELLYFIN_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Jellyfin" "8096" "jellyfin" - - prompt_yn "Start Jellyfin now? (y/n):" "y" START_JELLYFIN - if [ "$START_JELLYFIN" = "y" ] || [ "$START_JELLYFIN" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Jellyfin started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8096" - echo " Note: Hardware acceleration enabled (Intel GPU)" - echo "" - fi - fi - - # ---- FRIGATE NVR ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FRIGATE - AI-powered NVR for security cameras │" - echo "│ Object detection, recordings, 24/7 monitoring. │" - echo "│ Port: 5000 (web), 8554 (RTSP), 8555 (WebRTC) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Frigate? (y/n):" "n" INSTALL_FRIGATE - fi - - if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then - echo "Installing Frigate..." - FRIGATE_DIR="$DOCKER_DIR/frigate" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FRIGATE_DIR" - else - # STEP 1: Create directory and install docker-compose - mkdir -p "$FRIGATE_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$FRIGATE_DIR" - cd "$FRIGATE_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Default path - FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate" - - cat > docker-compose.yml << FRIGATE_COMPOSE -name: frigate - -services: - frigate: - image: ghcr.io/blakeblackshear/frigate:stable - container_name: frigate - hostname: frigate - restart: unless-stopped - privileged: true - shm_size: "256mb" - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - devices: - - /dev/dri/renderD128:/dev/dri/renderD128 - volumes: - - ./config:/config - - \${FRIGATE_MEDIA}:/media/frigate - - type: tmpfs - target: /tmp/cache - tmpfs: - size: 1000000000 - ports: - - "5000:5000" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" -FRIGATE_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try to configure (uses defaults if fails) - prompt_text "Path for recordings [$FRIGATE_PATH]:" "$FRIGATE_PATH" FRIGATE_PATH 2>/dev/null || FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate" - - cat > .env << FRIGATE_ENV -FRIGATE_MEDIA=$FRIGATE_PATH -FRIGATE_ENV - - mkdir -p config 2>/dev/null || true - mkdir -p "$FRIGATE_PATH" 2>/dev/null || echo " ⚠ Could not create $FRIGATE_PATH - create manually" - - # Create template config - cat > config/config.yml << 'FRIGATE_CONFIG' -# Frigate Configuration -# Docs: https://docs.frigate.video -# -# ⚠️ YOU MUST EDIT THIS FILE to add your cameras! - -mqtt: - enabled: false - -cameras: - # EXAMPLE - Replace with your camera: - # front_door: - # ffmpeg: - # inputs: - # - path: rtsp://user:pass@192.168.1.100:554/stream - # roles: [detect, record] - # detect: - # width: 1280 - # height: 720 - # fps: 5 - -detectors: - default: - type: cpu - -record: - enabled: true - retain: - days: 7 - mode: motion - -snapshots: - enabled: true - retain: - default: 7 -FRIGATE_CONFIG - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_DIR" 2>/dev/null || true - - echo "" - echo "✓ Frigate installed at $FRIGATE_DIR" - echo "" - echo " ⚠️ Note: You should edit config/config.yml to add cameras before starting." - prompt_yn "Start Frigate now anyway? (y/n):" "n" START_FRIGATE - if [ "$START_FRIGATE" = "y" ] || [ "$START_FRIGATE" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Frigate started" || echo " ⚠ Failed to start" - fi - - echo " Access: http://localhost:5000" - echo " Docs: https://docs.frigate.video" - echo "" - fi - fi - - # ---- CADDY REVERSE PROXY (Legacy) ---- - # Note: This is the legacy Caddy installation - # The newer installation above includes CrowdSec support - # This section is kept for backwards compatibility - if [ "$WHIPTAIL_USED" != true ] && [ "$INSTALL_CADDY" != "y" ] && [ "$INSTALL_CADDY" != "Y" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CADDY - Automatic HTTPS reverse proxy (Legacy) │" - echo "│ Route domains to containers with automatic SSL certificates. │" - echo "│ Ports: 80, 443 │" - echo "└─────────────────────────────────────────────────────────────────┘" - if [ -z "$INSTALL_CADDY_LEGACY" ]; then - prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY_LEGACY - fi - INSTALL_CADDY="$INSTALL_CADDY_LEGACY" - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - echo "Installing Caddy..." - CADDY_DIR="$DOCKER_DIR/caddy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $CADDY_DIR" - else - # STEP 1: Create directory and install docker-compose - mkdir -p "$CADDY_DIR" 2>/dev/null || true - cd "$CADDY_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'CADDY_COMPOSE' -name: caddy - -services: - caddy: - image: caddy:latest - container_name: caddy - hostname: caddy - restart: unless-stopped - env_file: .env - ports: - - "80:80" - - "443:443" - - "443:443/udp" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - ./config:/config - - ./data:/data - - ./site:/srv - -networks: - default: - name: caddy_net - external: true -CADDY_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try configuration (uses defaults if fails) - CADDY_DOMAIN="" - prompt_text "Your domain (e.g., example.com) [blank for local]:" "" CADDY_DOMAIN 2>/dev/null || CADDY_DOMAIN="" - - cat > .env << CADDY_ENV -MY_DOMAIN=${CADDY_DOMAIN:-localhost} -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -CADDY_ENV - - # Create Docker network for Caddy (ignore if exists) - docker network create caddy_net 2>/dev/null || true - - # Create comprehensive Caddyfile with all services - cat > Caddyfile << 'CADDY_FILE' -# Caddy reverse proxy configuration -# Edit MY_DOMAIN in .env file, then uncomment services below -# -# To use: containers must be on 'caddy_net' network -# Add to each container's docker-compose.yml: -# networks: -# default: -# name: caddy_net -# external: true - -# ============================================================================ -# GLOBAL OPTIONS -# ============================================================================ -{ - # Uncomment for local-only (no domain/SSL): - # auto_https off -} - -# ============================================================================ -# MEDIA SERVERS -# ============================================================================ - -# Immich (photo backup) -# immich.{$MY_DOMAIN} { -# reverse_proxy immich_server:2283 -# } - -# Jellyfin (media server) -# jellyfin.{$MY_DOMAIN} { -# reverse_proxy jellyfin:8096 -# } - -# Emby (media server) -# emby.{$MY_DOMAIN} { -# reverse_proxy emby:8096 -# } - -# Audiobookshelf -# audiobooks.{$MY_DOMAIN} { -# reverse_proxy audiobookshelf:80 -# } - -# Lyrion Music Server -# music.{$MY_DOMAIN} { -# reverse_proxy lms:9000 -# } - -# ============================================================================ -# HOME AUTOMATION & MONITORING -# ============================================================================ - -# Frigate NVR -# frigate.{$MY_DOMAIN} { -# reverse_proxy frigate:5000 -# } - -# Uptime Kuma -# status.{$MY_DOMAIN} { -# reverse_proxy uptime-kuma:3001 -# } - -# Magic Mirror -# mirror.{$MY_DOMAIN} { -# reverse_proxy magicmirror:8080 -# } - -# Traccar GPS -# gps.{$MY_DOMAIN} { -# reverse_proxy traccar:8082 -# } - -# FindMyDevice -# fmd.{$MY_DOMAIN} { -# reverse_proxy fmd:8080 -# } - -# ============================================================================ -# UTILITIES -# ============================================================================ - -# Filebrowser -# files.{$MY_DOMAIN} { -# reverse_proxy filebrowser:80 -# } - -# Mealie (recipes) -# recipes.{$MY_DOMAIN} { -# reverse_proxy mealie:9000 -# } - -# Home Assistant (home) -# home.{$MY_DOMAIN} { -# reverse_proxy homeassistant:8123 -# } - -# ntfy (notifications) -# ntfy.{$MY_DOMAIN} { -# reverse_proxy ntfy:80 -# } - -# Portainer -# docker.{$MY_DOMAIN} { -# reverse_proxy portainer:9000 -# } - -# Kopia backup UI -# backup.{$MY_DOMAIN} { -# reverse_proxy kopia:51515 -# } - -# ============================================================================ -# TESTING / CATCH-ALL -# ============================================================================ - -# Local testing (always responds) -:80 { - respond "Caddy is running! Edit Caddyfile to enable your services." -} -CADDY_FILE - - mkdir -p config data site 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CADDY_DIR" 2>/dev/null || true - - echo "" - echo "✓ Caddy installed at $CADDY_DIR" - - prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY - if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start" - fi - - echo " Domain: ${CADDY_DOMAIN:-localhost} (edit .env)" - echo " Config: $CADDY_DIR/Caddyfile (uncomment services)" - echo "" - echo " ⚠️ Containers must be on 'caddy_net' network" - echo " Docs: https://caddyserver.com/docs/" - echo "" - fi - fi - - # ---- DDCLIENT DYNAMIC DNS ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_DDCLIENT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ DDCLIENT - Dynamic DNS updater │" - echo "│ Keep your domain pointing to your home IP. │" - echo "│ Supports: Cloudflare, DuckDNS, No-IP, and more. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ddclient? (y/n):" "n" INSTALL_DDCLIENT - fi - - if [ "$INSTALL_DDCLIENT" = "y" ] || [ "$INSTALL_DDCLIENT" = "Y" ]; then - echo "Installing ddclient..." - DDCLIENT_DIR="$DOCKER_DIR/ddclient" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $DDCLIENT_DIR" - else - # STEP 1: Install docker-compose - mkdir -p "$DDCLIENT_DIR" 2>/dev/null || true - cd "$DDCLIENT_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'DDCLIENT_COMPOSE' -name: ddclient - -services: - ddclient: - image: lscr.io/linuxserver/ddclient:latest - container_name: ddclient - hostname: ddclient - restart: unless-stopped - environment: - - PUID=1000 - - PGID=1000 - - TZ=${TZ} - volumes: - - ./config:/config -DDCLIENT_COMPOSE - echo "✓ Installed docker-compose.yml" - - cat > .env << DDCLIENT_ENV -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -DDCLIENT_ENV - - mkdir -p config 2>/dev/null || true - - # Create template config - cat > config/ddclient.conf << 'DDCLIENT_CONF' -# ddclient configuration -# ⚠️ YOU MUST EDIT THIS FILE! -# Uncomment and edit for your DNS provider - -daemon=300 -syslog=yes -pid=/var/run/ddclient/ddclient.pid -ssl=yes - -# Cloudflare example: -# use=web, web=cloudflare -# protocol=cloudflare -# zone=example.com -# login=token -# password=your-api-token -# example.com - -# DuckDNS example: -# use=web -# protocol=duckdns -# password=your-duckdns-token -# yourdomain.duckdns.org -DDCLIENT_CONF - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DDCLIENT_DIR" 2>/dev/null || true - - echo "" - echo "✓ ddclient installed at $DDCLIENT_DIR" - echo "" - echo " ⚠️ Note: You should edit config/ddclient.conf before starting." - prompt_yn "Start ddclient now anyway? (y/n):" "n" START_DDCLIENT - if [ "$START_DDCLIENT" = "y" ] || [ "$START_DDCLIENT" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ddclient started" || echo " ⚠ Failed to start" - fi - - echo " Docs: https://ddclient.net/" - echo "" - fi - fi - - # ---- NTFY NOTIFICATIONS ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_NTFY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ NTFY - Push notifications server │" - echo "│ Send notifications from scripts to your phone. │" - echo "│ Port: 8090 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ntfy? (y/n):" "n" INSTALL_NTFY - fi - - if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then - echo "Installing ntfy..." - NTFY_DIR="$DOCKER_DIR/ntfy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $NTFY_DIR" - else - mkdir -p "$NTFY_DIR" - ensure_docker_dir_ownership "$NTFY_DIR" - cd "$NTFY_DIR" - - cat > docker-compose.yml << 'NTFY_COMPOSE' -name: ntfy - -services: - ntfy: - image: binwiederhier/ntfy:latest - container_name: ntfy - hostname: ntfy - restart: unless-stopped - command: serve - environment: - - TZ=${TZ} - volumes: - - ./cache:/var/cache/ntfy - - ./config:/etc/ntfy - ports: - - "8090:80" -NTFY_COMPOSE - - cat > .env << NTFY_ENV -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -NTFY_ENV - - mkdir -p cache config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" - - echo "" - echo "✓ ntfy configured at $NTFY_DIR" - - prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY - if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ntfy started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8090" - echo "" - echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic" - echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic" - echo "" - fi - fi - - # ---- UPTIME KUMA ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_UPTIMEKUMA" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ UPTIME KUMA - Service monitoring dashboard │" - echo "│ Monitor websites, servers, Docker containers. │" - echo "│ Port: 3001 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Uptime Kuma? (y/n):" "n" INSTALL_UPTIMEKUMA - fi - - if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then - echo "Installing Uptime Kuma..." - UPTIME_DIR="$DOCKER_DIR/uptime-kuma" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $UPTIME_DIR" - else - mkdir -p "$UPTIME_DIR" - ensure_docker_dir_ownership "$UPTIME_DIR" - cd "$UPTIME_DIR" - - cat > docker-compose.yml << 'UPTIME_COMPOSE' -name: uptime-kuma - -services: - uptime-kuma: - image: louislam/uptime-kuma:1 - container_name: uptime-kuma - hostname: uptime-kuma - restart: unless-stopped - volumes: - - ./data:/app/data - - /var/run/docker.sock:/var/run/docker.sock:ro - ports: - - "3001:3001" -UPTIME_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPTIME_DIR" - - echo "" - echo "✓ Uptime Kuma configured at $UPTIME_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Uptime Kuma" "3001" "uptime" - - prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME - if [ "$START_UPTIME" = "y" ] || [ "$START_UPTIME" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Uptime Kuma started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:3001" - echo "" - fi - fi - - # ---- WG-EASY (WireGuard with Web UI) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WGEASY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ WG-EASY - WireGuard VPN with web management │" - echo "│ Easy WireGuard setup with QR codes for clients. │" - echo "│ Port: 51821 (web), 51820 (VPN) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install wg-easy? (y/n):" "n" INSTALL_WGEASY - fi - - if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then - echo "Installing wg-easy..." - WGEASY_DIR="$DOCKER_DIR/wg-easy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $WGEASY_DIR" - else - mkdir -p "$WGEASY_DIR" - ensure_docker_dir_ownership "$WGEASY_DIR" - cd "$WGEASY_DIR" - - # Get public IP or hostname - PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "your-public-ip") - WG_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) - - prompt_text "Public IP or hostname for VPN [default: $PUBLIC_IP]:" "$PUBLIC_IP" WG_HOST - - cat > docker-compose.yml << WGEASY_COMPOSE -name: wg-easy - -services: - wg-easy: - image: ghcr.io/wg-easy/wg-easy:latest - container_name: wg-easy - hostname: wg-easy - restart: unless-stopped - cap_add: - - NET_ADMIN - - SYS_MODULE - sysctls: - - net.ipv4.ip_forward=1 - - net.ipv4.conf.all.src_valid_mark=1 - environment: - - WG_HOST=\${WG_HOST} - - PASSWORD=\${WG_PASSWORD} - - WG_DEFAULT_DNS=1.1.1.1 - volumes: - - ./config:/etc/wireguard - ports: - - "51820:51820/udp" - - "51821:51821/tcp" -WGEASY_COMPOSE - - cat > .env << WGEASY_ENV -WG_HOST=$WG_HOST -WG_PASSWORD=$WG_PASSWORD -WGEASY_ENV - - mkdir -p config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WGEASY_DIR" - - echo "" - echo "✓ wg-easy configured at $WGEASY_DIR" - - prompt_yn "Start wg-easy now? (y/n):" "y" START_WGEASY - if [ "$START_WGEASY" = "y" ] || [ "$START_WGEASY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ wg-easy started" || echo " ⚠ Failed to start" - fi - - echo " Web UI: http://localhost:51821" - echo " Password: $WG_PASSWORD (saved in .env)" - echo " VPN Port: 51820/udp (forward this in your router)" - echo "" - fi - fi - - # ---- TRACCAR GPS TRACKING ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_TRACCAR" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ TRACCAR - GPS tracking server │" - echo "│ Track phones, vehicles, assets with OwnTracks/Traccar apps. │" - echo "│ Port: 8082 (web), 5055 (OsmAnd), 5000+ (devices) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Traccar? (y/n):" "n" INSTALL_TRACCAR - fi - - if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then - echo "Installing Traccar..." - TRACCAR_DIR="$DOCKER_DIR/traccar" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $TRACCAR_DIR" - else - mkdir -p "$TRACCAR_DIR" - ensure_docker_dir_ownership "$TRACCAR_DIR" - cd "$TRACCAR_DIR" - - cat > docker-compose.yml << 'TRACCAR_COMPOSE' -name: traccar - -services: - traccar: - image: traccar/traccar:latest - container_name: traccar - hostname: traccar - restart: unless-stopped - volumes: - - ./logs:/opt/traccar/logs:rw - - ./data:/opt/traccar/data:rw - - ./config/traccar.xml:/opt/traccar/conf/traccar.xml:ro - ports: - - "8082:8082" - - "5000-5150:5000-5150" - - "5000-5150:5000-5150/udp" -TRACCAR_COMPOSE - - mkdir -p logs data config - - # Create basic traccar.xml config - cat > config/traccar.xml << 'TRACCAR_XML' - - - - - - ./conf/default.xml - org.h2.Driver - jdbc:h2:/opt/traccar/data/database - sa - - -TRACCAR_XML - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR" - - echo "" - echo "✓ Traccar configured at $TRACCAR_DIR" - - prompt_yn "Start Traccar now? (y/n):" "y" START_TRACCAR - if [ "$START_TRACCAR" = "y" ] || [ "$START_TRACCAR" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Traccar started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8082" - echo " Default: admin@admin.com / admin (change immediately!)" - echo "" - fi - fi - - # ---- PORTAINER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_PORTAINER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ PORTAINER - Docker management web UI │" - echo "│ Manage containers, images, volumes via browser. │" - echo "│ Port: 9443 (https), 9000 (http) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Portainer? (y/n):" "n" INSTALL_PORTAINER - fi - - if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then - echo "Installing Portainer..." - PORTAINER_DIR="$DOCKER_DIR/portainer" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $PORTAINER_DIR" - else - mkdir -p "$PORTAINER_DIR" - ensure_docker_dir_ownership "$PORTAINER_DIR" - cd "$PORTAINER_DIR" - - cat > docker-compose.yml << 'PORTAINER_COMPOSE' -name: portainer - -services: - portainer: - image: portainer/portainer-ce:latest - container_name: portainer - hostname: portainer - restart: always - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./data:/data - ports: - - "9000:9000" - - "9443:9443" -PORTAINER_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR" - - echo "" - echo "✓ Portainer configured at $PORTAINER_DIR" - - prompt_yn "Start Portainer now? (y/n):" "y" START_PORTAINER - if [ "$START_PORTAINER" = "y" ] || [ "$START_PORTAINER" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Portainer started" || echo " ⚠ Failed to start" - fi - - echo " Access at: https://localhost:9443" - echo " Create admin account on first visit" - echo "" - fi - fi - - # ---- MESHCENTRAL SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MESHCENTRAL_SERVER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MESHCENTRAL SERVER - Self-hosted remote management │" - echo "│ Full MeshCentral server (not just agent). Manage all devices. │" - echo "│ Port: 4430 (https), 4433 (agent) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install MeshCentral Server? (y/n):" "n" INSTALL_MESHCENTRAL_SERVER - fi - - if [ "$INSTALL_MESHCENTRAL_SERVER" = "y" ] || [ "$INSTALL_MESHCENTRAL_SERVER" = "Y" ]; then - echo "Installing MeshCentral Server..." - MC_DIR="$DOCKER_DIR/meshcentral" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MC_DIR" - else - mkdir -p "$MC_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$MC_DIR" - cd "$MC_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'MC_COMPOSE' -name: meshcentral - -services: - meshcentral: - image: ghcr.io/ylianst/meshcentral:latest - container_name: meshcentral - hostname: meshcentral - restart: unless-stopped - environment: - - NODE_ENV=production - - HOSTNAME=${MC_HOSTNAME:-localhost} - - REVERSE_PROXY=${MC_REVERSE_PROXY:-false} - - REVERSE_PROXY_TLS_PORT=${MC_TLS_PORT:-443} - - IFRAME=false - - ALLOW_NEW_ACCOUNTS=true - - WEBRTC=true - volumes: - - ./data:/opt/meshcentral/meshcentral-data - - ./files:/opt/meshcentral/meshcentral-files - - ./backups:/opt/meshcentral/meshcentral-backups - ports: - - "4430:443" - - "4433:4433" -MC_COMPOSE - echo "✓ Installed docker-compose.yml" - - # Ask for hostname - echo "" - prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME 2>/dev/null || MC_HOSTNAME="localhost" - - cat > .env << MC_ENV -MC_HOSTNAME=$MC_HOSTNAME -MC_REVERSE_PROXY=false -MC_TLS_PORT=443 -MC_ENV - - mkdir -p data files backups 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" 2>/dev/null || true - - echo "" - echo "✓ MeshCentral Server installed at $MC_DIR" - - prompt_yn "Start MeshCentral now? (y/n):" "y" START_MESHCENTRAL - if [ "$START_MESHCENTRAL" = "y" ] || [ "$START_MESHCENTRAL" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ MeshCentral started" || echo " ⚠ Failed to start" - fi - - echo " Access: https://localhost:4430" - echo "" - echo " First visit: Create admin account" - echo " Then: Add devices → Download agent for each OS" - echo "" - echo " ⚠️ For remote access, set MC_HOSTNAME in .env to your domain/IP" - echo " Docs: https://meshcentral.com/docs/" - echo "" - fi - fi - - # ---- FINDMYDEVICE (FMD) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FMD" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FINDMYDEVICE - Self-hosted device tracking │" - echo "│ Track and locate Android devices. Alternative to Google Find. │" - echo "│ Port: 8084 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install FindMyDevice server? (y/n):" "n" INSTALL_FMD - fi - - if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then - echo "Installing FindMyDevice..." - FMD_DIR="$DOCKER_DIR/fmd" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FMD_DIR" - else - mkdir -p "$FMD_DIR" - ensure_docker_dir_ownership "$FMD_DIR" - cd "$FMD_DIR" - - # Generate random admin password - FMD_ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) - - cat > docker-compose.yml << FMD_COMPOSE -name: fmd - -services: - fmd: - image: nulide/findmydevice - container_name: fmd - hostname: fmd - restart: unless-stopped - environment: - - FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD} - volumes: - - ./data:/fmd/data - ports: - - "8084:8080" -FMD_COMPOSE - - cat > .env << FMD_ENV -FMD_ADMIN_PASSWORD=$FMD_ADMIN_PASS -FMD_ENV - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" - - echo "" - echo "✓ FindMyDevice configured at $FMD_DIR" - - prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD - if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ FindMyDevice started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8084" - echo " Admin password: $FMD_ADMIN_PASS (saved in .env)" - echo "" - echo " Mobile app: Install 'FindMyDevice' from F-Droid" - echo " Configure app to point to: http://YOUR-SERVER-IP:8084" - echo "" - fi - fi - - # ---- FRIGATE-NOTIFY ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE_NOTIFY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FRIGATE-NOTIFY - Push notifications for Frigate events │" - echo "│ Get alerts when Frigate detects people, cars, etc. │" - echo "│ Sends to: ntfy, Pushover, Discord, Gotify, and more. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Frigate-Notify? (y/n):" "n" INSTALL_FRIGATE_NOTIFY - fi - - if [ "$INSTALL_FRIGATE_NOTIFY" = "y" ] || [ "$INSTALL_FRIGATE_NOTIFY" = "Y" ]; then - echo "Installing Frigate-Notify..." - FN_DIR="$DOCKER_DIR/frigate-notify" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FN_DIR" - else - # STEP 1: Create directory and docker-compose (always succeeds) - mkdir -p "$FN_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$FN_DIR" - cd "$FN_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'FN_COMPOSE' -name: frigate-notify - -services: - frigate-notify: - image: ghcr.io/0x2142/frigate-notify:latest - container_name: frigate-notify - hostname: frigate-notify - restart: unless-stopped - volumes: - - ./config.yml:/app/config.yml:ro -FN_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try configuration (uses defaults if prompts fail) - echo "" - echo "Attempting auto-configuration..." - - # Set smart defaults based on what's installed - FRIGATE_URL="http://frigate:5000" - NTFY_URL="https://ntfy.sh" - NTFY_TOPIC="frigate-alerts" - - [ -d "$DOCKER_DIR/frigate" ] && echo " ✓ Frigate detected" || echo " ⚠ Frigate not found (using default URL)" - [ -d "$DOCKER_DIR/ntfy" ] && { NTFY_URL="http://ntfy:80"; echo " ✓ ntfy detected"; } || echo " ⚠ ntfy not found (using ntfy.sh)" - - # Try prompts, use defaults if they fail - echo "" - prompt_text "Frigate URL [$FRIGATE_URL]:" "$FRIGATE_URL" FRIGATE_URL 2>/dev/null || FRIGATE_URL="http://frigate:5000" - prompt_text "ntfy server [$NTFY_URL]:" "$NTFY_URL" NTFY_URL 2>/dev/null || NTFY_URL="https://ntfy.sh" - prompt_text "ntfy topic [frigate-alerts]:" "frigate-alerts" NTFY_TOPIC 2>/dev/null || NTFY_TOPIC="frigate-alerts" - - # Create config (template with user values or defaults) - cat > config.yml << FN_CONFIG -# Frigate-Notify Configuration -# Docs: https://frigate-notify.0x2142.com -# -# ⚠️ YOU MAY NEED TO EDIT THIS FILE! -# If notifications don't work, check: -# - Frigate server URL is correct -# - ntfy server is reachable -# - Containers are on same Docker network - -frigate: - server: $FRIGATE_URL - webapi: - enabled: true - interval: 30 - -alerts: - general: - send_startup_message: true - labels: - - person - - car - # - dog - # - package - -notifiers: - - name: ntfy - enabled: true - provider: ntfy - config: - server: $NTFY_URL - topic: $NTFY_TOPIC -FN_CONFIG - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FN_DIR" 2>/dev/null || true - - echo "" - echo "✓ Frigate-Notify installed at $FN_DIR" - - prompt_yn "Start Frigate-Notify now? (y/n):" "y" START_FN - if [ "$START_FN" = "y" ] || [ "$START_FN" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Frigate-Notify started" || echo " ⚠ Failed to start" - fi - - echo " Config: $FN_DIR/config.yml (edit if needed)" - echo " Docs: https://frigate-notify.0x2142.com" - echo "" - fi - fi - - # ---- WATCHTOWER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WATCHTOWER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ WATCHTOWER - Container update monitoring │" - echo "│ Monitor containers for updates. NOTIFY ONLY by default. │" - echo "│ Why notify-only? Apps like Immich have breaking DB migrations. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Watchtower? (y/n):" "n" INSTALL_WATCHTOWER - fi - - if [ "$INSTALL_WATCHTOWER" = "y" ] || [ "$INSTALL_WATCHTOWER" = "Y" ]; then - echo "Installing Watchtower..." - WT_DIR="$DOCKER_DIR/watchtower" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $WT_DIR" - else - mkdir -p "$WT_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$WT_DIR" - cd "$WT_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Ask about mode - echo "" - echo "Watchtower Mode:" - echo " [M] Monitor only - Get notifications about available updates (SAFE)" - echo " [A] Auto-update - Automatically pull and restart containers (RISKY)" - echo "" - echo " ⚠️ Auto-update can break apps like Immich that need DB migrations!" - echo " Recommendation: Use monitor mode, update manually when ready." - echo "" - WT_MODE="M" - prompt_text "Mode [M/A]:" "M" WT_MODE 2>/dev/null || WT_MODE="M" - WT_MODE=$(echo "$WT_MODE" | tr '[:lower:]' '[:upper:]') - - if [ "$WT_MODE" = "A" ]; then - MONITOR_ONLY="false" - echo " Mode: Auto-update (containers will be updated automatically)" - else - MONITOR_ONLY="true" - echo " Mode: Monitor only (you'll be notified of updates)" - fi - - # Check for ntfy - NTFY_URL="" - if [ -d "$DOCKER_DIR/ntfy" ]; then - echo " ✓ ntfy detected - configuring notifications" - NTFY_URL="http://ntfy/watchtower" - fi - - cat > docker-compose.yml << WT_COMPOSE -name: watchtower - -services: - watchtower: - image: containrrr/watchtower:latest - container_name: watchtower - hostname: watchtower - restart: unless-stopped - environment: - # Check for updates daily at 4 AM - - WATCHTOWER_SCHEDULE=0 0 4 * * * - # Monitor only - don't auto-update (change to false for auto-update) - - WATCHTOWER_MONITOR_ONLY=${MONITOR_ONLY} - # Cleanup old images after update - - WATCHTOWER_CLEANUP=true - # Include stopped containers - - WATCHTOWER_INCLUDE_STOPPED=true - # Notification URL (ntfy, Discord, Slack, etc.) - - WATCHTOWER_NOTIFICATION_URL=${NOTIFICATION_URL:-} - # Show debug info - - WATCHTOWER_DEBUG=false - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro -WT_COMPOSE - - # Create .env - cat > .env << WT_ENV -# Watchtower Configuration -# ========================= -# -# Monitor-only mode: Watchtower checks for updates but doesn't apply them. -# This is SAFER because some apps (Immich, Mealie) have database migrations -# that can break if you update without proper procedures. -# -# To update manually: -# cd ~/docker/{app} -# docker compose pull -# docker compose up -d - -# Set to "false" to enable auto-updates (RISKY!) -MONITOR_ONLY=$MONITOR_ONLY - -# Notification URL (optional) -# Examples: -# ntfy: ntfy://ntfy.example.com/watchtower -# Discord: discord://token@id -# Slack: slack://hook-url -# Gotify: gotify://hostname/token -# -# Full list: https://containrrr.dev/shoutrrr/services/overview/ -NOTIFICATION_URL=$NTFY_URL -WT_ENV - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WT_DIR" 2>/dev/null || true - - echo "" - echo "✓ Watchtower installed at $WT_DIR" - - prompt_yn "Start Watchtower now? (y/n):" "y" START_WATCHTOWER - if [ "$START_WATCHTOWER" = "y" ] || [ "$START_WATCHTOWER" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Watchtower started" || echo " ⚠ Failed to start" - fi - - echo " Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update")" - echo "" - echo " Checks for updates daily at 4 AM." - if [ -n "$NTFY_URL" ]; then - echo " Notifications: $NTFY_URL" - else - echo " Configure NOTIFICATION_URL in .env for alerts." - fi - echo "" - echo " To exclude a container from Watchtower:" - echo " Add label: com.centurylinklabs.watchtower.enable=false" - echo "" - fi - fi - - fi # End SKIP_DOCKER_INSTALLS check - - # ============================================================================ - # KOPIA BACKUP FOR DOCKER CONTAINERS - # ============================================================================ - if [ "$WHIPTAIL_USED" != true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DOCKER CONTAINER BACKUP (Kopia)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Backup all Docker containers (configs, databases, app data) to your" - echo "backup drives. Essential for disaster recovery - if your OS drive" - echo "fails, you can restore everything including:" - echo " • Immich memories, facial recognition data" - echo " • Emby/Jellyfin metadata, watch history" - echo " • Minecraft worlds, mods, permissions" - echo " • All app configs, users, and databases" - echo "" - prompt_yn "Set up Kopia container backup? (y/n):" "n" INSTALL_KOPIA - - if [ "$INSTALL_KOPIA" = "y" ] || [ "$INSTALL_KOPIA" = "Y" ]; then - echo "Installing Kopia backup..." - KOPIA_DIR="$DOCKER_DIR/kopia" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $KOPIA_DIR" - else - mkdir -p "$KOPIA_DIR" - ensure_docker_dir_ownership "$KOPIA_DIR" - cd "$KOPIA_DIR" - - KOPIA_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) - - echo "" - echo "Select backup destination(s):" - echo " Backups will be stored in ~/drives/{backup-drive}/kopia-repo/" - echo "" - - # List available backup drives - echo "Available mount points in ~/drives/:" - ls -1 "$ACTUAL_HOME/drives/" 2>/dev/null | grep -v "^primary$" || echo " (none found - set up drives first)" - echo "" - - prompt_text "Backup drive name [default: backup1]:" "backup1" KOPIA_BACKUP_DRIVE - - KOPIA_REPO="$ACTUAL_HOME/drives/$KOPIA_BACKUP_DRIVE/kopia-repo" - - cat > docker-compose.yml << KOPIA_COMPOSE -name: kopia - -services: - kopia: - image: kopia/kopia:latest - container_name: kopia - hostname: kopia - restart: unless-stopped - privileged: true - devices: - - /dev/fuse:/dev/fuse:rwm - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - KOPIA_PASSWORD=\${KOPIA_PASSWORD} - command: > - server start - --tls-generate-cert - --disable-csrf-token-checks - --address=0.0.0.0:51515 - --server-username=admin - --server-password=\${KOPIA_PASSWORD} - volumes: - - ./config:/app/config - - ./cache:/app/cache - - ./logs:/app/logs - - $DOCKER_DIR:/data/docker:ro - - $KOPIA_REPO:/repository - - ./tmp:/tmp:shared - ports: - - "51515:51515" -KOPIA_COMPOSE - - cat > .env << KOPIA_ENV -KOPIA_PASSWORD=$KOPIA_PASSWORD -KOPIA_ENV - - mkdir -p config cache logs tmp - mkdir -p "$KOPIA_REPO" - - # Create backup script - cat > backup-containers.sh << 'BACKUP_SCRIPT' -#!/bin/bash -# Backup all Docker containers using Kopia - -DOCKER_DIR="$HOME/docker" -TIMESTAMP=$(date +%Y%m%d-%H%M%S) - -echo "=== Docker Container Backup: $TIMESTAMP ===" -echo "" - -# Stop containers before backup for consistency (optional) -read -p "Stop containers during backup for consistency? (y/n): " STOP_CONTAINERS - -if [ "$STOP_CONTAINERS" = "y" ]; then - echo "Stopping containers..." - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ]; then - echo " Stopping $(basename $dir)..." - (cd "$dir" && docker compose stop) 2>/dev/null - fi - done -fi - -echo "" -echo "Running Kopia backup..." -docker exec kopia kopia snapshot create /data/docker --description "Container backup $TIMESTAMP" - -if [ "$STOP_CONTAINERS" = "y" ]; then - echo "" - echo "Restarting containers..." - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ]; then - echo " Starting $(basename $dir)..." - (cd "$dir" && docker compose start) 2>/dev/null - fi - done -fi - -echo "" -echo "=== Backup Complete ===" -docker exec kopia kopia snapshot list /data/docker --max-results 5 -BACKUP_SCRIPT - - # Create restore script - cat > restore-containers.sh << 'RESTORE_SCRIPT' -#!/bin/bash -# Restore Docker containers from Kopia backup - -echo "=== Docker Container Restore ===" -echo "" -echo "Available snapshots:" -docker exec kopia kopia snapshot list /data/docker - -echo "" -echo "To restore a specific snapshot:" -echo " docker exec kopia kopia restore /tmp/restore" -echo " Then copy files from ~/docker/kopia/tmp/restore/ to ~/docker/" -echo "" -echo "To mount snapshots for browsing:" -echo " docker exec kopia kopia mount all /tmp/mnt &" -echo " Then browse: ~/docker/kopia/tmp/mnt/" -RESTORE_SCRIPT - - chmod +x backup-containers.sh restore-containers.sh - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$KOPIA_DIR" - - echo "" - echo "✓ Kopia backup configured at $KOPIA_DIR" - - prompt_yn "Start Kopia now? (y/n):" "y" START_KOPIA - if [ "$START_KOPIA" = "y" ] || [ "$START_KOPIA" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Kopia started" || echo " ⚠ Failed to start" - fi - - echo " Web UI: https://localhost:51515" - echo " Username: admin" - echo " Password: $KOPIA_PASSWORD (saved in .env)" - echo "" - echo " Repository at: $KOPIA_REPO" - echo "" - echo " Backup now: cd $KOPIA_DIR && ./backup-containers.sh" - echo " Restore: cd $KOPIA_DIR && ./restore-containers.sh" - echo "" - echo " ⚠️ SAVE YOUR KOPIA PASSWORD! Without it, backups cannot be restored." - echo "" - fi - fi - fi # End WHIPTAIL_USED check for Kopia - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Docker applications configured in: $DOCKER_DIR" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To start an application:" - echo " cd ~/docker/{appname}" - echo " docker compose up -d" - echo "" - echo "To view logs:" - echo " docker compose logs -f" - echo "" - echo "To stop:" - echo " docker compose down" - echo "" -fi - -# Backup System (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "LOCAL BACKUP SYSTEM (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Set up rsync backup from primary drive(s) to backup drive(s)." -echo "" -echo "Why rsync instead of RAID?" -echo " • RAID mirrors corruption instantly - rsync gives you time to notice" -echo " • RAID requires identical drives - rsync works with any sizes" -echo " • RAID is complex to set up/recover - rsync is simple copy" -echo " • rsync can run on schedule - RAID is always-on (more wear)" -echo " • With rsync, backup drives can be disconnected for safety" -echo "" - -# Check if backup is already configured -BACKUP_CONFIGURED=false -if [ -f /usr/local/bin/backup-scripts/rsync-backup.sh ]; then - BACKUP_CONFIGURED=true - echo "Local backup system is already configured." - echo "" - prompt_yn "Reconfigure local backup system? (y/n):" "n" SETUP_BACKUP -else - prompt_yn "Set up local backup system? (y/n):" "n" SETUP_BACKUP -fi - -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Configure your drive mount points in ~/drives/" - echo "Default names: primary, backup1, backup2, etc." - echo "You can customize these (e.g., 'media', 'documents', 'photos-backup')" - echo "" - - # Install rsync if needed - if ! is_rsync_installed; then - run_cmd apt install -y rsync || echo "Warning: rsync installation failed" - fi - - # Create backup script directory - mkdir -p /usr/local/bin/backup-scripts - - # Ask for primary drive name - echo "PRIMARY DRIVE (source for backups):" - prompt_text " Mount point name [default: primary]:" "primary" PRIMARY_NAME - PRIMARY_NAME="${PRIMARY_NAME:-primary}" - - # Ask for number of backup drives - echo "" - echo "BACKUP DRIVES (destinations):" - prompt_text " How many backup drives? [1-4, default: 1]:" "1" NUM_BACKUPS - NUM_BACKUPS="${NUM_BACKUPS:-1}" - - # Validate number - case $NUM_BACKUPS in - 1|2|3|4) ;; - *) NUM_BACKUPS=1 ;; - esac - - # Collect backup drive names - declare -a BACKUP_NAMES - for i in $(seq 1 $NUM_BACKUPS); do - prompt_text " Backup drive $i name [default: backup$i]:" "backup$i" "BACKUP_NAME_$i" - eval "BACKUP_NAMES[$i]=\${BACKUP_NAME_$i:-backup$i}" - done - - # Create mount point directories - echo "" - echo "Creating mount point directories..." - mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME" - for i in $(seq 1 $NUM_BACKUPS); do - mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - done - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - echo "✓ Created mount points:" - echo " Primary: $ACTUAL_HOME/drives/$PRIMARY_NAME" - for i in $(seq 1 $NUM_BACKUPS); do - echo " Backup$i: $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - done - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE MOUNTING" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Available block devices:" - echo "" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - - prompt_yn "Mount drives now? (y/n):" "n" MOUNT_NOW - - if [ "$MOUNT_NOW" = "y" ] || [ "$MOUNT_NOW" = "Y" ]; then - echo "" - echo "Enter device paths (e.g., /dev/sdb1) or leave blank to skip" - echo "" - - read -p "Primary drive ($PRIMARY_NAME) device: " PRIMARY_DEV - - declare -a BACKUP_DEVS - for i in $(seq 1 $NUM_BACKUPS); do - read -p "Backup drive ${BACKUP_NAMES[$i]} device: " "BACKUP_DEV_$i" - eval "BACKUP_DEVS[$i]=\$BACKUP_DEV_$i" - done - - # Mount primary - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - echo "Mounting $PRIMARY_DEV to $ACTUAL_HOME/drives/$PRIMARY_NAME..." - mount "$PRIMARY_DEV" "$ACTUAL_HOME/drives/$PRIMARY_NAME" && echo "✓ $PRIMARY_NAME mounted" || echo "✗ Failed to mount $PRIMARY_NAME" - fi - - # Mount backups - for i in $(seq 1 $NUM_BACKUPS); do - if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then - echo "Mounting ${BACKUP_DEVS[$i]} to $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}..." - mount "${BACKUP_DEVS[$i]}" "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" && echo "✓ ${BACKUP_NAMES[$i]} mounted" || echo "✗ Failed to mount ${BACKUP_NAMES[$i]}" - fi - done - - echo "" - echo "Current mounts:" - df -h | grep "$ACTUAL_HOME/drives" || echo " (no drives currently mounted)" - - echo "" - prompt_yn "Add to /etc/fstab for auto-mount at boot? (y/n):" "n" ADD_FSTAB - - if [ "$ADD_FSTAB" = "y" ] || [ "$ADD_FSTAB" = "Y" ]; then - echo "" - echo "Adding entries to /etc/fstab..." - cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) - - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV") - if [ -n "$PRIMARY_UUID" ]; then - echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab - echo "✓ Added $PRIMARY_NAME to fstab" - fi - fi - - for i in $(seq 1 $NUM_BACKUPS); do - if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then - BACKUP_UUID=$(blkid -s UUID -o value "${BACKUP_DEVS[$i]}") - if [ -n "$BACKUP_UUID" ]; then - echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab - echo "✓ Added ${BACKUP_NAMES[$i]} to fstab" - fi - fi - done - fi - fi - - # Create rsync backup script - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CREATING BACKUP SCRIPT" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - - # Build backup destinations list - BACKUP_DESTS="" - for i in $(seq 1 $NUM_BACKUPS); do - BACKUP_DESTS="$BACKUP_DESTS \"$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}\"" - done - - cat > /usr/local/bin/backup-scripts/rsync-backup.sh << BACKUP_SCRIPT -#!/bin/bash -################################################################################ -# rsync Local Backup Script -# Backs up primary drive to all backup drives -################################################################################ - -PRIMARY="$ACTUAL_HOME/drives/$PRIMARY_NAME" -BACKUP_DRIVES=($BACKUP_DESTS) -LOG="/var/log/rsync-backup.log" - -echo "=== rsync Backup Started: \$(date) ===" | tee -a "\$LOG" -echo "Source: \$PRIMARY" | tee -a "\$LOG" -echo "" | tee -a "\$LOG" - -if [ ! -d "\$PRIMARY" ] || [ -z "\$(ls -A \$PRIMARY 2>/dev/null)" ]; then - echo "ERROR: Primary drive not mounted or empty at \$PRIMARY" | tee -a "\$LOG" - exit 1 -fi - -for BACKUP in "\${BACKUP_DRIVES[@]}"; do - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "\$LOG" - echo "Backing up to: \$BACKUP" | tee -a "\$LOG" - - if [ ! -d "\$BACKUP" ]; then - echo "⚠️ WARNING: \$BACKUP not mounted, skipping" | tee -a "\$LOG" - continue - fi - - # rsync options: - # -a = archive mode (preserves permissions, timestamps, etc.) - # -v = verbose - # -h = human-readable sizes - # --delete = remove files from backup that don't exist on primary - # --progress = show progress - - rsync -avh --delete --progress "\$PRIMARY/" "\$BACKUP/" 2>&1 | tee -a "\$LOG" - - if [ \$? -eq 0 ]; then - echo "✓ Backup to \$BACKUP completed" | tee -a "\$LOG" - else - echo "✗ Backup to \$BACKUP FAILED" | tee -a "\$LOG" - fi -done - -echo "" | tee -a "\$LOG" -echo "=== Backup Completed: \$(date) ===" | tee -a "\$LOG" -BACKUP_SCRIPT - - chmod +x /usr/local/bin/backup-scripts/rsync-backup.sh - - # Create systemd service - cat > /etc/systemd/system/rsync-backup.service << SERVICE -[Unit] -Description=rsync Local Backup Service -After=network.target - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/backup-scripts/rsync-backup.sh -User=root -SERVICE - - # Create systemd timer for daily backups at 2 AM - cat > /etc/systemd/system/rsync-backup.timer << TIMER -[Unit] -Description=Daily rsync Backup Timer -Requires=rsync-backup.service - -[Timer] -OnCalendar=*-*-* 02:00:00 -Persistent=true - -[Install] -WantedBy=timers.target -TIMER - - echo "" - echo "✓ Backup script created: /usr/local/bin/backup-scripts/rsync-backup.sh" - echo "✓ Systemd service/timer created (disabled by default)" - echo "" - echo "Quick start:" - echo " Test backup: sudo /usr/local/bin/backup-scripts/rsync-backup.sh" - echo " Enable daily: sudo systemctl enable --now rsync-backup.timer" - echo " View log: tail -f /var/log/rsync-backup.log" -else - echo "Skipping local backup setup." -fi -fi # End WHIPTAIL_USED check for local backup - -# Cloud Backup with rclone (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "CLOUD BACKUP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Set up encrypted cloud backup using rclone." -echo "Supports: Google Drive, OneDrive, Dropbox, and 40+ other providers." -echo "" -echo "Your files are encrypted BEFORE upload - the cloud provider cannot read them." -echo "" - -prompt_yn "Set up encrypted cloud backup? (y/n):" "n" SETUP_CLOUD_BACKUP - -if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then - echo "" - - # Install rclone if needed - if ! is_rclone_installed; then - echo "Installing rclone..." - run_cmd apt install -y rclone || echo "Warning: rclone installation failed" - fi - - if is_rclone_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CHOOSE CLOUD PROVIDER" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo " [1] Google Drive (15GB free)" - echo " [2] Microsoft OneDrive (5GB free, 1TB with Microsoft 365)" - echo " [3] Other (manual rclone config)" - echo "" - prompt_text "Select provider [1/2/3]:" "1" CLOUD_PROVIDER - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "RCLONE CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - case $CLOUD_PROVIDER in - 1) - echo "Setting up Google Drive..." - echo "" - echo "Step 1: Run 'rclone config' to create a Google Drive remote" - echo "Step 2: When prompted:" - echo " - Choose 'n' for new remote" - echo " - Name it 'gdrive'" - echo " - Choose 'drive' (Google Drive)" - echo " - Leave client_id and client_secret blank" - echo " - Choose scope '1' (full access)" - echo " - Leave root_folder_id blank" - echo " - Leave service_account_file blank" - echo " - Choose 'n' for advanced config" - echo " - Choose 'y' for auto config (opens browser)" - echo " - Choose 'n' for team drive" - echo " - Confirm with 'y'" - echo "" - CLOUD_REMOTE="gdrive" - ;; - 2) - echo "Setting up Microsoft OneDrive..." - echo "" - echo "Step 1: Run 'rclone config' to create a OneDrive remote" - echo "Step 2: When prompted:" - echo " - Choose 'n' for new remote" - echo " - Name it 'onedrive'" - echo " - Choose 'onedrive' (Microsoft OneDrive)" - echo " - Leave client_id and client_secret blank" - echo " - Choose region (usually 'global')" - echo " - Choose 'n' for advanced config" - echo " - Choose 'y' for auto config (opens browser)" - echo " - Choose 'onedrive' for account type" - echo " - Choose your drive from the list (usually option 0)" - echo " - Confirm with 'y'" - echo "" - CLOUD_REMOTE="onedrive" - ;; - *) - echo "Manual configuration selected." - echo "Run 'rclone config' to set up your cloud provider." - echo "" - prompt_text "Enter the remote name you will create:" "cloud" CLOUD_REMOTE - ;; - esac - - if [ "$UNATTENDED" != true ]; then - echo "Press Enter to launch rclone config..." - read - sudo -u "$ACTUAL_USER" rclone config - else - echo "Skipping interactive rclone config (unattended mode)" - echo "Run 'rclone config' manually to complete setup" - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "ENCRYPTION SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Now we'll create an encrypted wrapper around your cloud storage." - echo "This encrypts file names AND contents before upload." - echo "" - echo "⚠️ IMPORTANT: You will set a password. Without this password and" - echo " the rclone config file, your files CANNOT be recovered!" - echo "" - - if [ "$UNATTENDED" != true ]; then - echo "In rclone config:" - echo " - Choose 'n' for new remote" - echo " - Name it '${CLOUD_REMOTE}-crypt'" - echo " - Choose 'crypt' (Encrypt/Decrypt)" - echo " - Remote: '${CLOUD_REMOTE}:backup' (folder on cloud storage)" - echo " - Choose 'standard' for filename encryption" - echo " - Choose 'true' for directory name encryption" - echo " - Choose 'y' to enter your own password" - echo " - Enter a STRONG password (you'll need this to decrypt!)" - echo " - Choose 'y' for salt password (or 'n' to skip)" - echo " - Confirm with 'y'" - echo "" - echo "Press Enter to continue rclone config..." - read - sudo -u "$ACTUAL_USER" rclone config - fi - - # Backup the rclone config to local drives - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "BACKUP YOUR RCLONE CONFIG" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Your rclone config contains your encryption keys." - echo "WITHOUT IT, YOUR ENCRYPTED CLOUD FILES CANNOT BE DECRYPTED!" - echo "" - echo "The config file is at: $ACTUAL_HOME/.config/rclone/rclone.conf" - echo "" - - # Copy config to any mounted backup drives - if [ -d "$ACTUAL_HOME/drives" ]; then - for drive_dir in "$ACTUAL_HOME/drives"/*/; do - if [ -d "$drive_dir" ] && mountpoint -q "$drive_dir" 2>/dev/null; then - mkdir -p "${drive_dir}.rclone-config-backup" - if [ -f "$ACTUAL_HOME/.config/rclone/rclone.conf" ]; then - cp "$ACTUAL_HOME/.config/rclone/rclone.conf" "${drive_dir}.rclone-config-backup/rclone.conf.backup" - echo "✓ Config backed up to: ${drive_dir}.rclone-config-backup/" - fi - fi - done - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "SECURE OFF-SITE BACKUP OF CONFIG" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "For disaster recovery, store a copy of rclone.conf OFF-SITE:" - echo "" - echo "Recommended secure methods:" - echo " • Signal (end-to-end encrypted, send to yourself or trusted contact)" - echo " • Box.com (better privacy policy than Dropbox)" - echo " • Password manager (1Password, Bitwarden, etc.)" - echo " • Encrypted USB drive stored at another location" - echo "" - echo "⚠️ Dropbox: Works but has broader data access policies." - echo " Consider encrypting the config file before uploading." - echo "" - echo "To use on another computer:" - echo " 1. Install rclone" - echo " 2. Copy rclone.conf to ~/.config/rclone/" - echo " 3. Run: rclone ls ${CLOUD_REMOTE}-crypt:" - echo "" - - # Create cloud backup script - mkdir -p /usr/local/bin/backup-scripts - - SOURCE_PATH="${PRIMARY_NAME:-primary}" - - cat > /usr/local/bin/backup-scripts/cloud-backup.sh << CLOUD_SCRIPT -#!/bin/bash -################################################################################ -# rclone Encrypted Cloud Backup Script -################################################################################ - -SOURCE="$ACTUAL_HOME/drives/$SOURCE_PATH" -REMOTE="${CLOUD_REMOTE:-gdrive}-crypt" -LOG="/var/log/cloud-backup.log" - -echo "=== Cloud Backup Started: \$(date) ===" | tee -a "\$LOG" -echo "Source: \$SOURCE" | tee -a "\$LOG" -echo "Destination: \$REMOTE:" | tee -a "\$LOG" -echo "" | tee -a "\$LOG" - -if [ ! -d "\$SOURCE" ] || [ -z "\$(ls -A \$SOURCE 2>/dev/null)" ]; then - echo "ERROR: Source not mounted or empty at \$SOURCE" | tee -a "\$LOG" - exit 1 -fi - -# Sync to encrypted cloud storage -rclone sync "\$SOURCE" "\$REMOTE:" \\ - --progress \\ - --stats=30s \\ - --log-file="\$LOG" \\ - --log-level INFO - -if [ \$? -eq 0 ]; then - echo "" | tee -a "\$LOG" - echo "✓ Cloud backup completed: \$(date)" | tee -a "\$LOG" -else - echo "" | tee -a "\$LOG" - echo "✗ Cloud backup FAILED: \$(date)" | tee -a "\$LOG" -fi -CLOUD_SCRIPT - - chmod +x /usr/local/bin/backup-scripts/cloud-backup.sh - chown "$ACTUAL_USER:$ACTUAL_USER" /usr/local/bin/backup-scripts/cloud-backup.sh - - echo "" - echo "✓ Cloud backup script created: /usr/local/bin/backup-scripts/cloud-backup.sh" - echo "" - echo "Quick start:" - echo " Test backup: sudo /usr/local/bin/backup-scripts/cloud-backup.sh" - echo " View log: tail -f /var/log/cloud-backup.log" - else - echo "rclone installation failed. Skipping cloud backup setup." - fi -else - echo "Skipping cloud backup setup." -fi -fi # End WHIPTAIL_USED check for cloud backup - -# UFW Firewall Configuration (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "UFW FIREWALL (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "UFW (Uncomplicated Firewall) provides a simple interface for managing" -echo "iptables firewall rules." -echo "" - -UFW_ACTIVE=false -if is_ufw_installed && ufw status 2>/dev/null | grep -q "Status: active"; then - UFW_ACTIVE=true - echo "UFW is already enabled." - ufw status 2>/dev/null | head -20 - echo "" - prompt_yn "Reconfigure UFW? (y/n):" "n" CONFIGURE_UFW -else - if is_ufw_installed; then - echo "UFW is installed but not enabled." - else - echo "UFW is not installed." - fi - echo "" - prompt_yn "Enable and configure UFW firewall? (y/n):" "y" CONFIGURE_UFW -fi - -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo "" - echo "Configuring UFW firewall..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install ufw if needed" - echo "[DRY-RUN] Would allow SSH (port 22)" - if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo "[DRY-RUN] Would allow Samba" - fi - echo "[DRY-RUN] Would enable UFW" - else - # Install UFW if not present - if ! is_ufw_installed; then - apt install -y ufw || echo "Warning: UFW installation failed" - fi - - if is_ufw_installed; then - # Always allow SSH first (before enabling!) - ufw allow ssh - echo "✓ Allowed SSH (port 22)" - - # Allow Samba if installed - if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ] || is_samba_installed; then - ufw allow samba - echo "✓ Allowed Samba" - fi - - # Allow Docker service ports (only if Docker was installed) - if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo "" - echo "Opening firewall ports for Docker services..." - - prompt_yn "Open firewall ports for installed Docker services? (y/n):" "y" OPEN_DOCKER_PORTS - if [ "$OPEN_DOCKER_PORTS" = "y" ] || [ "$OPEN_DOCKER_PORTS" = "Y" ]; then - - # Reverse proxies (NPM or Caddy) - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - ufw allow 80/tcp comment 'HTTP' 2>/dev/null - ufw allow 443/tcp comment 'HTTPS' 2>/dev/null - echo " ✓ Allowed HTTP/HTTPS (80, 443)" - fi - - # Media servers - if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then - ufw allow 2283/tcp comment 'Immich' 2>/dev/null - echo " ✓ Allowed Immich (2283)" - fi - if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then - ufw allow 8096/tcp comment 'Jellyfin' 2>/dev/null - echo " ✓ Allowed Jellyfin (8096)" - fi - if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then - ufw allow 8096/tcp comment 'Emby' 2>/dev/null - echo " ✓ Allowed Emby (8096)" - fi - - # NVR - if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then - ufw allow 5000/tcp comment 'Frigate' 2>/dev/null - ufw allow 8554/tcp comment 'Frigate RTSP' 2>/dev/null - ufw allow 8555/tcp comment 'Frigate WebRTC' 2>/dev/null - ufw allow 8555/udp comment 'Frigate WebRTC UDP' 2>/dev/null - echo " ✓ Allowed Frigate (5000, 8554, 8555)" - fi - - # Utilities - if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then - ufw allow 9000/tcp comment 'Portainer HTTP' 2>/dev/null - ufw allow 9443/tcp comment 'Portainer HTTPS' 2>/dev/null - echo " ✓ Allowed Portainer (9000, 9443)" - fi - if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then - ufw allow 3001/tcp comment 'Uptime Kuma' 2>/dev/null - echo " ✓ Allowed Uptime Kuma (3001)" - fi - - # VPN - if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then - ufw allow 51820/udp comment 'WireGuard VPN' 2>/dev/null - ufw allow 51821/tcp comment 'WG-Easy Web UI' 2>/dev/null - echo " ✓ Allowed WireGuard (51820/udp, 51821)" - fi - - # GPS Tracking - if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then - ufw allow 8082/tcp comment 'Traccar' 2>/dev/null - ufw allow 5055/tcp comment 'Traccar OsmAnd' 2>/dev/null - echo " ✓ Allowed Traccar (8082, 5055)" - fi - - # Music server - if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then - ufw allow 9000/tcp comment 'Lyrion Music Server' 2>/dev/null - ufw allow 3483/tcp comment 'LMS Players' 2>/dev/null - ufw allow 3483/udp comment 'LMS Players UDP' 2>/dev/null - echo " ✓ Allowed Lyrion Music Server (9000, 3483)" - fi - - # Notifications - if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then - ufw allow 8090/tcp comment 'ntfy' 2>/dev/null - echo " ✓ Allowed ntfy (8090)" - fi - - # Minecraft - if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then - ufw allow 25565/tcp comment 'Minecraft' 2>/dev/null - echo " ✓ Allowed Minecraft (25565)" - fi - - # Other services - if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then - ufw allow 8085/tcp comment 'Filebrowser' 2>/dev/null - echo " ✓ Allowed Filebrowser (8085)" - fi - if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then - ufw allow 8084/tcp comment 'FindMyDevice' 2>/dev/null - echo " ✓ Allowed FindMyDevice (8084)" - fi - if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then - ufw allow 9925/tcp comment 'Mealie' 2>/dev/null - echo " ✓ Allowed Mealie (9925)" - fi - if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then - ufw allow 8123/tcp comment 'Home Assistant' 2>/dev/null - echo " ✓ Allowed Home Assistant (8123)" - fi - if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then - ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null - echo " ✓ Allowed MagicMirror (8081-8083)" - fi - if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then - ufw allow 8080/tcp comment 'A.R.M.' 2>/dev/null - echo " ✓ Allowed A.R.M. (8080)" - fi - if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then - ufw allow 13378/tcp comment 'Audiobookshelf' 2>/dev/null - echo " ✓ Allowed Audiobookshelf (13378)" - fi - - echo "" - fi - fi - - # Enable UFW (with --force to avoid prompt) - ufw --force enable - echo "✓ UFW firewall enabled" - - echo "" - echo "Current UFW status:" - ufw status - else - echo "✗ UFW installation failed, skipping configuration" - fi - fi -else - echo "Skipping UFW configuration." -fi - -CONFIGURE_UFW="${CONFIGURE_UFW:-n}" -fi # End WHIPTAIL_USED check for UFW - -# Full system upgrade (optional) -echo "" -prompt_yn "Perform full system upgrade? (y/n):" "n" DO_SYSTEM_UPGRADE -if [ "$DO_SYSTEM_UPGRADE" = "y" ] || [ "$DO_SYSTEM_UPGRADE" = "Y" ]; then - echo "Performing full system upgrade..." - run_cmd apt upgrade -y -else - echo "Skipping system upgrade." -fi - -# Clean up -echo "" -echo "Cleaning up..." -run_cmd apt autoremove -y -run_cmd apt autoclean - -echo "" -echo "=== Installation Complete! ===" -echo "" -echo "Installed Software:" -echo " ✓ net-tools, ncdu, git, curl, wget, htop, tree, zip/unzip" -echo " ✓ OpenSSH Server - SSH remote access" -if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then - echo " ✓ CrowdSec - intrusion prevention (SSH/Caddy, geo + IP reputation)" -fi -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo " ✓ Docker Engine + Docker Compose" -fi -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo " ✓ Samba - File sharing (Primary drive shared)" -fi -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo " ✓ NetBird - Mesh VPN" -fi -if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then - echo " ✓ WireGuard - VPN" -fi -if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then - echo " ✓ Tailscale - Mesh VPN" -fi -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo " ✓ RustDesk - Remote desktop" -fi -if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then - echo " ✓ TeamViewer - Remote desktop" -fi -if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then - echo " ✓ MeshCentral Agent - Remote management" -fi -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then - echo " ✓ Local backup configured (rsync)" -fi -if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then - echo " ✓ Cloud backup configured (rclone)" -fi -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo " ✓ UFW Firewall - enabled" -fi -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SSH AUTHENTICATION SETUP" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " SSH Key for This Computer:" -if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - if [ -f "$ACTUAL_HOME/.ssh/id_rsa.pub" ]; then -echo " ✓ 4096-bit RSA key generated" -echo " Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" -echo " View with: cat ~/.ssh/id_rsa.pub" - else -echo " ✗ Key generation was attempted but may have failed" - fi -else -echo " Not generated (skipped during install)" -echo " Generate later: ssh-keygen -t rsa -b 4096 -C \"your@email.com\"" -fi -echo "" -echo " SSH Server Status:" -if [ "$KEYS_IMPORTED" = true ]; then -echo " Password authentication: DISABLED (key-only access)" -echo " Imported SSH keys: ~/.ssh/authorized_keys" -else -echo " Password authentication: ENABLED" -fi -echo "" -echo " Traditional SSH Access:" -echo " - Uses keys from GitHub/Launchpad (if imported)" -echo " - Connect with: ssh user@hostname" -if [ "$KEYS_IMPORTED" = true ]; then -echo " - Password login: DISABLED (keys required)" -else -echo " - Password login: ENABLED" -fi -echo "" -echo " NetBird SSH Access (independent of traditional SSH):" -echo " - NetBird manages its own keys automatically" -echo " - Works even with password auth disabled" -echo " - Connect with: netbird ssh " -echo " - Enable in NetBird dashboard first" -echo "" -echo " You can use ANY combination:" -echo " ✓ GitHub + Launchpad + NetBird SSH" -echo " ✓ GitHub + NetBird SSH" -echo " ✓ Launchpad + NetBird SSH" -echo " ✓ GitHub + Launchpad (no NetBird)" -echo " ✓ Just GitHub or just Launchpad" -echo " ✓ Just NetBird SSH" -echo " ✓ None (password auth only - if no keys imported)" -echo "" -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "BACKUP SYSTEM - $BACKUP_TOOL ($BACKUP_MODE mode)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " Mount points:" -echo " $ACTUAL_HOME/drives/primary" -echo " $ACTUAL_HOME/drives/backup1" -if [ "$BACKUP_MODE" = "split" ]; then -echo " $ACTUAL_HOME/drives/backup2" -fi -echo "" -echo " Backup script: /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo " Log file: /var/log/${BACKUP_TOOL}-backup.log" -echo "" -if [ "$BACKUP_MODE" = "split" ]; then -echo " ⚠️ CONFIGURE BEFORE RUNNING:" -echo " Edit the script to set which folders go to which backup drive." -echo " sudo nano /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo "" -fi -echo " Quick start:" -echo " 1. Test (dry-run): sudo ${BACKUP_TOOL}-backup.sh --dry-run # (edit script first)" -echo " 2. Run manually: sudo /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo " 3. Enable auto: sudo systemctl enable ${BACKUP_TOOL}-backup.timer" -echo " sudo systemctl start ${BACKUP_TOOL}-backup.timer" -echo "" -echo " Monitor: tail -f /var/log/${BACKUP_TOOL}-backup.log" -fi - -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "OTHER IMPORTANT NOTES" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo " Samba File Sharing:" - if command -v smbd &> /dev/null && grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then - echo " ✓ Share 'Primary' is accessible at:" - echo " Windows: \\\\$(hostname)\\Primary" - echo " Mac/Linux: smb://$(hostname)/Primary" - echo " • Username: $ACTUAL_USER" - echo " • Use the Samba password you just set" - echo "" - else - echo " ✗ Installation may have failed - check 'systemctl status smbd'" - echo "" - fi -fi -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo " Docker: Log out and back in for group membership to take effect" - echo "" -fi -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo " NetBird:" - echo " 1. Run 'netbird up' (opens browser for authentication)" - echo " 2. View connected peers: netbird status" - echo " 3. Configure ACLs in dashboard: https://app.netbird.io" - echo "" -fi -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo " RustDesk: Launch from applications menu or run 'rustdesk'" - echo "" -fi -if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then - echo " CrowdSec:" - echo " • Check metrics: sudo cscli metrics" - echo " • View active bans: sudo cscli decisions list" - echo " • Unban IP: sudo cscli decisions delete --ip " - echo "" -fi -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo " UFW Firewall:" - echo " • Check status: sudo ufw status" - echo " • Allow port: sudo ufw allow " - echo " • Deny port: sudo ufw deny " - echo " • Disable: sudo ufw disable" - echo "" -fi \ No newline at end of file diff --git a/ubuntu-post-install-24.04-no-keycloak.sh b/ubuntu-post-install-24.04-no-keycloak.sh deleted file mode 100644 index acfd309..0000000 --- a/ubuntu-post-install-24.04-no-keycloak.sh +++ /dev/null @@ -1,7354 +0,0 @@ -#!/bin/bash - -# Ubuntu 24.04 Post-Installation Script -# Run with: sudo bash post-install.sh -# This script is rerunnable - it detects existing installations - -# ============================================================================ -# COMMAND-LINE ARGUMENT PARSING -# ============================================================================ - -DRY_RUN=false -UNATTENDED=false -LOG_FILE="/var/log/post-install.log" -WHIPTAIL_USED=false - -show_help() { - echo "Ubuntu 24.04 Post-Installation Script" - echo "" - echo "Usage: sudo ./post-install.sh [OPTIONS]" - echo "" - echo "Options:" - echo " --dry-run Preview what would be installed without making changes" - echo " --unattended Run with default options (no prompts)" - echo " Defaults: skip key generation, no SSH imports," - echo " install Docker, skip VPNs/Remote Desktop/Backup" - echo " --restore Disaster recovery mode - restore from backup" - echo " --help Show this help message" - echo "" - echo "Examples:" - echo " sudo ./post-install.sh # Interactive mode" - echo " sudo ./post-install.sh --dry-run # Preview installations" - echo " sudo ./post-install.sh --unattended # Automated install" - echo " sudo ./post-install.sh --restore # Disaster recovery" - echo "" - exit 0 -} - -RESTORE_MODE=false - -for arg in "$@"; do - case $arg in - --dry-run) - DRY_RUN=true - ;; - --unattended) - UNATTENDED=true - ;; - --restore) - RESTORE_MODE=true - ;; - --help|-h) - show_help - ;; - *) - echo "Unknown option: $arg" - echo "Use --help for usage information" - exit 1 - ;; - esac -done - -# ============================================================================ -# LOGGING SETUP -# ============================================================================ - -# Create log file and tee all output -exec > >(tee -a "$LOG_FILE") 2>&1 -echo "" -echo "=== Post-Install Log Started: $(date) ===" >> "$LOG_FILE" - -echo "=== Ubuntu 24.04 Post-Installation Script ===" -echo "" - -if [ "$DRY_RUN" = true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRY RUN MODE - No changes will be made" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" -fi - -if [ "$UNATTENDED" = true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "UNATTENDED MODE - Using default options" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" -fi - -# Check if running as root -if [ "$EUID" -ne 0 ]; then - echo "Please run as root (use sudo)" - exit 1 -fi - -# Get the actual user (not root) -ACTUAL_USER="${SUDO_USER:-$USER}" -ACTUAL_HOME=$(getent passwd "$ACTUAL_USER" | cut -d: -f6) -DOCKER_DIR="$ACTUAL_HOME/docker" - -echo "Note: Script will continue even if individual packages fail to install" -echo "Log file: $LOG_FILE" -echo "" - -# ============================================================================ -# INSTALLATION MODE SELECTOR -# ============================================================================ - -INSTALL_MODE="normal" - -# Disaster recovery function -run_disaster_recovery() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DISASTER RECOVERY MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This will restore your system and Docker apps from a Kopia backup." - echo "" - echo "Requirements:" - echo " • Backup drive connected (with kopia-repo folder)" - echo " • Kopia password (saved in .env or you remember it)" - echo "" - - # Step 1: Install core utilities - echo "Step 1: Installing core utilities" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Installing essential packages..." - apt-get update - apt-get install -y \ - net-tools \ - openssh-server \ - git \ - curl \ - wget \ - htop \ - ncdu \ - tree \ - zip \ - unzip \ - whiptail \ - 2>/dev/null || echo " ⚠ Some packages may have failed" - - # Install Kopia for backup management - echo "Installing Kopia..." - if ! command -v kopia &> /dev/null; then - curl -s https://kopia.io/signing-key | gpg --dearmor -o /usr/share/keyrings/kopia-keyring.gpg 2>/dev/null - echo "deb [signed-by=/usr/share/keyrings/kopia-keyring.gpg] https://packages.kopia.io/apt/ stable main" | tee /etc/apt/sources.list.d/kopia.list - apt-get update - apt-get install -y kopia 2>/dev/null || echo " ⚠ Kopia install failed" - fi - - # Start SSH - systemctl enable ssh 2>/dev/null || true - systemctl start ssh 2>/dev/null || true - echo "✓ Core utilities installed" - echo "" - - # Step 2: Find/mount backup drive - echo "Step 2: Locate backup drive" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Available block devices:" - lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null || lsblk - echo "" - - # Check if drives are already mounted - if [ -d "$ACTUAL_HOME/drives" ]; then - echo "Existing mount points in ~/drives/:" - ls -la "$ACTUAL_HOME/drives/" 2>/dev/null || echo " (none)" - echo "" - fi - - read -p "Enter path to backup drive or Kopia repo (e.g., /dev/sdb1 or ~/drives/backup1): " BACKUP_PATH - - # If it's a device, mount it - if [[ "$BACKUP_PATH" == /dev/* ]]; then - MOUNT_POINT="$ACTUAL_HOME/drives/restore-backup" - mkdir -p "$MOUNT_POINT" 2>/dev/null - echo "Mounting $BACKUP_PATH to $MOUNT_POINT..." - mount "$BACKUP_PATH" "$MOUNT_POINT" || { echo "Failed to mount. Check device path."; return 1; } - BACKUP_PATH="$MOUNT_POINT" - fi - - # Expand ~ to home directory - BACKUP_PATH="${BACKUP_PATH/#\~/$ACTUAL_HOME}" - - # Look for kopia-repo - KOPIA_REPO="" - if [ -d "$BACKUP_PATH/kopia-repo" ]; then - KOPIA_REPO="$BACKUP_PATH/kopia-repo" - elif [ -d "$BACKUP_PATH/kopia.repository" ] || [ -f "$BACKUP_PATH/kopia.repository.f" ]; then - KOPIA_REPO="$BACKUP_PATH" - else - echo "" - echo "Looking for Kopia repository..." - FOUND_REPO=$(find "$BACKUP_PATH" -maxdepth 3 -name "kopia.repository*" -type f 2>/dev/null | head -1) - if [ -n "$FOUND_REPO" ]; then - KOPIA_REPO=$(dirname "$FOUND_REPO") - fi - fi - - if [ -z "$KOPIA_REPO" ] || [ ! -d "$KOPIA_REPO" ]; then - echo "❌ Could not find Kopia repository at $BACKUP_PATH" - echo " Look for a folder containing 'kopia.repository' files" - return 1 - fi - - echo "✓ Found Kopia repository at: $KOPIA_REPO" - echo "" - - # Step 3: Get Kopia password - echo "Step 3: Kopia password" - echo "━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Try to find password in backed-up .env - if [ -f "$BACKUP_PATH/kopia/.env" ]; then - KOPIA_PASS=$(grep "KOPIA_PASSWORD" "$BACKUP_PATH/kopia/.env" 2>/dev/null | cut -d= -f2) - if [ -n "$KOPIA_PASS" ]; then - echo "Found password in backup. Use this? (y/n)" - read -p "> " USE_FOUND_PASS - [ "$USE_FOUND_PASS" != "y" ] && KOPIA_PASS="" - fi - fi - - if [ -z "$KOPIA_PASS" ]; then - read -s -p "Enter Kopia repository password: " KOPIA_PASS - echo "" - fi - - # Step 4: Install Docker if needed - echo "" - echo "Step 4: Ensure Docker is installed" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - if ! command -v docker &> /dev/null; then - echo "Installing Docker..." - apt-get update - apt-get install -y ca-certificates curl gnupg - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg - chmod a+r /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - usermod -aG docker "$ACTUAL_USER" - echo "✓ Docker installed" - else - echo "✓ Docker already installed" - fi - echo "" - - # Step 5: List snapshots and let user choose - echo "Step 5: Select snapshot to restore" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Connecting to repository and listing snapshots..." - echo "" - - # Use docker to run kopia - SNAPSHOT_LIST=$(docker run --rm \ - -v "$KOPIA_REPO:/repository" \ - -e KOPIA_PASSWORD="$KOPIA_PASS" \ - kopia/kopia:latest \ - snapshot list --all 2>&1) - - if echo "$SNAPSHOT_LIST" | grep -q "invalid password"; then - echo "❌ Invalid password" - return 1 - fi - - echo "$SNAPSHOT_LIST" - echo "" - read -p "Enter snapshot ID to restore (or 'latest' for most recent): " SNAPSHOT_ID - - if [ "$SNAPSHOT_ID" = "latest" ]; then - SNAPSHOT_ID=$(echo "$SNAPSHOT_LIST" | grep -oE "^[a-f0-9]+" | tail -1) - echo "Using latest snapshot: $SNAPSHOT_ID" - fi - - # Step 6: Restore to temp location - echo "" - echo "Step 6: Restoring from backup" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - RESTORE_TEMP="$ACTUAL_HOME/docker-restore-temp" - mkdir -p "$RESTORE_TEMP" - - echo "Restoring to $RESTORE_TEMP..." - docker run --rm \ - -v "$KOPIA_REPO:/repository" \ - -v "$RESTORE_TEMP:/restore" \ - -e KOPIA_PASSWORD="$KOPIA_PASS" \ - kopia/kopia:latest \ - restore "$SNAPSHOT_ID" /restore - - if [ $? -ne 0 ]; then - echo "❌ Restore failed" - return 1 - fi - echo "✓ Snapshot restored to temp location" - echo "" - - # Step 7: Detect and select services to restore - echo "Step 7: Select services to restore" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - mkdir -p "$DOCKER_DIR" - ensure_docker_dir_ownership "$DOCKER_DIR" - - # Find all docker-compose.yml files in restored backup - declare -A SERVICE_DIRS - SERVICES_FOUND=() - for compose_file in $(find "$RESTORE_TEMP" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null); do - SERVICE_DIR=$(dirname "$compose_file") - SERVICE_NAME=$(basename "$SERVICE_DIR") - SERVICES_FOUND+=("$SERVICE_NAME") - SERVICE_DIRS["$SERVICE_NAME"]="$SERVICE_DIR" - done - - if [ ${#SERVICES_FOUND[@]} -eq 0 ]; then - echo "No Docker services found in backup" - else - echo "Found ${#SERVICES_FOUND[@]} services in backup." - echo "" - - # Check if whiptail is available for nice UI - if command -v whiptail &> /dev/null; then - # Build whiptail checklist arguments - CHECKLIST_ARGS=() - for svc in "${SERVICES_FOUND[@]}"; do - CHECKLIST_ARGS+=("$svc" "" "ON") - done - - # Show checklist - returns selected items - SELECTED=$(whiptail --title "Select Services to Restore" \ - --checklist "Use SPACE to select/deselect, ENTER to confirm:" \ - 20 60 12 \ - "${CHECKLIST_ARGS[@]}" \ - 3>&1 1>&2 2>&3) - - # Parse selected services (whiptail returns quoted strings) - SELECTED_SERVICES=() - for item in $SELECTED; do - # Remove quotes - svc=$(echo "$item" | tr -d '"') - SELECTED_SERVICES+=("$svc") - done - else - # Fallback: text-based selection - echo "Select services to restore:" - echo " [A] All services" - echo " [N] None (skip restore)" - echo " [S] Select individually" - echo "" - read -p "Choice (A/N/S) [A]: " RESTORE_CHOICE - - case "${RESTORE_CHOICE^^}" in - N) - SELECTED_SERVICES=() - ;; - S) - SELECTED_SERVICES=() - for svc in "${SERVICES_FOUND[@]}"; do - read -p " Restore $svc? (y/n) [y]: " RESTORE_SVC - if [ "$RESTORE_SVC" != "n" ] && [ "$RESTORE_SVC" != "N" ]; then - SELECTED_SERVICES+=("$svc") - fi - done - ;; - *) - SELECTED_SERVICES=("${SERVICES_FOUND[@]}") - ;; - esac - fi - - # Restore selected services - echo "" - if [ ${#SELECTED_SERVICES[@]} -eq 0 ]; then - echo "No services selected for restore." - else - echo "Restoring ${#SELECTED_SERVICES[@]} services..." - echo "" - for svc in "${SELECTED_SERVICES[@]}"; do - SERVICE_DIR="${SERVICE_DIRS[$svc]}" - TARGET_DIR="$DOCKER_DIR/$svc" - - echo " Restoring $svc..." - - # Copy entire service directory - cp -r "$SERVICE_DIR" "$TARGET_DIR" 2>/dev/null || true - - # Fix ownership - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TARGET_DIR" 2>/dev/null || true - - echo " ✓ $svc restored to $TARGET_DIR" - done - fi - fi - - # Step 8: Start services - echo "" - echo "Step 8: Starting services" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Count restored services - RESTORED_COUNT=0 - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - ((RESTORED_COUNT++)) - fi - done - - if [ $RESTORED_COUNT -eq 0 ]; then - echo "No services to start." - else - echo "Start restored services?" - echo " [A] All services" - echo " [S] Select which to start" - echo " [N] None (start manually later)" - echo "" - read -p "Choice (A/S/N) [A]: " START_CHOICE - - case "${START_CHOICE^^}" in - N) - echo "Services not started. Start manually with:" - echo " cd ~/docker/{service} && docker compose up -d" - ;; - S) - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - SERVICE_NAME=$(basename "$dir") - read -p " Start $SERVICE_NAME? (y/n) [y]: " START_SVC - if [ "$START_SVC" != "n" ] && [ "$START_SVC" != "N" ]; then - echo " Starting $SERVICE_NAME..." - (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME" - fi - fi - done - ;; - *) - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - SERVICE_NAME=$(basename "$dir") - echo " Starting $SERVICE_NAME..." - (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME" - fi - done - ;; - esac - fi - - # Cleanup - echo "" - read -p "Remove temporary restore files? (y/n): " CLEANUP - if [ "$CLEANUP" = "y" ]; then - rm -rf "$RESTORE_TEMP" - echo "✓ Cleaned up temp files" - fi - - # Step 9: Reconnect Kopia for future backups - echo "" - echo "Step 9: Setting up future backups" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Reconnecting Kopia to backup repository for future backups..." - - if command -v kopia &> /dev/null && [ -n "$KOPIA_REPO" ]; then - # Reconnect to the repository - if kopia repository connect filesystem --path="$KOPIA_REPO" --password="$KOPIA_PASSWORD" 2>/dev/null; then - echo "✓ Kopia reconnected to repository" - echo " Repository: $KOPIA_REPO" - - # Verify backup scripts exist - if [ -f "$DOCKER_DIR/kopia/backup-containers.sh" ]; then - chmod +x "$DOCKER_DIR/kopia/backup-containers.sh" 2>/dev/null || true - echo "✓ Backup script ready: $DOCKER_DIR/kopia/backup-containers.sh" - else - echo " ⚠ Backup script not found - run normal install to set up" - fi - else - echo " ⚠ Could not reconnect to repository" - echo " Run manually: kopia repository connect filesystem --path=\"$KOPIA_REPO\"" - fi - else - echo " ⚠ Kopia not available or repository not found" - echo " Run the normal install script to set up backups" - fi - echo "" - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DISASTER RECOVERY COMPLETE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Services restored to: $DOCKER_DIR" - echo "" - echo "Backups: $(command -v kopia &>/dev/null && kopia repository status &>/dev/null && echo "✓ Connected" || echo "⚠ Not connected")" - echo " Run backups: $DOCKER_DIR/kopia/backup-containers.sh" - echo "" - echo "To check running containers:" - echo " docker ps" - echo "" - echo "To view logs:" - echo " docker compose logs -f" - echo "" - echo "⚠️ Some services may need manual configuration:" - echo " • Frigate: Edit config/config.yml with camera URLs" - echo " • Caddy: Update Caddyfile with your domain" - echo " • ddclient: Edit config/ddclient.conf with DNS credentials" - echo "" - - return 0 -} - -# Migration function - import existing Docker containers -run_migration() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "MIGRATION MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This will migrate existing Docker containers to this script's structure." - echo "Containers will be copied (not moved) and versions preserved." - echo "" - - # Step 1: Get source Docker directory - echo "Step 1: Locate existing containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Try to auto-detect Docker directories - DETECTED_DIRS=() - - # Check common locations - for check_dir in /var/docker /opt/docker "$HOME/docker" /home/*/docker; do - if [ -d "$check_dir" ] && [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then - DETECTED_DIRS+=("$check_dir") - fi - done - - # Check mounted drives: ~/drives/*, /mnt/*, /media/* - for mount_base in "$HOME/drives" "$HOME_DIR/drives" /mnt /media; do - if [ -d "$mount_base" ]; then - for mount_dir in "$mount_base"/*; do - if [ -d "$mount_dir/docker" ]; then - check_dir="$mount_dir/docker" - if [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then - DETECTED_DIRS+=("$check_dir") - fi - fi - done - fi - done - - if [ ${#DETECTED_DIRS[@]} -gt 0 ]; then - echo "Auto-detected Docker directories:" - for i in "${!DETECTED_DIRS[@]}"; do - dir="${DETECTED_DIRS[$i]}" - count=$(find "$dir" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null | wc -l) - echo " [$((i+1))] $dir ($count containers)" - done - echo "" - echo "Enter a number to select, or type a custom path:" - else - echo "No Docker directories auto-detected." - echo "" - echo "Common locations:" - echo " • ~/docker" - echo " • ~/drives/primary/docker" - echo " • /var/docker" - echo " • /opt/docker" - echo " • /mnt/data/docker" - echo "" - echo "Enter the full path to your Docker directory:" - fi - echo "" - read -p "Source directory: " SOURCE_DOCKER_DIR - - # Check if user entered a number (to select from detected list) - if [[ "$SOURCE_DOCKER_DIR" =~ ^[0-9]+$ ]] && [ ${#DETECTED_DIRS[@]} -gt 0 ]; then - idx=$((SOURCE_DOCKER_DIR - 1)) - if [ $idx -ge 0 ] && [ $idx -lt ${#DETECTED_DIRS[@]} ]; then - SOURCE_DOCKER_DIR="${DETECTED_DIRS[$idx]}" - echo "Selected: $SOURCE_DOCKER_DIR" - fi - fi - - # Expand ~ if used - SOURCE_DOCKER_DIR="${SOURCE_DOCKER_DIR/#\~/$HOME}" - - if [ ! -d "$SOURCE_DOCKER_DIR" ]; then - echo "❌ Directory not found: $SOURCE_DOCKER_DIR" - return 1 - fi - - # Step 2: Scan for containers - echo "" - echo "Step 2: Scanning for containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - CONTAINERS_FOUND=() - CONTAINER_PATHS=() - - while IFS= read -r compose_file; do - if [ -n "$compose_file" ]; then - container_dir=$(dirname "$compose_file") - container_name=$(basename "$container_dir") - CONTAINERS_FOUND+=("$container_name") - CONTAINER_PATHS+=("$container_dir") - fi - done < <(find "$SOURCE_DOCKER_DIR" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null) - - if [ ${#CONTAINERS_FOUND[@]} -eq 0 ]; then - echo "❌ No Docker containers found in $SOURCE_DOCKER_DIR" - return 1 - fi - - echo "Found ${#CONTAINERS_FOUND[@]} container(s):" - for i in "${!CONTAINERS_FOUND[@]}"; do - container="${CONTAINERS_FOUND[$i]}" - path="${CONTAINER_PATHS[$i]}" - size=$(du -sh "$path" 2>/dev/null | cut -f1) - echo " [$((i+1))] $container ($size)" - done - echo "" - - # Step 3: Select containers to migrate - echo "Step 3: Select containers to migrate" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - SELECTED_CONTAINERS=() - - if command -v whiptail &> /dev/null && [ ${#CONTAINERS_FOUND[@]} -gt 3 ]; then - # Use whiptail for many containers - CHECKLIST_ARGS=() - for container in "${CONTAINERS_FOUND[@]}"; do - CHECKLIST_ARGS+=("$container" "" "ON") - done - - SELECTED=$(whiptail --title "Select Containers to Migrate" \ - --checklist "Use SPACE to select/deselect, ENTER to confirm:" \ - 20 60 12 \ - "${CHECKLIST_ARGS[@]}" \ - 3>&1 1>&2 2>&3) - - if [ $? -eq 0 ] && [ -n "$SELECTED" ]; then - # Parse whiptail output - for container in $SELECTED; do - # Remove quotes - container="${container//\"/}" - SELECTED_CONTAINERS+=("$container") - done - fi - else - # Text-based selection - echo "Select containers to migrate:" - echo " [A] All containers" - echo " [S] Select individually" - echo " [N] None (cancel)" - echo "" - read -p "Choice (A/S/N) [A]: " MIGRATE_CHOICE - - case "${MIGRATE_CHOICE^^}" in - N) - echo "Migration cancelled." - return 0 - ;; - S) - for container in "${CONTAINERS_FOUND[@]}"; do - read -p " Migrate $container? (y/n) [y]: " MIGRATE_THIS - if [ "$MIGRATE_THIS" != "n" ] && [ "$MIGRATE_THIS" != "N" ]; then - SELECTED_CONTAINERS+=("$container") - fi - done - ;; - *) - SELECTED_CONTAINERS=("${CONTAINERS_FOUND[@]}") - ;; - esac - fi - - if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then - echo "No containers selected." - return 0 - fi - - echo "" - echo "Will migrate ${#SELECTED_CONTAINERS[@]} container(s):" - for container in "${SELECTED_CONTAINERS[@]}"; do - echo " • $container" - done - echo "" - - # Step 4: Stop running containers (optional) - echo "Step 4: Container status" - echo "━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Should running containers be stopped before migration?" - echo " • Stopping ensures clean copy of data/databases" - echo " • Not stopping may result in inconsistent state" - echo "" - read -p "Stop containers before copying? (y/n) [y]: " STOP_CONTAINERS - - if [ "$STOP_CONTAINERS" != "n" ] && [ "$STOP_CONTAINERS" != "N" ]; then - echo "Stopping containers..." - for container in "${SELECTED_CONTAINERS[@]}"; do - for i in "${!CONTAINERS_FOUND[@]}"; do - if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then - container_dir="${CONTAINER_PATHS[$i]}" - if [ -f "$container_dir/docker-compose.yml" ] || [ -f "$container_dir/compose.yml" ]; then - echo " Stopping $container..." - (cd "$container_dir" && docker compose down 2>/dev/null) || true - fi - fi - done - done - echo "" - fi - - # Step 5: Decide migration method - echo "Step 5: Migration method" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Check if source is on a different drive (mounted drive, not home on OS) - SOURCE_ON_EXTERNAL=false - if [[ "$SOURCE_DOCKER_DIR" == /mnt/* ]] || [[ "$SOURCE_DOCKER_DIR" == /media/* ]] || [[ "$SOURCE_DOCKER_DIR" == */drives/* ]]; then - SOURCE_ON_EXTERNAL=true - fi - - MIGRATION_METHOD="copy" - if [ "$SOURCE_ON_EXTERNAL" = true ]; then - echo "Source is on a mounted drive: $SOURCE_DOCKER_DIR" - echo "" - echo "How would you like to migrate?" - echo "" - echo " [C] Copy - Copy containers to ~/docker on OS drive" - echo " Best for: old OS drive mounted temporarily" - echo "" - echo " [S] Symlink - Create ~/docker as symlink to source location" - echo " Best for: data drive you'll keep using" - echo "" - echo " [U] Use in-place - Use source location directly, no copy" - echo " Best for: already on your data drive" - echo "" - read -p "Method (C/S/U) [C]: " MIGRATION_METHOD - - case "${MIGRATION_METHOD^^}" in - S) - MIGRATION_METHOD="symlink" - ;; - U) - MIGRATION_METHOD="use" - ;; - *) - MIGRATION_METHOD="copy" - ;; - esac - else - echo "Source: $SOURCE_DOCKER_DIR" - echo "Target: $DOCKER_DIR" - echo "" - echo "Will copy containers to $DOCKER_DIR" - fi - - echo "" - - # Handle different migration methods - case "$MIGRATION_METHOD" in - symlink) - echo "Creating symlink: $DOCKER_DIR → $SOURCE_DOCKER_DIR" - echo "" - - # Check if target exists - if [ -e "$DOCKER_DIR" ] || [ -L "$DOCKER_DIR" ]; then - echo " ⚠ $DOCKER_DIR already exists" - read -p " Remove and create symlink? (y/n) [n]: " REMOVE_EXISTING - if [ "$REMOVE_EXISTING" = "y" ] || [ "$REMOVE_EXISTING" = "Y" ]; then - rm -rf "$DOCKER_DIR" - else - echo " Cancelled." - return 1 - fi - fi - - ln -s "$SOURCE_DOCKER_DIR" "$DOCKER_DIR" - chown -h "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - - echo "✓ Symlink created" - echo " Containers stay at: $SOURCE_DOCKER_DIR" - echo " Accessible via: $DOCKER_DIR → $SOURCE_DOCKER_DIR" - MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]} - ;; - - use) - echo "Using source location directly" - echo "" - DOCKER_DIR="$SOURCE_DOCKER_DIR" - echo "✓ DOCKER_DIR set to: $DOCKER_DIR" - echo " No files copied - containers remain in place" - MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]} - ;; - - copy|*) - echo "Copying containers to $DOCKER_DIR" - echo "" - - # Create target directory - mkdir -p "$DOCKER_DIR" - chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - - MIGRATED_COUNT=0 - for container in "${SELECTED_CONTAINERS[@]}"; do - for i in "${!CONTAINERS_FOUND[@]}"; do - if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then - source_dir="${CONTAINER_PATHS[$i]}" - target_dir="$DOCKER_DIR/$container" - - # Check if already exists - if [ -d "$target_dir" ]; then - echo " ⚠ $container already exists at $target_dir" - read -p " Overwrite? (y/n) [n]: " OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Skipping $container" - continue - fi - rm -rf "$target_dir" - fi - - echo " Copying $container..." - cp -a "$source_dir" "$target_dir" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$target_dir" - ((MIGRATED_COUNT++)) - echo " ✓ $container copied" - fi - done - done - ;; - esac - - echo "" - echo "✓ Migrated $MIGRATED_COUNT container(s)" - echo "" - - # Step 6: Update volume paths - echo "Step 6: Update volume paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Scanning docker-compose files for volume mounts that may need updating..." - echo "" - - # Find all absolute paths in volume mounts (excluding relative paths like ./ or named volumes) - PATHS_FOUND=() - PATHS_FILES=() - - for container in "${SELECTED_CONTAINERS[@]}"; do - compose_file="$DOCKER_DIR/$container/docker-compose.yml" - [ -f "$compose_file" ] || compose_file="$DOCKER_DIR/$container/compose.yml" - [ -f "$compose_file" ] || continue - - # Extract volume mount paths (lines with : that look like /path:/container/path) - while IFS= read -r line; do - # Match absolute paths in volume mounts (starting with /) - if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*(/[^:]+): ]]; then - host_path="${BASH_REMATCH[1]}" - # Skip if it's a relative path or standard paths - if [[ "$host_path" != "./"* ]] && [[ "$host_path" != "/etc/"* ]] && [[ "$host_path" != "/var/run/"* ]]; then - # Check if this path doesn't exist on current system - if [ ! -e "$host_path" ]; then - # Avoid duplicates - if [[ ! " ${PATHS_FOUND[*]} " =~ " ${host_path} " ]]; then - PATHS_FOUND+=("$host_path") - PATHS_FILES+=("$compose_file") - fi - fi - fi - fi - done < "$compose_file" - done - - if [ ${#PATHS_FOUND[@]} -eq 0 ]; then - echo "✓ No volume paths need updating (all paths exist or are relative)" - echo "" - else - echo "Found ${#PATHS_FOUND[@]} volume path(s) that don't exist on this system:" - echo "" - - # Default new base path - DEFAULT_NEW_BASE="$HOME_DIR/drives/primary" - - for i in "${!PATHS_FOUND[@]}"; do - old_path="${PATHS_FOUND[$i]}" - compose_file="${PATHS_FILES[$i]}" - container_name=$(basename "$(dirname "$compose_file")") - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Container: $container_name" - echo "Old path: $old_path" - - # Suggest a new path based on the old path structure - # Extract the last part of the path for suggestion - path_tail=$(basename "$old_path") - suggested_path="$DEFAULT_NEW_BASE/$path_tail" - - echo "Suggested: $suggested_path" - echo "" - echo "Options:" - echo " [Enter] Accept suggested path" - echo " [S] Skip - keep original path" - echo " [path] Enter custom path" - echo "" - read -p "New path: " NEW_PATH_INPUT - - case "$NEW_PATH_INPUT" in - ""|" ") - new_path="$suggested_path" - ;; - [Ss]) - echo " Skipping - keeping original path" - continue - ;; - *) - new_path="${NEW_PATH_INPUT/#\~/$HOME_DIR}" - ;; - esac - - # Update the compose file - echo " Updating: $old_path → $new_path" - - # Escape paths for sed (handle slashes) - old_escaped=$(printf '%s\n' "$old_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g') - new_escaped=$(printf '%s\n' "$new_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g') - - sed -i "s|$old_path|$new_path|g" "$compose_file" - - # Create directory if it doesn't exist - if [ ! -d "$new_path" ]; then - echo " Creating directory: $new_path" - mkdir -p "$new_path" 2>/dev/null || echo " ⚠ Could not create (may need manual creation)" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$new_path" 2>/dev/null || true - fi - - echo " ✓ Updated" - echo "" - done - - echo "✓ Volume paths updated" - echo "" - fi - - # Step 7: Start migrated containers (optional) - echo "Step 7: Start containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - read -p "Start migrated containers? (y/n) [y]: " START_MIGRATED - - if [ "$START_MIGRATED" != "n" ] && [ "$START_MIGRATED" != "N" ]; then - for container in "${SELECTED_CONTAINERS[@]}"; do - target_dir="$DOCKER_DIR/$container" - if [ -d "$target_dir" ]; then - echo " Starting $container..." - (cd "$target_dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $container" - fi - done - echo "" - fi - - # Step 8: Offer additional services - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "MIGRATION COMPLETE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Containers migrated to: $DOCKER_DIR" - echo "" - echo "Would you like to install additional services not in your migration?" - echo "This will continue with the normal install process where you can" - echo "choose which additional apps to install (Immich, Frigate, etc.)" - echo "" - read -p "Continue to install additional services? (y/n) [y]: " INSTALL_MORE - - if [ "$INSTALL_MORE" = "n" ] || [ "$INSTALL_MORE" = "N" ]; then - echo "" - echo "Migration complete. You can run this script again to install more services." - echo "" - echo "To check running containers:" - echo " docker ps" - echo "" - return 0 - fi - - # Return to continue with normal install - INSTALL_MODE="post-migration" - return 0 -} - -# Check for restore mode flag -if [ "$RESTORE_MODE" = true ]; then - run_disaster_recovery - exit $? -fi - -# Interactive mode selector (if not unattended) -if [ "$UNATTENDED" != true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "INSTALLATION MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo " [N] Normal install - Fresh install or modify existing" - echo " [M] Migration - Import existing Docker containers" - echo " [R] Disaster recovery - Restore from Kopia backup" - echo "" - read -p "Select mode (N/M/R) [N]: " MODE_SELECT - - case "${MODE_SELECT^^}" in - R) - run_disaster_recovery - exit $? - ;; - M) - run_migration - # If migration returns 0 and INSTALL_MODE is post-migration, continue - if [ $? -ne 0 ]; then - exit 1 - fi - if [ "$INSTALL_MODE" != "post-migration" ]; then - exit 0 - fi - echo "" - echo "Continuing with additional service installation..." - echo "" - ;; - *) - INSTALL_MODE="normal" - ;; - esac - echo "" -fi - -# ============================================================================ -# DRIVE SETUP (Runs before everything else) -# ============================================================================ - -setup_drives() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This script uses ~/drives/ for data storage:" - echo " ~/drives/primary - Main data drive (media, photos, etc.)" - echo " ~/drives/backup1 - Backup drive(s)" - echo "" - - # Create base drives directory - mkdir -p "$ACTUAL_HOME/drives" - chown "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - # Check for existing mounts - if mount | grep -q "$ACTUAL_HOME/drives/"; then - echo "Existing mounted drives:" - df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " (" $2 " total, " $4 " free)"}' - echo "" - prompt_yn "Configure additional drives? (y/n):" "n" SETUP_DRIVES - else - echo "No drives currently mounted in ~/drives/" - echo "" - prompt_yn "Set up drives now? (y/n):" "y" SETUP_DRIVES - fi - - if [ "$SETUP_DRIVES" != "y" ] && [ "$SETUP_DRIVES" != "Y" ]; then - echo "Skipping drive setup. You can run this script again later." - return 0 - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "AVAILABLE BLOCK DEVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - - # Detect drives without partition tables (new drives) - echo "Checking for new/unpartitioned drives..." - NEW_DRIVES=() - for disk in /dev/sd? /dev/nvme?n?; do - [ -b "$disk" ] || continue - # Check if disk has no partitions - if ! lsblk -n "$disk" | grep -q "part"; then - # Check if it has no partition table - if ! blkid "$disk" &>/dev/null; then - size=$(lsblk -n -d -o SIZE "$disk" 2>/dev/null) - NEW_DRIVES+=("$disk ($size)") - fi - fi - done - - if [ ${#NEW_DRIVES[@]} -gt 0 ]; then - echo "" - echo "⚠️ Found unpartitioned drives (no filesystem):" - for drive in "${NEW_DRIVES[@]}"; do - echo " • $drive" - done - echo "" - prompt_yn "Would you like to partition and format any of these? (y/n):" "n" FORMAT_DRIVES - - if [ "$FORMAT_DRIVES" = "y" ] || [ "$FORMAT_DRIVES" = "Y" ]; then - for drive_info in "${NEW_DRIVES[@]}"; do - drive=$(echo "$drive_info" | cut -d' ' -f1) - echo "" - prompt_yn "Format $drive_info as ext4? (ALL DATA WILL BE ERASED) (y/n):" "n" FORMAT_THIS - - if [ "$FORMAT_THIS" = "y" ] || [ "$FORMAT_THIS" = "Y" ]; then - echo " Creating partition table on $drive..." - parted -s "$drive" mklabel gpt - parted -s "$drive" mkpart primary ext4 0% 100% - - # Wait for partition to appear - sleep 2 - - # Format the partition - part="${drive}1" - [ -b "${drive}p1" ] && part="${drive}p1" # nvme drives - - if [ -b "$part" ]; then - echo " Formatting $part as ext4..." - mkfs.ext4 -F "$part" - echo " ✓ Formatted $part" - else - echo " ⚠ Could not find partition after creation" - fi - fi - done - echo "" - echo "Updated block devices:" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - fi - fi - - # Configure mount points - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CONFIGURE MOUNT POINTS" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Primary drive - echo "PRIMARY DRIVE (for media, photos, docker data):" - prompt_text " Mount point name [primary]:" "primary" PRIMARY_NAME - PRIMARY_NAME="${PRIMARY_NAME:-primary}" - mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME" - - # Check if already mounted - if mount | grep -q "$ACTUAL_HOME/drives/$PRIMARY_NAME"; then - echo " ✓ Already mounted" - else - echo " Enter device (e.g., /dev/sdb1) or leave blank to skip:" - read -p " Device for $PRIMARY_NAME: " PRIMARY_DEV - - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - # Add to fstab - PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV" 2>/dev/null) - if [ -n "$PRIMARY_UUID" ]; then - # Check if not already in fstab - if ! grep -q "$PRIMARY_UUID" /etc/fstab 2>/dev/null; then - cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true - echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab - echo " ✓ Added to fstab" - fi - fi - fi - fi - - # Backup drives - echo "" - echo "BACKUP DRIVES (optional, for rsync backups):" - prompt_text " How many backup drives? [0-4, default: 0]:" "0" NUM_BACKUPS - NUM_BACKUPS="${NUM_BACKUPS:-0}" - - # Validate number - case $NUM_BACKUPS in - 0|1|2|3|4) ;; - *) NUM_BACKUPS=0 ;; - esac - - declare -a BACKUP_NAMES - declare -a BACKUP_DEVS - for i in $(seq 1 $NUM_BACKUPS); do - echo "" - prompt_text " Backup drive $i name [backup$i]:" "backup$i" "BACKUP_NAME" - BACKUP_NAMES[$i]="${BACKUP_NAME:-backup$i}" - mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - - if ! mount | grep -q "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"; then - read -p " Device for ${BACKUP_NAMES[$i]}: " "BACKUP_DEV" - BACKUP_DEVS[$i]="$BACKUP_DEV" - - if [ -n "$BACKUP_DEV" ] && [ -b "$BACKUP_DEV" ]; then - BACKUP_UUID=$(blkid -s UUID -o value "$BACKUP_DEV" 2>/dev/null) - if [ -n "$BACKUP_UUID" ]; then - if ! grep -q "$BACKUP_UUID" /etc/fstab 2>/dev/null; then - echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab - echo " ✓ Added ${BACKUP_NAMES[$i]} to fstab" - fi - fi - fi - else - echo " ✓ Already mounted" - fi - done - - # Set permissions - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - # Mount all from fstab - echo "" - echo "Mounting drives from fstab..." - mount -a 2>/dev/null || true - - echo "" - echo "Current mounts in ~/drives/:" - df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " - " $2 " total, " $4 " free"}' || echo " (none)" - echo "" - - # Export drive names for later use - export PRIMARY_DRIVE_NAME="$PRIMARY_NAME" - export BACKUP_DRIVE_COUNT="$NUM_BACKUPS" - for i in $(seq 1 $NUM_BACKUPS); do - export "BACKUP_DRIVE_${i}_NAME=${BACKUP_NAMES[$i]}" - done - - echo "✓ Drive setup complete" - echo "" -} - -# Run drive setup for normal installs (not migration/recovery) -if [ "$INSTALL_MODE" = "normal" ] && [ "$UNATTENDED" != true ]; then - setup_drives -fi - -# ============================================================================ -# SOFTWARE DETECTION FUNCTIONS -# ============================================================================ - -is_docker_installed() { - command -v docker &> /dev/null && systemctl is-active --quiet docker 2>/dev/null -} - -is_samba_installed() { - command -v smbd &> /dev/null && systemctl is-active --quiet smbd 2>/dev/null -} - -is_netbird_installed() { - command -v netbird &> /dev/null -} - -is_rustdesk_installed() { - command -v rustdesk &> /dev/null || dpkg -l rustdesk &> /dev/null -} - -is_rclone_installed() { - command -v rclone &> /dev/null -} - -is_rsync_installed() { - command -v rsync &> /dev/null -} - -is_ufw_installed() { - command -v ufw &> /dev/null -} - -is_wireguard_installed() { - command -v wg &> /dev/null -} - -is_tailscale_installed() { - command -v tailscale &> /dev/null -} - -is_teamviewer_installed() { - command -v teamviewer &> /dev/null || dpkg -l teamviewer &> /dev/null 2>&1 -} - -is_meshcentral_installed() { - # MeshCentral agent is typically installed as meshagent - command -v meshagent &> /dev/null || [ -f /usr/local/mesh_services/meshagent/meshagent ] -} - -# ============================================================================ -# DRY-RUN AND UNATTENDED HELPER FUNCTIONS -# ============================================================================ - -# Run a command, but skip it in dry-run mode -run_cmd() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would execute: $*" - return 0 - else - "$@" - fi -} - -# Ensure Docker directories are owned by the actual user (not root) -# Usage: ensure_docker_dir_ownership /path/to/dir [additional_paths...] -ensure_docker_dir_ownership() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER" - return 0 - fi - - for dir in "$@"; do - if [ -d "$dir" ]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true - fi - done -} - -# Generate a secure random password with alphanumeric characters only (no special chars) -# Usage: generate_password [length] -# Default length: 32 -generate_password() { - local length="${1:-32}" - openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" -} - -# Validate password (alphanumeric only, minimum length) -# Usage: validate_password "password" [min_length] -# Returns 0 if valid, 1 if invalid -validate_password() { - local password="$1" - local min_length="${2:-12}" - - # Check length - if [ ${#password} -lt "$min_length" ]; then - echo " ⚠ Password must be at least $min_length characters long" - return 1 - fi - - # Check for special characters (not allowed) - if echo "$password" | grep -q '[^a-zA-Z0-9]'; then - echo " ⚠ Password must contain only letters and numbers (no special characters)" - return 1 - fi - - return 0 -} - -# Prompt for yes/no, with unattended default -# Usage: prompt_yn "Question?" "default" VARNAME -# default can be "y" or "n" -prompt_yn() { - local question="$1" - local default="$2" - local varname="$3" - - if [ "$UNATTENDED" = true ]; then - eval "$varname='$default'" - echo "$question [auto: $default]" - return - fi - - read -p "$question " response - eval "$varname='$response'" -} - -# Prompt for text input, with unattended default -# Usage: prompt_text "Question?" "default" VARNAME -prompt_text() { - local question="$1" - local default="$2" - local varname="$3" - - if [ "$UNATTENDED" = true ]; then - eval "$varname='$default'" - echo "$question [auto: $default]" - return - fi - - read -p "$question " response - eval "$varname='$response'" -} - -# ============================================================================ -# CADDY CONFIGURATION HELPER -# ============================================================================ -configure_caddy_for_service() { - local SERVICE_NAME="$1" - local SERVICE_PORT="$2" - local DEFAULT_SUBDOMAIN="$3" - local EXTRA_CONFIG="${4:-}" # Optional extra Caddy directives - - # Check if Caddy is installed - if [ ! -d "$DOCKER_DIR/caddy" ]; then - return 0 # Caddy not installed, skip configuration - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " CADDY REVERSE PROXY CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Caddy is installed. You can configure reverse proxy for $SERVICE_NAME." - echo "" - - local CONFIGURE_CADDY="" - 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" - return 0 - fi - - # Get domain/subdomain - echo "" - echo "Enter the full domain for $SERVICE_NAME:" - echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" - echo "" - local SERVICE_DOMAIN="" - prompt_text "Domain:" "" SERVICE_DOMAIN - - if [ -z "$SERVICE_DOMAIN" ]; then - echo " ⚠ No domain provided, skipping Caddy configuration." - return 0 - fi - - # Backup Caddyfile - local CADDY_DIR="$DOCKER_DIR/caddy" - local CADDYFILE="$CADDY_DIR/Caddyfile" - local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - - if [ -f "$CADDYFILE" ]; then - echo " Backing up Caddyfile to: $(basename $BACKUP_FILE)" - cp "$CADDYFILE" "$BACKUP_FILE" - else - echo " Creating new Caddyfile" - touch "$CADDYFILE" - fi - - # Check if service already configured - if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then - echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile" - local OVERWRITE="" - prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Keeping existing configuration." - return 0 - fi - # Remove existing configuration - sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE" - fi - - # Add service configuration to Caddyfile - echo " Adding $SERVICE_NAME configuration to Caddyfile..." - - cat >> "$CADDYFILE" << CADDY_BLOCK - -# $SERVICE_NAME -$SERVICE_DOMAIN { - reverse_proxy localhost:$SERVICE_PORT - - # Security headers - 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" - } - - # Logging for fail2ban - log { - output file /var/log/caddy/${SERVICE_DOMAIN}.log - format json - } -$EXTRA_CONFIG -} -CADDY_BLOCK - - echo " ✓ Configuration added to Caddyfile" - - # Reload Caddy - echo " Reloading Caddy configuration..." - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddy reloaded successfully" - else - echo " ⚠ Failed to reload Caddy (will retry after formatting)" - fi - - # Format Caddyfile - echo " Formatting Caddyfile..." - if docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddyfile formatted" - else - echo " ⚠ Failed to format Caddyfile" - fi - - # Final reload after formatting - echo " Final reload..." - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddy configuration active" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $SERVICE_NAME is now accessible at:" - echo " https://$SERVICE_DOMAIN" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - else - echo " ⚠ Failed final reload. Check Caddy logs:" - echo " docker logs caddy" - echo " You can restore from backup: $BACKUP_FILE" - fi - - echo "" -} - -# ============================================================================ -# CHECK IF SERVICE EXISTS AND ASK USER ACTION -# ============================================================================ -check_service_exists() { - local SERVICE_NAME="$1" - local SERVICE_DIR="$2" - local RETURN_VAR="$3" # Variable name to set (true/false) - - if [ -f "$SERVICE_DIR/docker-compose.yml" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $SERVICE_NAME is already installed at $SERVICE_DIR" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Options:" - echo " 1. Skip (keep existing configuration)" - echo " 2. Reconfigure (will backup existing config)" - echo " 3. Restart containers only" - echo "" - local ACTION="" - prompt_text "Choose option [1/2/3]:" "1" ACTION - - case "$ACTION" in - 2) - echo " Backing up existing configuration..." - local BACKUP_NAME=$(echo "$SERVICE_NAME" | tr '[:upper:] ' '[:lower:]-') - BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$BACKUP_NAME" - mkdir -p "$BACKUP_DIR" - cp -r "$SERVICE_DIR" "$BACKUP_DIR/" - echo " ✓ Backup saved to: $BACKUP_DIR" - eval "$RETURN_VAR=true" - return 0 - ;; - 3) - echo " Restarting $SERVICE_NAME containers..." - cd "$SERVICE_DIR" - docker compose restart 2>/dev/null && echo " ✓ $SERVICE_NAME restarted" || echo " ⚠ Failed to restart" - eval "$RETURN_VAR=false" - return 0 - ;; - *) - echo " Skipping $SERVICE_NAME (already configured)" - eval "$RETURN_VAR=false" - return 0 - ;; - esac - else - # Service doesn't exist, proceed with installation - eval "$RETURN_VAR=true" - return 0 - fi -} - -# ============================================================================ -# DETECT AVAILABLE DRIVES (RUN ONCE) -# ============================================================================ -detect_drives() { - # Global variables set by this function: - # - DRIVES_DETECTED: true/false - # - PRIMARY_DRIVE: name of first drive (e.g., "storage1") - # - PRIMARY_DRIVE_PATH: full path to first drive - # - DRIVES_DIR: base drives directory - # - AVAILABLE_DRIVES_COUNT: number of drives found - - DRIVES_DIR="$ACTUAL_HOME/drives" - DRIVES_DETECTED=false - PRIMARY_DRIVE="" - PRIMARY_DRIVE_PATH="" - AVAILABLE_DRIVES_COUNT=0 - - if [ -d "$DRIVES_DIR" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " DETECTED DRIVES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - for drive in "$DRIVES_DIR"/*; do - if [ -d "$drive" ]; then - drive_name=$(basename "$drive") - drive_size=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $2}') - drive_used=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $3}') - drive_avail=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $4}') - - echo " ✓ $drive_name" - if [ -n "$drive_size" ]; then - echo " Path: $drive" - echo " Size: $drive_size (Used: $drive_used, Available: $drive_avail)" - else - echo " Path: $drive" - fi - - # Set PRIMARY_DRIVE to the first drive found - if [ -z "$PRIMARY_DRIVE" ]; then - PRIMARY_DRIVE="$drive_name" - PRIMARY_DRIVE_PATH="$drive" - fi - - AVAILABLE_DRIVES_COUNT=$((AVAILABLE_DRIVES_COUNT + 1)) - fi - done - - if [ $AVAILABLE_DRIVES_COUNT -gt 0 ]; then - DRIVES_DETECTED=true - echo "" - echo " Using '$PRIMARY_DRIVE' as primary drive for default paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - fi - fi - - # If no drives detected, use home directory - if [ "$DRIVES_DETECTED" = "false" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " No ~/drives directory found" - echo " Using $ACTUAL_HOME for default paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - PRIMARY_DRIVE_PATH="$ACTUAL_HOME" - fi -} - -# ============================================================================ -# SHOW CURRENT INSTALLATION STATUS -# ============================================================================ -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "CURRENT SYSTEM STATUS" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -if is_docker_installed; then - echo " ✓ Docker: Installed ($(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ','))" -else - echo " ○ Docker: Not installed" -fi -if is_samba_installed; then - echo " ✓ Samba: Installed and running" -else - echo " ○ Samba: Not installed" -fi -if is_netbird_installed; then - echo " ✓ NetBird: Installed" -else - echo " ○ NetBird: Not installed" -fi -if is_rustdesk_installed; then - echo " ✓ RustDesk: Installed" -else - echo " ○ RustDesk: Not installed" -fi -if is_teamviewer_installed; then - echo " ✓ TeamViewer: Installed" -else - echo " ○ TeamViewer: Not installed" -fi -if is_meshcentral_installed; then - echo " ✓ MeshCentral Agent: Installed" -else - echo " ○ MeshCentral Agent: Not installed" -fi -if is_wireguard_installed; then - echo " ✓ WireGuard: Installed" -else - echo " ○ WireGuard: Not installed" -fi -if is_tailscale_installed; then - echo " ✓ Tailscale: Installed" -else - echo " ○ Tailscale: Not installed" -fi -if is_rclone_installed; then - echo " ✓ rclone: Installed" -else - echo " ○ rclone: Not installed" -fi -if is_rsync_installed; then - echo " ✓ rsync: Installed" -else - echo " ○ rsync: Not installed" -fi -if is_ufw_installed; then - if ufw status 2>/dev/null | grep -q "Status: active"; then - echo " ✓ UFW Firewall: Enabled" - else - echo " ○ UFW Firewall: Installed but disabled" - fi -else - echo " ○ UFW Firewall: Not installed" -fi -echo "" -echo "This script can reinstall/reconfigure any component." -echo "" - -# Update package list -echo "Updating package lists..." -run_cmd apt update - -# Install basic utilities -echo "" -echo "Installing basic utilities..." -echo " - net-tools: Network configuration tools (ifconfig, netstat, etc.)" -echo " - ncdu: Disk usage analyzer with ncurses interface" -echo " - git: Version control system" -echo " - curl: Command-line tool for transferring data with URLs" -echo " - wget: Network downloader" -echo " - htop: Interactive process viewer" -echo " - tree: Display directory structure in tree format" -echo " - zip/unzip: Archive compression utilities" -echo " - rclone: Rsync for cloud storage and local drives (backup tool)" -echo "" - -run_cmd apt install -y \ - net-tools \ - ncdu \ - git \ - curl \ - wget \ - htop \ - tree \ - zip \ - unzip \ - rclone || echo "Warning: Some utilities failed to install, continuing..." - -# Install OpenSSH Server -echo "" -echo "Installing OpenSSH Server..." -echo " - openssh-server: SSH server for remote access" -echo "" - -apt install -y openssh-server || echo "Warning: OpenSSH server installation failed, continuing..." - -# Start and enable SSH service -systemctl start ssh || echo "Warning: Failed to start SSH" -systemctl enable ssh || echo "Warning: Failed to enable SSH" - -# On rerun, skip SSH configuration if already set up -SKIP_SSH_CONFIG=false -if (systemctl is-active ssh &>/dev/null || systemctl is-active sshd &>/dev/null) && \ - { [ -f "$ACTUAL_HOME/.ssh/authorized_keys" ] && [ -s "$ACTUAL_HOME/.ssh/authorized_keys" ]; } || \ - [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then - echo "" - echo "SSH is already configured on this system." - prompt_yn "Reconfigure SSH keys and authentication? (y/n):" "n" RECONFIG_SSH - if [ "$RECONFIG_SSH" != "y" ] && [ "$RECONFIG_SSH" != "Y" ]; then - SKIP_SSH_CONFIG=true - fi -fi - -if [ "$SKIP_SSH_CONFIG" != true ]; then - -# Generate SSH key for this computer -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SSH KEY GENERATION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -prompt_yn "Generate a new SSH key for this computer? (y/n):" "n" GENERATE_KEY - -if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - echo "" - read -p "Enter a label/comment for the key (e.g., email or hostname) [default: $ACTUAL_USER@$(hostname)]: " KEY_COMMENT - - if [ -z "$KEY_COMMENT" ]; then - KEY_COMMENT="$ACTUAL_USER@$(hostname)" - fi - - # Check if key already exists - if [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then - echo "" - echo "⚠️ WARNING: SSH key already exists at $ACTUAL_HOME/.ssh/id_rsa" - read -p "Overwrite existing key? This cannot be undone! (y/n): " OVERWRITE_KEY - - if [ "$OVERWRITE_KEY" != "y" ] && [ "$OVERWRITE_KEY" != "Y" ]; then - echo "Skipping key generation." - GENERATE_KEY="n" - fi - fi - - if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - echo "" - echo "Generating 4096-bit RSA key pair..." - echo "This may take a moment..." - - # Generate key as the actual user, not root - sudo -u "$ACTUAL_USER" ssh-keygen -t rsa -b 4096 -C "$KEY_COMMENT" -f "$ACTUAL_HOME/.ssh/id_rsa" -N "" - - if [ $? -eq 0 ]; then - echo "" - echo "✓ SSH key generated successfully!" - echo "" - echo "Private key: $ACTUAL_HOME/.ssh/id_rsa (keep this secret!)" - echo "Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Your PUBLIC key (safe to share):" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - cat "$ACTUAL_HOME/.ssh/id_rsa.pub" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To use this key:" - echo " • Add to GitHub: Settings → SSH and GPG keys → New SSH key" - echo " • Add to servers: Copy above key to remote ~/.ssh/authorized_keys" - echo " • View anytime: cat ~/.ssh/id_rsa.pub" - echo "" - else - echo "✗ Failed to generate SSH key" - fi - fi -else - echo "Skipping SSH key generation." -fi - -# Import SSH keys from GitHub/Launchpad -echo "" -echo "You can import SSH public keys from GitHub or Launchpad for easier SSH access." -echo "" -prompt_yn "Import SSH keys from GitHub or Launchpad? (y/n):" "n" IMPORT_SSH_KEYS - -KEYS_IMPORTED=false -GITHUB_USER="" -LAUNCHPAD_USER="" - -if [ "$IMPORT_SSH_KEYS" = "y" ] || [ "$IMPORT_SSH_KEYS" = "Y" ]; then - prompt_text "GitHub username (or leave blank to skip):" "" GITHUB_USER - prompt_text "Launchpad username (or leave blank to skip):" "" LAUNCHPAD_USER -fi - -# Create .ssh directory if it doesn't exist -mkdir -p "$ACTUAL_HOME/.ssh" -touch "$ACTUAL_HOME/.ssh/authorized_keys" -chmod 700 "$ACTUAL_HOME/.ssh" -chmod 600 "$ACTUAL_HOME/.ssh/authorized_keys" - -if [ -n "$GITHUB_USER" ]; then - echo "Importing SSH keys from GitHub user: $GITHUB_USER" - if curl -fsSL "https://github.com/$GITHUB_USER.keys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then - echo "✓ GitHub keys imported successfully" - KEYS_IMPORTED=true - else - echo "✗ Failed to import GitHub keys" - fi -fi - -if [ -n "$LAUNCHPAD_USER" ]; then - echo "Importing SSH keys from Launchpad user: $LAUNCHPAD_USER" - if curl -fsSL "https://launchpad.net/~$LAUNCHPAD_USER/+sshkeys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then - echo "✓ Launchpad keys imported successfully" - KEYS_IMPORTED=true - else - echo "✗ Failed to import Launchpad keys" - fi -fi - -# Fix ownership -chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/.ssh" - -# Disable password authentication if keys were imported -if [ "$KEYS_IMPORTED" = true ]; then - echo "" - echo "SSH keys imported. Disabling password authentication..." - - # Backup sshd_config - cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup - - # Disable password authentication - sed -i 's/^#*PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config - sed -i 's/^#*PasswordAuthentication no/PasswordAuthentication no/' /etc/ssh/sshd_config - - # Ensure these settings are also set - grep -q "^PasswordAuthentication" /etc/ssh/sshd_config || echo "PasswordAuthentication no" >> /etc/ssh/sshd_config - - # Restart SSH service to apply changes - systemctl restart ssh - - echo "✓ Password authentication disabled. SSH key authentication required." - echo "✓ Backup saved to /etc/ssh/sshd_config.backup" -else - echo "" - echo "No SSH keys imported. Password authentication remains enabled." - - # Offer fail2ban since password auth is still enabled - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "FAIL2BAN (Recommended with password SSH)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Since password authentication is enabled, fail2ban is recommended" - echo "to protect against brute-force SSH attacks." - echo "" - - prompt_yn "Install and enable fail2ban? (y/n):" "y" INSTALL_FAIL2BAN - - if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then - echo "" - echo "Installing fail2ban..." - run_cmd apt install -y fail2ban - - if [ "$DRY_RUN" != true ]; then - # Create local config to protect SSH - cat > /etc/fail2ban/jail.local << 'FAIL2BAN_CONFIG' -[sshd] -enabled = true -port = ssh -filter = sshd -logpath = /var/log/auth.log -maxretry = 5 -bantime = 3600 -findtime = 600 -FAIL2BAN_CONFIG - - run_cmd systemctl enable fail2ban - run_cmd systemctl restart fail2ban - echo "✓ fail2ban installed and configured" - echo " - 5 failed attempts = 1 hour ban" - echo " - View banned IPs: sudo fail2ban-client status sshd" - fi - else - echo "Skipping fail2ban installation." - fi -fi - -INSTALL_FAIL2BAN="${INSTALL_FAIL2BAN:-n}" - -fi # End SKIP_SSH_CONFIG - -# Docker Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "DOCKER INSTALLATION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -if is_docker_installed; then - echo "Docker is already installed: $(docker --version 2>/dev/null)" - prompt_yn "Reinstall Docker? (y/n):" "n" INSTALL_DOCKER -else - prompt_yn "Install Docker? (y/n):" "y" INSTALL_DOCKER -fi - -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo "" - echo "Installing Docker prerequisites..." - echo " - ca-certificates: SSL/TLS certificates for secure connections" - echo " - gnupg: GNU Privacy Guard for package verification" - echo " - lsb-release: Provides Ubuntu version information" - echo "" - - run_cmd apt install -y \ - ca-certificates \ - gnupg \ - lsb-release || echo "Warning: Some prerequisites failed to install, continuing..." - - echo "" - echo "Installing Docker..." - echo " - Docker Engine: Container runtime platform" - echo " - Docker Compose: Multi-container application orchestration" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would remove old Docker packages" - echo "[DRY-RUN] Would add Docker GPG key and repository" - echo "[DRY-RUN] Would install docker-ce, docker-ce-cli, containerd.io, plugins" - echo "[DRY-RUN] Would start and enable Docker service" - echo "[DRY-RUN] Would add $SUDO_USER to docker group" - else - # Remove old Docker packages if they exist - apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true - - # Add Docker's official GPG key - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc - chmod a+r /etc/apt/keyrings/docker.asc - - # Add Docker repository - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ - tee /etc/apt/sources.list.d/docker.list > /dev/null - - # Update package list with Docker repo - apt update - - # Install Docker Engine, CLI, containerd, and Docker Compose plugin - apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || echo "Warning: Docker installation failed, continuing..." - - # Start and enable Docker service - systemctl start docker || echo "Warning: Failed to start Docker" - systemctl enable docker || echo "Warning: Failed to enable Docker" - - # Add current user to docker group (if not root) - if [ -n "$SUDO_USER" ]; then - usermod -aG docker "$SUDO_USER" - echo "User $SUDO_USER added to docker group" - fi - - # Verify Docker installation - echo "" - echo "Verifying Docker installation..." - docker --version || echo "Warning: Docker verification failed" - docker compose version || echo "Warning: Docker Compose verification failed" - fi -else - echo "Skipping Docker installation." -fi - -# Samba File Sharing (Optional) -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SAMBA FILE SHARING (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Samba allows you to share folders over the network to Windows, Mac, and Linux." -echo "The script will share your primary drive at ~/drives/primary" -echo "" - -if is_samba_installed; then - echo "Samba is already installed and running." - if grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then - echo " Share 'Primary' is configured at: $ACTUAL_HOME/drives/primary" - fi - echo "" - prompt_yn "Reconfigure Samba? (y/n):" "n" INSTALL_SAMBA -else - prompt_yn "Install and configure Samba file sharing? (y/n):" "n" INSTALL_SAMBA -fi - -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo "" - echo "Installing Samba file sharing..." - echo " - Samba: SMB/CIFS file server for network file sharing" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install samba" - echo "[DRY-RUN] Would configure [Primary] share at $ACTUAL_HOME/drives/primary" - echo "[DRY-RUN] Would prompt for Samba password" - echo "[DRY-RUN] Would enable and start smbd/nmbd services" - else - run_cmd apt install -y samba || echo "Warning: Samba installation failed, continuing..." - - # Configure Samba share for primary drive - if command -v smbd &> /dev/null; then - echo "" - echo "Configuring Samba share for primary drive..." - - # Backup existing config - cp /etc/samba/smb.conf /etc/samba/smb.conf.backup-$(date +%Y%m%d-%H%M%S) - - # Add Primary share configuration - if ! grep -q "\[Primary\]" /etc/samba/smb.conf; then - cat >> /etc/samba/smb.conf << SAMBA_CONFIG - -# Primary drive share - added by post-install script -[Primary] - comment = Primary Drive - path = $ACTUAL_HOME/drives/primary - browseable = yes - read only = no - writable = yes - valid users = $ACTUAL_USER - create mask = 0775 - directory mask = 0775 -SAMBA_CONFIG - echo "✓ Added [Primary] share to Samba configuration" - else - echo "Samba [Primary] share already configured, skipping..." - fi - - # Add Samba user (skip in unattended mode - user must set password manually) - if [ "$UNATTENDED" != true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "SAMBA PASSWORD SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Set a password for Samba file sharing access." - echo "Tip: Using the same password as your system login is convenient." - echo "" - smbpasswd -a "$ACTUAL_USER" - else - echo "Skipping Samba password setup (unattended mode)" - echo "Set password later with: sudo smbpasswd -a $ACTUAL_USER" - fi - - # Enable and restart Samba services - systemctl enable smbd nmbd || echo "Warning: Failed to enable Samba services" - systemctl restart smbd nmbd || echo "Warning: Failed to restart Samba services" - - echo "" - echo "✓ Samba configured successfully" - echo " Share name: Primary" - echo " Path: $ACTUAL_HOME/drives/primary" - echo " Access: \\\\$(hostname)\\Primary (Windows) or smb://$(hostname)/Primary (Mac/Linux)" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "ADDING MORE SAMBA SHARES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To add another share, edit the Samba config file:" - echo "" - echo " sudo nano /etc/samba/smb.conf" - echo "" - echo "Add a new section at the end:" - echo "" - echo " [ShareName]" - echo " comment = Description of share" - echo " path = /path/to/folder" - echo " browseable = yes" - echo " read only = no" - echo " writable = yes" - echo " valid users = $ACTUAL_USER" - echo " create mask = 0775" - echo " directory mask = 0775" - echo "" - echo "Save (Ctrl+O, Enter) and exit (Ctrl+X), then restart Samba:" - echo "" - echo " sudo systemctl restart smbd nmbd" - echo "" - echo "Verify the share is active:" - echo "" - echo " testparm -s" - echo "" - else - echo "✗ Samba installation failed, skipping configuration" - fi - fi -else - echo "Skipping Samba installation." -fi - -# On rerun, skip VPN configuration if already set up -SKIP_VPN_CONFIG=false -if is_netbird_installed || is_wireguard_installed || is_tailscale_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "VPN SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "VPN services already configured:" - is_netbird_installed && echo " ✓ NetBird" - is_wireguard_installed && echo " ✓ WireGuard" - is_tailscale_installed && echo " ✓ Tailscale" - echo "" - prompt_yn "Reconfigure VPN services? (y/n):" "n" RECONFIG_VPN - if [ "$RECONFIG_VPN" != "y" ] && [ "$RECONFIG_VPN" != "Y" ]; then - SKIP_VPN_CONFIG=true - fi -fi - -if [ "$SKIP_VPN_CONFIG" != true ]; then - -# NetBird Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "NETBIRD MESH VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "NetBird is a secure mesh VPN for connecting devices across networks." -echo "" - -if is_netbird_installed; then - echo "NetBird is already installed." - netbird status 2>/dev/null || true - echo "" - prompt_yn "Reinstall NetBird? (y/n):" "n" INSTALL_NETBIRD -else - prompt_yn "Install NetBird? (y/n):" "n" INSTALL_NETBIRD -fi - -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo "" - echo "Installing NetBird..." - echo " - NetBird: Secure mesh VPN for connecting devices" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download and run NetBird install script" - echo "[DRY-RUN] Would ensure openssh-server is installed (required for NetBird SSH)" - echo "[DRY-RUN] Would configure netbird systemd service with --allow-server-ssh" - else - # NetBird v0.60.0+ requires openssh-server for SSH access. - # It injects /etc/ssh/sshd_config.d/99-netbird.conf and listens on port 22022. - echo "Ensuring openssh-server is installed (required for NetBird SSH)..." - apt install -y openssh-server 2>/dev/null || echo "Warning: openssh-server install failed, continuing..." - systemctl enable ssh 2>/dev/null || true - systemctl start ssh 2>/dev/null || true - - curl -fsSL https://pkgs.netbird.io/install.sh | sh || echo "Warning: NetBird installation failed, continuing..." - - # Persist --allow-server-ssh so this machine accepts NetBird SSH connections - # without requiring interactive re-authentication on every connection. - echo "Configuring NetBird to allow SSH server (persistent across reboots)..." - mkdir -p /etc/systemd/system/netbird.service.d - cat > /etc/systemd/system/netbird.service.d/ssh-server.conf << 'NETBIRD_OVERRIDE' -[Service] -ExecStart= -ExecStart=/usr/bin/netbird service run --allow-server-ssh -NETBIRD_OVERRIDE - systemctl daemon-reload 2>/dev/null || true - echo " ✓ NetBird will start with --allow-server-ssh on every boot" - - echo "" - echo "NetBird installed. Setup instructions:" - echo " 1. Create account at https://app.netbird.io (or self-host)" - echo " 2. Run 'netbird up' and authenticate via browser" - echo "" - echo "For NetBird SSH functionality (v0.60.0+ method):" - echo " • openssh-server is installed and --allow-server-ssh is persisted" - echo " • Enable SSH per-peer in the NetBird dashboard (Peers > [peer] > SSH)" - echo " • NetBird injects /etc/ssh/sshd_config.d/99-netbird.conf automatically" - echo " • Connect from another NetBird peer: ssh user@" - echo " • Get peer IPs with: netbird status" - echo " • SSH will work without re-authenticating each connection" - echo "" - fi -else - echo "Skipping NetBird installation." -fi - -# WireGuard Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "WIREGUARD VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "WireGuard is a fast, modern VPN protocol." -echo " • Lightweight and high-performance" -echo " • Simple configuration via config files" -echo " • Built into Linux kernel" -echo "" - -if is_wireguard_installed; then - echo "WireGuard is already installed." - echo "" - prompt_yn "Reinstall WireGuard? (y/n):" "n" INSTALL_WIREGUARD -else - prompt_yn "Install WireGuard? (y/n):" "n" INSTALL_WIREGUARD -fi - -if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then - echo "" - echo "Installing WireGuard..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install wireguard wireguard-tools" - else - apt install -y wireguard wireguard-tools || echo "Warning: WireGuard installation failed, continuing..." - - echo "" - echo "WireGuard installed. Setup instructions:" - echo "" - echo "Generate keys:" - echo " wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey" - echo "" - echo "Create config at /etc/wireguard/wg0.conf:" - echo " [Interface]" - echo " PrivateKey = " - echo " Address = 10.0.0.1/24" - echo " ListenPort = 51820" - echo "" - echo " [Peer]" - echo " PublicKey = " - echo " AllowedIPs = 10.0.0.2/32" - echo " Endpoint = peer.example.com:51820" - echo "" - echo "Start WireGuard:" - echo " sudo wg-quick up wg0" - echo " sudo systemctl enable wg-quick@wg0 # Start on boot" - echo "" - fi -else - echo "Skipping WireGuard installation." -fi - -# Tailscale Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "TAILSCALE VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Tailscale is a zero-config mesh VPN built on WireGuard." -echo " • Easy setup - just sign in" -echo " • Built on WireGuard for performance" -echo " • Automatic NAT traversal" -echo " • Built-in SSH (Tailscale SSH)" -echo "" - -if is_tailscale_installed; then - echo "Tailscale is already installed." - tailscale status 2>/dev/null || true - echo "" - prompt_yn "Reinstall Tailscale? (y/n):" "n" INSTALL_TAILSCALE -else - prompt_yn "Install Tailscale? (y/n):" "n" INSTALL_TAILSCALE -fi - -if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then - echo "" - echo "Installing Tailscale..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would add Tailscale apt repository" - echo "[DRY-RUN] Would install tailscale" - else - # Add Tailscale's package signing key and repository - curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null - curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list - - apt update - apt install -y tailscale || echo "Warning: Tailscale installation failed, continuing..." - - echo "" - echo "Tailscale installed. Setup instructions:" - echo "" - echo "Connect to Tailscale network:" - echo " sudo tailscale up" - echo "" - echo "This opens a browser to authenticate. After that:" - echo " tailscale status # View connected devices" - echo " tailscale ip # Show your Tailscale IP" - echo "" - echo "Tailscale SSH (optional - enable in admin console):" - echo " • Enable 'SSH' in Tailscale admin console for this machine" - echo " • Connect with: ssh user@device-name (uses Tailscale identity)" - echo " • No SSH keys needed - Tailscale handles authentication" - echo "" - fi -else - echo "Skipping Tailscale installation." -fi - -fi # End SKIP_VPN_CONFIG - -# On rerun, skip remote desktop configuration if already set up -SKIP_RD_CONFIG=false -if is_rustdesk_installed || is_teamviewer_installed || is_meshcentral_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "REMOTE DESKTOP SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Remote desktop services already configured:" - is_rustdesk_installed && echo " ✓ RustDesk" - is_teamviewer_installed && echo " ✓ TeamViewer" - is_meshcentral_installed && echo " ✓ MeshCentral Agent" - echo "" - prompt_yn "Reconfigure remote desktop services? (y/n):" "n" RECONFIG_RD - if [ "$RECONFIG_RD" != "y" ] && [ "$RECONFIG_RD" != "Y" ]; then - SKIP_RD_CONFIG=true - fi -fi - -if [ "$SKIP_RD_CONFIG" != true ]; then - -# RustDesk Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "RUSTDESK REMOTE DESKTOP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "RustDesk is an open-source remote desktop software." -echo "" - -if is_rustdesk_installed; then - echo "RustDesk is already installed." - echo "" - prompt_yn "Reinstall RustDesk? (y/n):" "n" INSTALL_RUSTDESK -else - prompt_yn "Install RustDesk? (y/n):" "n" INSTALL_RUSTDESK -fi - -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo "" - echo "Installing RustDesk..." - echo " - RustDesk: Open-source remote desktop software" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download latest RustDesk from GitHub" - echo "[DRY-RUN] Would install rustdesk .deb package" - else - # Download latest RustDesk .deb package - RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")') - RUSTDESK_URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/rustdesk-${RUSTDESK_VERSION}-x86_64.deb" - - wget -O /tmp/rustdesk.deb "$RUSTDESK_URL" || echo "Warning: RustDesk download failed, continuing..." - - if [ -f /tmp/rustdesk.deb ]; then - apt install -y /tmp/rustdesk.deb || echo "Warning: RustDesk installation failed, continuing..." - rm /tmp/rustdesk.deb - echo "✓ RustDesk installed" - fi - fi -else - echo "Skipping RustDesk installation." -fi - -# TeamViewer Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "TEAMVIEWER REMOTE DESKTOP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "TeamViewer is commercial remote desktop software with a free tier." -echo " • Cross-platform (Windows, Mac, Linux, mobile)" -echo " • Easy to use - no port forwarding needed" -echo " • Requires TeamViewer account for unattended access" -echo "" - -if is_teamviewer_installed; then - echo "TeamViewer is already installed." - echo "" - prompt_yn "Reinstall TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER -else - prompt_yn "Install TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER -fi - -if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then - echo "" - echo "Installing TeamViewer..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download TeamViewer .deb package" - echo "[DRY-RUN] Would install teamviewer" - else - # Download TeamViewer .deb package - wget -O /tmp/teamviewer.deb "https://download.teamviewer.com/download/linux/teamviewer_amd64.deb" || echo "Warning: TeamViewer download failed, continuing..." - - if [ -f /tmp/teamviewer.deb ]; then - apt install -y /tmp/teamviewer.deb || echo "Warning: TeamViewer installation failed, continuing..." - rm /tmp/teamviewer.deb - - echo "" - echo "TeamViewer installed. Setup instructions:" - echo "" - echo "Start TeamViewer:" - echo " teamviewer" - echo "" - echo "For unattended access:" - echo " 1. Open TeamViewer" - echo " 2. Go to Extras → Options → Security" - echo " 3. Set a personal password for unattended access" - echo " 4. Note your TeamViewer ID (shown in main window)" - echo "" - echo "✓ TeamViewer installed" - fi - fi -else - echo "Skipping TeamViewer installation." -fi - -# MeshCentral Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "MESHCENTRAL AGENT (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "MeshCentral is an open-source remote management solution." -echo " • Self-hosted or use public servers" -echo " • Web-based remote desktop (no client software needed)" -echo " • Terminal, file transfer, and remote desktop" -echo " • Requires a MeshCentral server to connect to" -echo "" - -if is_meshcentral_installed; then - echo "MeshCentral Agent is already installed." - echo "" - prompt_yn "Reinstall MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL -else - prompt_yn "Install MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL -fi - -if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then - echo "" - echo "MeshCentral requires a server URL to connect to." - echo "" - echo "If you have a MeshCentral server, the agent install is typically done by:" - echo " 1. Log into your MeshCentral web interface" - echo " 2. Go to 'My Devices' → 'Add Agent'" - echo " 3. Download and run the Linux agent installer" - echo "" - echo "Example (replace with your server's URL):" - echo " wget -O meshagent https://your-meshcentral-server/meshagents?id=XXXXX" - echo " chmod +x meshagent" - echo " sudo ./meshagent -install" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would prompt for MeshCentral server URL" - echo "[DRY-RUN] Would download and install meshagent" - else - prompt_text "Enter MeshCentral agent URL (or leave blank to skip):" "" MESHCENTRAL_URL - - if [ -n "$MESHCENTRAL_URL" ]; then - wget -O /tmp/meshagent "$MESHCENTRAL_URL" || echo "Warning: MeshCentral agent download failed" - - if [ -f /tmp/meshagent ]; then - chmod +x /tmp/meshagent - /tmp/meshagent -install || echo "Warning: MeshCentral agent installation failed" - rm /tmp/meshagent - echo "" - echo "✓ MeshCentral Agent installed" - echo " Check your MeshCentral server - this device should appear shortly." - fi - else - echo "" - echo "No URL provided. Skipping MeshCentral agent installation." - echo "You can install later by downloading the agent from your MeshCentral server." - fi - fi -else - echo "Skipping MeshCentral installation." -fi - -fi # End SKIP_RD_CONFIG - -# ============================================================================ -# SELF-HOSTED DOCKER APPLICATIONS (Optional) -# ============================================================================ -# These applications run in Docker containers using docker-compose -# Each app is installed to ~/docker/{appname}/ - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SELF-HOSTED DOCKER APPLICATIONS (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Install self-hosted applications using Docker Compose." -echo "Each app will be installed to ~/docker/{appname}/" -echo "" -echo "Note: Docker must be installed for these applications." -echo "" - -if ! is_docker_installed && [ "$INSTALL_DOCKER" != "y" ] && [ "$INSTALL_DOCKER" != "Y" ]; then - echo "Docker is not installed. Skipping self-hosted applications." - echo "Install Docker first, then rerun this script." -else - # Create docker apps directory - DOCKER_DIR="$ACTUAL_HOME/docker" - if [ "$DRY_RUN" != true ]; then - mkdir -p "$DOCKER_DIR" - chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - fi - - # ============================================================================ - # DETECT DRIVES (ONCE FOR ALL SERVICES) - # ============================================================================ - detect_drives - - # ============================================================================ - # SERVICE SELECTION MENU - # ============================================================================ - - # Use whiptail for service selection if available - if command -v whiptail &> /dev/null; then - # Detect existing services - echo "Detecting existing services..." - declare -A EXISTING_SERVICES - [ -d "$DOCKER_DIR/immich" ] && EXISTING_SERVICES[IMMICH]="ON" - [ -d "$DOCKER_DIR/audiobookshelf" ] && EXISTING_SERVICES[AUDIOBOOKSHELF]="ON" - [ -d "$DOCKER_DIR/emby" ] && EXISTING_SERVICES[EMBY]="ON" - [ -d "$DOCKER_DIR/arm" ] && EXISTING_SERVICES[ARM]="ON" - [ -d "$DOCKER_DIR/filebrowser" ] && EXISTING_SERVICES[FILEBROWSER]="ON" - [ -d "$DOCKER_DIR/magicmirror" ] && EXISTING_SERVICES[MAGICMIRROR]="ON" - [ -d "$DOCKER_DIR/actualbudget" ] && EXISTING_SERVICES[ACTUALBUDGET]="ON" - [ -d "$DOCKER_DIR/authelia" ] && EXISTING_SERVICES[AUTHELIA]="ON" - [ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON" - [ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON" - [ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON" - [ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON" - [ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON" - [ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON" - [ -d "$DOCKER_DIR/ntfy" ] && EXISTING_SERVICES[NTFY]="ON" - [ -d "$DOCKER_DIR/uptime-kuma" ] && EXISTING_SERVICES[UPTIMEKUMA]="ON" - [ -d "$DOCKER_DIR/wg-easy" ] && EXISTING_SERVICES[WGEASY]="ON" - [ -d "$DOCKER_DIR/traccar" ] && EXISTING_SERVICES[TRACCAR]="ON" - [ -d "$DOCKER_DIR/portainer" ] && EXISTING_SERVICES[PORTAINER]="ON" - [ -d "$DOCKER_DIR/meshcentral" ] && EXISTING_SERVICES[MESHCENTRAL]="ON" - [ -d "$DOCKER_DIR/fmd" ] && EXISTING_SERVICES[FINDMYDEVICE]="ON" - [ -d "$DOCKER_DIR/frigate-notify" ] && EXISTING_SERVICES[FRIGATE_NOTIFY]="ON" - [ -d "$DOCKER_DIR/watchtower" ] && EXISTING_SERVICES[WATCHTOWER]="ON" - command -v fail2ban-client &> /dev/null && EXISTING_SERVICES[FAIL2BAN]="ON" - - # Ask user what action to perform - ACTION=$(whiptail --title "Service Management" --menu \ - "Choose an action:" 15 60 3 \ - "1" "Install new services" \ - "2" "Uninstall existing services" \ - "3" "Cancel and skip" \ - 3>&1 1>&2 2>&3) - - case "$ACTION" in - 1) - # INSTALL MODE - MENU_MODE="INSTALL" - # Build checklist - mark existing services as ON - SELECTED_SERVICES=$(whiptail --title "Select Docker Services to Install" \ - --checklist "Use SPACE to select, ENTER to confirm. [*] = already installed" 25 78 17 \ - "IMMICH" "Photo & video backup (like Google Photos)" ${EXISTING_SERVICES[IMMICH]:-OFF} \ - "AUDIOBOOKSHELF" "Audiobook & podcast server" ${EXISTING_SERVICES[AUDIOBOOKSHELF]:-OFF} \ - "EMBY" "Media server for movies, TV, music" ${EXISTING_SERVICES[EMBY]:-OFF} \ - "ARM" "Automatic Ripping Machine for DVDs/Blu-rays" ${EXISTING_SERVICES[ARM]:-OFF} \ - "FILEBROWSER" "Web-based file manager" ${EXISTING_SERVICES[FILEBROWSER]:-OFF} \ - "MAGICMIRROR" "Smart mirror / dashboard display" ${EXISTING_SERVICES[MAGICMIRROR]:-OFF} \ - "ACTUALBUDGET" "Personal finance management with bank sync" ${EXISTING_SERVICES[ACTUALBUDGET]:-OFF} \ - "AUTHELIA" "SSO + 2FA auth portal for Caddy" ${EXISTING_SERVICES[AUTHELIA]:-OFF} \ - "CADDY" "Reverse proxy with automatic HTTPS" ${EXISTING_SERVICES[CADDY]:-OFF} \ - "FAIL2BAN" "Intrusion prevention system" ${EXISTING_SERVICES[FAIL2BAN]:-OFF} \ - "LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \ - "MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \ - "MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \ - "JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \ - "FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \ - "NTFY" "Push notifications server" ${EXISTING_SERVICES[NTFY]:-OFF} \ - "UPTIMEKUMA" "Service monitoring dashboard" ${EXISTING_SERVICES[UPTIMEKUMA]:-OFF} \ - "WGEASY" "WireGuard VPN with web UI" ${EXISTING_SERVICES[WGEASY]:-OFF} \ - "TRACCAR" "GPS tracking server" ${EXISTING_SERVICES[TRACCAR]:-OFF} \ - "PORTAINER" "Docker management web UI" ${EXISTING_SERVICES[PORTAINER]:-OFF} \ - "MESHCENTRAL" "Remote management server" ${EXISTING_SERVICES[MESHCENTRAL]:-OFF} \ - "FINDMYDEVICE" "Device tracking (like Find My)" ${EXISTING_SERVICES[FINDMYDEVICE]:-OFF} \ - "FRIGATE_NOTIFY" "Push notifications for Frigate" ${EXISTING_SERVICES[FRIGATE_NOTIFY]:-OFF} \ - "WATCHTOWER" "Automatic container updates" ${EXISTING_SERVICES[WATCHTOWER]:-OFF} \ - 3>&1 1>&2 2>&3) - ;; - 2) - # UNINSTALL MODE - MENU_MODE="UNINSTALL" - # Only show services that exist - UNINSTALL_OPTIONS="" - [ -n "${EXISTING_SERVICES[IMMICH]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS IMMICH \"Photo & video backup\" ON" - [ -n "${EXISTING_SERVICES[AUDIOBOOKSHELF]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUDIOBOOKSHELF \"Audiobook server\" ON" - [ -n "${EXISTING_SERVICES[EMBY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS EMBY \"Media server\" ON" - [ -n "${EXISTING_SERVICES[ARM]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ARM \"Automatic Ripping Machine\" ON" - [ -n "${EXISTING_SERVICES[FILEBROWSER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FILEBROWSER \"Web file manager\" ON" - [ -n "${EXISTING_SERVICES[MAGICMIRROR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MAGICMIRROR \"Smart mirror\" ON" - [ -n "${EXISTING_SERVICES[ACTUALBUDGET]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ACTUALBUDGET \"Personal finance\" ON" - [ -n "${EXISTING_SERVICES[AUTHELIA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUTHELIA \"SSO + 2FA auth portal\" ON" - [ -n "${EXISTING_SERVICES[CADDY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CADDY \"Reverse proxy\" ON" - [ -n "${EXISTING_SERVICES[FAIL2BAN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FAIL2BAN \"Intrusion prevention\" ON" - [ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON" - [ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON" - [ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON" - [ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON" - [ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON" - [ -n "${EXISTING_SERVICES[NTFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS NTFY \"Push notifications\" ON" - [ -n "${EXISTING_SERVICES[UPTIMEKUMA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS UPTIMEKUMA \"Service monitoring\" ON" - [ -n "${EXISTING_SERVICES[WGEASY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WGEASY \"WireGuard VPN\" ON" - [ -n "${EXISTING_SERVICES[TRACCAR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS TRACCAR \"GPS tracking\" ON" - [ -n "${EXISTING_SERVICES[PORTAINER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS PORTAINER \"Docker management\" ON" - [ -n "${EXISTING_SERVICES[MESHCENTRAL]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MESHCENTRAL \"Remote management\" ON" - [ -n "${EXISTING_SERVICES[FINDMYDEVICE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FINDMYDEVICE \"Device tracking\" ON" - [ -n "${EXISTING_SERVICES[FRIGATE_NOTIFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE_NOTIFY \"Frigate notifications\" ON" - [ -n "${EXISTING_SERVICES[WATCHTOWER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WATCHTOWER \"Container updates\" ON" - - if [ -z "$UNINSTALL_OPTIONS" ]; then - whiptail --title "No Services Found" --msgbox "No services detected to uninstall." 8 50 - SELECTED_SERVICES="" - else - SELECTED_SERVICES=$(eval "whiptail --title 'Select Services to Uninstall' \ - --checklist 'WARNING: Data will be backed up but services will be removed' 25 78 17 \ - $UNINSTALL_OPTIONS \ - 3>&1 1>&2 2>&3") - fi - ;; - 3|*) - # Cancel - SELECTED_SERVICES="" - MENU_MODE="CANCEL" - ;; - esac - - # Check if user cancelled - if [ $? -ne 0 ]; then - echo "Service selection cancelled. Skipping Docker applications." - SELECTED_SERVICES="" - fi - - # Parse selections (whiptail returns quoted strings) - # Only set to "n" if not already set (preserve any earlier choices) - : ${INSTALL_IMMICH:="n"} - : ${INSTALL_AUDIOBOOKSHELF:="n"} - : ${INSTALL_EMBY:="n"} - : ${INSTALL_ARM:="n"} - : ${INSTALL_FILEBROWSER:="n"} - : ${INSTALL_MAGICMIRROR:="n"} - : ${INSTALL_ACTUALBUDGET:="n"} - : ${INSTALL_AUTHELIA:="n"} - : ${INSTALL_CADDY:="n"} - : ${INSTALL_FAIL2BAN:="n"} - : ${INSTALL_LMS:="n"} - : ${INSTALL_MEALIE:="n"} - : ${INSTALL_MINECRAFT:="n"} - : ${INSTALL_JELLYFIN:="n"} - : ${INSTALL_FRIGATE:="n"} - : ${INSTALL_NTFY:="n"} - : ${INSTALL_UPTIMEKUMA:="n"} - : ${INSTALL_WGEASY:="n"} - : ${INSTALL_TRACCAR:="n"} - : ${INSTALL_PORTAINER:="n"} - : ${INSTALL_MESHCENTRAL_SERVER:="n"} - : ${INSTALL_FMD:="n"} - : ${INSTALL_FRIGATE_NOTIFY:="n"} - : ${INSTALL_WATCHTOWER:="n"} - - # Set installation flags based on selections - if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then INSTALL_IMMICH="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then INSTALL_AUDIOBOOKSHELF="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then INSTALL_EMBY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "ARM"; then INSTALL_ARM="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then INSTALL_FILEBROWSER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then INSTALL_MAGICMIRROR="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then INSTALL_ACTUALBUDGET="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUTHELIA"; then INSTALL_AUTHELIA="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then INSTALL_CADDY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FAIL2BAN"; then INSTALL_FAIL2BAN="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then INSTALL_NTFY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then INSTALL_UPTIMEKUMA="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then INSTALL_WGEASY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then INSTALL_TRACCAR="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then INSTALL_PORTAINER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then INSTALL_MESHCENTRAL_SERVER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then INSTALL_FMD="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then INSTALL_FRIGATE_NOTIFY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then INSTALL_WATCHTOWER="y"; fi - - if [ "$MENU_MODE" = "UNINSTALL" ]; then - echo "" - echo "Services selected for uninstallation:" - echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /' - echo "" - - # Process uninstalls - if [ -n "$SELECTED_SERVICES" ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " UNINSTALLING SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Define uninstall function - uninstall_service() { - local SERVICE_NAME=$1 - local SERVICE_DIR=$2 - local CONTAINER_NAME=$3 - - echo "Uninstalling $SERVICE_NAME..." - - if [ -d "$SERVICE_DIR" ]; then - cd "$SERVICE_DIR" 2>/dev/null || return - - # Stop and remove containers - if [ -f "docker-compose.yml" ]; then - echo " Stopping containers..." - docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true - fi - - # Backup data - BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$SERVICE_NAME" - mkdir -p "$BACKUP_DIR" - echo " Backing up to $BACKUP_DIR..." - cp -r "$SERVICE_DIR" "$BACKUP_DIR/" 2>/dev/null || true - - # Remove service directory - echo " Removing $SERVICE_DIR..." - rm -rf "$SERVICE_DIR" - - echo " ✓ $SERVICE_NAME uninstalled (backup: $BACKUP_DIR)" - else - echo " ⚠ $SERVICE_NAME directory not found, skipping" - fi - echo "" - } - - # Uninstall selected services - if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then uninstall_service "Immich" "$DOCKER_DIR/immich" "immich"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then uninstall_service "AudioBookshelf" "$DOCKER_DIR/audiobookshelf" "audiobookshelf"; fi - if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then uninstall_service "Emby" "$DOCKER_DIR/emby" "emby"; fi - if echo "$SELECTED_SERVICES" | grep -q "ARM"; then uninstall_service "ARM" "$DOCKER_DIR/arm" "arm"; fi - if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then uninstall_service "FileBrowser" "$DOCKER_DIR/filebrowser" "filebrowser"; fi - if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then uninstall_service "MagicMirror" "$DOCKER_DIR/magicmirror" "magicmirror"; fi - if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then uninstall_service "ActualBudget" "$DOCKER_DIR/actualbudget" "actualbudget"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUTHELIA"; then uninstall_service "Authelia" "$DOCKER_DIR/authelia" "authelia"; fi - if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi - if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi - if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi - if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi - if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi - if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then uninstall_service "ntfy" "$DOCKER_DIR/ntfy" "ntfy"; fi - if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then uninstall_service "Uptime Kuma" "$DOCKER_DIR/uptime-kuma" "uptime-kuma"; fi - if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then uninstall_service "wg-easy" "$DOCKER_DIR/wg-easy" "wg-easy"; fi - if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then uninstall_service "Traccar" "$DOCKER_DIR/traccar" "traccar"; fi - if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then uninstall_service "Portainer" "$DOCKER_DIR/portainer" "portainer"; fi - if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then uninstall_service "MeshCentral" "$DOCKER_DIR/meshcentral" "meshcentral"; fi - if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then uninstall_service "FindMyDevice" "$DOCKER_DIR/fmd" "fmd"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then uninstall_service "Frigate-Notify" "$DOCKER_DIR/frigate-notify" "frigate-notify"; fi - if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then uninstall_service "Watchtower" "$DOCKER_DIR/watchtower" "watchtower"; fi - - # Special handling for fail2ban (system package) - if echo "$SELECTED_SERVICES" | grep -q "FAIL2BAN"; then - echo "Uninstalling fail2ban..." - echo " Stopping fail2ban service..." - systemctl stop fail2ban 2>/dev/null || true - systemctl disable fail2ban 2>/dev/null || true - echo " Removing fail2ban package..." - apt-get remove --purge -y fail2ban 2>/dev/null || true - echo " ✓ fail2ban uninstalled" - echo "" - fi - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " Uninstallation complete!" - echo " Backups saved to: $DOCKER_DIR/backups/" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - fi - - # After uninstall, exit the Docker apps section - WHIPTAIL_USED=true - - elif [ "$MENU_MODE" = "INSTALL" ]; then - echo "" - echo "Selected services:" - echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /' - echo "" - - # Mark that whiptail was used - skip all individual prompts - WHIPTAIL_USED=true - else - # Cancel or other mode - WHIPTAIL_USED=false - fi - else - # Whiptail not available - will use individual prompts - WHIPTAIL_USED=false - MENU_MODE="INSTALL" - fi - - # Skip installation section if we just did uninstalls - if [ "$MENU_MODE" = "UNINSTALL" ]; then - echo "Skipping installation section (uninstall mode was selected)." - # Jump to end of Docker section by setting a flag - SKIP_DOCKER_INSTALLS=true - else - SKIP_DOCKER_INSTALLS=false - fi - - # ============================================================================ - # DOCKER SERVICE INSTALLATIONS - # ============================================================================ - - if [ "$SKIP_DOCKER_INSTALLS" != true ]; then - # ---- IMMICH ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_IMMICH" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ IMMICH - Self-hosted photo & video backup │" - echo "│ Like Google Photos but private. Mobile app auto-uploads. │" - echo "│ Port: 2283 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Immich? (y/n):" "n" INSTALL_IMMICH - fi - - if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then - IMMICH_DIR="$DOCKER_DIR/immich" - - # Check if already installed and ask what to do - check_service_exists "Immich" "$IMMICH_DIR" IMMICH_RECONFIGURE - - if [ "$IMMICH_RECONFIGURE" = "true" ]; then - echo "Installing Immich..." - - # ── Photo library setup ───────────────────────────────────── - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ PHOTO LIBRARY SETUP │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - - DEFAULT_PHOTOS_DIR="$PRIMARY_DRIVE_PATH/photos" - IMMICH_STRATEGY="" - EXTERNAL_LIBRARY="" - EXISTING_PHOTOS_SOURCE="" - - prompt_yn " Do you have existing photos to include? (y/n):" "n" HAS_EXISTING_PHOTOS - - if [ "$HAS_EXISTING_PHOTOS" = "y" ] || [ "$HAS_EXISTING_PHOTOS" = "Y" ]; then - echo "" - echo " How should Immich handle your existing photos?" - echo "" - echo " [1] Import everything into Immich (recommended)" - echo " Immich manages all photos in one unified library." - echo " Dates preserved via EXIF metadata. Organized by date." - echo " Your original folder names are NOT kept on disk" - echo " (use Immich albums to organize instead)." - echo "" - echo " [2] Keep existing photos in place (read-only external library)" - echo " Immich indexes your existing photos without moving them." - echo " New uploads go to a separate folder." - echo " Your folder structure stays intact." - echo "" - - if [ "$UNATTENDED" = true ]; then - IMMICH_STRATEGY="1" - echo " Strategy: [auto: 1]" - else - read -p " Choose [1/2]: " IMMICH_STRATEGY - IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}" - fi - - if [ "$IMMICH_STRATEGY" = "2" ]; then - # Strategy 2: external library - ask where existing photos are - echo "" - echo " Where are your existing photos?" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Existing photos path:" "$DEFAULT_PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$DEFAULT_PHOTOS_DIR" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" - - UPLOAD_LOCATION="$PRIMARY_DRIVE_PATH/immich-uploads" - EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE" - - echo "" - echo " Setup:" - echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)" - echo " New uploads: $UPLOAD_LOCATION" - else - # Strategy 1 with existing photos: ask for library path, will import into it - echo "" - echo " Where should Immich store your photo library?" - echo " (Existing photos will be imported here)" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR" - PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}" - PHOTOS_DIR="${PHOTOS_DIR%/}" - - echo "" - echo " Where are your existing photos now?" - echo " Default: $PHOTOS_DIR" - prompt_text " Existing photos path:" "$PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$PHOTOS_DIR" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" - - UPLOAD_LOCATION="$PHOTOS_DIR" - EXTERNAL_LIBRARY="" - - echo "" - echo " All photos (existing + new) will live in:" - echo " $PHOTOS_DIR" - echo " └── 2026/01/filename.jpg organized by date" - fi - else - # No existing photos: simplest path - just ask where to store - IMMICH_STRATEGY="1" - echo "" - echo " Where should Immich store your photo library?" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR" - PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}" - PHOTOS_DIR="${PHOTOS_DIR%/}" - - UPLOAD_LOCATION="$PHOTOS_DIR" - EXTERNAL_LIBRARY="" - - echo "" - echo " All photos will be stored in:" - echo " $PHOTOS_DIR" - echo " └── 2026/01/filename.jpg organized by date" - fi - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $IMMICH_DIR" - echo "[DRY-RUN] Would store uploads at $UPLOAD_LOCATION" - [ -n "$EXTERNAL_LIBRARY" ] && echo "[DRY-RUN] Would mount external library at $EXTERNAL_LIBRARY" - echo "[DRY-RUN] Would create docker-compose.yml and .env" - else - mkdir -p "$IMMICH_DIR" 2>/dev/null || true - mkdir -p "$UPLOAD_LOCATION" 2>/dev/null || true - [ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" 2>/dev/null || true - - # Create required subdirectories with .immich marker files - # Immich checks these on startup and fails if they're missing - for subdir in thumbs upload backups library profile encoded-video; do - mkdir -p "$UPLOAD_LOCATION/$subdir" 2>/dev/null || true - touch "$UPLOAD_LOCATION/$subdir/.immich" 2>/dev/null || true - done - cd "$IMMICH_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Create docker-compose.yml - if [ -n "$EXTERNAL_LIBRARY" ]; then - # Strategy 2: external library mount included - cat > docker-compose.yml << 'IMMICH_COMPOSE' -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - ${EXTERNAL_LIBRARY}:/usr/src/app/external:ro - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false - - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_DB: ${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -IMMICH_COMPOSE - else - # Strategy 1: unified library, no external mount - cat > docker-compose.yml << 'IMMICH_COMPOSE' -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false - - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_DB: ${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -IMMICH_COMPOSE - fi - - # Generate random password - DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) - - # Create .env file with strategy-appropriate comments - if [ "$IMMICH_STRATEGY" = "2" ]; then - cat > .env << IMMICH_ENV -# ============================================================ -# IMMICH CONFIGURATION — External Library Mode -# ============================================================ -# -# STORAGE TEMPLATE (configure in Immich web UI): -# Admin → Settings → Storage Template → Enable -# Template: {{y}}/{{MM}}/{{filename}} -# This organizes new uploads into: immich-uploads/2026/01/filename.jpg -# -# EXTERNAL LIBRARY SETUP: -# Admin → External Libraries → Create Library -# Import path: /usr/src/app/external -# Click "Scan" to index your existing photos. -# -# ============================================================ - -# New uploads from phone/web -UPLOAD_LOCATION=$UPLOAD_LOCATION - -# Existing photos (read-only, indexed by Immich) -# Mounted at /usr/src/app/external inside the container -EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY - -# Database location (keep on fast storage) -DB_DATA_LOCATION=./postgres - -IMMICH_VERSION=release - -DB_PASSWORD=$DB_PASS -DB_USERNAME=postgres -DB_DATABASE_NAME=immich - -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -IMMICH_ENV - else - cat > .env << IMMICH_ENV -# ============================================================ -# IMMICH CONFIGURATION — Unified Library -# ============================================================ -# -# All photos (imported + new uploads) are stored in one location. -# Storage template organizes files by date automatically. -# -# To import existing photos, run: -# $IMMICH_DIR/import-photos.sh -# -# The import script handles storage template configuration, -# CLI installation, and uploads with EXIF date preservation. -# -# ============================================================ - -# All photos stored here (uploads + imported) -UPLOAD_LOCATION=$UPLOAD_LOCATION - -# Database location (keep on fast storage) -DB_DATA_LOCATION=./postgres - -IMMICH_VERSION=release - -DB_PASSWORD=$DB_PASS -DB_USERNAME=postgres -DB_DATABASE_NAME=immich - -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -IMMICH_ENV - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" 2>/dev/null || true - [ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true - - # Create import helper script for Strategy 1 with existing photos - if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then - cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_HEAD' -#!/bin/bash -################################################################################ -# Immich Photo Import Script -# Generated by ubuntu-post-install -# -# This script imports your existing photo collection into Immich with proper -# EXIF date preservation. Photos are uploaded through the API, which triggers -# Immich's metadata extraction pipeline — dates, GPS, camera info are all -# read from the original files. -# -# What this script does: -# 1. Creates admin account (if first run) or logs in -# 2. Generates an API key automatically -# 3. Configures the storage template (date-based organization) -# 4. Installs the Immich CLI (if needed) -# 5. Uploads all photos with EXIF metadata preserved -# -# Usage: -# ./import-photos.sh # interactive (prompts for everything) -# ./import-photos.sh # skip account setup, use existing key -################################################################################ - -IMPORT_SCRIPT_HEAD - - cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_SCRIPT_VARS -IMMICH_URL="http://localhost:2283" -SOURCE_DIR="$EXISTING_PHOTOS_SOURCE" -IMMICH_DIR="$IMMICH_DIR" -IMPORT_SCRIPT_VARS - - cat >> "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_BODY' - -echo "" -echo "┌─────────────────────────────────────────────────────────────────┐" -echo "│ IMMICH PHOTO IMPORT │" -echo "└─────────────────────────────────────────────────────────────────┘" -echo "" - -# ── Preflight checks ──────────────────────────────────────────── -echo "Checking Immich server..." -if ! curl -s "$IMMICH_URL/api/server/ping" > /dev/null 2>&1; then - echo "" - echo " ✗ Immich is not running at $IMMICH_URL" - echo " Start it with: cd $IMMICH_DIR && docker compose up -d" - echo "" - exit 1 -fi -echo " ✓ Immich is running" - -# Check source directory -if [ ! -d "$SOURCE_DIR" ]; then - echo "" - echo " ✗ Source directory not found: $SOURCE_DIR" - echo " Update SOURCE_DIR in this script if your photos moved." - echo "" - exit 1 -fi -echo " ✓ Source directory: $SOURCE_DIR" - -echo -n " Scanning for photos/videos (this may take a while)..." -PHOTO_COUNT=$(find "$SOURCE_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" -o -iname "*.heif" -o -iname "*.webp" -o -iname "*.gif" -o -iname "*.tiff" -o -iname "*.bmp" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.webm" \) 2>/dev/null | wc -l) -echo " done" -echo " ✓ Found ~$PHOTO_COUNT photos/videos" - -# ── Get or create API key ─────────────────────────────────────── -API_KEY="${1:-}" - -if [ -z "$API_KEY" ]; then - echo "" - # Check if server needs initial setup - SERVER_CONFIG=$(curl -s "$IMMICH_URL/api/server/config" 2>/dev/null) - IS_INITIALIZED=$(echo "$SERVER_CONFIG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('isInitialized', True))" 2>/dev/null) - - if [ "$IS_INITIALIZED" = "False" ]; then - # ── First-time setup: create admin account ────────────── - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FIRST-TIME SETUP — Creating admin account │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - echo " Immich needs an admin account. Enter your details:" - echo "" - read -p " Admin email: " ADMIN_EMAIL - while [ -z "$ADMIN_EMAIL" ]; do - read -p " Admin email (required): " ADMIN_EMAIL - done - read -sp " Admin password: " ADMIN_PASS - echo "" - while [ ${#ADMIN_PASS} -lt 8 ]; do - echo " Password must be at least 8 characters." - read -sp " Admin password: " ADMIN_PASS - echo "" - done - read -p " Your name [Admin]: " ADMIN_NAME - ADMIN_NAME="${ADMIN_NAME:-Admin}" - - echo "" - echo " Creating admin account..." - SIGNUP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/auth/admin-sign-up" \ - -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"name\":\"$ADMIN_NAME\"}" 2>/dev/null) - SIGNUP_CODE=$(echo "$SIGNUP_RESPONSE" | tail -1) - SIGNUP_BODY=$(echo "$SIGNUP_RESPONSE" | sed '$d') - - if [ "$SIGNUP_CODE" = "201" ]; then - echo " ✓ Admin account created" - else - echo " ✗ Failed to create admin account (HTTP $SIGNUP_CODE)" - echo " Response: $SIGNUP_BODY" - echo "" - echo " Create your account manually at $IMMICH_URL" - echo " Then re-run this script with your API key:" - echo " $0 " - exit 1 - fi - else - # Server already initialized, need credentials to log in - echo " Immich is already set up. Log in to generate an API key." - echo "" - read -p " Admin email: " ADMIN_EMAIL - while [ -z "$ADMIN_EMAIL" ]; do - read -p " Admin email (required): " ADMIN_EMAIL - done - read -sp " Admin password: " ADMIN_PASS - echo "" - fi - - # ── Login to get bearer token ─────────────────────────────── - echo " Logging in..." - LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/auth/login" \ - -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" 2>/dev/null) - LOGIN_CODE=$(echo "$LOGIN_RESPONSE" | tail -1) - LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | sed '$d') - - if [ "$LOGIN_CODE" != "201" ]; then - echo " ✗ Login failed (HTTP $LOGIN_CODE)" - echo "" - echo " Check your email/password and try again, or pass an API key:" - echo " $0 " - exit 1 - fi - - ACCESS_TOKEN=$(echo "$LOGIN_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])" 2>/dev/null) - if [ -z "$ACCESS_TOKEN" ]; then - echo " ✗ Could not extract access token from login response" - exit 1 - fi - echo " ✓ Logged in" - - # ── Create API key ────────────────────────────────────────── - echo " Creating API key..." - APIKEY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - "$IMMICH_URL/api/api-keys" \ - -d '{"name":"import-photos-script"}' 2>/dev/null) - APIKEY_CODE=$(echo "$APIKEY_RESPONSE" | tail -1) - APIKEY_BODY=$(echo "$APIKEY_RESPONSE" | sed '$d') - - if [ "$APIKEY_CODE" = "201" ]; then - API_KEY=$(echo "$APIKEY_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['secret'])" 2>/dev/null) - if [ -n "$API_KEY" ]; then - echo " ✓ API key created" - else - echo " ✗ Could not extract API key from response" - echo " Create one manually: $IMMICH_URL → Account Settings → API Keys" - echo " Then re-run: $0 " - exit 1 - fi - else - echo " ✗ Failed to create API key (HTTP $APIKEY_CODE)" - echo " Create one manually: $IMMICH_URL → Account Settings → API Keys" - echo " Then re-run: $0 " - exit 1 - fi -else - # API key provided as argument — verify it - echo "" - echo " Verifying API key..." - VERIFY_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "x-api-key: $API_KEY" "$IMMICH_URL/api/users/me" 2>/dev/null) - if [ "$VERIFY_CODE" != "200" ]; then - echo " ✗ Invalid API key (HTTP $VERIFY_CODE)" - echo " Double-check the key and try again." - exit 1 - fi - echo " ✓ API key valid" -fi - -# ── Configure storage template via API ────────────────────────── -echo "" -echo " Configuring storage template ({{y}}/{{MM}}/{{filename}})..." -CURRENT_CONFIG=$(curl -s -H "x-api-key: $API_KEY" "$IMMICH_URL/api/system-config" 2>/dev/null) -if [ -n "$CURRENT_CONFIG" ] && command -v python3 &> /dev/null; then - UPDATED_CONFIG=$(echo "$CURRENT_CONFIG" | python3 -c " -import sys, json -config = json.load(sys.stdin) -config['storageTemplate']['enabled'] = True -config['storageTemplate']['template'] = '{{y}}/{{MM}}/{{filename}}' -json.dump(config, sys.stdout) -" 2>/dev/null) - if [ -n "$UPDATED_CONFIG" ]; then - RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ - -H "x-api-key: $API_KEY" \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/system-config" \ - -d "$UPDATED_CONFIG" 2>/dev/null) - if [ "$RESULT" = "200" ]; then - echo " ✓ Storage template configured" - else - echo " ⚠ Could not set storage template (HTTP $RESULT)" - echo " Set manually: Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - fi - else - echo " ⚠ Could not parse config. Set storage template manually:" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - fi -else - echo " ⚠ python3 not found. Set storage template manually:" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" -fi - -# ── Install immich-cli if needed ──────────────────────────────── -echo "" -IMMICH_CMD="" - -# Immich CLI requires Node.js >= 20 (File global class) -NODE_OK=false -if command -v node &> /dev/null; then - NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then - NODE_OK=true - fi -fi - -if [ "$NODE_OK" = false ]; then - echo " Immich CLI requires Node.js >= 20 (found: $(node -v 2>/dev/null || echo 'none'))." - read -p " Install Node.js 22 LTS now? (y/n): " INSTALL_NODE_YN - if [ "$INSTALL_NODE_YN" = "y" ] || [ "$INSTALL_NODE_YN" = "Y" ]; then - echo " Installing Node.js 22 LTS from NodeSource..." - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - 2>/dev/null - sudo apt-get install -y -qq nodejs 2>/dev/null - NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then - NODE_OK=true - echo " ✓ Node.js $(node -v) installed" - else - echo " ✗ Installation failed." - echo " Install Node.js 20+ manually: https://nodejs.org/" - echo " Then re-run: $0 $API_KEY" - exit 1 - fi - else - echo "" - echo " Install Node.js 20+ later and re-run this script:" - echo " curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -" - echo " sudo apt-get install -y nodejs" - echo " $0 $API_KEY" - exit 0 - fi -fi - -if command -v immich &> /dev/null; then - IMMICH_CMD="immich" - echo " ✓ Immich CLI found" -elif command -v npx &> /dev/null; then - echo " Immich CLI not installed. Will use npx (downloads temporarily)." - IMMICH_CMD="npx --yes @immich/cli" -elif command -v npm &> /dev/null; then - echo " Installing Immich CLI globally..." - if npm install -g @immich/cli 2>/dev/null; then - IMMICH_CMD="immich" - echo " ✓ Immich CLI installed" - else - echo " ✗ npm install failed. Trying npx..." - IMMICH_CMD="npx --yes @immich/cli" - fi -fi - -if [ -z "$IMMICH_CMD" ]; then - echo " ✗ Could not find npm or npx despite Node.js being installed." - echo " Install manually: npm install -g @immich/cli" - echo " Then re-run: $0 $API_KEY" - exit 1 -fi - -# ── Run the import ────────────────────────────────────────────── -echo "" -echo " Authenticating CLI with Immich..." -if ! $IMMICH_CMD login "$IMMICH_URL/api" "$API_KEY"; then - echo " ✗ CLI login failed" - exit 1 -fi - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " Starting import from: $SOURCE_DIR" -echo " Importing ~$PHOTO_COUNT files. This may take a while." -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -$IMMICH_CMD upload --recursive "$SOURCE_DIR" - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " Import complete!" -echo " View your photos at: $IMMICH_URL" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -IMPORT_SCRIPT_BODY - - chmod +x "$IMMICH_DIR/import-photos.sh" - chown "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR/import-photos.sh" - fi - - echo "" - echo "✓ Immich configured at $IMMICH_DIR" - if [ -n "$EXTERNAL_LIBRARY" ]; then - echo " Existing photos: $EXTERNAL_LIBRARY (read-only)" - echo " New uploads: $UPLOAD_LOCATION" - else - echo " Photo library: $UPLOAD_LOCATION" - fi - echo "" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Immich" "2283" "photos" - - # Ask to start container - prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH - if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then - echo " Starting Immich..." - docker compose up -d 2>/dev/null && echo " ✓ Immich started" || echo " ⚠ Failed to start" - fi - - # ── Post-setup instructions ───────────────────────────── - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ IMMICH SETUP STEPS │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - - if [ -n "$EXISTING_PHOTOS_SOURCE" ] && [ "$IMMICH_STRATEGY" != "2" ]; then - echo " Import your existing photos AFTER this script finishes:" - echo " $IMMICH_DIR/import-photos.sh" - echo "" - echo " The script handles everything automatically:" - echo " creates your admin account, generates an API key," - echo " configures the storage template, installs the CLI," - echo " and imports all photos from $EXISTING_PHOTOS_SOURCE" - echo " with EXIF dates preserved." - echo "" - echo " NOTE: Run import-photos.sh separately after this setup" - echo " script has completed. It may take a while for large" - echo " photo collections." - echo "" - echo " After import, open http://localhost:2283 to browse your photos." - elif [ "$IMMICH_STRATEGY" = "2" ]; then - echo " 1. Open http://localhost:2283 and create your admin account" - echo "" - echo " 2. Enable storage template (keeps new uploads organized):" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - echo "" - echo " 3. Set up your existing photo library:" - echo " Admin → External Libraries → Create Library" - echo " Import path: /usr/src/app/external" - echo " Click Scan to index your photos" - echo "" - echo " New uploads from the mobile app or web UI are stored" - echo " separately in: $UPLOAD_LOCATION" - else - echo " 1. Open http://localhost:2283 and create your admin account" - echo "" - echo " 2. Enable storage template (keeps files organized by date):" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - echo "" - echo " That's it. Upload photos from the mobile app or web UI." - echo " All photos are stored in: $PHOTOS_DIR" - fi - echo "" - fi - fi # End IMMICH_RECONFIGURE check - fi # End INSTALL_IMMICH check - - # ---- AUDIOBOOKSHELF ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUDIOBOOKSHELF" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ AUDIOBOOKSHELF - Audiobook & podcast server │" - echo "│ Stream audiobooks with progress sync across devices. │" - echo "│ Port: 13378 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Audiobookshelf? (y/n):" "n" INSTALL_AUDIOBOOKSHELF - fi - - if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then - ABS_DIR="$DOCKER_DIR/audiobookshelf" - - # Check if already installed and ask what to do - check_service_exists "AudioBookshelf" "$ABS_DIR" ABS_RECONFIGURE - - if [ "$ABS_RECONFIGURE" = "true" ]; then - echo "Installing Audiobookshelf..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $ABS_DIR" - else - mkdir -p "$ABS_DIR" - ensure_docker_dir_ownership "$ABS_DIR" - cd "$ABS_DIR" - - prompt_text "Path to audiobooks folder [default: $PRIMARY_DRIVE_PATH/audiobooks]:" "$PRIMARY_DRIVE_PATH/audiobooks" AUDIOBOOKS_PATH - - cat > docker-compose.yml << ABS_COMPOSE -name: audiobookshelf - -services: - audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf:latest - container_name: audiobookshelf - hostname: audiobookshelf - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - ./metadata:/metadata - - ${AUDIOBOOKS_PATH}:/audiobooks - - ${PODCASTS_PATH:-./podcasts}:/podcasts - ports: - - "13378:80" -ABS_COMPOSE - - cat > .env << ABS_ENV -AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH -PODCASTS_PATH=./podcasts -ABS_ENV - - mkdir -p config metadata podcasts - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR" - - echo "" - echo "✓ Audiobookshelf configured at $ABS_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks" - - prompt_yn "Start Audiobookshelf now? (y/n):" "y" START_ABS - if [ "$START_ABS" = "y" ] || [ "$START_ABS" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Audiobookshelf started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:13378" - echo "" - fi - fi # End ABS_RECONFIGURE check - fi # End INSTALL_AUDIOBOOKSHELF check - - # ---- EMBY ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_EMBY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ EMBY - Media server for movies, TV, music │" - echo "│ Stream your media library to any device. │" - echo "│ Port: 8096 (web), 8920 (https) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Emby? (y/n):" "n" INSTALL_EMBY - fi - - if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then - EMBY_DIR="$DOCKER_DIR/emby" - - # Check if already installed and ask what to do - check_service_exists "Emby" "$EMBY_DIR" EMBY_RECONFIGURE - - if [ "$EMBY_RECONFIGURE" = "true" ]; then - echo "Installing Emby..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $EMBY_DIR" - else - mkdir -p "$EMBY_DIR" - ensure_docker_dir_ownership "$EMBY_DIR" - cd "$EMBY_DIR" - - prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH - - cat > docker-compose.yml << EMBY_COMPOSE -name: emby - -services: - emby: - image: emby/embyserver:latest - container_name: emby - hostname: emby - restart: unless-stopped - environment: - - UID=$(id -u "$ACTUAL_USER") - - GID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - ${MEDIA_PATH}:/media - ports: - - "8096:8096" - - "8920:8920" - # Uncomment for hardware transcoding (Intel/AMD) - # devices: - # - /dev/dri:/dev/dri -EMBY_COMPOSE - - cat > .env << EMBY_ENV -MEDIA_PATH=$MEDIA_PATH -EMBY_ENV - - mkdir -p config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR" - - echo "" - echo "✓ Emby configured at $EMBY_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Emby" "8096" "emby" - - prompt_yn "Start Emby now? (y/n):" "y" START_EMBY - if [ "$START_EMBY" = "y" ] || [ "$START_EMBY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Emby started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8096" - echo "" - fi - fi # End EMBY_RECONFIGURE check - fi # End INSTALL_EMBY check - - # ---- A.R.M. (Automatic Ripping Machine) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ARM" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ A.R.M. - Automatic Ripping Machine │" - echo "│ Automatically rip DVDs, Blu-rays, and CDs. │" - echo "│ Port: 8080 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install A.R.M.? (y/n):" "n" INSTALL_ARM - fi - - if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then - ARM_DIR="$DOCKER_DIR/arm" - check_service_exists "A.R.M." "$ARM_DIR" ARM_RECONFIGURE - - if [ "$ARM_RECONFIGURE" = "true" ]; then - echo "Installing A.R.M...." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $ARM_DIR" - else - mkdir -p "$ARM_DIR" - ensure_docker_dir_ownership "$ARM_DIR" - cd "$ARM_DIR" - - prompt_text "Path for ripped media output [default: $PRIMARY_DRIVE_PATH/ripped]:" "$PRIMARY_DRIVE_PATH/ripped" ARM_OUTPUT - - # Detect optical drives - echo "" - echo "Detecting optical drives..." - OPTICAL_DRIVES=$(ls /dev/sr* 2>/dev/null || echo "") - if [ -n "$OPTICAL_DRIVES" ]; then - echo "Found: $OPTICAL_DRIVES" - else - echo "No optical drives detected. You can add them later." - OPTICAL_DRIVES="/dev/sr0" - fi - - cat > docker-compose.yml << ARM_COMPOSE -name: arm - -services: - automatic-ripping-machine: - image: automaticrippingmachine/automatic-ripping-machine:latest - container_name: arm - hostname: arm - restart: unless-stopped - environment: - - ARM_UID=$(id -u "$ACTUAL_USER") - - ARM_GID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/etc/arm/config - - ./logs:/home/arm/logs - - ${ARM_OUTPUT}/movies:/home/arm/media/completed - - ${ARM_OUTPUT}/music:/home/arm/music - ports: - - "8080:8080" - devices: - - /dev/sr0:/dev/sr0 - # Add more drives as needed: - # - /dev/sr1:/dev/sr1 - privileged: true -ARM_COMPOSE - - cat > .env << ARM_ENV -ARM_OUTPUT=$ARM_OUTPUT -ARM_ENV - - mkdir -p config logs - mkdir -p "$ARM_OUTPUT/movies" "$ARM_OUTPUT/music" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_DIR" - - echo "" - echo "✓ A.R.M. configured at $ARM_DIR" - - prompt_yn "Start A.R.M. now? (y/n):" "y" START_ARM - if [ "$START_ARM" = "y" ] || [ "$START_ARM" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ A.R.M. started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8080" - echo " ⚠️ Complete setup in browser on first visit!" - echo " Note: Edit docker-compose.yml to add more optical drives" - echo "" - fi - fi # End ARM_RECONFIGURE check - fi # End INSTALL_ARM check - - # ---- FILEBROWSER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FILEBROWSER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FILEBROWSER - Web-based file manager │" - echo "│ Browse, upload, download files via web interface. │" - echo "│ Port: 8085 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Filebrowser? (y/n):" "n" INSTALL_FILEBROWSER - fi - - if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then - FB_DIR="$DOCKER_DIR/filebrowser" - check_service_exists "FileBrowser" "$FB_DIR" FB_RECONFIGURE - - if [ "$FB_RECONFIGURE" = "true" ]; then - echo "Installing Filebrowser..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FB_DIR" - else - mkdir -p "$FB_DIR" - ensure_docker_dir_ownership "$FB_DIR" - cd "$FB_DIR" - - prompt_text "Path to browse [default: $PRIMARY_DRIVE_PATH]:" "$PRIMARY_DRIVE_PATH" FB_PATH - - cat > docker-compose.yml << FB_COMPOSE -name: filebrowser - -services: - filebrowser: - image: filebrowser/filebrowser:s6 - container_name: filebrowser - hostname: filebrowser - restart: unless-stopped - environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ${FB_PATH}:/srv - - ./database/filebrowser.db:/database/filebrowser.db - - ./config/settings.json:/config/settings.json - ports: - - "8085:80" -FB_COMPOSE - - cat > .env << FB_ENV -FB_PATH=$FB_PATH -FB_ENV - - mkdir -p database config - touch database/filebrowser.db - cat > config/settings.json << 'FB_SETTINGS' -{ - "port": 80, - "baseURL": "", - "address": "", - "log": "stdout", - "database": "/database/filebrowser.db", - "root": "/srv" -} -FB_SETTINGS - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR" - - echo "" - echo "✓ Filebrowser configured at $FB_DIR" - - prompt_yn "Start Filebrowser now? (y/n):" "y" START_FB - if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Filebrowser started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8085" - echo " Default login: admin / admin (change immediately!)" - echo "" - fi - fi # End FB_RECONFIGURE check - fi # End INSTALL_FILEBROWSER check - - # ---- MAGIC MIRROR ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MAGICMIRROR" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MAGIC MIRROR - Smart mirror / dashboard display │" - echo "│ Modular smart mirror platform. Run up to 3 instances. │" - echo "│ Ports: 8081, 8082, 8083 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Magic Mirror? (y/n):" "n" INSTALL_MAGICMIRROR - fi - - if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then - echo "" - prompt_text "How many Magic Mirror instances? [1-3, default: 1]:" "1" MM_COUNT - MM_COUNT=${MM_COUNT:-1} - if [ "$MM_COUNT" -gt 3 ]; then MM_COUNT=3; fi - if [ "$MM_COUNT" -lt 1 ]; then MM_COUNT=1; fi - - echo "Installing $MM_COUNT Magic Mirror instance(s)..." - - for i in $(seq 1 $MM_COUNT); do - MM_PORT=$((8080 + i)) - MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MM_DIR (port $MM_PORT)" - else - mkdir -p "$MM_DIR" - ensure_docker_dir_ownership "$MM_DIR" - cd "$MM_DIR" - - cat > docker-compose.yml << MM_COMPOSE -name: mm-$MM_PORT - -services: - magicmirror: - image: karsten13/magicmirror:latest - container_name: magicmirror-$MM_PORT - hostname: magicmirror-$MM_PORT - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/opt/magic_mirror/config - - ./modules:/opt/magic_mirror/modules - - ./css:/opt/magic_mirror/css - ports: - - "$MM_PORT:8080" -MM_COMPOSE - - mkdir -p config modules css - - # Ask if user has existing config to copy - echo "" - echo " Config options:" - echo " [1] Use default config (basic modules)" - echo " [2] Copy existing config from path" - read -p " Choose [1]: " MM_CONFIG_CHOICE - MM_CONFIG_CHOICE=${MM_CONFIG_CHOICE:-1} - - if [ "$MM_CONFIG_CHOICE" = "2" ]; then - read -p " Path to config.js: " MM_CONFIG_PATH - if [ -f "$MM_CONFIG_PATH" ]; then - cp "$MM_CONFIG_PATH" config/config.js - echo " ✓ Copied config from $MM_CONFIG_PATH" - - # Also copy custom.css if exists in same directory - MM_CSS_PATH="${MM_CONFIG_PATH%/*}/custom.css" - if [ -f "$MM_CSS_PATH" ]; then - cp "$MM_CSS_PATH" css/custom.css - echo " ✓ Copied custom.css" - fi - - # Parse config for third-party modules and offer to download - echo "" - echo " Scanning for third-party modules..." - - # Extract module names starting with MMM- (third-party convention) - THIRD_PARTY_MODULES=$(grep -oP 'module:\s*["\x27]MMM-[^"\x27]+["\x27]' config/config.js 2>/dev/null | sed "s/module:\s*[\"']//g" | sed "s/[\"']//g" | sort -u) - - if [ -n "$THIRD_PARTY_MODULES" ]; then - echo " Found third-party modules:" - echo "$THIRD_PARTY_MODULES" | while read mod; do - echo " - $mod" - done - echo "" - prompt_yn " Download these modules from GitHub? (y/n):" "y" MM_DOWNLOAD_MODS - if [ "$MM_DOWNLOAD_MODS" = "y" ] || [ "$MM_DOWNLOAD_MODS" = "Y" ]; then - cd modules - echo "$THIRD_PARTY_MODULES" | while read mod; do - if [ -n "$mod" ] && [ ! -d "$mod" ]; then - echo " Downloading $mod..." - # Try common GitHub patterns - git clone --depth 1 "https://github.com/MichMich/$mod.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/bugsounet/$mod.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/MagicMirrorOrg/$mod.git" 2>/dev/null || \ - echo " ⚠ Could not find $mod - search at https://github.com/topics/magicmirror" - fi - done - cd .. - - # Run npm install for each downloaded module - # Note: This runs AFTER container is started, inside the container - echo "" - echo " Note: Module dependencies will be installed when container starts" - echo " If you need to manually install module dependencies, run:" - echo " docker exec magicmirror-$MM_PORT sh -c 'cd /opt/magic_mirror/modules/ && npm install --production'" - echo "" - fi - else - echo " No third-party modules (MMM-*) found in config" - fi - else - echo " ⚠ File not found: $MM_CONFIG_PATH" - echo " Using default config..." - MM_CONFIG_CHOICE="1" - fi - fi - - # Create default config if not copying - if [ "$MM_CONFIG_CHOICE" != "2" ]; then - # Create basic config.js - cat > config/config.js << 'MM_CONFIG' -let config = { - address: "0.0.0.0", - port: 8080, - ipWhitelist: [], - language: "en", - timeFormat: 12, - units: "imperial", - modules: [ - { - module: "alert", - }, - { - module: "clock", - position: "top_left" - }, - { - module: "calendar", - header: "Calendar", - position: "top_left", - config: { - calendars: [ - { - symbol: "calendar-check", - url: "webcal://www.calendarlabs.com/ical-calendar/ics/76/US_Holidays.ics" - } - ] - } - }, - { - module: "weather", - position: "top_right", - config: { - weatherProvider: "openmeteo", - type: "current", - lat: 40.7128, - lon: -74.0060 - } - }, - { - module: "weather", - position: "top_right", - header: "Weather Forecast", - config: { - weatherProvider: "openmeteo", - type: "forecast", - lat: 40.7128, - lon: -74.0060 - } - }, - { - module: "newsfeed", - position: "bottom_bar", - config: { - feeds: [ - { - title: "BBC", - url: "https://feeds.bbci.co.uk/news/rss.xml" - } - ], - showSourceTitle: true, - showPublishDate: true, - broadcastNewsFeeds: true, - broadcastNewsUpdates: true - } - }, - ] -}; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") {module.exports = config;} -MM_CONFIG - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MM_DIR" - - echo " ✓ Magic Mirror #$i configured at $MM_DIR (port $MM_PORT)" - fi - done - - if [ "$DRY_RUN" != true ]; then - echo "" - prompt_yn "Start Magic Mirror instance(s) now? (y/n):" "y" START_MM - if [ "$START_MM" = "y" ] || [ "$START_MM" = "Y" ]; then - for i in $(seq 1 $MM_COUNT); do - MM_PORT=$((8080 + i)) - MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT" - (cd "$MM_DIR" && docker compose up -d 2>/dev/null) && echo " ✓ Magic Mirror #$i started (port $MM_PORT)" || echo " ⚠ Failed to start Magic Mirror #$i" - - # Install npm dependencies for third-party modules inside container - if [ -d "$MM_DIR/modules" ]; then - echo " Installing module dependencies inside container..." - sleep 3 # Wait for container to fully start - for mod_dir in "$MM_DIR/modules"/MMM-*/; do - if [ -d "$mod_dir" ] && [ -f "$mod_dir/package.json" ]; then - mod_name=$(basename "$mod_dir") - echo " Installing $mod_name dependencies..." - docker exec magicmirror-$MM_PORT sh -c "cd /opt/magic_mirror/modules/$mod_name && npm install --production" 2>/dev/null && \ - echo " ✓ $mod_name dependencies installed" || \ - echo " ⚠ $mod_name - npm install failed (container may need restart)" - fi - done - fi - done - fi - - echo " Access at: http://localhost:808X" - echo " Edit config: ~/docker/magicmirror-808X/config/config.js" - echo "" - fi - fi - - # ---- ACTUALBUDGET ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ACTUALBUDGET" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ ACTUALBUDGET - Open-source Personal Finance Management │" - echo "│ Budget tracking with bank account synchronization via SimpleFIN│" - echo "│ Port: 5006 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ActualBudget? (y/n):" "n" INSTALL_ACTUALBUDGET - fi - - if [ "$INSTALL_ACTUALBUDGET" = "y" ] || [ "$INSTALL_ACTUALBUDGET" = "Y" ]; then - AB_DIR="$DOCKER_DIR/actualbudget" - check_service_exists "ActualBudget" "$AB_DIR" AB_RECONFIGURE - - if [ "$AB_RECONFIGURE" = "true" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $AB_DIR" - else - echo "Installing ActualBudget..." - mkdir -p "$AB_DIR/data" - ensure_docker_dir_ownership "$AB_DIR" - cd "$AB_DIR" - - # Create .env file for environment variables - cat > .env << 'AB_ENV' -# ActualBudget Environment Variables -TZ=UTC -AB_ENV - - cat > docker-compose.yml << 'AB_COMPOSE' -name: actualbudget - -services: - actualbudget: - image: actualbudget/actual-server:latest - container_name: actualbudget - restart: unless-stopped - ports: - - "5006:5006" - volumes: - - ./data:/data - env_file: - - .env - labels: - - "io.podman.annotations.label/fail2ban.enable=true" - - "io.podman.annotations.label/fail2ban.filter=caddy-auth" -AB_COMPOSE - - echo " ✓ ActualBudget configured at $AB_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "ActualBudget" "5006" "budget" - - prompt_yn "Start ActualBudget now? (y/n):" "y" START_AB - if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ActualBudget started" || echo " ⚠ Failed to start ActualBudget" - fi - - echo "" - echo " Access at: http://localhost:5006" - echo " Bank sync: https://simplefin.org/ (SimpleFIN account required)" - echo " Data dir: $AB_DIR/data" - echo "" - fi - fi # End AB_RECONFIGURE check - fi # End INSTALL_ACTUALBUDGET check - - # ---- CADDY WEB SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_CADDY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CADDY - Modern Web Server & Reverse Proxy │" - echo "│ Automatic HTTPS, reverse proxy for all your services │" - echo "│ Port: 80 (HTTP), 443 (HTTPS) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - CADDY_DIR="$DOCKER_DIR/caddy" - - # Check if Caddy is already installed - if [ -f "$CADDY_DIR/Caddyfile" ] || [ -f "$CADDY_DIR/docker-compose.yml" ]; then - echo "" - echo "⚠ Caddy appears to be already installed at $CADDY_DIR" - prompt_yn "Do you want to reconfigure it? (y/n):" "n" RECONFIGURE_CADDY - if [ "$RECONFIGURE_CADDY" != "y" ] && [ "$RECONFIGURE_CADDY" != "Y" ]; then - echo " Skipping Caddy installation" - INSTALL_CADDY="n" - fi - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $CADDY_DIR" - else - echo "Installing Caddy..." - mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config" - ensure_docker_dir_ownership "$CADDY_DIR" - - # Backup existing Caddyfile if it exists - if [ -f "$CADDY_DIR/Caddyfile" ]; then - mkdir -p "$CADDY_DIR/backups" - BACKUP_FILE="$CADDY_DIR/backups/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)" - cp "$CADDY_DIR/Caddyfile" "$BACKUP_FILE" - echo " ✓ Backed up existing Caddyfile to: $BACKUP_FILE" - fi - - cd "$CADDY_DIR" - - cat > docker-compose.yml << 'CADDY_COMPOSE' -name: caddy - -services: - caddy: - image: caddy:latest - container_name: caddy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - - "443:443/udp" # HTTP/3 - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - ./data:/data - - ./config:/config - - /var/log/caddy:/var/log/caddy - environment: - - ACME_AGREE=true - labels: - - "io.podman.annotations.label/fail2ban.enable=true" -CADDY_COMPOSE - - # Create Caddyfile if it doesn't exist - if [ ! -f "Caddyfile" ]; then - cat > Caddyfile << 'CADDYFILE' -{ - # Global options - admin off - # Email for Let's Encrypt notifications - # email admin@yourdomain.com -} - -# Example configuration - edit this for your services -# Uncomment and modify these examples: - -# ── Authelia SSO snippet (auto-added by installer if Authelia is installed) ─── -# (authelia) { -# forward_auth authelia:9091 { -# uri /api/authz/forward-auth -# copy_headers Remote-User Remote-Groups Remote-Name Remote-Email -# } -# } -# -# Authelia login portal -# auth.yourdomain.com { -# reverse_proxy authelia:9091 -# } -# -# To protect any service with Authelia, add: import authelia -# Example: -# myservice.yourdomain.com { -# import authelia -# reverse_proxy localhost:PORT -# } - -# ActualBudget -# budget.yourdomain.com { -# log { -# output file /var/log/caddy/actualbudget-access.log -# format json -# level INFO -# } -# reverse_proxy localhost:5006 -# header { -# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" -# X-Frame-Options "SAMEORIGIN" -# X-Content-Type-Options "nosniff" -# X-XSS-Protection "1; mode=block" -# Referrer-Policy "strict-origin-when-cross-origin" -# } -# } - -# Add more services here... -CADDYFILE - echo " ✓ Created example Caddyfile" - else - echo " ℹ Using existing Caddyfile" - fi - - echo " ✓ Caddy configured at $CADDY_DIR" - - prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY - if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start Caddy" - fi - - echo "" - echo " Configuration file: $CADDY_DIR/Caddyfile" - echo " Edit Caddyfile to add your domains and services" - echo " Reload config: cd $CADDY_DIR && docker exec -w /etc/caddy caddy caddy reload" - echo "" - echo " ⚠ IMPORTANT: Edit the Caddyfile to configure your domains!" - echo " - Uncomment and modify the example configurations" - echo " - Add your domain names" - echo " - Configure services you want to expose" - echo "" - fi - fi - fi - - # ---- AUTHELIA ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUTHELIA" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ AUTHELIA - SSO & Two-Factor Authentication Portal │" - echo "│ Protects any Caddy subdomain with a single login + TOTP │" - echo "│ Port: 9091 (internal only, accessed via Caddy) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Authelia? (y/n):" "n" INSTALL_AUTHELIA - fi - - if [ "$INSTALL_AUTHELIA" = "y" ] || [ "$INSTALL_AUTHELIA" = "Y" ]; then - AUTHELIA_DIR="$DOCKER_DIR/authelia" - check_service_exists "Authelia" "$AUTHELIA_DIR" AUTHELIA_RECONFIGURE - - if [ "$AUTHELIA_RECONFIGURE" = "true" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $AUTHELIA_DIR" - else - echo "Installing Authelia..." - mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data" - - # Collect configuration from user - echo "" - echo " Authelia needs a few details to configure." - echo "" - prompt_text " Your domain (e.g., example.com):" "example.com" AUTHELIA_DOMAIN - prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER - prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY - prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL - prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST - prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT - prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER - prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS - prompt_text " Timezone (e.g., America/New_York):" "America/New_York" AUTHELIA_TZ - - # Generate secrets - echo "" - echo " Generating secrets..." - JWT_SECRET=$(openssl rand -hex 32) - SESSION_SECRET=$(openssl rand -hex 32) - STORAGE_SECRET=$(openssl rand -hex 32) - - echo "$JWT_SECRET" > "$AUTHELIA_DIR/config/secrets/jwt_secret" - echo "$SESSION_SECRET" > "$AUTHELIA_DIR/config/secrets/session_secret" - echo "$STORAGE_SECRET" > "$AUTHELIA_DIR/config/secrets/storage_secret" - echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password" - chmod 600 "$AUTHELIA_DIR/config/secrets/"* - echo " ✓ Secrets generated" - - # Generate password hash for admin user - echo "" - echo " Generating password hash for admin user..." - prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS - AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \ - authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \ - | grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH") - if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then - echo " ⚠ Could not generate hash automatically. Run this after install:" - echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'" - echo " Then update $AUTHELIA_DIR/config/users.yml" - else - echo " ✓ Password hash generated" - fi - - ensure_docker_dir_ownership "$AUTHELIA_DIR" - # Authelia requires its config/data owned by uid 1000 - chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" - - cd "$AUTHELIA_DIR" - - # .env file - cat > .env << AUTHELIA_ENV -MY_DOMAIN=${AUTHELIA_DOMAIN} -SMTP_USER=${AUTHELIA_SMTP_USER} -DOCKER_MY_NETWORK=caddy_net -TZ=${AUTHELIA_TZ} -AUTHELIA_ENV - - # docker-compose.yml - cat > docker-compose.yml << 'AUTHELIA_COMPOSE' -name: authelia - -services: - authelia: - image: authelia/authelia:4.39.20 - pull_policy: missing - container_name: authelia - user: "1000:1000" - volumes: - - ./config:/config - - ./data:/data - environment: - - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret - - AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret - - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret - - AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password - - AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER} - - AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}> - expose: - - 9091 - restart: unless-stopped - networks: - - caddy_net - -networks: - caddy_net: - external: true -AUTHELIA_COMPOSE - - # configuration.yml - cat > config/configuration.yml << AUTHELIA_CONFIG ---- -# Authelia configuration -# Secrets are injected via AUTHELIA_* environment variables in docker-compose.yml - -theme: dark - -server: - address: tcp://0.0.0.0:9091 - -log: - level: info - file_path: /data/authelia.log - -totp: - period: 30 - skew: 1 - -authentication_backend: - file: - path: /config/users.yml - password: - algorithm: argon2 - argon2: - variant: argon2id - iterations: 3 - memory: 65536 - parallelism: 4 - key_length: 32 - salt_length: 16 - -access_control: - default_policy: deny - rules: - - domain: "*.${AUTHELIA_DOMAIN}" - policy: two_factor - -session: - name: authelia_session - expiration: 12h - inactivity: 2h - remember_me: 7d - cookies: - - domain: ${AUTHELIA_DOMAIN} - authelia_url: https://auth.${AUTHELIA_DOMAIN} - default_redirection_url: https://${AUTHELIA_DOMAIN} - -storage: - local: - path: /data/db.sqlite3 - -notifier: - disable_startup_check: false - smtp: - address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT} - timeout: 10s - identifier: localhost - subject: "[Authelia] {title}" - startup_check_address: ${AUTHELIA_SMTP_USER} - disable_require_tls: false - disable_starttls: false -AUTHELIA_CONFIG - - # users.yml - cat > config/users.yml << AUTHELIA_USERS ---- -# Authelia users database -# To add users: copy a block, update username/email/displayname, restart authelia -# To generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword' -# Login with username (not email). Tell users to use "Forgot Password" to set their own password. - -users: - ${AUTHELIA_ADMIN_USER}: - displayname: "${AUTHELIA_ADMIN_DISPLAY}" - email: ${AUTHELIA_ADMIN_EMAIL} - password: "${AUTHELIA_HASH}" - groups: - - admins - - users -AUTHELIA_USERS - - chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" - - echo " ✓ Authelia configured at $AUTHELIA_DIR" - echo "" - - # Ensure caddy_net Docker network exists - if ! docker network ls --format '{{.Name}}' | grep -q "^caddy_net$"; then - echo " Creating Docker network caddy_net..." - docker network create caddy_net && echo " ✓ caddy_net created" || echo " ⚠ Failed to create caddy_net" - else - echo " ✓ Docker network caddy_net already exists" - fi - - # Inject Authelia snippet into Caddyfile if Caddy is installed - CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" - if [ -f "$CADDY_FILE" ]; then - echo "" - echo " Configuring Caddy for Authelia..." - - # Add (authelia) snippet at top if not already present - if ! grep -q "(authelia)" "$CADDY_FILE"; then - cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)" - AUTHELIA_SNIPPET=$(cat << 'SNIPPET_EOF' -# ── Authelia forward auth snippet ───────────────────────────────────────────── -(authelia) { - forward_auth authelia:9091 { - uri /api/authz/forward-auth - copy_headers Remote-User Remote-Groups Remote-Name Remote-Email - } -} - -SNIPPET_EOF -) - # Prepend snippet before existing content - echo "$AUTHELIA_SNIPPET" | cat - "$CADDY_FILE" > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE" - echo " ✓ Authelia snippet added to Caddyfile" - else - echo " ✓ Authelia snippet already in Caddyfile" - fi - - # Add auth portal site block if not already present - AUTH_DOMAIN="auth.${AUTHELIA_DOMAIN}" - if ! grep -q "$AUTH_DOMAIN" "$CADDY_FILE"; then - cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK - -# ── Authelia login portal ────────────────────────────────────────────────────── -${AUTH_DOMAIN} { - reverse_proxy authelia:9091 - log { - output file /var/log/caddy/auth.log - } -} -CADDY_AUTH_BLOCK - echo " ✓ Authelia portal block added for ${AUTH_DOMAIN}" - fi - - # Reload Caddy if it's running - if docker ps --format '{{.Names}}' | grep -q "^caddy$"; then - docker exec -w /etc/caddy caddy caddy reload 2>/dev/null \ - && echo " ✓ Caddy reloaded" \ - || echo " ⚠ Caddy reload failed — reload manually after fixing Caddyfile" - fi - else - echo " ℹ Caddy not yet installed. Install Caddy and add this to your Caddyfile:" - echo "" - echo ' (authelia) {' - echo ' forward_auth authelia:9091 {' - echo ' uri /api/authz/forward-auth' - echo ' copy_headers Remote-User Remote-Groups Remote-Name Remote-Email' - echo ' }' - echo ' }' - echo "" - echo " auth.${AUTHELIA_DOMAIN} {" - echo " reverse_proxy authelia:9091" - echo " }" - echo "" - fi - - prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA - if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Authelia started" || echo " ⚠ Failed to start Authelia" - sleep 2 - docker compose logs --tail=20 authelia 2>/dev/null || true - fi - - echo "" - echo " Auth portal: https://auth.${AUTHELIA_DOMAIN}" - echo " Config dir: $AUTHELIA_DIR/config" - echo " Users file: $AUTHELIA_DIR/config/users.yml" - echo " Data dir: $AUTHELIA_DIR/data" - echo "" - echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set real password)" - echo "" - echo " To protect a Caddy site, add 'import authelia' to its block:" - echo " myservice.${AUTHELIA_DOMAIN} {" - echo " import authelia" - echo " reverse_proxy localhost:PORT" - echo " }" - echo "" - echo " To add users:" - echo " Edit $AUTHELIA_DIR/config/users.yml then:" - echo " cd $AUTHELIA_DIR && docker compose restart authelia" - echo "" - fi - fi # End AUTHELIA_RECONFIGURE check - fi # End INSTALL_AUTHELIA check - - # ---- FAIL2BAN ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FAIL2BAN" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FAIL2BAN - Intrusion Prevention System │" - echo "│ Automatically ban IPs with failed auth attempts │" - echo "│ Protects SSH, Caddy, and other services │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install and configure fail2ban? (y/n):" "n" INSTALL_FAIL2BAN - fi - - if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install fail2ban" - else - echo "Installing fail2ban..." - - # Check if fail2ban is already installed - if command -v fail2ban-client &> /dev/null; then - echo " ✓ fail2ban is already installed" - else - echo " Installing fail2ban package..." - if sudo apt update && sudo apt install -y fail2ban; then - echo " ✓ fail2ban installed successfully" - else - echo " ⚠ Failed to install fail2ban" - echo " You may need to install it manually: sudo apt install fail2ban" - fi - fi - - # Create log directory for Caddy - if [ ! -d "/var/log/caddy" ]; then - sudo mkdir -p /var/log/caddy - sudo chmod 755 /var/log/caddy - echo " ✓ Created /var/log/caddy directory" - fi - - # Check if Caddy filter exists - FILTER_FILE="/etc/fail2ban/filter.d/caddy-auth.conf" - if [ ! -f "$FILTER_FILE" ]; then - echo " Creating fail2ban filter for Caddy..." - - FILTER_CONTENT='[Definition] -failregex = ^.*"remote_ip":"".*"status":(?:401|403|429).*$ - ^.*"remote_addr":".*"status":(?:401|403|429).*$ -ignoreregex = ^.*"remote_ip":"(?:127\.0\.0\.1|::1)".*$ -datepattern = "ts":%%s' - - if echo "$FILTER_CONTENT" | sudo tee "$FILTER_FILE" > /dev/null; then - echo " ✓ Created Caddy fail2ban filter" - else - echo " ⚠ Failed to create filter - you may need to create it manually" - fi - else - echo " ✓ Caddy fail2ban filter already exists" - fi - - # Check if Caddy jail exists - JAIL_FILE="/etc/fail2ban/jail.d/caddy.conf" - if [ ! -f "$JAIL_FILE" ]; then - echo " Creating fail2ban jail for Caddy..." - echo "" - echo " Configure fail2ban settings (press Enter for defaults):" - - prompt_text " Max retries before ban:" "5" F2B_MAXRETRY - prompt_text " Find time window (seconds):" "600" F2B_FINDTIME - prompt_text " Ban duration (seconds):" "3600" F2B_BANTIME - - JAIL_CONTENT="[caddy-auth] -enabled = true -port = http,https -filter = caddy-auth -logpath = /var/log/caddy/access.log - /var/log/caddy/*-access.log -maxretry = $F2B_MAXRETRY -findtime = $F2B_FINDTIME -bantime = $F2B_BANTIME -action = iptables-multiport[name=CaddyAuth, port=\"http,https\", protocol=tcp] -backend = auto" - - if echo "$JAIL_CONTENT" | sudo tee "$JAIL_FILE" > /dev/null; then - echo " ✓ Created Caddy fail2ban jail" - else - echo " ⚠ Failed to create jail - you may need to create it manually" - fi - else - echo " ✓ Caddy fail2ban jail already exists" - fi - - # Test fail2ban configuration - echo "" - echo " Testing fail2ban configuration..." - if sudo fail2ban-client -t &> /dev/null; then - echo " ✓ fail2ban configuration is valid" - else - echo " ⚠ fail2ban configuration has errors" - echo " Check with: sudo fail2ban-client -t" - fi - - # Restart fail2ban - prompt_yn "Restart fail2ban to apply changes? (y/n):" "y" RESTART_F2B - if [ "$RESTART_F2B" = "y" ] || [ "$RESTART_F2B" = "Y" ]; then - if sudo systemctl restart fail2ban; then - echo " ✓ fail2ban restarted successfully" - - # Wait for fail2ban to start - sleep 2 - - # Check jail status - if sudo fail2ban-client status caddy-auth &> /dev/null; then - echo " ✓ caddy-auth jail is active" - echo "" - sudo fail2ban-client status caddy-auth - else - echo " ⚠ caddy-auth jail is not active (may need Caddy logs to exist first)" - fi - else - echo " ⚠ Failed to restart fail2ban" - echo " Check logs: sudo journalctl -u fail2ban -n 50" - fi - fi - - echo "" - echo " Useful commands:" - echo " Check jail status: sudo fail2ban-client status caddy-auth" - echo " View banned IPs: sudo fail2ban-client get caddy-auth banip" - echo " Unban IP: sudo fail2ban-client set caddy-auth unbanip 1.2.3.4" - echo " View logs: sudo tail -f /var/log/fail2ban.log" - echo "" - fi - fi - - # ---- LYRION MUSIC SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_LMS" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ LYRION MUSIC SERVER (LMS) - Music streaming server │" - echo "│ Stream music to Squeezebox devices, apps, and Chromecast. │" - echo "│ Port: 9000 (web), 9090 (CLI), 3483 (players) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Lyrion Music Server? (y/n):" "n" INSTALL_LMS - fi - - if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then - echo "Installing Lyrion Music Server..." - LMS_DIR="$DOCKER_DIR/lyrion" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $LMS_DIR" - else - mkdir -p "$LMS_DIR" - ensure_docker_dir_ownership "$LMS_DIR" - cd "$LMS_DIR" - - prompt_text "Path to music folder [default: $PRIMARY_DRIVE_PATH/music]:" "$PRIMARY_DRIVE_PATH/music" MUSIC_PATH - - cat > docker-compose.yml << LMS_COMPOSE -name: lyrion - -services: - lyrion: - image: lmscommunity/lyrionmusicserver:stable - container_name: lyrion - hostname: lyrion - restart: unless-stopped - network_mode: host - environment: - - HTTP_PORT=9000 - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config:rw - - ${MUSIC_PATH}:/music:ro - - ./playlists:/playlists:rw - - /etc/localtime:/etc/localtime:ro -LMS_COMPOSE - - cat > .env << LMS_ENV -MUSIC_PATH=$MUSIC_PATH -LMS_ENV - - mkdir -p config playlists - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LMS_DIR" - - echo "" - echo "✓ Lyrion Music Server configured at $LMS_DIR" - - prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS - if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Lyrion Music Server started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:9000" - echo " Note: Uses host networking for Chromecast support" - echo "" - fi - fi - - # ---- MEALIE ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MEALIE" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MEALIE - Recipe manager & meal planner │" - echo "│ Save recipes, plan meals, generate shopping lists. │" - echo "│ Port: 9925 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Mealie? (y/n):" "n" INSTALL_MEALIE - fi - - if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then - echo "Installing Mealie..." - MEALIE_DIR="$DOCKER_DIR/mealie" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MEALIE_DIR" - else - mkdir -p "$MEALIE_DIR" - ensure_docker_dir_ownership "$MEALIE_DIR" - cd "$MEALIE_DIR" - - cat > docker-compose.yml << MEALIE_COMPOSE -name: mealie - -services: - mealie: - image: ghcr.io/mealie-recipes/mealie:latest - container_name: mealie - hostname: mealie - restart: unless-stopped - environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - ALLOW_SIGNUP=true - - MAX_WORKERS=1 - - WEB_CONCURRENCY=1 - - BASE_URL=http://localhost:9925 - volumes: - - ./data:/app/data - ports: - - "9925:9000" -MEALIE_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" - - echo "" - echo "✓ Mealie configured at $MEALIE_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Mealie" "9925" "recipes" - - prompt_yn "Start Mealie now? (y/n):" "y" START_MEALIE - if [ "$START_MEALIE" = "y" ] || [ "$START_MEALIE" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Mealie started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:9925" - echo " Default: changeme@email.com / MyPassword" - echo "" - fi - fi - - # ---- MINECRAFT SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MINECRAFT SERVER - Game server with RAM limit │" - echo "│ Fabric server with configurable memory allocation. │" - echo "│ Port: 25565 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Minecraft Server? (y/n):" "n" INSTALL_MINECRAFT - fi - - if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then - echo "Installing Minecraft Server..." - MC_DIR="$DOCKER_DIR/minecraft" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MC_DIR" - else - mkdir -p "$MC_DIR" - ensure_docker_dir_ownership "$MC_DIR" - cd "$MC_DIR" - - echo "" - prompt_text "Maximum RAM for Minecraft (e.g., 2G, 4G) [default: 2G]:" "2G" MC_RAM - MC_RAM=${MC_RAM:-2G} - - cat > docker-compose.yml << MC_COMPOSE -name: minecraft - -services: - minecraft: - image: itzg/minecraft-server:latest - container_name: minecraft - hostname: minecraft - restart: unless-stopped - tty: true - stdin_open: true - environment: - - EULA=TRUE - - TYPE=FABRIC - - VERSION=LATEST - - MEMORY=${MC_RAM} - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - OPS= - - MOTD=A Minecraft Server - - DIFFICULTY=normal - - MODE=survival - volumes: - - ./data:/data - ports: - - "25565:25565" - deploy: - resources: - limits: - memory: ${MC_RAM} -MC_COMPOSE - - cat > .env << MC_ENV -MC_RAM=$MC_RAM -MC_ENV - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" - - echo "" - echo "✓ Minecraft Server configured at $MC_DIR" - - prompt_yn "Start Minecraft Server now? (y/n):" "y" START_MC - if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Minecraft Server started" || echo " ⚠ Failed to start" - fi - - echo " Connect: localhost:25565" - echo " RAM limit: $MC_RAM" - echo " Console: docker attach minecraft (Ctrl+P, Ctrl+Q to detach)" - echo "" - fi - fi - - # ---- LINUX-TO-SYNC (Private Repo) ---- - if [ "$WHIPTAIL_USED" != true ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ LINUX-TO-SYNC - Private sync repository │" - echo "│ Clone and set up your private linux-to-sync repository. │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - echo "Note: This requires access to github.com/outis1one/linux-to-sync" - echo "" - echo "To grant access, you need ONE of these:" - echo " 1. SSH key already added to your GitHub account" - echo " 2. GitHub Personal Access Token (PAT)" - echo " 3. GitHub CLI (gh) authenticated" - echo "" - prompt_yn "Set up linux-to-sync? (y/n):" "n" INSTALL_LINUXTOSYNC - - if [ "$INSTALL_LINUXTOSYNC" = "y" ] || [ "$INSTALL_LINUXTOSYNC" = "Y" ]; then - SYNC_DIR="$DOCKER_DIR/linux-to-sync" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would clone linux-to-sync to $SYNC_DIR" - else - echo "" - echo "Choose authentication method:" - echo " [1] SSH (if you have SSH key added to GitHub)" - echo " [2] HTTPS with token (requires Personal Access Token)" - echo "" - prompt_text "Enter 1 or 2 [default: 1]:" "1" AUTH_METHOD - - if [ "$AUTH_METHOD" = "2" ]; then - echo "" - echo "Create a Personal Access Token at:" - echo " https://github.com/settings/tokens/new" - echo " - Select 'repo' scope for full repository access" - echo "" - prompt_text "Enter your GitHub Personal Access Token:" "" GH_TOKEN - - if [ -n "$GH_TOKEN" ]; then - git clone "https://$GH_TOKEN@github.com/outis1one/linux-to-sync.git" "$SYNC_DIR" 2>/dev/null - if [ $? -eq 0 ]; then - # Remove token from remote URL for security - cd "$SYNC_DIR" - git remote set-url origin "https://github.com/outis1one/linux-to-sync.git" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" - echo "" - echo "✓ linux-to-sync cloned to $SYNC_DIR" - echo " Note: You'll need to enter token again for push/pull" - echo " Or set up: git config credential.helper store" - else - echo "✗ Clone failed. Check your token and try again." - fi - else - echo "No token provided. Skipping." - fi - else - echo "" - echo "Attempting SSH clone..." - echo "(Make sure your SSH key is added to GitHub)" - echo "" - git clone git@github.com:outis1one/linux-to-sync.git "$SYNC_DIR" 2>/dev/null - if [ $? -eq 0 ]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" - echo "" - echo "✓ linux-to-sync cloned to $SYNC_DIR" - else - echo "" - echo "✗ SSH clone failed." - echo "" - echo "To add your SSH key to GitHub:" - echo " 1. Copy your public key: cat ~/.ssh/id_rsa.pub" - echo " 2. Go to: https://github.com/settings/keys" - echo " 3. Click 'New SSH key' and paste your key" - echo "" - echo "Then retry this script or manually clone:" - echo " git clone git@github.com:outis1one/linux-to-sync.git ~/docker/linux-to-sync" - fi - fi - fi - fi - fi # End WHIPTAIL_USED check for linux-to-sync - - # ---- JELLYFIN (Alternative to Emby) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_JELLYFIN" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ JELLYFIN - Free media server (alternative to Emby) │" - echo "│ Stream movies, TV, music. No premium features locked. │" - echo "│ Port: 8096 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Jellyfin? (y/n):" "n" INSTALL_JELLYFIN - fi - - if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then - echo "Installing Jellyfin..." - JELLYFIN_DIR="$DOCKER_DIR/jellyfin" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $JELLYFIN_DIR" - else - mkdir -p "$JELLYFIN_DIR" - ensure_docker_dir_ownership "$JELLYFIN_DIR" - cd "$JELLYFIN_DIR" - - prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH - - # Get render group ID for hardware acceleration - RENDER_GID=$(getent group render | cut -d: -f3 2>/dev/null || echo "989") - - cat > docker-compose.yml << JELLYFIN_COMPOSE -name: jellyfin - -services: - jellyfin: - image: jellyfin/jellyfin:latest - container_name: jellyfin - hostname: jellyfin - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - devices: - - /dev/dri/renderD128:/dev/dri/renderD128 - group_add: - - "$RENDER_GID" - volumes: - - ./config:/config - - ./cache:/cache - - \${MEDIA_PATH}:/media:ro - ports: - - "8096:8096" - - "1900:1900/udp" - - "7359:7359/udp" -JELLYFIN_COMPOSE - - cat > .env << JELLYFIN_ENV -MEDIA_PATH=$MEDIA_PATH -JELLYFIN_ENV - - mkdir -p config cache - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" - - echo "" - echo "✓ Jellyfin configured at $JELLYFIN_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Jellyfin" "8096" "jellyfin" - - prompt_yn "Start Jellyfin now? (y/n):" "y" START_JELLYFIN - if [ "$START_JELLYFIN" = "y" ] || [ "$START_JELLYFIN" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Jellyfin started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8096" - echo " Note: Hardware acceleration enabled (Intel GPU)" - echo "" - fi - fi - - # ---- FRIGATE NVR ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FRIGATE - AI-powered NVR for security cameras │" - echo "│ Object detection, recordings, 24/7 monitoring. │" - echo "│ Port: 5000 (web), 8554 (RTSP), 8555 (WebRTC) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Frigate? (y/n):" "n" INSTALL_FRIGATE - fi - - if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then - echo "Installing Frigate..." - FRIGATE_DIR="$DOCKER_DIR/frigate" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FRIGATE_DIR" - else - # STEP 1: Create directory and install docker-compose - mkdir -p "$FRIGATE_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$FRIGATE_DIR" - cd "$FRIGATE_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Default path - FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate" - - cat > docker-compose.yml << FRIGATE_COMPOSE -name: frigate - -services: - frigate: - image: ghcr.io/blakeblackshear/frigate:stable - container_name: frigate - hostname: frigate - restart: unless-stopped - privileged: true - shm_size: "256mb" - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - devices: - - /dev/dri/renderD128:/dev/dri/renderD128 - volumes: - - ./config:/config - - \${FRIGATE_MEDIA}:/media/frigate - - type: tmpfs - target: /tmp/cache - tmpfs: - size: 1000000000 - ports: - - "5000:5000" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" -FRIGATE_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try to configure (uses defaults if fails) - prompt_text "Path for recordings [$FRIGATE_PATH]:" "$FRIGATE_PATH" FRIGATE_PATH 2>/dev/null || FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate" - - cat > .env << FRIGATE_ENV -FRIGATE_MEDIA=$FRIGATE_PATH -FRIGATE_ENV - - mkdir -p config 2>/dev/null || true - mkdir -p "$FRIGATE_PATH" 2>/dev/null || echo " ⚠ Could not create $FRIGATE_PATH - create manually" - - # Create template config - cat > config/config.yml << 'FRIGATE_CONFIG' -# Frigate Configuration -# Docs: https://docs.frigate.video -# -# ⚠️ YOU MUST EDIT THIS FILE to add your cameras! - -mqtt: - enabled: false - -cameras: - # EXAMPLE - Replace with your camera: - # front_door: - # ffmpeg: - # inputs: - # - path: rtsp://user:pass@192.168.1.100:554/stream - # roles: [detect, record] - # detect: - # width: 1280 - # height: 720 - # fps: 5 - -detectors: - default: - type: cpu - -record: - enabled: true - retain: - days: 7 - mode: motion - -snapshots: - enabled: true - retain: - default: 7 -FRIGATE_CONFIG - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_DIR" 2>/dev/null || true - - echo "" - echo "✓ Frigate installed at $FRIGATE_DIR" - echo "" - echo " ⚠️ Note: You should edit config/config.yml to add cameras before starting." - prompt_yn "Start Frigate now anyway? (y/n):" "n" START_FRIGATE - if [ "$START_FRIGATE" = "y" ] || [ "$START_FRIGATE" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Frigate started" || echo " ⚠ Failed to start" - fi - - echo " Access: http://localhost:5000" - echo " Docs: https://docs.frigate.video" - echo "" - fi - fi - - # ---- CADDY REVERSE PROXY (Legacy) ---- - # Note: This is the legacy Caddy installation - # The newer installation above includes fail2ban support - # This section is kept for backwards compatibility - if [ "$WHIPTAIL_USED" != true ] && [ "$INSTALL_CADDY" != "y" ] && [ "$INSTALL_CADDY" != "Y" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CADDY - Automatic HTTPS reverse proxy (Legacy) │" - echo "│ Route domains to containers with automatic SSL certificates. │" - echo "│ Ports: 80, 443 │" - echo "└─────────────────────────────────────────────────────────────────┘" - if [ -z "$INSTALL_CADDY_LEGACY" ]; then - prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY_LEGACY - fi - INSTALL_CADDY="$INSTALL_CADDY_LEGACY" - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - echo "Installing Caddy..." - CADDY_DIR="$DOCKER_DIR/caddy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $CADDY_DIR" - else - # STEP 1: Create directory and install docker-compose - mkdir -p "$CADDY_DIR" 2>/dev/null || true - cd "$CADDY_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'CADDY_COMPOSE' -name: caddy - -services: - caddy: - image: caddy:latest - container_name: caddy - hostname: caddy - restart: unless-stopped - env_file: .env - ports: - - "80:80" - - "443:443" - - "443:443/udp" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - ./config:/config - - ./data:/data - - ./site:/srv - -networks: - default: - name: caddy_net - external: true -CADDY_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try configuration (uses defaults if fails) - CADDY_DOMAIN="" - prompt_text "Your domain (e.g., example.com) [blank for local]:" "" CADDY_DOMAIN 2>/dev/null || CADDY_DOMAIN="" - - cat > .env << CADDY_ENV -MY_DOMAIN=${CADDY_DOMAIN:-localhost} -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -CADDY_ENV - - # Create Docker network for Caddy (ignore if exists) - docker network create caddy_net 2>/dev/null || true - - # Create comprehensive Caddyfile with all services - cat > Caddyfile << 'CADDY_FILE' -# Caddy reverse proxy configuration -# Edit MY_DOMAIN in .env file, then uncomment services below -# -# To use: containers must be on 'caddy_net' network -# Add to each container's docker-compose.yml: -# networks: -# default: -# name: caddy_net -# external: true - -# ============================================================================ -# GLOBAL OPTIONS -# ============================================================================ -{ - # Uncomment for local-only (no domain/SSL): - # auto_https off -} - -# ============================================================================ -# MEDIA SERVERS -# ============================================================================ - -# Immich (photo backup) -# immich.{$MY_DOMAIN} { -# reverse_proxy immich_server:2283 -# } - -# Jellyfin (media server) -# jellyfin.{$MY_DOMAIN} { -# reverse_proxy jellyfin:8096 -# } - -# Emby (media server) -# emby.{$MY_DOMAIN} { -# reverse_proxy emby:8096 -# } - -# Audiobookshelf -# audiobooks.{$MY_DOMAIN} { -# reverse_proxy audiobookshelf:80 -# } - -# Lyrion Music Server -# music.{$MY_DOMAIN} { -# reverse_proxy lms:9000 -# } - -# ============================================================================ -# HOME AUTOMATION & MONITORING -# ============================================================================ - -# Frigate NVR -# frigate.{$MY_DOMAIN} { -# reverse_proxy frigate:5000 -# } - -# Uptime Kuma -# status.{$MY_DOMAIN} { -# reverse_proxy uptime-kuma:3001 -# } - -# Magic Mirror -# mirror.{$MY_DOMAIN} { -# reverse_proxy magicmirror:8080 -# } - -# Traccar GPS -# gps.{$MY_DOMAIN} { -# reverse_proxy traccar:8082 -# } - -# FindMyDevice -# fmd.{$MY_DOMAIN} { -# reverse_proxy fmd:8080 -# } - -# ============================================================================ -# UTILITIES -# ============================================================================ - -# Filebrowser -# files.{$MY_DOMAIN} { -# reverse_proxy filebrowser:80 -# } - -# Mealie (recipes) -# recipes.{$MY_DOMAIN} { -# reverse_proxy mealie:9000 -# } - -# ntfy (notifications) -# ntfy.{$MY_DOMAIN} { -# reverse_proxy ntfy:80 -# } - -# Portainer -# docker.{$MY_DOMAIN} { -# reverse_proxy portainer:9000 -# } - -# Kopia backup UI -# backup.{$MY_DOMAIN} { -# reverse_proxy kopia:51515 -# } - -# ============================================================================ -# TESTING / CATCH-ALL -# ============================================================================ - -# Local testing (always responds) -:80 { - respond "Caddy is running! Edit Caddyfile to enable your services." -} -CADDY_FILE - - mkdir -p config data site 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CADDY_DIR" 2>/dev/null || true - - echo "" - echo "✓ Caddy installed at $CADDY_DIR" - - prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY - if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start" - fi - - echo " Domain: ${CADDY_DOMAIN:-localhost} (edit .env)" - echo " Config: $CADDY_DIR/Caddyfile (uncomment services)" - echo "" - echo " ⚠️ Containers must be on 'caddy_net' network" - echo " Docs: https://caddyserver.com/docs/" - echo "" - fi - fi - - # ---- DDCLIENT DYNAMIC DNS ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_DDCLIENT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ DDCLIENT - Dynamic DNS updater │" - echo "│ Keep your domain pointing to your home IP. │" - echo "│ Supports: Cloudflare, DuckDNS, No-IP, and more. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ddclient? (y/n):" "n" INSTALL_DDCLIENT - fi - - if [ "$INSTALL_DDCLIENT" = "y" ] || [ "$INSTALL_DDCLIENT" = "Y" ]; then - echo "Installing ddclient..." - DDCLIENT_DIR="$DOCKER_DIR/ddclient" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $DDCLIENT_DIR" - else - # STEP 1: Install docker-compose - mkdir -p "$DDCLIENT_DIR" 2>/dev/null || true - cd "$DDCLIENT_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'DDCLIENT_COMPOSE' -name: ddclient - -services: - ddclient: - image: lscr.io/linuxserver/ddclient:latest - container_name: ddclient - hostname: ddclient - restart: unless-stopped - environment: - - PUID=1000 - - PGID=1000 - - TZ=${TZ} - volumes: - - ./config:/config -DDCLIENT_COMPOSE - echo "✓ Installed docker-compose.yml" - - cat > .env << DDCLIENT_ENV -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -DDCLIENT_ENV - - mkdir -p config 2>/dev/null || true - - # Create template config - cat > config/ddclient.conf << 'DDCLIENT_CONF' -# ddclient configuration -# ⚠️ YOU MUST EDIT THIS FILE! -# Uncomment and edit for your DNS provider - -daemon=300 -syslog=yes -pid=/var/run/ddclient/ddclient.pid -ssl=yes - -# Cloudflare example: -# use=web, web=cloudflare -# protocol=cloudflare -# zone=example.com -# login=token -# password=your-api-token -# example.com - -# DuckDNS example: -# use=web -# protocol=duckdns -# password=your-duckdns-token -# yourdomain.duckdns.org -DDCLIENT_CONF - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DDCLIENT_DIR" 2>/dev/null || true - - echo "" - echo "✓ ddclient installed at $DDCLIENT_DIR" - echo "" - echo " ⚠️ Note: You should edit config/ddclient.conf before starting." - prompt_yn "Start ddclient now anyway? (y/n):" "n" START_DDCLIENT - if [ "$START_DDCLIENT" = "y" ] || [ "$START_DDCLIENT" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ddclient started" || echo " ⚠ Failed to start" - fi - - echo " Docs: https://ddclient.net/" - echo "" - fi - fi - - # ---- NTFY NOTIFICATIONS ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_NTFY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ NTFY - Push notifications server │" - echo "│ Send notifications from scripts to your phone. │" - echo "│ Port: 8090 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ntfy? (y/n):" "n" INSTALL_NTFY - fi - - if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then - echo "Installing ntfy..." - NTFY_DIR="$DOCKER_DIR/ntfy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $NTFY_DIR" - else - mkdir -p "$NTFY_DIR" - ensure_docker_dir_ownership "$NTFY_DIR" - cd "$NTFY_DIR" - - cat > docker-compose.yml << 'NTFY_COMPOSE' -name: ntfy - -services: - ntfy: - image: binwiederhier/ntfy:latest - container_name: ntfy - hostname: ntfy - restart: unless-stopped - command: serve - environment: - - TZ=${TZ} - volumes: - - ./cache:/var/cache/ntfy - - ./config:/etc/ntfy - ports: - - "8090:80" -NTFY_COMPOSE - - cat > .env << NTFY_ENV -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -NTFY_ENV - - mkdir -p cache config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" - - echo "" - echo "✓ ntfy configured at $NTFY_DIR" - - prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY - if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ntfy started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8090" - echo "" - echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic" - echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic" - echo "" - fi - fi - - # ---- UPTIME KUMA ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_UPTIMEKUMA" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ UPTIME KUMA - Service monitoring dashboard │" - echo "│ Monitor websites, servers, Docker containers. │" - echo "│ Port: 3001 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Uptime Kuma? (y/n):" "n" INSTALL_UPTIMEKUMA - fi - - if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then - echo "Installing Uptime Kuma..." - UPTIME_DIR="$DOCKER_DIR/uptime-kuma" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $UPTIME_DIR" - else - mkdir -p "$UPTIME_DIR" - ensure_docker_dir_ownership "$UPTIME_DIR" - cd "$UPTIME_DIR" - - cat > docker-compose.yml << 'UPTIME_COMPOSE' -name: uptime-kuma - -services: - uptime-kuma: - image: louislam/uptime-kuma:1 - container_name: uptime-kuma - hostname: uptime-kuma - restart: unless-stopped - volumes: - - ./data:/app/data - - /var/run/docker.sock:/var/run/docker.sock:ro - ports: - - "3001:3001" -UPTIME_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPTIME_DIR" - - echo "" - echo "✓ Uptime Kuma configured at $UPTIME_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Uptime Kuma" "3001" "uptime" - - prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME - if [ "$START_UPTIME" = "y" ] || [ "$START_UPTIME" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Uptime Kuma started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:3001" - echo "" - fi - fi - - # ---- WG-EASY (WireGuard with Web UI) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WGEASY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ WG-EASY - WireGuard VPN with web management │" - echo "│ Easy WireGuard setup with QR codes for clients. │" - echo "│ Port: 51821 (web), 51820 (VPN) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install wg-easy? (y/n):" "n" INSTALL_WGEASY - fi - - if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then - echo "Installing wg-easy..." - WGEASY_DIR="$DOCKER_DIR/wg-easy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $WGEASY_DIR" - else - mkdir -p "$WGEASY_DIR" - ensure_docker_dir_ownership "$WGEASY_DIR" - cd "$WGEASY_DIR" - - # Get public IP or hostname - PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "your-public-ip") - WG_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) - - prompt_text "Public IP or hostname for VPN [default: $PUBLIC_IP]:" "$PUBLIC_IP" WG_HOST - - cat > docker-compose.yml << WGEASY_COMPOSE -name: wg-easy - -services: - wg-easy: - image: ghcr.io/wg-easy/wg-easy:latest - container_name: wg-easy - hostname: wg-easy - restart: unless-stopped - cap_add: - - NET_ADMIN - - SYS_MODULE - sysctls: - - net.ipv4.ip_forward=1 - - net.ipv4.conf.all.src_valid_mark=1 - environment: - - WG_HOST=\${WG_HOST} - - PASSWORD=\${WG_PASSWORD} - - WG_DEFAULT_DNS=1.1.1.1 - volumes: - - ./config:/etc/wireguard - ports: - - "51820:51820/udp" - - "51821:51821/tcp" -WGEASY_COMPOSE - - cat > .env << WGEASY_ENV -WG_HOST=$WG_HOST -WG_PASSWORD=$WG_PASSWORD -WGEASY_ENV - - mkdir -p config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WGEASY_DIR" - - echo "" - echo "✓ wg-easy configured at $WGEASY_DIR" - - prompt_yn "Start wg-easy now? (y/n):" "y" START_WGEASY - if [ "$START_WGEASY" = "y" ] || [ "$START_WGEASY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ wg-easy started" || echo " ⚠ Failed to start" - fi - - echo " Web UI: http://localhost:51821" - echo " Password: $WG_PASSWORD (saved in .env)" - echo " VPN Port: 51820/udp (forward this in your router)" - echo "" - fi - fi - - # ---- TRACCAR GPS TRACKING ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_TRACCAR" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ TRACCAR - GPS tracking server │" - echo "│ Track phones, vehicles, assets with OwnTracks/Traccar apps. │" - echo "│ Port: 8082 (web), 5055 (OsmAnd), 5000+ (devices) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Traccar? (y/n):" "n" INSTALL_TRACCAR - fi - - if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then - echo "Installing Traccar..." - TRACCAR_DIR="$DOCKER_DIR/traccar" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $TRACCAR_DIR" - else - mkdir -p "$TRACCAR_DIR" - ensure_docker_dir_ownership "$TRACCAR_DIR" - cd "$TRACCAR_DIR" - - cat > docker-compose.yml << 'TRACCAR_COMPOSE' -name: traccar - -services: - traccar: - image: traccar/traccar:latest - container_name: traccar - hostname: traccar - restart: unless-stopped - volumes: - - ./logs:/opt/traccar/logs:rw - - ./data:/opt/traccar/data:rw - - ./config/traccar.xml:/opt/traccar/conf/traccar.xml:ro - ports: - - "8082:8082" - - "5000-5150:5000-5150" - - "5000-5150:5000-5150/udp" -TRACCAR_COMPOSE - - mkdir -p logs data config - - # Create basic traccar.xml config - cat > config/traccar.xml << 'TRACCAR_XML' - - - - - - ./conf/default.xml - org.h2.Driver - jdbc:h2:/opt/traccar/data/database - sa - - -TRACCAR_XML - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR" - - echo "" - echo "✓ Traccar configured at $TRACCAR_DIR" - - prompt_yn "Start Traccar now? (y/n):" "y" START_TRACCAR - if [ "$START_TRACCAR" = "y" ] || [ "$START_TRACCAR" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Traccar started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8082" - echo " Default: admin@admin.com / admin (change immediately!)" - echo "" - fi - fi - - # ---- PORTAINER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_PORTAINER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ PORTAINER - Docker management web UI │" - echo "│ Manage containers, images, volumes via browser. │" - echo "│ Port: 9443 (https), 9000 (http) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Portainer? (y/n):" "n" INSTALL_PORTAINER - fi - - if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then - echo "Installing Portainer..." - PORTAINER_DIR="$DOCKER_DIR/portainer" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $PORTAINER_DIR" - else - mkdir -p "$PORTAINER_DIR" - ensure_docker_dir_ownership "$PORTAINER_DIR" - cd "$PORTAINER_DIR" - - cat > docker-compose.yml << 'PORTAINER_COMPOSE' -name: portainer - -services: - portainer: - image: portainer/portainer-ce:latest - container_name: portainer - hostname: portainer - restart: always - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./data:/data - ports: - - "9000:9000" - - "9443:9443" -PORTAINER_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR" - - echo "" - echo "✓ Portainer configured at $PORTAINER_DIR" - - prompt_yn "Start Portainer now? (y/n):" "y" START_PORTAINER - if [ "$START_PORTAINER" = "y" ] || [ "$START_PORTAINER" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Portainer started" || echo " ⚠ Failed to start" - fi - - echo " Access at: https://localhost:9443" - echo " Create admin account on first visit" - echo "" - fi - fi - - # ---- MESHCENTRAL SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MESHCENTRAL_SERVER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MESHCENTRAL SERVER - Self-hosted remote management │" - echo "│ Full MeshCentral server (not just agent). Manage all devices. │" - echo "│ Port: 4430 (https), 4433 (agent) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install MeshCentral Server? (y/n):" "n" INSTALL_MESHCENTRAL_SERVER - fi - - if [ "$INSTALL_MESHCENTRAL_SERVER" = "y" ] || [ "$INSTALL_MESHCENTRAL_SERVER" = "Y" ]; then - echo "Installing MeshCentral Server..." - MC_DIR="$DOCKER_DIR/meshcentral" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MC_DIR" - else - mkdir -p "$MC_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$MC_DIR" - cd "$MC_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'MC_COMPOSE' -name: meshcentral - -services: - meshcentral: - image: ghcr.io/ylianst/meshcentral:latest - container_name: meshcentral - hostname: meshcentral - restart: unless-stopped - environment: - - NODE_ENV=production - - HOSTNAME=${MC_HOSTNAME:-localhost} - - REVERSE_PROXY=${MC_REVERSE_PROXY:-false} - - REVERSE_PROXY_TLS_PORT=${MC_TLS_PORT:-443} - - IFRAME=false - - ALLOW_NEW_ACCOUNTS=true - - WEBRTC=true - volumes: - - ./data:/opt/meshcentral/meshcentral-data - - ./files:/opt/meshcentral/meshcentral-files - - ./backups:/opt/meshcentral/meshcentral-backups - ports: - - "4430:443" - - "4433:4433" -MC_COMPOSE - echo "✓ Installed docker-compose.yml" - - # Ask for hostname - echo "" - prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME 2>/dev/null || MC_HOSTNAME="localhost" - - cat > .env << MC_ENV -MC_HOSTNAME=$MC_HOSTNAME -MC_REVERSE_PROXY=false -MC_TLS_PORT=443 -MC_ENV - - mkdir -p data files backups 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" 2>/dev/null || true - - echo "" - echo "✓ MeshCentral Server installed at $MC_DIR" - - prompt_yn "Start MeshCentral now? (y/n):" "y" START_MESHCENTRAL - if [ "$START_MESHCENTRAL" = "y" ] || [ "$START_MESHCENTRAL" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ MeshCentral started" || echo " ⚠ Failed to start" - fi - - echo " Access: https://localhost:4430" - echo "" - echo " First visit: Create admin account" - echo " Then: Add devices → Download agent for each OS" - echo "" - echo " ⚠️ For remote access, set MC_HOSTNAME in .env to your domain/IP" - echo " Docs: https://meshcentral.com/docs/" - echo "" - fi - fi - - # ---- FINDMYDEVICE (FMD) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FMD" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FINDMYDEVICE - Self-hosted device tracking │" - echo "│ Track and locate Android devices. Alternative to Google Find. │" - echo "│ Port: 8084 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install FindMyDevice server? (y/n):" "n" INSTALL_FMD - fi - - if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then - echo "Installing FindMyDevice..." - FMD_DIR="$DOCKER_DIR/fmd" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FMD_DIR" - else - mkdir -p "$FMD_DIR" - ensure_docker_dir_ownership "$FMD_DIR" - cd "$FMD_DIR" - - # Generate random admin password - FMD_ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) - - cat > docker-compose.yml << FMD_COMPOSE -name: fmd - -services: - fmd: - image: nulide/findmydevice - container_name: fmd - hostname: fmd - restart: unless-stopped - environment: - - FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD} - volumes: - - ./data:/fmd/data - ports: - - "8084:8080" -FMD_COMPOSE - - cat > .env << FMD_ENV -FMD_ADMIN_PASSWORD=$FMD_ADMIN_PASS -FMD_ENV - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" - - echo "" - echo "✓ FindMyDevice configured at $FMD_DIR" - - prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD - if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ FindMyDevice started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8084" - echo " Admin password: $FMD_ADMIN_PASS (saved in .env)" - echo "" - echo " Mobile app: Install 'FindMyDevice' from F-Droid" - echo " Configure app to point to: http://YOUR-SERVER-IP:8084" - echo "" - fi - fi - - # ---- FRIGATE-NOTIFY ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE_NOTIFY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FRIGATE-NOTIFY - Push notifications for Frigate events │" - echo "│ Get alerts when Frigate detects people, cars, etc. │" - echo "│ Sends to: ntfy, Pushover, Discord, Gotify, and more. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Frigate-Notify? (y/n):" "n" INSTALL_FRIGATE_NOTIFY - fi - - if [ "$INSTALL_FRIGATE_NOTIFY" = "y" ] || [ "$INSTALL_FRIGATE_NOTIFY" = "Y" ]; then - echo "Installing Frigate-Notify..." - FN_DIR="$DOCKER_DIR/frigate-notify" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FN_DIR" - else - # STEP 1: Create directory and docker-compose (always succeeds) - mkdir -p "$FN_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$FN_DIR" - cd "$FN_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'FN_COMPOSE' -name: frigate-notify - -services: - frigate-notify: - image: ghcr.io/0x2142/frigate-notify:latest - container_name: frigate-notify - hostname: frigate-notify - restart: unless-stopped - volumes: - - ./config.yml:/app/config.yml:ro -FN_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try configuration (uses defaults if prompts fail) - echo "" - echo "Attempting auto-configuration..." - - # Set smart defaults based on what's installed - FRIGATE_URL="http://frigate:5000" - NTFY_URL="https://ntfy.sh" - NTFY_TOPIC="frigate-alerts" - - [ -d "$DOCKER_DIR/frigate" ] && echo " ✓ Frigate detected" || echo " ⚠ Frigate not found (using default URL)" - [ -d "$DOCKER_DIR/ntfy" ] && { NTFY_URL="http://ntfy:80"; echo " ✓ ntfy detected"; } || echo " ⚠ ntfy not found (using ntfy.sh)" - - # Try prompts, use defaults if they fail - echo "" - prompt_text "Frigate URL [$FRIGATE_URL]:" "$FRIGATE_URL" FRIGATE_URL 2>/dev/null || FRIGATE_URL="http://frigate:5000" - prompt_text "ntfy server [$NTFY_URL]:" "$NTFY_URL" NTFY_URL 2>/dev/null || NTFY_URL="https://ntfy.sh" - prompt_text "ntfy topic [frigate-alerts]:" "frigate-alerts" NTFY_TOPIC 2>/dev/null || NTFY_TOPIC="frigate-alerts" - - # Create config (template with user values or defaults) - cat > config.yml << FN_CONFIG -# Frigate-Notify Configuration -# Docs: https://frigate-notify.0x2142.com -# -# ⚠️ YOU MAY NEED TO EDIT THIS FILE! -# If notifications don't work, check: -# - Frigate server URL is correct -# - ntfy server is reachable -# - Containers are on same Docker network - -frigate: - server: $FRIGATE_URL - webapi: - enabled: true - interval: 30 - -alerts: - general: - send_startup_message: true - labels: - - person - - car - # - dog - # - package - -notifiers: - - name: ntfy - enabled: true - provider: ntfy - config: - server: $NTFY_URL - topic: $NTFY_TOPIC -FN_CONFIG - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FN_DIR" 2>/dev/null || true - - echo "" - echo "✓ Frigate-Notify installed at $FN_DIR" - - prompt_yn "Start Frigate-Notify now? (y/n):" "y" START_FN - if [ "$START_FN" = "y" ] || [ "$START_FN" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Frigate-Notify started" || echo " ⚠ Failed to start" - fi - - echo " Config: $FN_DIR/config.yml (edit if needed)" - echo " Docs: https://frigate-notify.0x2142.com" - echo "" - fi - fi - - # ---- WATCHTOWER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WATCHTOWER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ WATCHTOWER - Container update monitoring │" - echo "│ Monitor containers for updates. NOTIFY ONLY by default. │" - echo "│ Why notify-only? Apps like Immich have breaking DB migrations. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Watchtower? (y/n):" "n" INSTALL_WATCHTOWER - fi - - if [ "$INSTALL_WATCHTOWER" = "y" ] || [ "$INSTALL_WATCHTOWER" = "Y" ]; then - echo "Installing Watchtower..." - WT_DIR="$DOCKER_DIR/watchtower" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $WT_DIR" - else - mkdir -p "$WT_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$WT_DIR" - cd "$WT_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Ask about mode - echo "" - echo "Watchtower Mode:" - echo " [M] Monitor only - Get notifications about available updates (SAFE)" - echo " [A] Auto-update - Automatically pull and restart containers (RISKY)" - echo "" - echo " ⚠️ Auto-update can break apps like Immich that need DB migrations!" - echo " Recommendation: Use monitor mode, update manually when ready." - echo "" - WT_MODE="M" - prompt_text "Mode [M/A]:" "M" WT_MODE 2>/dev/null || WT_MODE="M" - WT_MODE=$(echo "$WT_MODE" | tr '[:lower:]' '[:upper:]') - - if [ "$WT_MODE" = "A" ]; then - MONITOR_ONLY="false" - echo " Mode: Auto-update (containers will be updated automatically)" - else - MONITOR_ONLY="true" - echo " Mode: Monitor only (you'll be notified of updates)" - fi - - # Check for ntfy - NTFY_URL="" - if [ -d "$DOCKER_DIR/ntfy" ]; then - echo " ✓ ntfy detected - configuring notifications" - NTFY_URL="http://ntfy/watchtower" - fi - - cat > docker-compose.yml << WT_COMPOSE -name: watchtower - -services: - watchtower: - image: containrrr/watchtower:latest - container_name: watchtower - hostname: watchtower - restart: unless-stopped - environment: - # Check for updates daily at 4 AM - - WATCHTOWER_SCHEDULE=0 0 4 * * * - # Monitor only - don't auto-update (change to false for auto-update) - - WATCHTOWER_MONITOR_ONLY=${MONITOR_ONLY} - # Cleanup old images after update - - WATCHTOWER_CLEANUP=true - # Include stopped containers - - WATCHTOWER_INCLUDE_STOPPED=true - # Notification URL (ntfy, Discord, Slack, etc.) - - WATCHTOWER_NOTIFICATION_URL=${NOTIFICATION_URL:-} - # Show debug info - - WATCHTOWER_DEBUG=false - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro -WT_COMPOSE - - # Create .env - cat > .env << WT_ENV -# Watchtower Configuration -# ========================= -# -# Monitor-only mode: Watchtower checks for updates but doesn't apply them. -# This is SAFER because some apps (Immich, Mealie) have database migrations -# that can break if you update without proper procedures. -# -# To update manually: -# cd ~/docker/{app} -# docker compose pull -# docker compose up -d - -# Set to "false" to enable auto-updates (RISKY!) -MONITOR_ONLY=$MONITOR_ONLY - -# Notification URL (optional) -# Examples: -# ntfy: ntfy://ntfy.example.com/watchtower -# Discord: discord://token@id -# Slack: slack://hook-url -# Gotify: gotify://hostname/token -# -# Full list: https://containrrr.dev/shoutrrr/services/overview/ -NOTIFICATION_URL=$NTFY_URL -WT_ENV - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WT_DIR" 2>/dev/null || true - - echo "" - echo "✓ Watchtower installed at $WT_DIR" - - prompt_yn "Start Watchtower now? (y/n):" "y" START_WATCHTOWER - if [ "$START_WATCHTOWER" = "y" ] || [ "$START_WATCHTOWER" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Watchtower started" || echo " ⚠ Failed to start" - fi - - echo " Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update")" - echo "" - echo " Checks for updates daily at 4 AM." - if [ -n "$NTFY_URL" ]; then - echo " Notifications: $NTFY_URL" - else - echo " Configure NOTIFICATION_URL in .env for alerts." - fi - echo "" - echo " To exclude a container from Watchtower:" - echo " Add label: com.centurylinklabs.watchtower.enable=false" - echo "" - fi - fi - - fi # End SKIP_DOCKER_INSTALLS check - - # ============================================================================ - # KOPIA BACKUP FOR DOCKER CONTAINERS - # ============================================================================ - if [ "$WHIPTAIL_USED" != true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DOCKER CONTAINER BACKUP (Kopia)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Backup all Docker containers (configs, databases, app data) to your" - echo "backup drives. Essential for disaster recovery - if your OS drive" - echo "fails, you can restore everything including:" - echo " • Immich memories, facial recognition data" - echo " • Emby/Jellyfin metadata, watch history" - echo " • Minecraft worlds, mods, permissions" - echo " • All app configs, users, and databases" - echo "" - prompt_yn "Set up Kopia container backup? (y/n):" "n" INSTALL_KOPIA - - if [ "$INSTALL_KOPIA" = "y" ] || [ "$INSTALL_KOPIA" = "Y" ]; then - echo "Installing Kopia backup..." - KOPIA_DIR="$DOCKER_DIR/kopia" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $KOPIA_DIR" - else - mkdir -p "$KOPIA_DIR" - ensure_docker_dir_ownership "$KOPIA_DIR" - cd "$KOPIA_DIR" - - KOPIA_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) - - echo "" - echo "Select backup destination(s):" - echo " Backups will be stored in ~/drives/{backup-drive}/kopia-repo/" - echo "" - - # List available backup drives - echo "Available mount points in ~/drives/:" - ls -1 "$ACTUAL_HOME/drives/" 2>/dev/null | grep -v "^primary$" || echo " (none found - set up drives first)" - echo "" - - prompt_text "Backup drive name [default: backup1]:" "backup1" KOPIA_BACKUP_DRIVE - - KOPIA_REPO="$ACTUAL_HOME/drives/$KOPIA_BACKUP_DRIVE/kopia-repo" - - cat > docker-compose.yml << KOPIA_COMPOSE -name: kopia - -services: - kopia: - image: kopia/kopia:latest - container_name: kopia - hostname: kopia - restart: unless-stopped - privileged: true - devices: - - /dev/fuse:/dev/fuse:rwm - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - KOPIA_PASSWORD=\${KOPIA_PASSWORD} - command: > - server start - --tls-generate-cert - --disable-csrf-token-checks - --address=0.0.0.0:51515 - --server-username=admin - --server-password=\${KOPIA_PASSWORD} - volumes: - - ./config:/app/config - - ./cache:/app/cache - - ./logs:/app/logs - - $DOCKER_DIR:/data/docker:ro - - $KOPIA_REPO:/repository - - ./tmp:/tmp:shared - ports: - - "51515:51515" -KOPIA_COMPOSE - - cat > .env << KOPIA_ENV -KOPIA_PASSWORD=$KOPIA_PASSWORD -KOPIA_ENV - - mkdir -p config cache logs tmp - mkdir -p "$KOPIA_REPO" - - # Create backup script - cat > backup-containers.sh << 'BACKUP_SCRIPT' -#!/bin/bash -# Backup all Docker containers using Kopia - -DOCKER_DIR="$HOME/docker" -TIMESTAMP=$(date +%Y%m%d-%H%M%S) - -echo "=== Docker Container Backup: $TIMESTAMP ===" -echo "" - -# Stop containers before backup for consistency (optional) -read -p "Stop containers during backup for consistency? (y/n): " STOP_CONTAINERS - -if [ "$STOP_CONTAINERS" = "y" ]; then - echo "Stopping containers..." - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ]; then - echo " Stopping $(basename $dir)..." - (cd "$dir" && docker compose stop) 2>/dev/null - fi - done -fi - -echo "" -echo "Running Kopia backup..." -docker exec kopia kopia snapshot create /data/docker --description "Container backup $TIMESTAMP" - -if [ "$STOP_CONTAINERS" = "y" ]; then - echo "" - echo "Restarting containers..." - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ]; then - echo " Starting $(basename $dir)..." - (cd "$dir" && docker compose start) 2>/dev/null - fi - done -fi - -echo "" -echo "=== Backup Complete ===" -docker exec kopia kopia snapshot list /data/docker --max-results 5 -BACKUP_SCRIPT - - # Create restore script - cat > restore-containers.sh << 'RESTORE_SCRIPT' -#!/bin/bash -# Restore Docker containers from Kopia backup - -echo "=== Docker Container Restore ===" -echo "" -echo "Available snapshots:" -docker exec kopia kopia snapshot list /data/docker - -echo "" -echo "To restore a specific snapshot:" -echo " docker exec kopia kopia restore /tmp/restore" -echo " Then copy files from ~/docker/kopia/tmp/restore/ to ~/docker/" -echo "" -echo "To mount snapshots for browsing:" -echo " docker exec kopia kopia mount all /tmp/mnt &" -echo " Then browse: ~/docker/kopia/tmp/mnt/" -RESTORE_SCRIPT - - chmod +x backup-containers.sh restore-containers.sh - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$KOPIA_DIR" - - echo "" - echo "✓ Kopia backup configured at $KOPIA_DIR" - - prompt_yn "Start Kopia now? (y/n):" "y" START_KOPIA - if [ "$START_KOPIA" = "y" ] || [ "$START_KOPIA" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Kopia started" || echo " ⚠ Failed to start" - fi - - echo " Web UI: https://localhost:51515" - echo " Username: admin" - echo " Password: $KOPIA_PASSWORD (saved in .env)" - echo "" - echo " Repository at: $KOPIA_REPO" - echo "" - echo " Backup now: cd $KOPIA_DIR && ./backup-containers.sh" - echo " Restore: cd $KOPIA_DIR && ./restore-containers.sh" - echo "" - echo " ⚠️ SAVE YOUR KOPIA PASSWORD! Without it, backups cannot be restored." - echo "" - fi - fi - fi # End WHIPTAIL_USED check for Kopia - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Docker applications configured in: $DOCKER_DIR" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To start an application:" - echo " cd ~/docker/{appname}" - echo " docker compose up -d" - echo "" - echo "To view logs:" - echo " docker compose logs -f" - echo "" - echo "To stop:" - echo " docker compose down" - echo "" -fi - -# Backup System (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "LOCAL BACKUP SYSTEM (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Set up rsync backup from primary drive(s) to backup drive(s)." -echo "" -echo "Why rsync instead of RAID?" -echo " • RAID mirrors corruption instantly - rsync gives you time to notice" -echo " • RAID requires identical drives - rsync works with any sizes" -echo " • RAID is complex to set up/recover - rsync is simple copy" -echo " • rsync can run on schedule - RAID is always-on (more wear)" -echo " • With rsync, backup drives can be disconnected for safety" -echo "" - -# Check if backup is already configured -BACKUP_CONFIGURED=false -if [ -f /usr/local/bin/backup-scripts/rsync-backup.sh ]; then - BACKUP_CONFIGURED=true - echo "Local backup system is already configured." - echo "" - prompt_yn "Reconfigure local backup system? (y/n):" "n" SETUP_BACKUP -else - prompt_yn "Set up local backup system? (y/n):" "n" SETUP_BACKUP -fi - -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Configure your drive mount points in ~/drives/" - echo "Default names: primary, backup1, backup2, etc." - echo "You can customize these (e.g., 'media', 'documents', 'photos-backup')" - echo "" - - # Install rsync if needed - if ! is_rsync_installed; then - run_cmd apt install -y rsync || echo "Warning: rsync installation failed" - fi - - # Create backup script directory - mkdir -p /usr/local/bin/backup-scripts - - # Ask for primary drive name - echo "PRIMARY DRIVE (source for backups):" - prompt_text " Mount point name [default: primary]:" "primary" PRIMARY_NAME - PRIMARY_NAME="${PRIMARY_NAME:-primary}" - - # Ask for number of backup drives - echo "" - echo "BACKUP DRIVES (destinations):" - prompt_text " How many backup drives? [1-4, default: 1]:" "1" NUM_BACKUPS - NUM_BACKUPS="${NUM_BACKUPS:-1}" - - # Validate number - case $NUM_BACKUPS in - 1|2|3|4) ;; - *) NUM_BACKUPS=1 ;; - esac - - # Collect backup drive names - declare -a BACKUP_NAMES - for i in $(seq 1 $NUM_BACKUPS); do - prompt_text " Backup drive $i name [default: backup$i]:" "backup$i" "BACKUP_NAME_$i" - eval "BACKUP_NAMES[$i]=\${BACKUP_NAME_$i:-backup$i}" - done - - # Create mount point directories - echo "" - echo "Creating mount point directories..." - mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME" - for i in $(seq 1 $NUM_BACKUPS); do - mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - done - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - echo "✓ Created mount points:" - echo " Primary: $ACTUAL_HOME/drives/$PRIMARY_NAME" - for i in $(seq 1 $NUM_BACKUPS); do - echo " Backup$i: $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - done - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE MOUNTING" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Available block devices:" - echo "" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - - prompt_yn "Mount drives now? (y/n):" "n" MOUNT_NOW - - if [ "$MOUNT_NOW" = "y" ] || [ "$MOUNT_NOW" = "Y" ]; then - echo "" - echo "Enter device paths (e.g., /dev/sdb1) or leave blank to skip" - echo "" - - read -p "Primary drive ($PRIMARY_NAME) device: " PRIMARY_DEV - - declare -a BACKUP_DEVS - for i in $(seq 1 $NUM_BACKUPS); do - read -p "Backup drive ${BACKUP_NAMES[$i]} device: " "BACKUP_DEV_$i" - eval "BACKUP_DEVS[$i]=\$BACKUP_DEV_$i" - done - - # Mount primary - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - echo "Mounting $PRIMARY_DEV to $ACTUAL_HOME/drives/$PRIMARY_NAME..." - mount "$PRIMARY_DEV" "$ACTUAL_HOME/drives/$PRIMARY_NAME" && echo "✓ $PRIMARY_NAME mounted" || echo "✗ Failed to mount $PRIMARY_NAME" - fi - - # Mount backups - for i in $(seq 1 $NUM_BACKUPS); do - if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then - echo "Mounting ${BACKUP_DEVS[$i]} to $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}..." - mount "${BACKUP_DEVS[$i]}" "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" && echo "✓ ${BACKUP_NAMES[$i]} mounted" || echo "✗ Failed to mount ${BACKUP_NAMES[$i]}" - fi - done - - echo "" - echo "Current mounts:" - df -h | grep "$ACTUAL_HOME/drives" || echo " (no drives currently mounted)" - - echo "" - prompt_yn "Add to /etc/fstab for auto-mount at boot? (y/n):" "n" ADD_FSTAB - - if [ "$ADD_FSTAB" = "y" ] || [ "$ADD_FSTAB" = "Y" ]; then - echo "" - echo "Adding entries to /etc/fstab..." - cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) - - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV") - if [ -n "$PRIMARY_UUID" ]; then - echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab - echo "✓ Added $PRIMARY_NAME to fstab" - fi - fi - - for i in $(seq 1 $NUM_BACKUPS); do - if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then - BACKUP_UUID=$(blkid -s UUID -o value "${BACKUP_DEVS[$i]}") - if [ -n "$BACKUP_UUID" ]; then - echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab - echo "✓ Added ${BACKUP_NAMES[$i]} to fstab" - fi - fi - done - fi - fi - - # Create rsync backup script - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CREATING BACKUP SCRIPT" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - - # Build backup destinations list - BACKUP_DESTS="" - for i in $(seq 1 $NUM_BACKUPS); do - BACKUP_DESTS="$BACKUP_DESTS \"$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}\"" - done - - cat > /usr/local/bin/backup-scripts/rsync-backup.sh << BACKUP_SCRIPT -#!/bin/bash -################################################################################ -# rsync Local Backup Script -# Backs up primary drive to all backup drives -################################################################################ - -PRIMARY="$ACTUAL_HOME/drives/$PRIMARY_NAME" -BACKUP_DRIVES=($BACKUP_DESTS) -LOG="/var/log/rsync-backup.log" - -echo "=== rsync Backup Started: \$(date) ===" | tee -a "\$LOG" -echo "Source: \$PRIMARY" | tee -a "\$LOG" -echo "" | tee -a "\$LOG" - -if [ ! -d "\$PRIMARY" ] || [ -z "\$(ls -A \$PRIMARY 2>/dev/null)" ]; then - echo "ERROR: Primary drive not mounted or empty at \$PRIMARY" | tee -a "\$LOG" - exit 1 -fi - -for BACKUP in "\${BACKUP_DRIVES[@]}"; do - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "\$LOG" - echo "Backing up to: \$BACKUP" | tee -a "\$LOG" - - if [ ! -d "\$BACKUP" ]; then - echo "⚠️ WARNING: \$BACKUP not mounted, skipping" | tee -a "\$LOG" - continue - fi - - # rsync options: - # -a = archive mode (preserves permissions, timestamps, etc.) - # -v = verbose - # -h = human-readable sizes - # --delete = remove files from backup that don't exist on primary - # --progress = show progress - - rsync -avh --delete --progress "\$PRIMARY/" "\$BACKUP/" 2>&1 | tee -a "\$LOG" - - if [ \$? -eq 0 ]; then - echo "✓ Backup to \$BACKUP completed" | tee -a "\$LOG" - else - echo "✗ Backup to \$BACKUP FAILED" | tee -a "\$LOG" - fi -done - -echo "" | tee -a "\$LOG" -echo "=== Backup Completed: \$(date) ===" | tee -a "\$LOG" -BACKUP_SCRIPT - - chmod +x /usr/local/bin/backup-scripts/rsync-backup.sh - - # Create systemd service - cat > /etc/systemd/system/rsync-backup.service << SERVICE -[Unit] -Description=rsync Local Backup Service -After=network.target - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/backup-scripts/rsync-backup.sh -User=root -SERVICE - - # Create systemd timer for daily backups at 2 AM - cat > /etc/systemd/system/rsync-backup.timer << TIMER -[Unit] -Description=Daily rsync Backup Timer -Requires=rsync-backup.service - -[Timer] -OnCalendar=*-*-* 02:00:00 -Persistent=true - -[Install] -WantedBy=timers.target -TIMER - - echo "" - echo "✓ Backup script created: /usr/local/bin/backup-scripts/rsync-backup.sh" - echo "✓ Systemd service/timer created (disabled by default)" - echo "" - echo "Quick start:" - echo " Test backup: sudo /usr/local/bin/backup-scripts/rsync-backup.sh" - echo " Enable daily: sudo systemctl enable --now rsync-backup.timer" - echo " View log: tail -f /var/log/rsync-backup.log" -else - echo "Skipping local backup setup." -fi -fi # End WHIPTAIL_USED check for local backup - -# Cloud Backup with rclone (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "CLOUD BACKUP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Set up encrypted cloud backup using rclone." -echo "Supports: Google Drive, OneDrive, Dropbox, and 40+ other providers." -echo "" -echo "Your files are encrypted BEFORE upload - the cloud provider cannot read them." -echo "" - -prompt_yn "Set up encrypted cloud backup? (y/n):" "n" SETUP_CLOUD_BACKUP - -if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then - echo "" - - # Install rclone if needed - if ! is_rclone_installed; then - echo "Installing rclone..." - run_cmd apt install -y rclone || echo "Warning: rclone installation failed" - fi - - if is_rclone_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CHOOSE CLOUD PROVIDER" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo " [1] Google Drive (15GB free)" - echo " [2] Microsoft OneDrive (5GB free, 1TB with Microsoft 365)" - echo " [3] Other (manual rclone config)" - echo "" - prompt_text "Select provider [1/2/3]:" "1" CLOUD_PROVIDER - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "RCLONE CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - case $CLOUD_PROVIDER in - 1) - echo "Setting up Google Drive..." - echo "" - echo "Step 1: Run 'rclone config' to create a Google Drive remote" - echo "Step 2: When prompted:" - echo " - Choose 'n' for new remote" - echo " - Name it 'gdrive'" - echo " - Choose 'drive' (Google Drive)" - echo " - Leave client_id and client_secret blank" - echo " - Choose scope '1' (full access)" - echo " - Leave root_folder_id blank" - echo " - Leave service_account_file blank" - echo " - Choose 'n' for advanced config" - echo " - Choose 'y' for auto config (opens browser)" - echo " - Choose 'n' for team drive" - echo " - Confirm with 'y'" - echo "" - CLOUD_REMOTE="gdrive" - ;; - 2) - echo "Setting up Microsoft OneDrive..." - echo "" - echo "Step 1: Run 'rclone config' to create a OneDrive remote" - echo "Step 2: When prompted:" - echo " - Choose 'n' for new remote" - echo " - Name it 'onedrive'" - echo " - Choose 'onedrive' (Microsoft OneDrive)" - echo " - Leave client_id and client_secret blank" - echo " - Choose region (usually 'global')" - echo " - Choose 'n' for advanced config" - echo " - Choose 'y' for auto config (opens browser)" - echo " - Choose 'onedrive' for account type" - echo " - Choose your drive from the list (usually option 0)" - echo " - Confirm with 'y'" - echo "" - CLOUD_REMOTE="onedrive" - ;; - *) - echo "Manual configuration selected." - echo "Run 'rclone config' to set up your cloud provider." - echo "" - prompt_text "Enter the remote name you will create:" "cloud" CLOUD_REMOTE - ;; - esac - - if [ "$UNATTENDED" != true ]; then - echo "Press Enter to launch rclone config..." - read - sudo -u "$ACTUAL_USER" rclone config - else - echo "Skipping interactive rclone config (unattended mode)" - echo "Run 'rclone config' manually to complete setup" - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "ENCRYPTION SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Now we'll create an encrypted wrapper around your cloud storage." - echo "This encrypts file names AND contents before upload." - echo "" - echo "⚠️ IMPORTANT: You will set a password. Without this password and" - echo " the rclone config file, your files CANNOT be recovered!" - echo "" - - if [ "$UNATTENDED" != true ]; then - echo "In rclone config:" - echo " - Choose 'n' for new remote" - echo " - Name it '${CLOUD_REMOTE}-crypt'" - echo " - Choose 'crypt' (Encrypt/Decrypt)" - echo " - Remote: '${CLOUD_REMOTE}:backup' (folder on cloud storage)" - echo " - Choose 'standard' for filename encryption" - echo " - Choose 'true' for directory name encryption" - echo " - Choose 'y' to enter your own password" - echo " - Enter a STRONG password (you'll need this to decrypt!)" - echo " - Choose 'y' for salt password (or 'n' to skip)" - echo " - Confirm with 'y'" - echo "" - echo "Press Enter to continue rclone config..." - read - sudo -u "$ACTUAL_USER" rclone config - fi - - # Backup the rclone config to local drives - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "BACKUP YOUR RCLONE CONFIG" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Your rclone config contains your encryption keys." - echo "WITHOUT IT, YOUR ENCRYPTED CLOUD FILES CANNOT BE DECRYPTED!" - echo "" - echo "The config file is at: $ACTUAL_HOME/.config/rclone/rclone.conf" - echo "" - - # Copy config to any mounted backup drives - if [ -d "$ACTUAL_HOME/drives" ]; then - for drive_dir in "$ACTUAL_HOME/drives"/*/; do - if [ -d "$drive_dir" ] && mountpoint -q "$drive_dir" 2>/dev/null; then - mkdir -p "${drive_dir}.rclone-config-backup" - if [ -f "$ACTUAL_HOME/.config/rclone/rclone.conf" ]; then - cp "$ACTUAL_HOME/.config/rclone/rclone.conf" "${drive_dir}.rclone-config-backup/rclone.conf.backup" - echo "✓ Config backed up to: ${drive_dir}.rclone-config-backup/" - fi - fi - done - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "SECURE OFF-SITE BACKUP OF CONFIG" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "For disaster recovery, store a copy of rclone.conf OFF-SITE:" - echo "" - echo "Recommended secure methods:" - echo " • Signal (end-to-end encrypted, send to yourself or trusted contact)" - echo " • Box.com (better privacy policy than Dropbox)" - echo " • Password manager (1Password, Bitwarden, etc.)" - echo " • Encrypted USB drive stored at another location" - echo "" - echo "⚠️ Dropbox: Works but has broader data access policies." - echo " Consider encrypting the config file before uploading." - echo "" - echo "To use on another computer:" - echo " 1. Install rclone" - echo " 2. Copy rclone.conf to ~/.config/rclone/" - echo " 3. Run: rclone ls ${CLOUD_REMOTE}-crypt:" - echo "" - - # Create cloud backup script - mkdir -p /usr/local/bin/backup-scripts - - SOURCE_PATH="${PRIMARY_NAME:-primary}" - - cat > /usr/local/bin/backup-scripts/cloud-backup.sh << CLOUD_SCRIPT -#!/bin/bash -################################################################################ -# rclone Encrypted Cloud Backup Script -################################################################################ - -SOURCE="$ACTUAL_HOME/drives/$SOURCE_PATH" -REMOTE="${CLOUD_REMOTE:-gdrive}-crypt" -LOG="/var/log/cloud-backup.log" - -echo "=== Cloud Backup Started: \$(date) ===" | tee -a "\$LOG" -echo "Source: \$SOURCE" | tee -a "\$LOG" -echo "Destination: \$REMOTE:" | tee -a "\$LOG" -echo "" | tee -a "\$LOG" - -if [ ! -d "\$SOURCE" ] || [ -z "\$(ls -A \$SOURCE 2>/dev/null)" ]; then - echo "ERROR: Source not mounted or empty at \$SOURCE" | tee -a "\$LOG" - exit 1 -fi - -# Sync to encrypted cloud storage -rclone sync "\$SOURCE" "\$REMOTE:" \\ - --progress \\ - --stats=30s \\ - --log-file="\$LOG" \\ - --log-level INFO - -if [ \$? -eq 0 ]; then - echo "" | tee -a "\$LOG" - echo "✓ Cloud backup completed: \$(date)" | tee -a "\$LOG" -else - echo "" | tee -a "\$LOG" - echo "✗ Cloud backup FAILED: \$(date)" | tee -a "\$LOG" -fi -CLOUD_SCRIPT - - chmod +x /usr/local/bin/backup-scripts/cloud-backup.sh - chown "$ACTUAL_USER:$ACTUAL_USER" /usr/local/bin/backup-scripts/cloud-backup.sh - - echo "" - echo "✓ Cloud backup script created: /usr/local/bin/backup-scripts/cloud-backup.sh" - echo "" - echo "Quick start:" - echo " Test backup: sudo /usr/local/bin/backup-scripts/cloud-backup.sh" - echo " View log: tail -f /var/log/cloud-backup.log" - else - echo "rclone installation failed. Skipping cloud backup setup." - fi -else - echo "Skipping cloud backup setup." -fi -fi # End WHIPTAIL_USED check for cloud backup - -# UFW Firewall Configuration (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "UFW FIREWALL (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "UFW (Uncomplicated Firewall) provides a simple interface for managing" -echo "iptables firewall rules." -echo "" - -UFW_ACTIVE=false -if is_ufw_installed && ufw status 2>/dev/null | grep -q "Status: active"; then - UFW_ACTIVE=true - echo "UFW is already enabled." - ufw status 2>/dev/null | head -20 - echo "" - prompt_yn "Reconfigure UFW? (y/n):" "n" CONFIGURE_UFW -else - if is_ufw_installed; then - echo "UFW is installed but not enabled." - else - echo "UFW is not installed." - fi - echo "" - prompt_yn "Enable and configure UFW firewall? (y/n):" "y" CONFIGURE_UFW -fi - -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo "" - echo "Configuring UFW firewall..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install ufw if needed" - echo "[DRY-RUN] Would allow SSH (port 22)" - if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo "[DRY-RUN] Would allow Samba" - fi - echo "[DRY-RUN] Would enable UFW" - else - # Install UFW if not present - if ! is_ufw_installed; then - apt install -y ufw || echo "Warning: UFW installation failed" - fi - - if is_ufw_installed; then - # Always allow SSH first (before enabling!) - ufw allow ssh - echo "✓ Allowed SSH (port 22)" - - # Allow Samba if installed - if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ] || is_samba_installed; then - ufw allow samba - echo "✓ Allowed Samba" - fi - - # Allow Docker service ports (only if Docker was installed) - if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo "" - echo "Opening firewall ports for Docker services..." - - prompt_yn "Open firewall ports for installed Docker services? (y/n):" "y" OPEN_DOCKER_PORTS - if [ "$OPEN_DOCKER_PORTS" = "y" ] || [ "$OPEN_DOCKER_PORTS" = "Y" ]; then - - # Reverse proxies (NPM or Caddy) - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - ufw allow 80/tcp comment 'HTTP' 2>/dev/null - ufw allow 443/tcp comment 'HTTPS' 2>/dev/null - echo " ✓ Allowed HTTP/HTTPS (80, 443)" - fi - - # Media servers - if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then - ufw allow 2283/tcp comment 'Immich' 2>/dev/null - echo " ✓ Allowed Immich (2283)" - fi - if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then - ufw allow 8096/tcp comment 'Jellyfin' 2>/dev/null - echo " ✓ Allowed Jellyfin (8096)" - fi - if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then - ufw allow 8096/tcp comment 'Emby' 2>/dev/null - echo " ✓ Allowed Emby (8096)" - fi - - # NVR - if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then - ufw allow 5000/tcp comment 'Frigate' 2>/dev/null - ufw allow 8554/tcp comment 'Frigate RTSP' 2>/dev/null - ufw allow 8555/tcp comment 'Frigate WebRTC' 2>/dev/null - ufw allow 8555/udp comment 'Frigate WebRTC UDP' 2>/dev/null - echo " ✓ Allowed Frigate (5000, 8554, 8555)" - fi - - # Utilities - if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then - ufw allow 9000/tcp comment 'Portainer HTTP' 2>/dev/null - ufw allow 9443/tcp comment 'Portainer HTTPS' 2>/dev/null - echo " ✓ Allowed Portainer (9000, 9443)" - fi - if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then - ufw allow 3001/tcp comment 'Uptime Kuma' 2>/dev/null - echo " ✓ Allowed Uptime Kuma (3001)" - fi - - # VPN - if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then - ufw allow 51820/udp comment 'WireGuard VPN' 2>/dev/null - ufw allow 51821/tcp comment 'WG-Easy Web UI' 2>/dev/null - echo " ✓ Allowed WireGuard (51820/udp, 51821)" - fi - - # GPS Tracking - if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then - ufw allow 8082/tcp comment 'Traccar' 2>/dev/null - ufw allow 5055/tcp comment 'Traccar OsmAnd' 2>/dev/null - echo " ✓ Allowed Traccar (8082, 5055)" - fi - - # Music server - if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then - ufw allow 9000/tcp comment 'Lyrion Music Server' 2>/dev/null - ufw allow 3483/tcp comment 'LMS Players' 2>/dev/null - ufw allow 3483/udp comment 'LMS Players UDP' 2>/dev/null - echo " ✓ Allowed Lyrion Music Server (9000, 3483)" - fi - - # Notifications - if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then - ufw allow 8090/tcp comment 'ntfy' 2>/dev/null - echo " ✓ Allowed ntfy (8090)" - fi - - # Minecraft - if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then - ufw allow 25565/tcp comment 'Minecraft' 2>/dev/null - echo " ✓ Allowed Minecraft (25565)" - fi - - # Other services - if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then - ufw allow 8085/tcp comment 'Filebrowser' 2>/dev/null - echo " ✓ Allowed Filebrowser (8085)" - fi - if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then - ufw allow 8084/tcp comment 'FindMyDevice' 2>/dev/null - echo " ✓ Allowed FindMyDevice (8084)" - fi - if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then - ufw allow 9925/tcp comment 'Mealie' 2>/dev/null - echo " ✓ Allowed Mealie (9925)" - fi - if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then - ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null - echo " ✓ Allowed MagicMirror (8081-8083)" - fi - if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then - ufw allow 8080/tcp comment 'A.R.M.' 2>/dev/null - echo " ✓ Allowed A.R.M. (8080)" - fi - if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then - ufw allow 13378/tcp comment 'Audiobookshelf' 2>/dev/null - echo " ✓ Allowed Audiobookshelf (13378)" - fi - - echo "" - fi - fi - - # Enable UFW (with --force to avoid prompt) - ufw --force enable - echo "✓ UFW firewall enabled" - - echo "" - echo "Current UFW status:" - ufw status - else - echo "✗ UFW installation failed, skipping configuration" - fi - fi -else - echo "Skipping UFW configuration." -fi - -CONFIGURE_UFW="${CONFIGURE_UFW:-n}" -fi # End WHIPTAIL_USED check for UFW - -# Full system upgrade (optional) -echo "" -prompt_yn "Perform full system upgrade? (y/n):" "n" DO_SYSTEM_UPGRADE -if [ "$DO_SYSTEM_UPGRADE" = "y" ] || [ "$DO_SYSTEM_UPGRADE" = "Y" ]; then - echo "Performing full system upgrade..." - run_cmd apt upgrade -y -else - echo "Skipping system upgrade." -fi - -# Clean up -echo "" -echo "Cleaning up..." -run_cmd apt autoremove -y -run_cmd apt autoclean - -echo "" -echo "=== Installation Complete! ===" -echo "" -echo "Installed Software:" -echo " ✓ net-tools, ncdu, git, curl, wget, htop, tree, zip/unzip" -echo " ✓ OpenSSH Server - SSH remote access" -if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then - echo " ✓ fail2ban - SSH brute-force protection" -fi -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo " ✓ Docker Engine + Docker Compose" -fi -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo " ✓ Samba - File sharing (Primary drive shared)" -fi -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo " ✓ NetBird - Mesh VPN" -fi -if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then - echo " ✓ WireGuard - VPN" -fi -if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then - echo " ✓ Tailscale - Mesh VPN" -fi -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo " ✓ RustDesk - Remote desktop" -fi -if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then - echo " ✓ TeamViewer - Remote desktop" -fi -if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then - echo " ✓ MeshCentral Agent - Remote management" -fi -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then - echo " ✓ Local backup configured (rsync)" -fi -if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then - echo " ✓ Cloud backup configured (rclone)" -fi -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo " ✓ UFW Firewall - enabled" -fi -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SSH AUTHENTICATION SETUP" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " SSH Key for This Computer:" -if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - if [ -f "$ACTUAL_HOME/.ssh/id_rsa.pub" ]; then -echo " ✓ 4096-bit RSA key generated" -echo " Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" -echo " View with: cat ~/.ssh/id_rsa.pub" - else -echo " ✗ Key generation was attempted but may have failed" - fi -else -echo " Not generated (skipped during install)" -echo " Generate later: ssh-keygen -t rsa -b 4096 -C \"your@email.com\"" -fi -echo "" -echo " SSH Server Status:" -if [ "$KEYS_IMPORTED" = true ]; then -echo " Password authentication: DISABLED (key-only access)" -echo " Imported SSH keys: ~/.ssh/authorized_keys" -else -echo " Password authentication: ENABLED" -fi -echo "" -echo " Traditional SSH Access:" -echo " - Uses keys from GitHub/Launchpad (if imported)" -echo " - Connect with: ssh user@hostname" -if [ "$KEYS_IMPORTED" = true ]; then -echo " - Password login: DISABLED (keys required)" -else -echo " - Password login: ENABLED" -fi -echo "" -echo " NetBird SSH Access (independent of traditional SSH):" -echo " - NetBird manages its own keys automatically" -echo " - Works even with password auth disabled" -echo " - Connect with: netbird ssh " -echo " - Enable in NetBird dashboard first" -echo "" -echo " You can use ANY combination:" -echo " ✓ GitHub + Launchpad + NetBird SSH" -echo " ✓ GitHub + NetBird SSH" -echo " ✓ Launchpad + NetBird SSH" -echo " ✓ GitHub + Launchpad (no NetBird)" -echo " ✓ Just GitHub or just Launchpad" -echo " ✓ Just NetBird SSH" -echo " ✓ None (password auth only - if no keys imported)" -echo "" -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "BACKUP SYSTEM - $BACKUP_TOOL ($BACKUP_MODE mode)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " Mount points:" -echo " $ACTUAL_HOME/drives/primary" -echo " $ACTUAL_HOME/drives/backup1" -if [ "$BACKUP_MODE" = "split" ]; then -echo " $ACTUAL_HOME/drives/backup2" -fi -echo "" -echo " Backup script: /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo " Log file: /var/log/${BACKUP_TOOL}-backup.log" -echo "" -if [ "$BACKUP_MODE" = "split" ]; then -echo " ⚠️ CONFIGURE BEFORE RUNNING:" -echo " Edit the script to set which folders go to which backup drive." -echo " sudo nano /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo "" -fi -echo " Quick start:" -echo " 1. Test (dry-run): sudo ${BACKUP_TOOL}-backup.sh --dry-run # (edit script first)" -echo " 2. Run manually: sudo /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo " 3. Enable auto: sudo systemctl enable ${BACKUP_TOOL}-backup.timer" -echo " sudo systemctl start ${BACKUP_TOOL}-backup.timer" -echo "" -echo " Monitor: tail -f /var/log/${BACKUP_TOOL}-backup.log" -fi - -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "OTHER IMPORTANT NOTES" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo " Samba File Sharing:" - if command -v smbd &> /dev/null && grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then - echo " ✓ Share 'Primary' is accessible at:" - echo " Windows: \\\\$(hostname)\\Primary" - echo " Mac/Linux: smb://$(hostname)/Primary" - echo " • Username: $ACTUAL_USER" - echo " • Use the Samba password you just set" - echo "" - else - echo " ✗ Installation may have failed - check 'systemctl status smbd'" - echo "" - fi -fi -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo " Docker: Log out and back in for group membership to take effect" - echo "" -fi -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo " NetBird:" - echo " 1. Run 'netbird up' (opens browser for authentication)" - echo " 2. View connected peers: netbird status" - echo " 3. Configure ACLs in dashboard: https://app.netbird.io" - echo "" -fi -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo " RustDesk: Launch from applications menu or run 'rustdesk'" - echo "" -fi -if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then - echo " fail2ban:" - echo " • Check status: sudo fail2ban-client status sshd" - echo " • View banned IPs: sudo fail2ban-client status sshd" - echo " • Unban IP: sudo fail2ban-client set sshd unbanip " - echo "" -fi -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo " UFW Firewall:" - echo " • Check status: sudo ufw status" - echo " • Allow port: sudo ufw allow " - echo " • Deny port: sudo ufw deny " - echo " • Disable: sudo ufw disable" - echo "" -fi \ No newline at end of file diff --git a/ubuntu-post-install-26.04-crowdsec.sh b/ubuntu-post-install-26.04-crowdsec.sh deleted file mode 100644 index 6100d9b..0000000 --- a/ubuntu-post-install-26.04-crowdsec.sh +++ /dev/null @@ -1,7487 +0,0 @@ -#!/bin/bash - -# Ubuntu 26.04 Post-Installation Script -# Run with: sudo bash post-install.sh -# This script is rerunnable - it detects existing installations - -# ============================================================================ -# COMMAND-LINE ARGUMENT PARSING -# ============================================================================ - -DRY_RUN=false -UNATTENDED=false -LOG_FILE="/var/log/post-install.log" -WHIPTAIL_USED=false - -show_help() { - echo "Ubuntu 26.04 Post-Installation Script" - echo "" - echo "Usage: sudo ./post-install.sh [OPTIONS]" - echo "" - echo "Options:" - echo " --dry-run Preview what would be installed without making changes" - echo " --unattended Run with default options (no prompts)" - echo " Defaults: skip key generation, no SSH imports," - echo " install Docker, skip VPNs/Remote Desktop/Backup" - echo " --restore Disaster recovery mode - restore from backup" - echo " --help Show this help message" - echo "" - echo "Examples:" - echo " sudo ./post-install.sh # Interactive mode" - echo " sudo ./post-install.sh --dry-run # Preview installations" - echo " sudo ./post-install.sh --unattended # Automated install" - echo " sudo ./post-install.sh --restore # Disaster recovery" - echo "" - exit 0 -} - -RESTORE_MODE=false - -for arg in "$@"; do - case $arg in - --dry-run) - DRY_RUN=true - ;; - --unattended) - UNATTENDED=true - ;; - --restore) - RESTORE_MODE=true - ;; - --help|-h) - show_help - ;; - *) - echo "Unknown option: $arg" - echo "Use --help for usage information" - exit 1 - ;; - esac -done - -# ============================================================================ -# LOGGING SETUP -# ============================================================================ - -# Create log file and tee all output -exec > >(tee -a "$LOG_FILE") 2>&1 -echo "" -echo "=== Post-Install Log Started: $(date) ===" >> "$LOG_FILE" - -echo "=== Ubuntu 26.04 Post-Installation Script ===" -echo "" - -if [ "$DRY_RUN" = true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRY RUN MODE - No changes will be made" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" -fi - -if [ "$UNATTENDED" = true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "UNATTENDED MODE - Using default options" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" -fi - -# Check if running as root -if [ "$EUID" -ne 0 ]; then - echo "Please run as root (use sudo)" - exit 1 -fi - -# Get the actual user (not root) -ACTUAL_USER="${SUDO_USER:-$USER}" -ACTUAL_HOME=$(getent passwd "$ACTUAL_USER" | cut -d: -f6) -DOCKER_DIR="$ACTUAL_HOME/docker" - -echo "Note: Script will continue even if individual packages fail to install" -echo "Log file: $LOG_FILE" -echo "" - -# ============================================================================ -# INSTALLATION MODE SELECTOR -# ============================================================================ - -INSTALL_MODE="normal" - -# Disaster recovery function -run_disaster_recovery() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DISASTER RECOVERY MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This will restore your system and Docker apps from a Kopia backup." - echo "" - echo "Requirements:" - echo " • Backup drive connected (with kopia-repo folder)" - echo " • Kopia password (saved in .env or you remember it)" - echo "" - - # Step 1: Install core utilities - echo "Step 1: Installing core utilities" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Installing essential packages..." - apt-get update - apt-get install -y \ - net-tools \ - openssh-server \ - git \ - curl \ - wget \ - htop \ - ncdu \ - tree \ - zip \ - unzip \ - whiptail \ - 2>/dev/null || echo " ⚠ Some packages may have failed" - - # Install Kopia for backup management - echo "Installing Kopia..." - if ! command -v kopia &> /dev/null; then - curl -s https://kopia.io/signing-key | gpg --dearmor -o /usr/share/keyrings/kopia-keyring.gpg 2>/dev/null - echo "deb [signed-by=/usr/share/keyrings/kopia-keyring.gpg] https://packages.kopia.io/apt/ stable main" | tee /etc/apt/sources.list.d/kopia.list - apt-get update - apt-get install -y kopia 2>/dev/null || echo " ⚠ Kopia install failed" - fi - - # Start SSH - systemctl enable ssh 2>/dev/null || true - systemctl start ssh 2>/dev/null || true - echo "✓ Core utilities installed" - echo "" - - # Step 2: Find/mount backup drive - echo "Step 2: Locate backup drive" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Available block devices:" - lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null || lsblk - echo "" - - # Check if drives are already mounted - if [ -d "$ACTUAL_HOME/drives" ]; then - echo "Existing mount points in ~/drives/:" - ls -la "$ACTUAL_HOME/drives/" 2>/dev/null || echo " (none)" - echo "" - fi - - read -p "Enter path to backup drive or Kopia repo (e.g., /dev/sdb1 or ~/drives/backup1): " BACKUP_PATH - - # If it's a device, mount it - if [[ "$BACKUP_PATH" == /dev/* ]]; then - MOUNT_POINT="$ACTUAL_HOME/drives/restore-backup" - mkdir -p "$MOUNT_POINT" 2>/dev/null - echo "Mounting $BACKUP_PATH to $MOUNT_POINT..." - mount "$BACKUP_PATH" "$MOUNT_POINT" || { echo "Failed to mount. Check device path."; return 1; } - BACKUP_PATH="$MOUNT_POINT" - fi - - # Expand ~ to home directory - BACKUP_PATH="${BACKUP_PATH/#\~/$ACTUAL_HOME}" - - # Look for kopia-repo - KOPIA_REPO="" - if [ -d "$BACKUP_PATH/kopia-repo" ]; then - KOPIA_REPO="$BACKUP_PATH/kopia-repo" - elif [ -d "$BACKUP_PATH/kopia.repository" ] || [ -f "$BACKUP_PATH/kopia.repository.f" ]; then - KOPIA_REPO="$BACKUP_PATH" - else - echo "" - echo "Looking for Kopia repository..." - FOUND_REPO=$(find "$BACKUP_PATH" -maxdepth 3 -name "kopia.repository*" -type f 2>/dev/null | head -1) - if [ -n "$FOUND_REPO" ]; then - KOPIA_REPO=$(dirname "$FOUND_REPO") - fi - fi - - if [ -z "$KOPIA_REPO" ] || [ ! -d "$KOPIA_REPO" ]; then - echo "❌ Could not find Kopia repository at $BACKUP_PATH" - echo " Look for a folder containing 'kopia.repository' files" - return 1 - fi - - echo "✓ Found Kopia repository at: $KOPIA_REPO" - echo "" - - # Step 3: Get Kopia password - echo "Step 3: Kopia password" - echo "━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Try to find password in backed-up .env - if [ -f "$BACKUP_PATH/kopia/.env" ]; then - KOPIA_PASS=$(grep "KOPIA_PASSWORD" "$BACKUP_PATH/kopia/.env" 2>/dev/null | cut -d= -f2) - if [ -n "$KOPIA_PASS" ]; then - echo "Found password in backup. Use this? (y/n)" - read -p "> " USE_FOUND_PASS - [ "$USE_FOUND_PASS" != "y" ] && KOPIA_PASS="" - fi - fi - - if [ -z "$KOPIA_PASS" ]; then - read -s -p "Enter Kopia repository password: " KOPIA_PASS - echo "" - fi - - # Step 4: Install Docker if needed - echo "" - echo "Step 4: Ensure Docker is installed" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - if ! command -v docker &> /dev/null; then - echo "Installing Docker..." - apt-get update - apt-get install -y ca-certificates curl gnupg - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg - chmod a+r /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - usermod -aG docker "$ACTUAL_USER" - echo "✓ Docker installed" - else - echo "✓ Docker already installed" - fi - echo "" - - # Step 5: List snapshots and let user choose - echo "Step 5: Select snapshot to restore" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Connecting to repository and listing snapshots..." - echo "" - - # Use docker to run kopia - SNAPSHOT_LIST=$(docker run --rm \ - -v "$KOPIA_REPO:/repository" \ - -e KOPIA_PASSWORD="$KOPIA_PASS" \ - kopia/kopia:latest \ - snapshot list --all 2>&1) - - if echo "$SNAPSHOT_LIST" | grep -q "invalid password"; then - echo "❌ Invalid password" - return 1 - fi - - echo "$SNAPSHOT_LIST" - echo "" - read -p "Enter snapshot ID to restore (or 'latest' for most recent): " SNAPSHOT_ID - - if [ "$SNAPSHOT_ID" = "latest" ]; then - SNAPSHOT_ID=$(echo "$SNAPSHOT_LIST" | grep -oE "^[a-f0-9]+" | tail -1) - echo "Using latest snapshot: $SNAPSHOT_ID" - fi - - # Step 6: Restore to temp location - echo "" - echo "Step 6: Restoring from backup" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - RESTORE_TEMP="$ACTUAL_HOME/docker-restore-temp" - mkdir -p "$RESTORE_TEMP" - - echo "Restoring to $RESTORE_TEMP..." - docker run --rm \ - -v "$KOPIA_REPO:/repository" \ - -v "$RESTORE_TEMP:/restore" \ - -e KOPIA_PASSWORD="$KOPIA_PASS" \ - kopia/kopia:latest \ - restore "$SNAPSHOT_ID" /restore - - if [ $? -ne 0 ]; then - echo "❌ Restore failed" - return 1 - fi - echo "✓ Snapshot restored to temp location" - echo "" - - # Step 7: Detect and select services to restore - echo "Step 7: Select services to restore" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - mkdir -p "$DOCKER_DIR" - ensure_docker_dir_ownership "$DOCKER_DIR" - - # Find all docker-compose.yml files in restored backup - declare -A SERVICE_DIRS - SERVICES_FOUND=() - for compose_file in $(find "$RESTORE_TEMP" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null); do - SERVICE_DIR=$(dirname "$compose_file") - SERVICE_NAME=$(basename "$SERVICE_DIR") - SERVICES_FOUND+=("$SERVICE_NAME") - SERVICE_DIRS["$SERVICE_NAME"]="$SERVICE_DIR" - done - - if [ ${#SERVICES_FOUND[@]} -eq 0 ]; then - echo "No Docker services found in backup" - else - echo "Found ${#SERVICES_FOUND[@]} services in backup." - echo "" - - # Check if whiptail is available for nice UI - if command -v whiptail &> /dev/null; then - # Build whiptail checklist arguments - CHECKLIST_ARGS=() - for svc in "${SERVICES_FOUND[@]}"; do - CHECKLIST_ARGS+=("$svc" "" "ON") - done - - # Show checklist - returns selected items - SELECTED=$(whiptail --title "Select Services to Restore" \ - --checklist "Use SPACE to select/deselect, ENTER to confirm:" \ - 20 60 12 \ - "${CHECKLIST_ARGS[@]}" \ - 3>&1 1>&2 2>&3) - - # Parse selected services (whiptail returns quoted strings) - SELECTED_SERVICES=() - for item in $SELECTED; do - # Remove quotes - svc=$(echo "$item" | tr -d '"') - SELECTED_SERVICES+=("$svc") - done - else - # Fallback: text-based selection - echo "Select services to restore:" - echo " [A] All services" - echo " [N] None (skip restore)" - echo " [S] Select individually" - echo "" - read -p "Choice (A/N/S) [A]: " RESTORE_CHOICE - - case "${RESTORE_CHOICE^^}" in - N) - SELECTED_SERVICES=() - ;; - S) - SELECTED_SERVICES=() - for svc in "${SERVICES_FOUND[@]}"; do - read -p " Restore $svc? (y/n) [y]: " RESTORE_SVC - if [ "$RESTORE_SVC" != "n" ] && [ "$RESTORE_SVC" != "N" ]; then - SELECTED_SERVICES+=("$svc") - fi - done - ;; - *) - SELECTED_SERVICES=("${SERVICES_FOUND[@]}") - ;; - esac - fi - - # Restore selected services - echo "" - if [ ${#SELECTED_SERVICES[@]} -eq 0 ]; then - echo "No services selected for restore." - else - echo "Restoring ${#SELECTED_SERVICES[@]} services..." - echo "" - for svc in "${SELECTED_SERVICES[@]}"; do - SERVICE_DIR="${SERVICE_DIRS[$svc]}" - TARGET_DIR="$DOCKER_DIR/$svc" - - echo " Restoring $svc..." - - # Copy entire service directory - cp -r "$SERVICE_DIR" "$TARGET_DIR" 2>/dev/null || true - - # Fix ownership - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TARGET_DIR" 2>/dev/null || true - - echo " ✓ $svc restored to $TARGET_DIR" - done - fi - fi - - # Step 8: Start services - echo "" - echo "Step 8: Starting services" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Count restored services - RESTORED_COUNT=0 - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - ((RESTORED_COUNT++)) - fi - done - - if [ $RESTORED_COUNT -eq 0 ]; then - echo "No services to start." - else - echo "Start restored services?" - echo " [A] All services" - echo " [S] Select which to start" - echo " [N] None (start manually later)" - echo "" - read -p "Choice (A/S/N) [A]: " START_CHOICE - - case "${START_CHOICE^^}" in - N) - echo "Services not started. Start manually with:" - echo " cd ~/docker/{service} && docker compose up -d" - ;; - S) - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - SERVICE_NAME=$(basename "$dir") - read -p " Start $SERVICE_NAME? (y/n) [y]: " START_SVC - if [ "$START_SVC" != "n" ] && [ "$START_SVC" != "N" ]; then - echo " Starting $SERVICE_NAME..." - (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME" - fi - fi - done - ;; - *) - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - SERVICE_NAME=$(basename "$dir") - echo " Starting $SERVICE_NAME..." - (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME" - fi - done - ;; - esac - fi - - # Cleanup - echo "" - read -p "Remove temporary restore files? (y/n): " CLEANUP - if [ "$CLEANUP" = "y" ]; then - rm -rf "$RESTORE_TEMP" - echo "✓ Cleaned up temp files" - fi - - # Step 9: Reconnect Kopia for future backups - echo "" - echo "Step 9: Setting up future backups" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Reconnecting Kopia to backup repository for future backups..." - - if command -v kopia &> /dev/null && [ -n "$KOPIA_REPO" ]; then - # Reconnect to the repository - if kopia repository connect filesystem --path="$KOPIA_REPO" --password="$KOPIA_PASSWORD" 2>/dev/null; then - echo "✓ Kopia reconnected to repository" - echo " Repository: $KOPIA_REPO" - - # Verify backup scripts exist - if [ -f "$DOCKER_DIR/kopia/backup-containers.sh" ]; then - chmod +x "$DOCKER_DIR/kopia/backup-containers.sh" 2>/dev/null || true - echo "✓ Backup script ready: $DOCKER_DIR/kopia/backup-containers.sh" - else - echo " ⚠ Backup script not found - run normal install to set up" - fi - else - echo " ⚠ Could not reconnect to repository" - echo " Run manually: kopia repository connect filesystem --path=\"$KOPIA_REPO\"" - fi - else - echo " ⚠ Kopia not available or repository not found" - echo " Run the normal install script to set up backups" - fi - echo "" - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DISASTER RECOVERY COMPLETE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Services restored to: $DOCKER_DIR" - echo "" - echo "Backups: $(command -v kopia &>/dev/null && kopia repository status &>/dev/null && echo "✓ Connected" || echo "⚠ Not connected")" - echo " Run backups: $DOCKER_DIR/kopia/backup-containers.sh" - echo "" - echo "To check running containers:" - echo " docker ps" - echo "" - echo "To view logs:" - echo " docker compose logs -f" - echo "" - echo "⚠️ Some services may need manual configuration:" - echo " • Frigate: Edit config/config.yml with camera URLs" - echo " • Caddy: Update Caddyfile with your domain" - echo " • ddclient: Edit config/ddclient.conf with DNS credentials" - echo "" - - return 0 -} - -# Migration function - import existing Docker containers -run_migration() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "MIGRATION MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This will migrate existing Docker containers to this script's structure." - echo "Containers will be copied (not moved) and versions preserved." - echo "" - - # Step 1: Get source Docker directory - echo "Step 1: Locate existing containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Try to auto-detect Docker directories - DETECTED_DIRS=() - - # Check common locations - for check_dir in /var/docker /opt/docker "$HOME/docker" /home/*/docker; do - if [ -d "$check_dir" ] && [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then - DETECTED_DIRS+=("$check_dir") - fi - done - - # Check mounted drives: ~/drives/*, /mnt/*, /media/* - for mount_base in "$HOME/drives" "$HOME_DIR/drives" /mnt /media; do - if [ -d "$mount_base" ]; then - for mount_dir in "$mount_base"/*; do - if [ -d "$mount_dir/docker" ]; then - check_dir="$mount_dir/docker" - if [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then - DETECTED_DIRS+=("$check_dir") - fi - fi - done - fi - done - - if [ ${#DETECTED_DIRS[@]} -gt 0 ]; then - echo "Auto-detected Docker directories:" - for i in "${!DETECTED_DIRS[@]}"; do - dir="${DETECTED_DIRS[$i]}" - count=$(find "$dir" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null | wc -l) - echo " [$((i+1))] $dir ($count containers)" - done - echo "" - echo "Enter a number to select, or type a custom path:" - else - echo "No Docker directories auto-detected." - echo "" - echo "Common locations:" - echo " • ~/docker" - echo " • ~/drives/primary/docker" - echo " • /var/docker" - echo " • /opt/docker" - echo " • /mnt/data/docker" - echo "" - echo "Enter the full path to your Docker directory:" - fi - echo "" - read -p "Source directory: " SOURCE_DOCKER_DIR - - # Check if user entered a number (to select from detected list) - if [[ "$SOURCE_DOCKER_DIR" =~ ^[0-9]+$ ]] && [ ${#DETECTED_DIRS[@]} -gt 0 ]; then - idx=$((SOURCE_DOCKER_DIR - 1)) - if [ $idx -ge 0 ] && [ $idx -lt ${#DETECTED_DIRS[@]} ]; then - SOURCE_DOCKER_DIR="${DETECTED_DIRS[$idx]}" - echo "Selected: $SOURCE_DOCKER_DIR" - fi - fi - - # Expand ~ if used - SOURCE_DOCKER_DIR="${SOURCE_DOCKER_DIR/#\~/$HOME}" - - if [ ! -d "$SOURCE_DOCKER_DIR" ]; then - echo "❌ Directory not found: $SOURCE_DOCKER_DIR" - return 1 - fi - - # Step 2: Scan for containers - echo "" - echo "Step 2: Scanning for containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - CONTAINERS_FOUND=() - CONTAINER_PATHS=() - - while IFS= read -r compose_file; do - if [ -n "$compose_file" ]; then - container_dir=$(dirname "$compose_file") - container_name=$(basename "$container_dir") - CONTAINERS_FOUND+=("$container_name") - CONTAINER_PATHS+=("$container_dir") - fi - done < <(find "$SOURCE_DOCKER_DIR" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null) - - if [ ${#CONTAINERS_FOUND[@]} -eq 0 ]; then - echo "❌ No Docker containers found in $SOURCE_DOCKER_DIR" - return 1 - fi - - echo "Found ${#CONTAINERS_FOUND[@]} container(s):" - for i in "${!CONTAINERS_FOUND[@]}"; do - container="${CONTAINERS_FOUND[$i]}" - path="${CONTAINER_PATHS[$i]}" - size=$(du -sh "$path" 2>/dev/null | cut -f1) - echo " [$((i+1))] $container ($size)" - done - echo "" - - # Step 3: Select containers to migrate - echo "Step 3: Select containers to migrate" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - SELECTED_CONTAINERS=() - - if command -v whiptail &> /dev/null && [ ${#CONTAINERS_FOUND[@]} -gt 3 ]; then - # Use whiptail for many containers - CHECKLIST_ARGS=() - for container in "${CONTAINERS_FOUND[@]}"; do - CHECKLIST_ARGS+=("$container" "" "ON") - done - - SELECTED=$(whiptail --title "Select Containers to Migrate" \ - --checklist "Use SPACE to select/deselect, ENTER to confirm:" \ - 20 60 12 \ - "${CHECKLIST_ARGS[@]}" \ - 3>&1 1>&2 2>&3) - - if [ $? -eq 0 ] && [ -n "$SELECTED" ]; then - # Parse whiptail output - for container in $SELECTED; do - # Remove quotes - container="${container//\"/}" - SELECTED_CONTAINERS+=("$container") - done - fi - else - # Text-based selection - echo "Select containers to migrate:" - echo " [A] All containers" - echo " [S] Select individually" - echo " [N] None (cancel)" - echo "" - read -p "Choice (A/S/N) [A]: " MIGRATE_CHOICE - - case "${MIGRATE_CHOICE^^}" in - N) - echo "Migration cancelled." - return 0 - ;; - S) - for container in "${CONTAINERS_FOUND[@]}"; do - read -p " Migrate $container? (y/n) [y]: " MIGRATE_THIS - if [ "$MIGRATE_THIS" != "n" ] && [ "$MIGRATE_THIS" != "N" ]; then - SELECTED_CONTAINERS+=("$container") - fi - done - ;; - *) - SELECTED_CONTAINERS=("${CONTAINERS_FOUND[@]}") - ;; - esac - fi - - if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then - echo "No containers selected." - return 0 - fi - - echo "" - echo "Will migrate ${#SELECTED_CONTAINERS[@]} container(s):" - for container in "${SELECTED_CONTAINERS[@]}"; do - echo " • $container" - done - echo "" - - # Step 4: Stop running containers (optional) - echo "Step 4: Container status" - echo "━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Should running containers be stopped before migration?" - echo " • Stopping ensures clean copy of data/databases" - echo " • Not stopping may result in inconsistent state" - echo "" - read -p "Stop containers before copying? (y/n) [y]: " STOP_CONTAINERS - - if [ "$STOP_CONTAINERS" != "n" ] && [ "$STOP_CONTAINERS" != "N" ]; then - echo "Stopping containers..." - for container in "${SELECTED_CONTAINERS[@]}"; do - for i in "${!CONTAINERS_FOUND[@]}"; do - if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then - container_dir="${CONTAINER_PATHS[$i]}" - if [ -f "$container_dir/docker-compose.yml" ] || [ -f "$container_dir/compose.yml" ]; then - echo " Stopping $container..." - (cd "$container_dir" && docker compose down 2>/dev/null) || true - fi - fi - done - done - echo "" - fi - - # Step 5: Decide migration method - echo "Step 5: Migration method" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Check if source is on a different drive (mounted drive, not home on OS) - SOURCE_ON_EXTERNAL=false - if [[ "$SOURCE_DOCKER_DIR" == /mnt/* ]] || [[ "$SOURCE_DOCKER_DIR" == /media/* ]] || [[ "$SOURCE_DOCKER_DIR" == */drives/* ]]; then - SOURCE_ON_EXTERNAL=true - fi - - MIGRATION_METHOD="copy" - if [ "$SOURCE_ON_EXTERNAL" = true ]; then - echo "Source is on a mounted drive: $SOURCE_DOCKER_DIR" - echo "" - echo "How would you like to migrate?" - echo "" - echo " [C] Copy - Copy containers to ~/docker on OS drive" - echo " Best for: old OS drive mounted temporarily" - echo "" - echo " [S] Symlink - Create ~/docker as symlink to source location" - echo " Best for: data drive you'll keep using" - echo "" - echo " [U] Use in-place - Use source location directly, no copy" - echo " Best for: already on your data drive" - echo "" - read -p "Method (C/S/U) [C]: " MIGRATION_METHOD - - case "${MIGRATION_METHOD^^}" in - S) - MIGRATION_METHOD="symlink" - ;; - U) - MIGRATION_METHOD="use" - ;; - *) - MIGRATION_METHOD="copy" - ;; - esac - else - echo "Source: $SOURCE_DOCKER_DIR" - echo "Target: $DOCKER_DIR" - echo "" - echo "Will copy containers to $DOCKER_DIR" - fi - - echo "" - - # Handle different migration methods - case "$MIGRATION_METHOD" in - symlink) - echo "Creating symlink: $DOCKER_DIR → $SOURCE_DOCKER_DIR" - echo "" - - # Check if target exists - if [ -e "$DOCKER_DIR" ] || [ -L "$DOCKER_DIR" ]; then - echo " ⚠ $DOCKER_DIR already exists" - read -p " Remove and create symlink? (y/n) [n]: " REMOVE_EXISTING - if [ "$REMOVE_EXISTING" = "y" ] || [ "$REMOVE_EXISTING" = "Y" ]; then - rm -rf "$DOCKER_DIR" - else - echo " Cancelled." - return 1 - fi - fi - - ln -s "$SOURCE_DOCKER_DIR" "$DOCKER_DIR" - chown -h "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - - echo "✓ Symlink created" - echo " Containers stay at: $SOURCE_DOCKER_DIR" - echo " Accessible via: $DOCKER_DIR → $SOURCE_DOCKER_DIR" - MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]} - ;; - - use) - echo "Using source location directly" - echo "" - DOCKER_DIR="$SOURCE_DOCKER_DIR" - echo "✓ DOCKER_DIR set to: $DOCKER_DIR" - echo " No files copied - containers remain in place" - MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]} - ;; - - copy|*) - echo "Copying containers to $DOCKER_DIR" - echo "" - - # Create target directory - mkdir -p "$DOCKER_DIR" - chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - - MIGRATED_COUNT=0 - for container in "${SELECTED_CONTAINERS[@]}"; do - for i in "${!CONTAINERS_FOUND[@]}"; do - if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then - source_dir="${CONTAINER_PATHS[$i]}" - target_dir="$DOCKER_DIR/$container" - - # Check if already exists - if [ -d "$target_dir" ]; then - echo " ⚠ $container already exists at $target_dir" - read -p " Overwrite? (y/n) [n]: " OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Skipping $container" - continue - fi - rm -rf "$target_dir" - fi - - echo " Copying $container..." - cp -a "$source_dir" "$target_dir" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$target_dir" - ((MIGRATED_COUNT++)) - echo " ✓ $container copied" - fi - done - done - ;; - esac - - echo "" - echo "✓ Migrated $MIGRATED_COUNT container(s)" - echo "" - - # Step 6: Update volume paths - echo "Step 6: Update volume paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Scanning docker-compose files for volume mounts that may need updating..." - echo "" - - # Find all absolute paths in volume mounts (excluding relative paths like ./ or named volumes) - PATHS_FOUND=() - PATHS_FILES=() - - for container in "${SELECTED_CONTAINERS[@]}"; do - compose_file="$DOCKER_DIR/$container/docker-compose.yml" - [ -f "$compose_file" ] || compose_file="$DOCKER_DIR/$container/compose.yml" - [ -f "$compose_file" ] || continue - - # Extract volume mount paths (lines with : that look like /path:/container/path) - while IFS= read -r line; do - # Match absolute paths in volume mounts (starting with /) - if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*(/[^:]+): ]]; then - host_path="${BASH_REMATCH[1]}" - # Skip if it's a relative path or standard paths - if [[ "$host_path" != "./"* ]] && [[ "$host_path" != "/etc/"* ]] && [[ "$host_path" != "/var/run/"* ]]; then - # Check if this path doesn't exist on current system - if [ ! -e "$host_path" ]; then - # Avoid duplicates - if [[ ! " ${PATHS_FOUND[*]} " =~ " ${host_path} " ]]; then - PATHS_FOUND+=("$host_path") - PATHS_FILES+=("$compose_file") - fi - fi - fi - fi - done < "$compose_file" - done - - if [ ${#PATHS_FOUND[@]} -eq 0 ]; then - echo "✓ No volume paths need updating (all paths exist or are relative)" - echo "" - else - echo "Found ${#PATHS_FOUND[@]} volume path(s) that don't exist on this system:" - echo "" - - # Default new base path - DEFAULT_NEW_BASE="$HOME_DIR/drives/primary" - - for i in "${!PATHS_FOUND[@]}"; do - old_path="${PATHS_FOUND[$i]}" - compose_file="${PATHS_FILES[$i]}" - container_name=$(basename "$(dirname "$compose_file")") - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Container: $container_name" - echo "Old path: $old_path" - - # Suggest a new path based on the old path structure - # Extract the last part of the path for suggestion - path_tail=$(basename "$old_path") - suggested_path="$DEFAULT_NEW_BASE/$path_tail" - - echo "Suggested: $suggested_path" - echo "" - echo "Options:" - echo " [Enter] Accept suggested path" - echo " [S] Skip - keep original path" - echo " [path] Enter custom path" - echo "" - read -p "New path: " NEW_PATH_INPUT - - case "$NEW_PATH_INPUT" in - ""|" ") - new_path="$suggested_path" - ;; - [Ss]) - echo " Skipping - keeping original path" - continue - ;; - *) - new_path="${NEW_PATH_INPUT/#\~/$HOME_DIR}" - ;; - esac - - # Update the compose file - echo " Updating: $old_path → $new_path" - - # Escape paths for sed (handle slashes) - old_escaped=$(printf '%s\n' "$old_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g') - new_escaped=$(printf '%s\n' "$new_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g') - - sed -i "s|$old_path|$new_path|g" "$compose_file" - - # Create directory if it doesn't exist - if [ ! -d "$new_path" ]; then - echo " Creating directory: $new_path" - mkdir -p "$new_path" 2>/dev/null || echo " ⚠ Could not create (may need manual creation)" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$new_path" 2>/dev/null || true - fi - - echo " ✓ Updated" - echo "" - done - - echo "✓ Volume paths updated" - echo "" - fi - - # Step 7: Start migrated containers (optional) - echo "Step 7: Start containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - read -p "Start migrated containers? (y/n) [y]: " START_MIGRATED - - if [ "$START_MIGRATED" != "n" ] && [ "$START_MIGRATED" != "N" ]; then - for container in "${SELECTED_CONTAINERS[@]}"; do - target_dir="$DOCKER_DIR/$container" - if [ -d "$target_dir" ]; then - echo " Starting $container..." - (cd "$target_dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $container" - fi - done - echo "" - fi - - # Step 8: Offer additional services - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "MIGRATION COMPLETE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Containers migrated to: $DOCKER_DIR" - echo "" - echo "Would you like to install additional services not in your migration?" - echo "This will continue with the normal install process where you can" - echo "choose which additional apps to install (Immich, Frigate, etc.)" - echo "" - read -p "Continue to install additional services? (y/n) [y]: " INSTALL_MORE - - if [ "$INSTALL_MORE" = "n" ] || [ "$INSTALL_MORE" = "N" ]; then - echo "" - echo "Migration complete. You can run this script again to install more services." - echo "" - echo "To check running containers:" - echo " docker ps" - echo "" - return 0 - fi - - # Return to continue with normal install - INSTALL_MODE="post-migration" - return 0 -} - -# Check for restore mode flag -if [ "$RESTORE_MODE" = true ]; then - run_disaster_recovery - exit $? -fi - -# Interactive mode selector (if not unattended) -if [ "$UNATTENDED" != true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "INSTALLATION MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo " [N] Normal install - Fresh install or modify existing" - echo " [M] Migration - Import existing Docker containers" - echo " [R] Disaster recovery - Restore from Kopia backup" - echo "" - read -p "Select mode (N/M/R) [N]: " MODE_SELECT - - case "${MODE_SELECT^^}" in - R) - run_disaster_recovery - exit $? - ;; - M) - run_migration - # If migration returns 0 and INSTALL_MODE is post-migration, continue - if [ $? -ne 0 ]; then - exit 1 - fi - if [ "$INSTALL_MODE" != "post-migration" ]; then - exit 0 - fi - echo "" - echo "Continuing with additional service installation..." - echo "" - ;; - *) - INSTALL_MODE="normal" - ;; - esac - echo "" -fi - -# ============================================================================ -# DRIVE SETUP (Runs before everything else) -# ============================================================================ - -setup_drives() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This script uses ~/drives/ for data storage:" - echo " ~/drives/primary - Main data drive (media, photos, etc.)" - echo " ~/drives/backup1 - Backup drive(s)" - echo "" - - # Create base drives directory - mkdir -p "$ACTUAL_HOME/drives" - chown "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - # Check for existing mounts - if mount | grep -q "$ACTUAL_HOME/drives/"; then - echo "Existing mounted drives:" - df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " (" $2 " total, " $4 " free)"}' - echo "" - prompt_yn "Configure additional drives? (y/n):" "n" SETUP_DRIVES - else - echo "No drives currently mounted in ~/drives/" - echo "" - prompt_yn "Set up drives now? (y/n):" "y" SETUP_DRIVES - fi - - if [ "$SETUP_DRIVES" != "y" ] && [ "$SETUP_DRIVES" != "Y" ]; then - echo "Skipping drive setup. You can run this script again later." - return 0 - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "AVAILABLE BLOCK DEVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - - # Detect drives without partition tables (new drives) - echo "Checking for new/unpartitioned drives..." - NEW_DRIVES=() - for disk in /dev/sd? /dev/nvme?n?; do - [ -b "$disk" ] || continue - # Check if disk has no partitions - if ! lsblk -n "$disk" | grep -q "part"; then - # Check if it has no partition table - if ! blkid "$disk" &>/dev/null; then - size=$(lsblk -n -d -o SIZE "$disk" 2>/dev/null) - NEW_DRIVES+=("$disk ($size)") - fi - fi - done - - if [ ${#NEW_DRIVES[@]} -gt 0 ]; then - echo "" - echo "⚠️ Found unpartitioned drives (no filesystem):" - for drive in "${NEW_DRIVES[@]}"; do - echo " • $drive" - done - echo "" - prompt_yn "Would you like to partition and format any of these? (y/n):" "n" FORMAT_DRIVES - - if [ "$FORMAT_DRIVES" = "y" ] || [ "$FORMAT_DRIVES" = "Y" ]; then - for drive_info in "${NEW_DRIVES[@]}"; do - drive=$(echo "$drive_info" | cut -d' ' -f1) - echo "" - prompt_yn "Format $drive_info as ext4? (ALL DATA WILL BE ERASED) (y/n):" "n" FORMAT_THIS - - if [ "$FORMAT_THIS" = "y" ] || [ "$FORMAT_THIS" = "Y" ]; then - echo " Creating partition table on $drive..." - parted -s "$drive" mklabel gpt - parted -s "$drive" mkpart primary ext4 0% 100% - - # Wait for partition to appear - sleep 2 - - # Format the partition - part="${drive}1" - [ -b "${drive}p1" ] && part="${drive}p1" # nvme drives - - if [ -b "$part" ]; then - echo " Formatting $part as ext4..." - mkfs.ext4 -F "$part" - echo " ✓ Formatted $part" - else - echo " ⚠ Could not find partition after creation" - fi - fi - done - echo "" - echo "Updated block devices:" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - fi - fi - - # Configure mount points - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CONFIGURE MOUNT POINTS" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Primary drive - echo "PRIMARY DRIVE (for media, photos, docker data):" - prompt_text " Mount point name [primary]:" "primary" PRIMARY_NAME - PRIMARY_NAME="${PRIMARY_NAME:-primary}" - mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME" - - # Check if already mounted - if mount | grep -q "$ACTUAL_HOME/drives/$PRIMARY_NAME"; then - echo " ✓ Already mounted" - else - echo " Enter device (e.g., /dev/sdb1) or leave blank to skip:" - read -p " Device for $PRIMARY_NAME: " PRIMARY_DEV - - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - # Add to fstab - PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV" 2>/dev/null) - if [ -n "$PRIMARY_UUID" ]; then - # Check if not already in fstab - if ! grep -q "$PRIMARY_UUID" /etc/fstab 2>/dev/null; then - cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true - echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab - echo " ✓ Added to fstab" - fi - fi - fi - fi - - # Backup drives - echo "" - echo "BACKUP DRIVES (optional, for rsync backups):" - prompt_text " How many backup drives? [0-4, default: 0]:" "0" NUM_BACKUPS - NUM_BACKUPS="${NUM_BACKUPS:-0}" - - # Validate number - case $NUM_BACKUPS in - 0|1|2|3|4) ;; - *) NUM_BACKUPS=0 ;; - esac - - declare -a BACKUP_NAMES - declare -a BACKUP_DEVS - for i in $(seq 1 $NUM_BACKUPS); do - echo "" - prompt_text " Backup drive $i name [backup$i]:" "backup$i" "BACKUP_NAME" - BACKUP_NAMES[$i]="${BACKUP_NAME:-backup$i}" - mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - - if ! mount | grep -q "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"; then - read -p " Device for ${BACKUP_NAMES[$i]}: " "BACKUP_DEV" - BACKUP_DEVS[$i]="$BACKUP_DEV" - - if [ -n "$BACKUP_DEV" ] && [ -b "$BACKUP_DEV" ]; then - BACKUP_UUID=$(blkid -s UUID -o value "$BACKUP_DEV" 2>/dev/null) - if [ -n "$BACKUP_UUID" ]; then - if ! grep -q "$BACKUP_UUID" /etc/fstab 2>/dev/null; then - echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab - echo " ✓ Added ${BACKUP_NAMES[$i]} to fstab" - fi - fi - fi - else - echo " ✓ Already mounted" - fi - done - - # Set permissions - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - # Mount all from fstab - echo "" - echo "Mounting drives from fstab..." - mount -a 2>/dev/null || true - - echo "" - echo "Current mounts in ~/drives/:" - df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " - " $2 " total, " $4 " free"}' || echo " (none)" - echo "" - - # Export drive names for later use - export PRIMARY_DRIVE_NAME="$PRIMARY_NAME" - export BACKUP_DRIVE_COUNT="$NUM_BACKUPS" - for i in $(seq 1 $NUM_BACKUPS); do - export "BACKUP_DRIVE_${i}_NAME=${BACKUP_NAMES[$i]}" - done - - echo "✓ Drive setup complete" - echo "" -} - -# Run drive setup for normal installs (not migration/recovery) -if [ "$INSTALL_MODE" = "normal" ] && [ "$UNATTENDED" != true ]; then - setup_drives -fi - -# ============================================================================ -# SOFTWARE DETECTION FUNCTIONS -# ============================================================================ - -is_docker_installed() { - command -v docker &> /dev/null && systemctl is-active --quiet docker 2>/dev/null -} - -is_samba_installed() { - command -v smbd &> /dev/null && systemctl is-active --quiet smbd 2>/dev/null -} - -is_netbird_installed() { - command -v netbird &> /dev/null -} - -is_rustdesk_installed() { - command -v rustdesk &> /dev/null || dpkg -l rustdesk &> /dev/null -} - -is_rclone_installed() { - command -v rclone &> /dev/null -} - -is_rsync_installed() { - command -v rsync &> /dev/null -} - -is_ufw_installed() { - command -v ufw &> /dev/null -} - -is_wireguard_installed() { - command -v wg &> /dev/null -} - -is_tailscale_installed() { - command -v tailscale &> /dev/null -} - -is_teamviewer_installed() { - command -v teamviewer &> /dev/null || dpkg -l teamviewer &> /dev/null 2>&1 -} - -is_meshcentral_installed() { - # MeshCentral agent is typically installed as meshagent - command -v meshagent &> /dev/null || [ -f /usr/local/mesh_services/meshagent/meshagent ] -} - -# ============================================================================ -# DRY-RUN AND UNATTENDED HELPER FUNCTIONS -# ============================================================================ - -# Run a command, but skip it in dry-run mode -run_cmd() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would execute: $*" - return 0 - else - "$@" - fi -} - -# Ensure Docker directories are owned by the actual user (not root) -# Usage: ensure_docker_dir_ownership /path/to/dir [additional_paths...] -ensure_docker_dir_ownership() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER" - return 0 - fi - - for dir in "$@"; do - if [ -d "$dir" ]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true - fi - done -} - -# Generate a secure random password with alphanumeric characters only (no special chars) -# Usage: generate_password [length] -# Default length: 32 -generate_password() { - local length="${1:-32}" - openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" -} - -# Validate password (alphanumeric only, minimum length) -# Usage: validate_password "password" [min_length] -# Returns 0 if valid, 1 if invalid -validate_password() { - local password="$1" - local min_length="${2:-12}" - - # Check length - if [ ${#password} -lt "$min_length" ]; then - echo " ⚠ Password must be at least $min_length characters long" - return 1 - fi - - # Check for special characters (not allowed) - if echo "$password" | grep -q '[^a-zA-Z0-9]'; then - echo " ⚠ Password must contain only letters and numbers (no special characters)" - return 1 - fi - - return 0 -} - -# Prompt for yes/no, with unattended default -# Usage: prompt_yn "Question?" "default" VARNAME -# default can be "y" or "n" -prompt_yn() { - local question="$1" - local default="$2" - local varname="$3" - - if [ "$UNATTENDED" = true ]; then - eval "$varname='$default'" - echo "$question [auto: $default]" - return - fi - - read -p "$question " response - eval "$varname='$response'" -} - -# Prompt for text input, with unattended default -# Usage: prompt_text "Question?" "default" VARNAME -prompt_text() { - local question="$1" - local default="$2" - local varname="$3" - - if [ "$UNATTENDED" = true ]; then - eval "$varname='$default'" - echo "$question [auto: $default]" - return - fi - - read -p "$question " response - eval "$varname='$response'" -} - -# ============================================================================ -# CADDY CONFIGURATION HELPER -# ============================================================================ -configure_caddy_for_service() { - local SERVICE_NAME="$1" - local SERVICE_PORT="$2" - local DEFAULT_SUBDOMAIN="$3" - local EXTRA_CONFIG="${4:-}" # Optional extra Caddy directives - - # Check if Caddy is installed - if [ ! -d "$DOCKER_DIR/caddy" ]; then - return 0 # Caddy not installed, skip configuration - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " CADDY REVERSE PROXY CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Caddy is installed. You can configure reverse proxy for $SERVICE_NAME." - echo "" - - local CONFIGURE_CADDY="" - 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" - return 0 - fi - - # Get domain/subdomain - echo "" - echo "Enter the full domain for $SERVICE_NAME:" - echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" - echo "" - local SERVICE_DOMAIN="" - prompt_text "Domain:" "" SERVICE_DOMAIN - - if [ -z "$SERVICE_DOMAIN" ]; then - echo " ⚠ No domain provided, skipping Caddy configuration." - return 0 - fi - - # Backup Caddyfile - local CADDY_DIR="$DOCKER_DIR/caddy" - local CADDYFILE="$CADDY_DIR/Caddyfile" - local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - - if [ -f "$CADDYFILE" ]; then - echo " Backing up Caddyfile to: $(basename $BACKUP_FILE)" - cp "$CADDYFILE" "$BACKUP_FILE" - else - echo " Creating new Caddyfile" - touch "$CADDYFILE" - fi - - # Check if service already configured - if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then - echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile" - local OVERWRITE="" - prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Keeping existing configuration." - return 0 - fi - # Remove existing configuration - sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE" - fi - - # Add service configuration to Caddyfile - echo " Adding $SERVICE_NAME configuration to Caddyfile..." - - cat >> "$CADDYFILE" << CADDY_BLOCK - -# $SERVICE_NAME -$SERVICE_DOMAIN { - reverse_proxy localhost:$SERVICE_PORT - - # Security headers - 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" - } - - # Logging for CrowdSec (Caddy JSON access logs) - log { - output file /var/log/caddy/${SERVICE_DOMAIN}.log - format json - } -$EXTRA_CONFIG -} -CADDY_BLOCK - - echo " ✓ Configuration added to Caddyfile" - - # Reload Caddy - echo " Reloading Caddy configuration..." - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddy reloaded successfully" - else - echo " ⚠ Failed to reload Caddy (will retry after formatting)" - fi - - # Format Caddyfile - echo " Formatting Caddyfile..." - if docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddyfile formatted" - else - echo " ⚠ Failed to format Caddyfile" - fi - - # Final reload after formatting - echo " Final reload..." - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddy configuration active" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $SERVICE_NAME is now accessible at:" - echo " https://$SERVICE_DOMAIN" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - else - echo " ⚠ Failed final reload. Check Caddy logs:" - echo " docker logs caddy" - echo " You can restore from backup: $BACKUP_FILE" - fi - - echo "" -} - -# ============================================================================ -# CHECK IF SERVICE EXISTS AND ASK USER ACTION -# ============================================================================ -check_service_exists() { - local SERVICE_NAME="$1" - local SERVICE_DIR="$2" - local RETURN_VAR="$3" # Variable name to set (true/false) - - if [ -f "$SERVICE_DIR/docker-compose.yml" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $SERVICE_NAME is already installed at $SERVICE_DIR" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Options:" - echo " 1. Skip (keep existing configuration)" - echo " 2. Reconfigure (will backup existing config)" - echo " 3. Restart containers only" - echo "" - local ACTION="" - prompt_text "Choose option [1/2/3]:" "1" ACTION - - case "$ACTION" in - 2) - echo " Backing up existing configuration..." - local BACKUP_NAME=$(echo "$SERVICE_NAME" | tr '[:upper:] ' '[:lower:]-') - BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$BACKUP_NAME" - mkdir -p "$BACKUP_DIR" - cp -r "$SERVICE_DIR" "$BACKUP_DIR/" - echo " ✓ Backup saved to: $BACKUP_DIR" - eval "$RETURN_VAR=true" - return 0 - ;; - 3) - echo " Restarting $SERVICE_NAME containers..." - cd "$SERVICE_DIR" - docker compose restart 2>/dev/null && echo " ✓ $SERVICE_NAME restarted" || echo " ⚠ Failed to restart" - eval "$RETURN_VAR=false" - return 0 - ;; - *) - echo " Skipping $SERVICE_NAME (already configured)" - eval "$RETURN_VAR=false" - return 0 - ;; - esac - else - # Service doesn't exist, proceed with installation - eval "$RETURN_VAR=true" - return 0 - fi -} - -# ============================================================================ -# DETECT AVAILABLE DRIVES (RUN ONCE) -# ============================================================================ -detect_drives() { - # Global variables set by this function: - # - DRIVES_DETECTED: true/false - # - PRIMARY_DRIVE: name of first drive (e.g., "storage1") - # - PRIMARY_DRIVE_PATH: full path to first drive - # - DRIVES_DIR: base drives directory - # - AVAILABLE_DRIVES_COUNT: number of drives found - - DRIVES_DIR="$ACTUAL_HOME/drives" - DRIVES_DETECTED=false - PRIMARY_DRIVE="" - PRIMARY_DRIVE_PATH="" - AVAILABLE_DRIVES_COUNT=0 - - if [ -d "$DRIVES_DIR" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " DETECTED DRIVES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - for drive in "$DRIVES_DIR"/*; do - if [ -d "$drive" ]; then - drive_name=$(basename "$drive") - drive_size=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $2}') - drive_used=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $3}') - drive_avail=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $4}') - - echo " ✓ $drive_name" - if [ -n "$drive_size" ]; then - echo " Path: $drive" - echo " Size: $drive_size (Used: $drive_used, Available: $drive_avail)" - else - echo " Path: $drive" - fi - - # Set PRIMARY_DRIVE to the first drive found - if [ -z "$PRIMARY_DRIVE" ]; then - PRIMARY_DRIVE="$drive_name" - PRIMARY_DRIVE_PATH="$drive" - fi - - AVAILABLE_DRIVES_COUNT=$((AVAILABLE_DRIVES_COUNT + 1)) - fi - done - - if [ $AVAILABLE_DRIVES_COUNT -gt 0 ]; then - DRIVES_DETECTED=true - echo "" - echo " Using '$PRIMARY_DRIVE' as primary drive for default paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - fi - fi - - # If no drives detected, use home directory - if [ "$DRIVES_DETECTED" = "false" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " No ~/drives directory found" - echo " Using $ACTUAL_HOME for default paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - PRIMARY_DRIVE_PATH="$ACTUAL_HOME" - fi -} - -# ============================================================================ -# SHOW CURRENT INSTALLATION STATUS -# ============================================================================ -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "CURRENT SYSTEM STATUS" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -if is_docker_installed; then - echo " ✓ Docker: Installed ($(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ','))" -else - echo " ○ Docker: Not installed" -fi -if is_samba_installed; then - echo " ✓ Samba: Installed and running" -else - echo " ○ Samba: Not installed" -fi -if is_netbird_installed; then - echo " ✓ NetBird: Installed" -else - echo " ○ NetBird: Not installed" -fi -if is_rustdesk_installed; then - echo " ✓ RustDesk: Installed" -else - echo " ○ RustDesk: Not installed" -fi -if is_teamviewer_installed; then - echo " ✓ TeamViewer: Installed" -else - echo " ○ TeamViewer: Not installed" -fi -if is_meshcentral_installed; then - echo " ✓ MeshCentral Agent: Installed" -else - echo " ○ MeshCentral Agent: Not installed" -fi -if is_wireguard_installed; then - echo " ✓ WireGuard: Installed" -else - echo " ○ WireGuard: Not installed" -fi -if is_tailscale_installed; then - echo " ✓ Tailscale: Installed" -else - echo " ○ Tailscale: Not installed" -fi -if is_rclone_installed; then - echo " ✓ rclone: Installed" -else - echo " ○ rclone: Not installed" -fi -if is_rsync_installed; then - echo " ✓ rsync: Installed" -else - echo " ○ rsync: Not installed" -fi -if is_ufw_installed; then - if ufw status 2>/dev/null | grep -q "Status: active"; then - echo " ✓ UFW Firewall: Enabled" - else - echo " ○ UFW Firewall: Installed but disabled" - fi -else - echo " ○ UFW Firewall: Not installed" -fi -echo "" -echo "This script can reinstall/reconfigure any component." -echo "" - -# Update package list -echo "Updating package lists..." -run_cmd apt update - -# Install basic utilities -echo "" -echo "Installing basic utilities..." -echo " - net-tools: Network configuration tools (ifconfig, netstat, etc.)" -echo " - ncdu: Disk usage analyzer with ncurses interface" -echo " - git: Version control system" -echo " - curl: Command-line tool for transferring data with URLs" -echo " - wget: Network downloader" -echo " - htop: Interactive process viewer" -echo " - tree: Display directory structure in tree format" -echo " - zip/unzip: Archive compression utilities" -echo " - rclone: Rsync for cloud storage and local drives (backup tool)" -echo "" - -run_cmd apt install -y \ - net-tools \ - ncdu \ - git \ - curl \ - wget \ - htop \ - tree \ - zip \ - unzip \ - rclone || echo "Warning: Some utilities failed to install, continuing..." - -# Install glow (terminal markdown reader) from the Charm apt repo -echo "" -echo "Installing glow (terminal markdown reader)..." -if command -v glow >/dev/null 2>&1; then - echo " ✓ glow already installed" -elif [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow" -else - sudo mkdir -p /etc/apt/keyrings - if curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then - echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null - sudo apt update -y && sudo apt install -y glow && echo " ✓ glow installed" || echo " ⚠ glow install failed" - else - echo " ⚠ Could not fetch Charm signing key - skipping glow" - fi -fi - -# Install OpenSSH Server -echo "" -echo "Installing OpenSSH Server..." -echo " - openssh-server: SSH server for remote access" -echo "" - -apt install -y openssh-server || echo "Warning: OpenSSH server installation failed, continuing..." - -# Start and enable SSH service -systemctl start ssh || echo "Warning: Failed to start SSH" -systemctl enable ssh || echo "Warning: Failed to enable SSH" - -# On rerun, skip SSH configuration if already set up -SKIP_SSH_CONFIG=false -if (systemctl is-active ssh &>/dev/null || systemctl is-active sshd &>/dev/null) && \ - { [ -f "$ACTUAL_HOME/.ssh/authorized_keys" ] && [ -s "$ACTUAL_HOME/.ssh/authorized_keys" ]; } || \ - [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then - echo "" - echo "SSH is already configured on this system." - prompt_yn "Reconfigure SSH keys and authentication? (y/n):" "n" RECONFIG_SSH - if [ "$RECONFIG_SSH" != "y" ] && [ "$RECONFIG_SSH" != "Y" ]; then - SKIP_SSH_CONFIG=true - fi -fi - -if [ "$SKIP_SSH_CONFIG" != true ]; then - -# Generate SSH key for this computer -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SSH KEY GENERATION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -prompt_yn "Generate a new SSH key for this computer? (y/n):" "n" GENERATE_KEY - -if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - echo "" - read -p "Enter a label/comment for the key (e.g., email or hostname) [default: $ACTUAL_USER@$(hostname)]: " KEY_COMMENT - - if [ -z "$KEY_COMMENT" ]; then - KEY_COMMENT="$ACTUAL_USER@$(hostname)" - fi - - # Check if key already exists - if [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then - echo "" - echo "⚠️ WARNING: SSH key already exists at $ACTUAL_HOME/.ssh/id_rsa" - read -p "Overwrite existing key? This cannot be undone! (y/n): " OVERWRITE_KEY - - if [ "$OVERWRITE_KEY" != "y" ] && [ "$OVERWRITE_KEY" != "Y" ]; then - echo "Skipping key generation." - GENERATE_KEY="n" - fi - fi - - if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - echo "" - echo "Generating 4096-bit RSA key pair..." - echo "This may take a moment..." - - # Generate key as the actual user, not root - sudo -u "$ACTUAL_USER" ssh-keygen -t rsa -b 4096 -C "$KEY_COMMENT" -f "$ACTUAL_HOME/.ssh/id_rsa" -N "" - - if [ $? -eq 0 ]; then - echo "" - echo "✓ SSH key generated successfully!" - echo "" - echo "Private key: $ACTUAL_HOME/.ssh/id_rsa (keep this secret!)" - echo "Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Your PUBLIC key (safe to share):" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - cat "$ACTUAL_HOME/.ssh/id_rsa.pub" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To use this key:" - echo " • Add to GitHub: Settings → SSH and GPG keys → New SSH key" - echo " • Add to servers: Copy above key to remote ~/.ssh/authorized_keys" - echo " • View anytime: cat ~/.ssh/id_rsa.pub" - echo "" - else - echo "✗ Failed to generate SSH key" - fi - fi -else - echo "Skipping SSH key generation." -fi - -# Import SSH keys from GitHub/Launchpad -echo "" -echo "You can import SSH public keys from GitHub or Launchpad for easier SSH access." -echo "" -prompt_yn "Import SSH keys from GitHub or Launchpad? (y/n):" "n" IMPORT_SSH_KEYS - -KEYS_IMPORTED=false -GITHUB_USER="" -LAUNCHPAD_USER="" - -if [ "$IMPORT_SSH_KEYS" = "y" ] || [ "$IMPORT_SSH_KEYS" = "Y" ]; then - prompt_text "GitHub username (or leave blank to skip):" "" GITHUB_USER - prompt_text "Launchpad username (or leave blank to skip):" "" LAUNCHPAD_USER -fi - -# Create .ssh directory if it doesn't exist -mkdir -p "$ACTUAL_HOME/.ssh" -touch "$ACTUAL_HOME/.ssh/authorized_keys" -chmod 700 "$ACTUAL_HOME/.ssh" -chmod 600 "$ACTUAL_HOME/.ssh/authorized_keys" - -if [ -n "$GITHUB_USER" ]; then - echo "Importing SSH keys from GitHub user: $GITHUB_USER" - if curl -fsSL "https://github.com/$GITHUB_USER.keys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then - echo "✓ GitHub keys imported successfully" - KEYS_IMPORTED=true - else - echo "✗ Failed to import GitHub keys" - fi -fi - -if [ -n "$LAUNCHPAD_USER" ]; then - echo "Importing SSH keys from Launchpad user: $LAUNCHPAD_USER" - if curl -fsSL "https://launchpad.net/~$LAUNCHPAD_USER/+sshkeys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then - echo "✓ Launchpad keys imported successfully" - KEYS_IMPORTED=true - else - echo "✗ Failed to import Launchpad keys" - fi -fi - -# Fix ownership -chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/.ssh" - -# Disable password authentication if keys were imported -if [ "$KEYS_IMPORTED" = true ]; then - echo "" - echo "SSH keys imported. Disabling password authentication..." - - # Backup sshd_config - cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup - - # Disable password authentication - sed -i 's/^#*PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config - sed -i 's/^#*PasswordAuthentication no/PasswordAuthentication no/' /etc/ssh/sshd_config - - # Ensure these settings are also set - grep -q "^PasswordAuthentication" /etc/ssh/sshd_config || echo "PasswordAuthentication no" >> /etc/ssh/sshd_config - - # Restart SSH service to apply changes - systemctl restart ssh - - echo "✓ Password authentication disabled. SSH key authentication required." - echo "✓ Backup saved to /etc/ssh/sshd_config.backup" -else - echo "" - echo "No SSH keys imported. Password authentication remains enabled." - - # Offer CrowdSec since password auth is still enabled - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CROWDSEC (Recommended with password SSH)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Since password authentication is enabled, CrowdSec is recommended" - echo "to protect against brute-force SSH attacks. CrowdSec is the modern" - echo "successor to fail2ban and also adds geo-blocking and a community" - echo "IP-reputation blocklist." - echo "" - - prompt_yn "Install and enable CrowdSec? (y/n):" "y" INSTALL_CROWDSEC - - if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then - echo "" - echo "Installing CrowdSec..." - - if [ "$DRY_RUN" != true ]; then - # Add the CrowdSec APT repository and install the agent - if ! command -v cscli &> /dev/null; then - curl -s https://install.crowdsec.net | sudo sh - sudo apt install -y crowdsec - else - echo "✓ CrowdSec is already installed" - fi - - # Firewall bouncer enforces decisions via iptables/nftables - sudo apt install -y crowdsec-firewall-bouncer-iptables - - # SSH brute-force + base Linux detection scenarios - sudo cscli collections install crowdsecurity/sshd crowdsecurity/linux 2>/dev/null || true - - run_cmd systemctl enable crowdsec - run_cmd systemctl restart crowdsec - run_cmd systemctl enable crowdsec-firewall-bouncer - run_cmd systemctl restart crowdsec-firewall-bouncer - echo "✓ CrowdSec installed and configured" - echo " - Detects SSH brute-force via /var/log/auth.log" - echo " - View active bans: sudo cscli decisions list" - fi - else - echo "Skipping CrowdSec installation." - fi -fi - -INSTALL_CROWDSEC="${INSTALL_CROWDSEC:-n}" - -fi # End SKIP_SSH_CONFIG - -# Docker Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "DOCKER INSTALLATION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -if is_docker_installed; then - echo "Docker is already installed: $(docker --version 2>/dev/null)" - prompt_yn "Reinstall Docker? (y/n):" "n" INSTALL_DOCKER -else - prompt_yn "Install Docker? (y/n):" "y" INSTALL_DOCKER -fi - -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo "" - echo "Installing Docker prerequisites..." - echo " - ca-certificates: SSL/TLS certificates for secure connections" - echo " - gnupg: GNU Privacy Guard for package verification" - echo " - lsb-release: Provides Ubuntu version information" - echo "" - - run_cmd apt install -y \ - ca-certificates \ - gnupg \ - lsb-release || echo "Warning: Some prerequisites failed to install, continuing..." - - echo "" - echo "Installing Docker..." - echo " - Docker Engine: Container runtime platform" - echo " - Docker Compose: Multi-container application orchestration" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would remove old Docker packages" - echo "[DRY-RUN] Would add Docker GPG key and repository" - echo "[DRY-RUN] Would install docker-ce, docker-ce-cli, containerd.io, plugins" - echo "[DRY-RUN] Would start and enable Docker service" - echo "[DRY-RUN] Would add $SUDO_USER to docker group" - else - # Remove old Docker packages if they exist - apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true - - # Add Docker's official GPG key - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc - chmod a+r /etc/apt/keyrings/docker.asc - - # Add Docker repository - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ - tee /etc/apt/sources.list.d/docker.list > /dev/null - - # Update package list with Docker repo - apt update - - # Install Docker Engine, CLI, containerd, and Docker Compose plugin - apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || echo "Warning: Docker installation failed, continuing..." - - # Start and enable Docker service - systemctl start docker || echo "Warning: Failed to start Docker" - systemctl enable docker || echo "Warning: Failed to enable Docker" - - # Add current user to docker group (if not root) - if [ -n "$SUDO_USER" ]; then - usermod -aG docker "$SUDO_USER" - echo "User $SUDO_USER added to docker group" - fi - - # Verify Docker installation - echo "" - echo "Verifying Docker installation..." - docker --version || echo "Warning: Docker verification failed" - docker compose version || echo "Warning: Docker Compose verification failed" - fi -else - echo "Skipping Docker installation." -fi - -# Samba File Sharing (Optional) -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SAMBA FILE SHARING (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Samba allows you to share folders over the network to Windows, Mac, and Linux." -echo "The script will share your primary drive at ~/drives/primary" -echo "" - -if is_samba_installed; then - echo "Samba is already installed and running." - if grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then - echo " Share 'Primary' is configured at: $ACTUAL_HOME/drives/primary" - fi - echo "" - prompt_yn "Reconfigure Samba? (y/n):" "n" INSTALL_SAMBA -else - prompt_yn "Install and configure Samba file sharing? (y/n):" "n" INSTALL_SAMBA -fi - -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo "" - echo "Installing Samba file sharing..." - echo " - Samba: SMB/CIFS file server for network file sharing" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install samba" - echo "[DRY-RUN] Would configure [Primary] share at $ACTUAL_HOME/drives/primary" - echo "[DRY-RUN] Would prompt for Samba password" - echo "[DRY-RUN] Would enable and start smbd/nmbd services" - else - run_cmd apt install -y samba || echo "Warning: Samba installation failed, continuing..." - - # Configure Samba share for primary drive - if command -v smbd &> /dev/null; then - echo "" - echo "Configuring Samba share for primary drive..." - - # Backup existing config - cp /etc/samba/smb.conf /etc/samba/smb.conf.backup-$(date +%Y%m%d-%H%M%S) - - # Add Primary share configuration - if ! grep -q "\[Primary\]" /etc/samba/smb.conf; then - cat >> /etc/samba/smb.conf << SAMBA_CONFIG - -# Primary drive share - added by post-install script -[Primary] - comment = Primary Drive - path = $ACTUAL_HOME/drives/primary - browseable = yes - read only = no - writable = yes - valid users = $ACTUAL_USER - create mask = 0775 - directory mask = 0775 -SAMBA_CONFIG - echo "✓ Added [Primary] share to Samba configuration" - else - echo "Samba [Primary] share already configured, skipping..." - fi - - # Add Samba user (skip in unattended mode - user must set password manually) - if [ "$UNATTENDED" != true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "SAMBA PASSWORD SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Set a password for Samba file sharing access." - echo "Tip: Using the same password as your system login is convenient." - echo "" - smbpasswd -a "$ACTUAL_USER" - else - echo "Skipping Samba password setup (unattended mode)" - echo "Set password later with: sudo smbpasswd -a $ACTUAL_USER" - fi - - # Enable and restart Samba services - systemctl enable smbd nmbd || echo "Warning: Failed to enable Samba services" - systemctl restart smbd nmbd || echo "Warning: Failed to restart Samba services" - - echo "" - echo "✓ Samba configured successfully" - echo " Share name: Primary" - echo " Path: $ACTUAL_HOME/drives/primary" - echo " Access: \\\\$(hostname)\\Primary (Windows) or smb://$(hostname)/Primary (Mac/Linux)" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "ADDING MORE SAMBA SHARES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To add another share, edit the Samba config file:" - echo "" - echo " sudo nano /etc/samba/smb.conf" - echo "" - echo "Add a new section at the end:" - echo "" - echo " [ShareName]" - echo " comment = Description of share" - echo " path = /path/to/folder" - echo " browseable = yes" - echo " read only = no" - echo " writable = yes" - echo " valid users = $ACTUAL_USER" - echo " create mask = 0775" - echo " directory mask = 0775" - echo "" - echo "Save (Ctrl+O, Enter) and exit (Ctrl+X), then restart Samba:" - echo "" - echo " sudo systemctl restart smbd nmbd" - echo "" - echo "Verify the share is active:" - echo "" - echo " testparm -s" - echo "" - else - echo "✗ Samba installation failed, skipping configuration" - fi - fi -else - echo "Skipping Samba installation." -fi - -# On rerun, skip VPN configuration if already set up -SKIP_VPN_CONFIG=false -if is_netbird_installed || is_wireguard_installed || is_tailscale_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "VPN SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "VPN services already configured:" - is_netbird_installed && echo " ✓ NetBird" - is_wireguard_installed && echo " ✓ WireGuard" - is_tailscale_installed && echo " ✓ Tailscale" - echo "" - prompt_yn "Reconfigure VPN services? (y/n):" "n" RECONFIG_VPN - if [ "$RECONFIG_VPN" != "y" ] && [ "$RECONFIG_VPN" != "Y" ]; then - SKIP_VPN_CONFIG=true - fi -fi - -if [ "$SKIP_VPN_CONFIG" != true ]; then - -# NetBird Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "NETBIRD MESH VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "NetBird is a secure mesh VPN for connecting devices across networks." -echo "" - -if is_netbird_installed; then - echo "NetBird is already installed." - netbird status 2>/dev/null || true - echo "" - prompt_yn "Reinstall NetBird? (y/n):" "n" INSTALL_NETBIRD -else - prompt_yn "Install NetBird? (y/n):" "n" INSTALL_NETBIRD -fi - -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo "" - echo "Installing NetBird..." - echo " - NetBird: Secure mesh VPN for connecting devices" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download and run NetBird install script" - echo "[DRY-RUN] Would ensure openssh-server is installed (required for NetBird SSH)" - echo "[DRY-RUN] Would configure netbird systemd service with --allow-server-ssh" - else - # NetBird v0.60.0+ requires openssh-server for SSH access. - # It injects /etc/ssh/sshd_config.d/99-netbird.conf and listens on port 22022. - echo "Ensuring openssh-server is installed (required for NetBird SSH)..." - apt install -y openssh-server 2>/dev/null || echo "Warning: openssh-server install failed, continuing..." - systemctl enable ssh 2>/dev/null || true - systemctl start ssh 2>/dev/null || true - - curl -fsSL https://pkgs.netbird.io/install.sh | sh || echo "Warning: NetBird installation failed, continuing..." - - # Persist --allow-server-ssh so this machine accepts NetBird SSH connections - # without requiring interactive re-authentication on every connection. - echo "Configuring NetBird to allow SSH server (persistent across reboots)..." - mkdir -p /etc/systemd/system/netbird.service.d - cat > /etc/systemd/system/netbird.service.d/ssh-server.conf << 'NETBIRD_OVERRIDE' -[Service] -ExecStart= -ExecStart=/usr/bin/netbird service run --allow-server-ssh -NETBIRD_OVERRIDE - systemctl daemon-reload 2>/dev/null || true - echo " ✓ NetBird will start with --allow-server-ssh on every boot" - - echo "" - echo "NetBird installed. Setup instructions:" - echo " 1. Create account at https://app.netbird.io (or self-host)" - echo " 2. Run 'netbird up' and authenticate via browser" - echo "" - echo "For NetBird SSH functionality (v0.60.0+ method):" - echo " • openssh-server is installed and --allow-server-ssh is persisted" - echo " • Enable SSH per-peer in the NetBird dashboard (Peers > [peer] > SSH)" - echo " • NetBird injects /etc/ssh/sshd_config.d/99-netbird.conf automatically" - echo " • Connect from another NetBird peer: ssh user@" - echo " • Get peer IPs with: netbird status" - echo " • SSH will work without re-authenticating each connection" - echo "" - fi -else - echo "Skipping NetBird installation." -fi - -# WireGuard Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "WIREGUARD VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "WireGuard is a fast, modern VPN protocol." -echo " • Lightweight and high-performance" -echo " • Simple configuration via config files" -echo " • Built into Linux kernel" -echo "" - -if is_wireguard_installed; then - echo "WireGuard is already installed." - echo "" - prompt_yn "Reinstall WireGuard? (y/n):" "n" INSTALL_WIREGUARD -else - prompt_yn "Install WireGuard? (y/n):" "n" INSTALL_WIREGUARD -fi - -if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then - echo "" - echo "Installing WireGuard..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install wireguard wireguard-tools" - else - apt install -y wireguard wireguard-tools || echo "Warning: WireGuard installation failed, continuing..." - - echo "" - echo "WireGuard installed. Setup instructions:" - echo "" - echo "Generate keys:" - echo " wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey" - echo "" - echo "Create config at /etc/wireguard/wg0.conf:" - echo " [Interface]" - echo " PrivateKey = " - echo " Address = 10.0.0.1/24" - echo " ListenPort = 51820" - echo "" - echo " [Peer]" - echo " PublicKey = " - echo " AllowedIPs = 10.0.0.2/32" - echo " Endpoint = peer.example.com:51820" - echo "" - echo "Start WireGuard:" - echo " sudo wg-quick up wg0" - echo " sudo systemctl enable wg-quick@wg0 # Start on boot" - echo "" - fi -else - echo "Skipping WireGuard installation." -fi - -# Tailscale Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "TAILSCALE VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Tailscale is a zero-config mesh VPN built on WireGuard." -echo " • Easy setup - just sign in" -echo " • Built on WireGuard for performance" -echo " • Automatic NAT traversal" -echo " • Built-in SSH (Tailscale SSH)" -echo "" - -if is_tailscale_installed; then - echo "Tailscale is already installed." - tailscale status 2>/dev/null || true - echo "" - prompt_yn "Reinstall Tailscale? (y/n):" "n" INSTALL_TAILSCALE -else - prompt_yn "Install Tailscale? (y/n):" "n" INSTALL_TAILSCALE -fi - -if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then - echo "" - echo "Installing Tailscale..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would add Tailscale apt repository" - echo "[DRY-RUN] Would install tailscale" - else - # Add Tailscale's package signing key and repository - curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/resolute.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null - curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/resolute.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list - - apt update - apt install -y tailscale || echo "Warning: Tailscale installation failed, continuing..." - - echo "" - echo "Tailscale installed. Setup instructions:" - echo "" - echo "Connect to Tailscale network:" - echo " sudo tailscale up" - echo "" - echo "This opens a browser to authenticate. After that:" - echo " tailscale status # View connected devices" - echo " tailscale ip # Show your Tailscale IP" - echo "" - echo "Tailscale SSH (optional - enable in admin console):" - echo " • Enable 'SSH' in Tailscale admin console for this machine" - echo " • Connect with: ssh user@device-name (uses Tailscale identity)" - echo " • No SSH keys needed - Tailscale handles authentication" - echo "" - fi -else - echo "Skipping Tailscale installation." -fi - -fi # End SKIP_VPN_CONFIG - -# On rerun, skip remote desktop configuration if already set up -SKIP_RD_CONFIG=false -if is_rustdesk_installed || is_teamviewer_installed || is_meshcentral_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "REMOTE DESKTOP SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Remote desktop services already configured:" - is_rustdesk_installed && echo " ✓ RustDesk" - is_teamviewer_installed && echo " ✓ TeamViewer" - is_meshcentral_installed && echo " ✓ MeshCentral Agent" - echo "" - prompt_yn "Reconfigure remote desktop services? (y/n):" "n" RECONFIG_RD - if [ "$RECONFIG_RD" != "y" ] && [ "$RECONFIG_RD" != "Y" ]; then - SKIP_RD_CONFIG=true - fi -fi - -if [ "$SKIP_RD_CONFIG" != true ]; then - -# RustDesk Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "RUSTDESK REMOTE DESKTOP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "RustDesk is an open-source remote desktop software." -echo "" - -if is_rustdesk_installed; then - echo "RustDesk is already installed." - echo "" - prompt_yn "Reinstall RustDesk? (y/n):" "n" INSTALL_RUSTDESK -else - prompt_yn "Install RustDesk? (y/n):" "n" INSTALL_RUSTDESK -fi - -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo "" - echo "Installing RustDesk..." - echo " - RustDesk: Open-source remote desktop software" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download latest RustDesk from GitHub" - echo "[DRY-RUN] Would install rustdesk .deb package" - else - # Download latest RustDesk .deb package - RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")') - RUSTDESK_URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/rustdesk-${RUSTDESK_VERSION}-x86_64.deb" - - wget -O /tmp/rustdesk.deb "$RUSTDESK_URL" || echo "Warning: RustDesk download failed, continuing..." - - if [ -f /tmp/rustdesk.deb ]; then - apt install -y /tmp/rustdesk.deb || echo "Warning: RustDesk installation failed, continuing..." - rm /tmp/rustdesk.deb - echo "✓ RustDesk installed" - fi - fi -else - echo "Skipping RustDesk installation." -fi - -# TeamViewer Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "TEAMVIEWER REMOTE DESKTOP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "TeamViewer is commercial remote desktop software with a free tier." -echo " • Cross-platform (Windows, Mac, Linux, mobile)" -echo " • Easy to use - no port forwarding needed" -echo " • Requires TeamViewer account for unattended access" -echo "" - -if is_teamviewer_installed; then - echo "TeamViewer is already installed." - echo "" - prompt_yn "Reinstall TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER -else - prompt_yn "Install TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER -fi - -if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then - echo "" - echo "Installing TeamViewer..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download TeamViewer .deb package" - echo "[DRY-RUN] Would install teamviewer" - else - # Download TeamViewer .deb package - wget -O /tmp/teamviewer.deb "https://download.teamviewer.com/download/linux/teamviewer_amd64.deb" || echo "Warning: TeamViewer download failed, continuing..." - - if [ -f /tmp/teamviewer.deb ]; then - apt install -y /tmp/teamviewer.deb || echo "Warning: TeamViewer installation failed, continuing..." - rm /tmp/teamviewer.deb - - echo "" - echo "TeamViewer installed. Setup instructions:" - echo "" - echo "Start TeamViewer:" - echo " teamviewer" - echo "" - echo "For unattended access:" - echo " 1. Open TeamViewer" - echo " 2. Go to Extras → Options → Security" - echo " 3. Set a personal password for unattended access" - echo " 4. Note your TeamViewer ID (shown in main window)" - echo "" - echo "✓ TeamViewer installed" - fi - fi -else - echo "Skipping TeamViewer installation." -fi - -# MeshCentral Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "MESHCENTRAL AGENT (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "MeshCentral is an open-source remote management solution." -echo " • Self-hosted or use public servers" -echo " • Web-based remote desktop (no client software needed)" -echo " • Terminal, file transfer, and remote desktop" -echo " • Requires a MeshCentral server to connect to" -echo "" - -if is_meshcentral_installed; then - echo "MeshCentral Agent is already installed." - echo "" - prompt_yn "Reinstall MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL -else - prompt_yn "Install MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL -fi - -if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then - echo "" - echo "MeshCentral requires a server URL to connect to." - echo "" - echo "If you have a MeshCentral server, the agent install is typically done by:" - echo " 1. Log into your MeshCentral web interface" - echo " 2. Go to 'My Devices' → 'Add Agent'" - echo " 3. Download and run the Linux agent installer" - echo "" - echo "Example (replace with your server's URL):" - echo " wget -O meshagent https://your-meshcentral-server/meshagents?id=XXXXX" - echo " chmod +x meshagent" - echo " sudo ./meshagent -install" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would prompt for MeshCentral server URL" - echo "[DRY-RUN] Would download and install meshagent" - else - prompt_text "Enter MeshCentral agent URL (or leave blank to skip):" "" MESHCENTRAL_URL - - if [ -n "$MESHCENTRAL_URL" ]; then - wget -O /tmp/meshagent "$MESHCENTRAL_URL" || echo "Warning: MeshCentral agent download failed" - - if [ -f /tmp/meshagent ]; then - chmod +x /tmp/meshagent - /tmp/meshagent -install || echo "Warning: MeshCentral agent installation failed" - rm /tmp/meshagent - echo "" - echo "✓ MeshCentral Agent installed" - echo " Check your MeshCentral server - this device should appear shortly." - fi - else - echo "" - echo "No URL provided. Skipping MeshCentral agent installation." - echo "You can install later by downloading the agent from your MeshCentral server." - fi - fi -else - echo "Skipping MeshCentral installation." -fi - -fi # End SKIP_RD_CONFIG - -# ============================================================================ -# SELF-HOSTED DOCKER APPLICATIONS (Optional) -# ============================================================================ -# These applications run in Docker containers using docker-compose -# Each app is installed to ~/docker/{appname}/ - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SELF-HOSTED DOCKER APPLICATIONS (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Install self-hosted applications using Docker Compose." -echo "Each app will be installed to ~/docker/{appname}/" -echo "" -echo "Note: Docker must be installed for these applications." -echo "" - -if ! is_docker_installed && [ "$INSTALL_DOCKER" != "y" ] && [ "$INSTALL_DOCKER" != "Y" ]; then - echo "Docker is not installed. Skipping self-hosted applications." - echo "Install Docker first, then rerun this script." -else - # Create docker apps directory - DOCKER_DIR="$ACTUAL_HOME/docker" - if [ "$DRY_RUN" != true ]; then - mkdir -p "$DOCKER_DIR" - chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - fi - - # ============================================================================ - # DETECT DRIVES (ONCE FOR ALL SERVICES) - # ============================================================================ - detect_drives - - # ============================================================================ - # SERVICE SELECTION MENU - # ============================================================================ - - # Use whiptail for service selection if available - if command -v whiptail &> /dev/null; then - # Detect existing services - echo "Detecting existing services..." - declare -A EXISTING_SERVICES - [ -d "$DOCKER_DIR/immich" ] && EXISTING_SERVICES[IMMICH]="ON" - [ -d "$DOCKER_DIR/audiobookshelf" ] && EXISTING_SERVICES[AUDIOBOOKSHELF]="ON" - [ -d "$DOCKER_DIR/emby" ] && EXISTING_SERVICES[EMBY]="ON" - [ -d "$DOCKER_DIR/arm" ] && EXISTING_SERVICES[ARM]="ON" - [ -d "$DOCKER_DIR/filebrowser" ] && EXISTING_SERVICES[FILEBROWSER]="ON" - [ -d "$DOCKER_DIR/magicmirror" ] && EXISTING_SERVICES[MAGICMIRROR]="ON" - [ -d "$DOCKER_DIR/actualbudget" ] && EXISTING_SERVICES[ACTUALBUDGET]="ON" - [ -d "$DOCKER_DIR/authelia" ] && EXISTING_SERVICES[AUTHELIA]="ON" - [ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON" - [ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON" - [ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON" - [ -d "$DOCKER_DIR/homeassistant" ] && EXISTING_SERVICES[HOMEASSISTANT]="ON" - [ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON" - [ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON" - [ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON" - [ -d "$DOCKER_DIR/ntfy" ] && EXISTING_SERVICES[NTFY]="ON" - [ -d "$DOCKER_DIR/uptime-kuma" ] && EXISTING_SERVICES[UPTIMEKUMA]="ON" - [ -d "$DOCKER_DIR/wg-easy" ] && EXISTING_SERVICES[WGEASY]="ON" - [ -d "$DOCKER_DIR/traccar" ] && EXISTING_SERVICES[TRACCAR]="ON" - [ -d "$DOCKER_DIR/portainer" ] && EXISTING_SERVICES[PORTAINER]="ON" - [ -d "$DOCKER_DIR/meshcentral" ] && EXISTING_SERVICES[MESHCENTRAL]="ON" - [ -d "$DOCKER_DIR/fmd" ] && EXISTING_SERVICES[FINDMYDEVICE]="ON" - [ -d "$DOCKER_DIR/frigate-notify" ] && EXISTING_SERVICES[FRIGATE_NOTIFY]="ON" - [ -d "$DOCKER_DIR/watchtower" ] && EXISTING_SERVICES[WATCHTOWER]="ON" - command -v cscli &> /dev/null && EXISTING_SERVICES[CROWDSEC]="ON" - - # Ask user what action to perform - ACTION=$(whiptail --title "Service Management" --menu \ - "Choose an action:" 15 60 3 \ - "1" "Install new services" \ - "2" "Uninstall existing services" \ - "3" "Cancel and skip" \ - 3>&1 1>&2 2>&3) - - case "$ACTION" in - 1) - # INSTALL MODE - MENU_MODE="INSTALL" - # Build checklist - mark existing services as ON - SELECTED_SERVICES=$(whiptail --title "Select Docker Services to Install" \ - --checklist "Use SPACE to select, ENTER to confirm. [*] = already installed" 25 78 17 \ - "IMMICH" "Photo & video backup (like Google Photos)" ${EXISTING_SERVICES[IMMICH]:-OFF} \ - "AUDIOBOOKSHELF" "Audiobook & podcast server" ${EXISTING_SERVICES[AUDIOBOOKSHELF]:-OFF} \ - "EMBY" "Media server for movies, TV, music" ${EXISTING_SERVICES[EMBY]:-OFF} \ - "ARM" "Automatic Ripping Machine for DVDs/Blu-rays" ${EXISTING_SERVICES[ARM]:-OFF} \ - "FILEBROWSER" "Web-based file manager" ${EXISTING_SERVICES[FILEBROWSER]:-OFF} \ - "MAGICMIRROR" "Smart mirror / dashboard display" ${EXISTING_SERVICES[MAGICMIRROR]:-OFF} \ - "ACTUALBUDGET" "Personal finance management with bank sync" ${EXISTING_SERVICES[ACTUALBUDGET]:-OFF} \ - "AUTHELIA" "SSO + 2FA auth portal for Caddy" ${EXISTING_SERVICES[AUTHELIA]:-OFF} \ - "CADDY" "Reverse proxy with automatic HTTPS" ${EXISTING_SERVICES[CADDY]:-OFF} \ - "CROWDSEC" "Intrusion prevention (CrowdSec: bans + geo + reputation)" ${EXISTING_SERVICES[CROWDSEC]:-OFF} \ - "LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \ - "MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \ - "HOMEASSISTANT" "Home automation hub (Home Assistant)" ${EXISTING_SERVICES[HOMEASSISTANT]:-OFF} \ - "MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \ - "JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \ - "FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \ - "NTFY" "Push notifications server" ${EXISTING_SERVICES[NTFY]:-OFF} \ - "UPTIMEKUMA" "Service monitoring dashboard" ${EXISTING_SERVICES[UPTIMEKUMA]:-OFF} \ - "WGEASY" "WireGuard VPN with web UI" ${EXISTING_SERVICES[WGEASY]:-OFF} \ - "TRACCAR" "GPS tracking server" ${EXISTING_SERVICES[TRACCAR]:-OFF} \ - "PORTAINER" "Docker management web UI" ${EXISTING_SERVICES[PORTAINER]:-OFF} \ - "MESHCENTRAL" "Remote management server" ${EXISTING_SERVICES[MESHCENTRAL]:-OFF} \ - "FINDMYDEVICE" "Device tracking (like Find My)" ${EXISTING_SERVICES[FINDMYDEVICE]:-OFF} \ - "FRIGATE_NOTIFY" "Push notifications for Frigate" ${EXISTING_SERVICES[FRIGATE_NOTIFY]:-OFF} \ - "WATCHTOWER" "Automatic container updates" ${EXISTING_SERVICES[WATCHTOWER]:-OFF} \ - 3>&1 1>&2 2>&3) - ;; - 2) - # UNINSTALL MODE - MENU_MODE="UNINSTALL" - # Only show services that exist - UNINSTALL_OPTIONS="" - [ -n "${EXISTING_SERVICES[IMMICH]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS IMMICH \"Photo & video backup\" ON" - [ -n "${EXISTING_SERVICES[AUDIOBOOKSHELF]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUDIOBOOKSHELF \"Audiobook server\" ON" - [ -n "${EXISTING_SERVICES[EMBY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS EMBY \"Media server\" ON" - [ -n "${EXISTING_SERVICES[ARM]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ARM \"Automatic Ripping Machine\" ON" - [ -n "${EXISTING_SERVICES[FILEBROWSER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FILEBROWSER \"Web file manager\" ON" - [ -n "${EXISTING_SERVICES[MAGICMIRROR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MAGICMIRROR \"Smart mirror\" ON" - [ -n "${EXISTING_SERVICES[ACTUALBUDGET]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ACTUALBUDGET \"Personal finance\" ON" - [ -n "${EXISTING_SERVICES[AUTHELIA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUTHELIA \"SSO + 2FA auth portal\" ON" - [ -n "${EXISTING_SERVICES[CADDY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CADDY \"Reverse proxy\" ON" - [ -n "${EXISTING_SERVICES[CROWDSEC]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CROWDSEC \"Intrusion prevention\" ON" - [ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON" - [ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON" - [ -n "${EXISTING_SERVICES[HOMEASSISTANT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS HOMEASSISTANT \"Home automation\" ON" - [ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON" - [ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON" - [ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON" - [ -n "${EXISTING_SERVICES[NTFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS NTFY \"Push notifications\" ON" - [ -n "${EXISTING_SERVICES[UPTIMEKUMA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS UPTIMEKUMA \"Service monitoring\" ON" - [ -n "${EXISTING_SERVICES[WGEASY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WGEASY \"WireGuard VPN\" ON" - [ -n "${EXISTING_SERVICES[TRACCAR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS TRACCAR \"GPS tracking\" ON" - [ -n "${EXISTING_SERVICES[PORTAINER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS PORTAINER \"Docker management\" ON" - [ -n "${EXISTING_SERVICES[MESHCENTRAL]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MESHCENTRAL \"Remote management\" ON" - [ -n "${EXISTING_SERVICES[FINDMYDEVICE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FINDMYDEVICE \"Device tracking\" ON" - [ -n "${EXISTING_SERVICES[FRIGATE_NOTIFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE_NOTIFY \"Frigate notifications\" ON" - [ -n "${EXISTING_SERVICES[WATCHTOWER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WATCHTOWER \"Container updates\" ON" - - if [ -z "$UNINSTALL_OPTIONS" ]; then - whiptail --title "No Services Found" --msgbox "No services detected to uninstall." 8 50 - SELECTED_SERVICES="" - else - SELECTED_SERVICES=$(eval "whiptail --title 'Select Services to Uninstall' \ - --checklist 'WARNING: Data will be backed up but services will be removed' 25 78 17 \ - $UNINSTALL_OPTIONS \ - 3>&1 1>&2 2>&3") - fi - ;; - 3|*) - # Cancel - SELECTED_SERVICES="" - MENU_MODE="CANCEL" - ;; - esac - - # Check if user cancelled - if [ $? -ne 0 ]; then - echo "Service selection cancelled. Skipping Docker applications." - SELECTED_SERVICES="" - fi - - # Parse selections (whiptail returns quoted strings) - # Only set to "n" if not already set (preserve any earlier choices) - : ${INSTALL_IMMICH:="n"} - : ${INSTALL_AUDIOBOOKSHELF:="n"} - : ${INSTALL_EMBY:="n"} - : ${INSTALL_ARM:="n"} - : ${INSTALL_FILEBROWSER:="n"} - : ${INSTALL_MAGICMIRROR:="n"} - : ${INSTALL_ACTUALBUDGET:="n"} - : ${INSTALL_AUTHELIA:="n"} - : ${INSTALL_CADDY:="n"} - : ${INSTALL_CROWDSEC:="n"} - : ${INSTALL_LMS:="n"} - : ${INSTALL_MEALIE:="n"} - : ${INSTALL_HOMEASSISTANT:="n"} - : ${INSTALL_MINECRAFT:="n"} - : ${INSTALL_JELLYFIN:="n"} - : ${INSTALL_FRIGATE:="n"} - : ${INSTALL_NTFY:="n"} - : ${INSTALL_UPTIMEKUMA:="n"} - : ${INSTALL_WGEASY:="n"} - : ${INSTALL_TRACCAR:="n"} - : ${INSTALL_PORTAINER:="n"} - : ${INSTALL_MESHCENTRAL_SERVER:="n"} - : ${INSTALL_FMD:="n"} - : ${INSTALL_FRIGATE_NOTIFY:="n"} - : ${INSTALL_WATCHTOWER:="n"} - - # Set installation flags based on selections - if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then INSTALL_IMMICH="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then INSTALL_AUDIOBOOKSHELF="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then INSTALL_EMBY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "ARM"; then INSTALL_ARM="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then INSTALL_FILEBROWSER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then INSTALL_MAGICMIRROR="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then INSTALL_ACTUALBUDGET="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUTHELIA"; then INSTALL_AUTHELIA="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then INSTALL_CADDY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then INSTALL_CROWDSEC="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then INSTALL_HOMEASSISTANT="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then INSTALL_NTFY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then INSTALL_UPTIMEKUMA="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then INSTALL_WGEASY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then INSTALL_TRACCAR="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then INSTALL_PORTAINER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then INSTALL_MESHCENTRAL_SERVER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then INSTALL_FMD="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then INSTALL_FRIGATE_NOTIFY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then INSTALL_WATCHTOWER="y"; fi - - if [ "$MENU_MODE" = "UNINSTALL" ]; then - echo "" - echo "Services selected for uninstallation:" - echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /' - echo "" - - # Process uninstalls - if [ -n "$SELECTED_SERVICES" ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " UNINSTALLING SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Define uninstall function - uninstall_service() { - local SERVICE_NAME=$1 - local SERVICE_DIR=$2 - local CONTAINER_NAME=$3 - - echo "Uninstalling $SERVICE_NAME..." - - if [ -d "$SERVICE_DIR" ]; then - cd "$SERVICE_DIR" 2>/dev/null || return - - # Stop and remove containers - if [ -f "docker-compose.yml" ]; then - echo " Stopping containers..." - docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true - fi - - # Backup data - BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$SERVICE_NAME" - mkdir -p "$BACKUP_DIR" - echo " Backing up to $BACKUP_DIR..." - cp -r "$SERVICE_DIR" "$BACKUP_DIR/" 2>/dev/null || true - - # Remove service directory - echo " Removing $SERVICE_DIR..." - rm -rf "$SERVICE_DIR" - - echo " ✓ $SERVICE_NAME uninstalled (backup: $BACKUP_DIR)" - else - echo " ⚠ $SERVICE_NAME directory not found, skipping" - fi - echo "" - } - - # Uninstall selected services - if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then uninstall_service "Immich" "$DOCKER_DIR/immich" "immich"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then uninstall_service "AudioBookshelf" "$DOCKER_DIR/audiobookshelf" "audiobookshelf"; fi - if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then uninstall_service "Emby" "$DOCKER_DIR/emby" "emby"; fi - if echo "$SELECTED_SERVICES" | grep -q "ARM"; then uninstall_service "ARM" "$DOCKER_DIR/arm" "arm"; fi - if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then uninstall_service "FileBrowser" "$DOCKER_DIR/filebrowser" "filebrowser"; fi - if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then uninstall_service "MagicMirror" "$DOCKER_DIR/magicmirror" "magicmirror"; fi - if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then uninstall_service "ActualBudget" "$DOCKER_DIR/actualbudget" "actualbudget"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUTHELIA"; then uninstall_service "Authelia" "$DOCKER_DIR/authelia" "authelia"; fi - if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi - if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi - if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi - if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then uninstall_service "Home Assistant" "$DOCKER_DIR/homeassistant" "homeassistant"; fi - if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi - if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi - if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then uninstall_service "ntfy" "$DOCKER_DIR/ntfy" "ntfy"; fi - if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then uninstall_service "Uptime Kuma" "$DOCKER_DIR/uptime-kuma" "uptime-kuma"; fi - if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then uninstall_service "wg-easy" "$DOCKER_DIR/wg-easy" "wg-easy"; fi - if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then uninstall_service "Traccar" "$DOCKER_DIR/traccar" "traccar"; fi - if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then uninstall_service "Portainer" "$DOCKER_DIR/portainer" "portainer"; fi - if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then uninstall_service "MeshCentral" "$DOCKER_DIR/meshcentral" "meshcentral"; fi - if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then uninstall_service "FindMyDevice" "$DOCKER_DIR/fmd" "fmd"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then uninstall_service "Frigate-Notify" "$DOCKER_DIR/frigate-notify" "frigate-notify"; fi - if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then uninstall_service "Watchtower" "$DOCKER_DIR/watchtower" "watchtower"; fi - - # Special handling for CrowdSec (system packages) - if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then - echo "Uninstalling CrowdSec..." - echo " Stopping CrowdSec services..." - systemctl stop crowdsec 2>/dev/null || true - systemctl stop crowdsec-firewall-bouncer 2>/dev/null || true - systemctl disable crowdsec 2>/dev/null || true - systemctl disable crowdsec-firewall-bouncer 2>/dev/null || true - echo " Removing CrowdSec packages..." - apt-get remove --purge -y crowdsec crowdsec-firewall-bouncer-iptables 2>/dev/null || true - echo " ✓ CrowdSec uninstalled" - echo "" - fi - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " Uninstallation complete!" - echo " Backups saved to: $DOCKER_DIR/backups/" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - fi - - # After uninstall, exit the Docker apps section - WHIPTAIL_USED=true - - elif [ "$MENU_MODE" = "INSTALL" ]; then - echo "" - echo "Selected services:" - echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /' - echo "" - - # Mark that whiptail was used - skip all individual prompts - WHIPTAIL_USED=true - else - # Cancel or other mode - WHIPTAIL_USED=false - fi - else - # Whiptail not available - will use individual prompts - WHIPTAIL_USED=false - MENU_MODE="INSTALL" - fi - - # Skip installation section if we just did uninstalls - if [ "$MENU_MODE" = "UNINSTALL" ]; then - echo "Skipping installation section (uninstall mode was selected)." - # Jump to end of Docker section by setting a flag - SKIP_DOCKER_INSTALLS=true - else - SKIP_DOCKER_INSTALLS=false - fi - - # ============================================================================ - # DOCKER SERVICE INSTALLATIONS - # ============================================================================ - - if [ "$SKIP_DOCKER_INSTALLS" != true ]; then - # ---- IMMICH ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_IMMICH" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ IMMICH - Self-hosted photo & video backup │" - echo "│ Like Google Photos but private. Mobile app auto-uploads. │" - echo "│ Port: 2283 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Immich? (y/n):" "n" INSTALL_IMMICH - fi - - if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then - IMMICH_DIR="$DOCKER_DIR/immich" - - # Check if already installed and ask what to do - check_service_exists "Immich" "$IMMICH_DIR" IMMICH_RECONFIGURE - - if [ "$IMMICH_RECONFIGURE" = "true" ]; then - echo "Installing Immich..." - - # ── Photo library setup ───────────────────────────────────── - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ PHOTO LIBRARY SETUP │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - - DEFAULT_PHOTOS_DIR="$PRIMARY_DRIVE_PATH/photos" - IMMICH_STRATEGY="" - EXTERNAL_LIBRARY="" - EXISTING_PHOTOS_SOURCE="" - - prompt_yn " Do you have existing photos to include? (y/n):" "n" HAS_EXISTING_PHOTOS - - if [ "$HAS_EXISTING_PHOTOS" = "y" ] || [ "$HAS_EXISTING_PHOTOS" = "Y" ]; then - echo "" - echo " How should Immich handle your existing photos?" - echo "" - echo " [1] Import everything into Immich (recommended)" - echo " Immich manages all photos in one unified library." - echo " Dates preserved via EXIF metadata. Organized by date." - echo " Your original folder names are NOT kept on disk" - echo " (use Immich albums to organize instead)." - echo "" - echo " [2] Keep existing photos in place (read-only external library)" - echo " Immich indexes your existing photos without moving them." - echo " New uploads go to a separate folder." - echo " Your folder structure stays intact." - echo "" - - if [ "$UNATTENDED" = true ]; then - IMMICH_STRATEGY="1" - echo " Strategy: [auto: 1]" - else - read -p " Choose [1/2]: " IMMICH_STRATEGY - IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}" - fi - - if [ "$IMMICH_STRATEGY" = "2" ]; then - # Strategy 2: external library - ask where existing photos are - echo "" - echo " Where are your existing photos?" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Existing photos path:" "$DEFAULT_PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$DEFAULT_PHOTOS_DIR" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" - - UPLOAD_LOCATION="$PRIMARY_DRIVE_PATH/immich-uploads" - EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE" - - echo "" - echo " Setup:" - echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)" - echo " New uploads: $UPLOAD_LOCATION" - else - # Strategy 1 with existing photos: ask for library path, will import into it - echo "" - echo " Where should Immich store your photo library?" - echo " (Existing photos will be imported here)" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR" - PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}" - PHOTOS_DIR="${PHOTOS_DIR%/}" - - echo "" - echo " Where are your existing photos now?" - echo " Default: $PHOTOS_DIR" - prompt_text " Existing photos path:" "$PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$PHOTOS_DIR" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" - - UPLOAD_LOCATION="$PHOTOS_DIR" - EXTERNAL_LIBRARY="" - - echo "" - echo " All photos (existing + new) will live in:" - echo " $PHOTOS_DIR" - echo " └── 2026/01/filename.jpg organized by date" - fi - else - # No existing photos: simplest path - just ask where to store - IMMICH_STRATEGY="1" - echo "" - echo " Where should Immich store your photo library?" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR" - PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}" - PHOTOS_DIR="${PHOTOS_DIR%/}" - - UPLOAD_LOCATION="$PHOTOS_DIR" - EXTERNAL_LIBRARY="" - - echo "" - echo " All photos will be stored in:" - echo " $PHOTOS_DIR" - echo " └── 2026/01/filename.jpg organized by date" - fi - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $IMMICH_DIR" - echo "[DRY-RUN] Would store uploads at $UPLOAD_LOCATION" - [ -n "$EXTERNAL_LIBRARY" ] && echo "[DRY-RUN] Would mount external library at $EXTERNAL_LIBRARY" - echo "[DRY-RUN] Would create docker-compose.yml and .env" - else - mkdir -p "$IMMICH_DIR" 2>/dev/null || true - mkdir -p "$UPLOAD_LOCATION" 2>/dev/null || true - [ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" 2>/dev/null || true - - # Create required subdirectories with .immich marker files - # Immich checks these on startup and fails if they're missing - for subdir in thumbs upload backups library profile encoded-video; do - mkdir -p "$UPLOAD_LOCATION/$subdir" 2>/dev/null || true - touch "$UPLOAD_LOCATION/$subdir/.immich" 2>/dev/null || true - done - cd "$IMMICH_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Create docker-compose.yml - if [ -n "$EXTERNAL_LIBRARY" ]; then - # Strategy 2: external library mount included - cat > docker-compose.yml << 'IMMICH_COMPOSE' -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - ${EXTERNAL_LIBRARY}:/usr/src/app/external:ro - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false - - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_DB: ${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -IMMICH_COMPOSE - else - # Strategy 1: unified library, no external mount - cat > docker-compose.yml << 'IMMICH_COMPOSE' -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false - - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_DB: ${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -IMMICH_COMPOSE - fi - - # Generate random password - DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) - - # Create .env file with strategy-appropriate comments - if [ "$IMMICH_STRATEGY" = "2" ]; then - cat > .env << IMMICH_ENV -# ============================================================ -# IMMICH CONFIGURATION — External Library Mode -# ============================================================ -# -# STORAGE TEMPLATE (configure in Immich web UI): -# Admin → Settings → Storage Template → Enable -# Template: {{y}}/{{MM}}/{{filename}} -# This organizes new uploads into: immich-uploads/2026/01/filename.jpg -# -# EXTERNAL LIBRARY SETUP: -# Admin → External Libraries → Create Library -# Import path: /usr/src/app/external -# Click "Scan" to index your existing photos. -# -# ============================================================ - -# New uploads from phone/web -UPLOAD_LOCATION=$UPLOAD_LOCATION - -# Existing photos (read-only, indexed by Immich) -# Mounted at /usr/src/app/external inside the container -EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY - -# Database location (keep on fast storage) -DB_DATA_LOCATION=./postgres - -IMMICH_VERSION=release - -DB_PASSWORD=$DB_PASS -DB_USERNAME=postgres -DB_DATABASE_NAME=immich - -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -IMMICH_ENV - else - cat > .env << IMMICH_ENV -# ============================================================ -# IMMICH CONFIGURATION — Unified Library -# ============================================================ -# -# All photos (imported + new uploads) are stored in one location. -# Storage template organizes files by date automatically. -# -# To import existing photos, run: -# $IMMICH_DIR/import-photos.sh -# -# The import script handles storage template configuration, -# CLI installation, and uploads with EXIF date preservation. -# -# ============================================================ - -# All photos stored here (uploads + imported) -UPLOAD_LOCATION=$UPLOAD_LOCATION - -# Database location (keep on fast storage) -DB_DATA_LOCATION=./postgres - -IMMICH_VERSION=release - -DB_PASSWORD=$DB_PASS -DB_USERNAME=postgres -DB_DATABASE_NAME=immich - -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -IMMICH_ENV - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" 2>/dev/null || true - [ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true - - # Create import helper script for Strategy 1 with existing photos - if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then - cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_HEAD' -#!/bin/bash -################################################################################ -# Immich Photo Import Script -# Generated by ubuntu-post-install -# -# This script imports your existing photo collection into Immich with proper -# EXIF date preservation. Photos are uploaded through the API, which triggers -# Immich's metadata extraction pipeline — dates, GPS, camera info are all -# read from the original files. -# -# What this script does: -# 1. Creates admin account (if first run) or logs in -# 2. Generates an API key automatically -# 3. Configures the storage template (date-based organization) -# 4. Installs the Immich CLI (if needed) -# 5. Uploads all photos with EXIF metadata preserved -# -# Usage: -# ./import-photos.sh # interactive (prompts for everything) -# ./import-photos.sh # skip account setup, use existing key -################################################################################ - -IMPORT_SCRIPT_HEAD - - cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_SCRIPT_VARS -IMMICH_URL="http://localhost:2283" -SOURCE_DIR="$EXISTING_PHOTOS_SOURCE" -IMMICH_DIR="$IMMICH_DIR" -IMPORT_SCRIPT_VARS - - cat >> "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_BODY' - -echo "" -echo "┌─────────────────────────────────────────────────────────────────┐" -echo "│ IMMICH PHOTO IMPORT │" -echo "└─────────────────────────────────────────────────────────────────┘" -echo "" - -# ── Preflight checks ──────────────────────────────────────────── -echo "Checking Immich server..." -if ! curl -s "$IMMICH_URL/api/server/ping" > /dev/null 2>&1; then - echo "" - echo " ✗ Immich is not running at $IMMICH_URL" - echo " Start it with: cd $IMMICH_DIR && docker compose up -d" - echo "" - exit 1 -fi -echo " ✓ Immich is running" - -# Check source directory -if [ ! -d "$SOURCE_DIR" ]; then - echo "" - echo " ✗ Source directory not found: $SOURCE_DIR" - echo " Update SOURCE_DIR in this script if your photos moved." - echo "" - exit 1 -fi -echo " ✓ Source directory: $SOURCE_DIR" - -echo -n " Scanning for photos/videos (this may take a while)..." -PHOTO_COUNT=$(find "$SOURCE_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" -o -iname "*.heif" -o -iname "*.webp" -o -iname "*.gif" -o -iname "*.tiff" -o -iname "*.bmp" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.webm" \) 2>/dev/null | wc -l) -echo " done" -echo " ✓ Found ~$PHOTO_COUNT photos/videos" - -# ── Get or create API key ─────────────────────────────────────── -API_KEY="${1:-}" - -if [ -z "$API_KEY" ]; then - echo "" - # Check if server needs initial setup - SERVER_CONFIG=$(curl -s "$IMMICH_URL/api/server/config" 2>/dev/null) - IS_INITIALIZED=$(echo "$SERVER_CONFIG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('isInitialized', True))" 2>/dev/null) - - if [ "$IS_INITIALIZED" = "False" ]; then - # ── First-time setup: create admin account ────────────── - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FIRST-TIME SETUP — Creating admin account │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - echo " Immich needs an admin account. Enter your details:" - echo "" - read -p " Admin email: " ADMIN_EMAIL - while [ -z "$ADMIN_EMAIL" ]; do - read -p " Admin email (required): " ADMIN_EMAIL - done - read -sp " Admin password: " ADMIN_PASS - echo "" - while [ ${#ADMIN_PASS} -lt 8 ]; do - echo " Password must be at least 8 characters." - read -sp " Admin password: " ADMIN_PASS - echo "" - done - read -p " Your name [Admin]: " ADMIN_NAME - ADMIN_NAME="${ADMIN_NAME:-Admin}" - - echo "" - echo " Creating admin account..." - SIGNUP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/auth/admin-sign-up" \ - -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"name\":\"$ADMIN_NAME\"}" 2>/dev/null) - SIGNUP_CODE=$(echo "$SIGNUP_RESPONSE" | tail -1) - SIGNUP_BODY=$(echo "$SIGNUP_RESPONSE" | sed '$d') - - if [ "$SIGNUP_CODE" = "201" ]; then - echo " ✓ Admin account created" - else - echo " ✗ Failed to create admin account (HTTP $SIGNUP_CODE)" - echo " Response: $SIGNUP_BODY" - echo "" - echo " Create your account manually at $IMMICH_URL" - echo " Then re-run this script with your API key:" - echo " $0 " - exit 1 - fi - else - # Server already initialized, need credentials to log in - echo " Immich is already set up. Log in to generate an API key." - echo "" - read -p " Admin email: " ADMIN_EMAIL - while [ -z "$ADMIN_EMAIL" ]; do - read -p " Admin email (required): " ADMIN_EMAIL - done - read -sp " Admin password: " ADMIN_PASS - echo "" - fi - - # ── Login to get bearer token ─────────────────────────────── - echo " Logging in..." - LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/auth/login" \ - -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" 2>/dev/null) - LOGIN_CODE=$(echo "$LOGIN_RESPONSE" | tail -1) - LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | sed '$d') - - if [ "$LOGIN_CODE" != "201" ]; then - echo " ✗ Login failed (HTTP $LOGIN_CODE)" - echo "" - echo " Check your email/password and try again, or pass an API key:" - echo " $0 " - exit 1 - fi - - ACCESS_TOKEN=$(echo "$LOGIN_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])" 2>/dev/null) - if [ -z "$ACCESS_TOKEN" ]; then - echo " ✗ Could not extract access token from login response" - exit 1 - fi - echo " ✓ Logged in" - - # ── Create API key ────────────────────────────────────────── - echo " Creating API key..." - APIKEY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - "$IMMICH_URL/api/api-keys" \ - -d '{"name":"import-photos-script"}' 2>/dev/null) - APIKEY_CODE=$(echo "$APIKEY_RESPONSE" | tail -1) - APIKEY_BODY=$(echo "$APIKEY_RESPONSE" | sed '$d') - - if [ "$APIKEY_CODE" = "201" ]; then - API_KEY=$(echo "$APIKEY_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['secret'])" 2>/dev/null) - if [ -n "$API_KEY" ]; then - echo " ✓ API key created" - else - echo " ✗ Could not extract API key from response" - echo " Create one manually: $IMMICH_URL → Account Settings → API Keys" - echo " Then re-run: $0 " - exit 1 - fi - else - echo " ✗ Failed to create API key (HTTP $APIKEY_CODE)" - echo " Create one manually: $IMMICH_URL → Account Settings → API Keys" - echo " Then re-run: $0 " - exit 1 - fi -else - # API key provided as argument — verify it - echo "" - echo " Verifying API key..." - VERIFY_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "x-api-key: $API_KEY" "$IMMICH_URL/api/users/me" 2>/dev/null) - if [ "$VERIFY_CODE" != "200" ]; then - echo " ✗ Invalid API key (HTTP $VERIFY_CODE)" - echo " Double-check the key and try again." - exit 1 - fi - echo " ✓ API key valid" -fi - -# ── Configure storage template via API ────────────────────────── -echo "" -echo " Configuring storage template ({{y}}/{{MM}}/{{filename}})..." -CURRENT_CONFIG=$(curl -s -H "x-api-key: $API_KEY" "$IMMICH_URL/api/system-config" 2>/dev/null) -if [ -n "$CURRENT_CONFIG" ] && command -v python3 &> /dev/null; then - UPDATED_CONFIG=$(echo "$CURRENT_CONFIG" | python3 -c " -import sys, json -config = json.load(sys.stdin) -config['storageTemplate']['enabled'] = True -config['storageTemplate']['template'] = '{{y}}/{{MM}}/{{filename}}' -json.dump(config, sys.stdout) -" 2>/dev/null) - if [ -n "$UPDATED_CONFIG" ]; then - RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ - -H "x-api-key: $API_KEY" \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/system-config" \ - -d "$UPDATED_CONFIG" 2>/dev/null) - if [ "$RESULT" = "200" ]; then - echo " ✓ Storage template configured" - else - echo " ⚠ Could not set storage template (HTTP $RESULT)" - echo " Set manually: Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - fi - else - echo " ⚠ Could not parse config. Set storage template manually:" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - fi -else - echo " ⚠ python3 not found. Set storage template manually:" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" -fi - -# ── Install immich-cli if needed ──────────────────────────────── -echo "" -IMMICH_CMD="" - -# Immich CLI requires Node.js >= 20 (File global class) -NODE_OK=false -if command -v node &> /dev/null; then - NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then - NODE_OK=true - fi -fi - -if [ "$NODE_OK" = false ]; then - echo " Immich CLI requires Node.js >= 20 (found: $(node -v 2>/dev/null || echo 'none'))." - read -p " Install Node.js 22 LTS now? (y/n): " INSTALL_NODE_YN - if [ "$INSTALL_NODE_YN" = "y" ] || [ "$INSTALL_NODE_YN" = "Y" ]; then - echo " Installing Node.js 22 LTS from NodeSource..." - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - 2>/dev/null - sudo apt-get install -y -qq nodejs 2>/dev/null - NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then - NODE_OK=true - echo " ✓ Node.js $(node -v) installed" - else - echo " ✗ Installation failed." - echo " Install Node.js 20+ manually: https://nodejs.org/" - echo " Then re-run: $0 $API_KEY" - exit 1 - fi - else - echo "" - echo " Install Node.js 20+ later and re-run this script:" - echo " curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -" - echo " sudo apt-get install -y nodejs" - echo " $0 $API_KEY" - exit 0 - fi -fi - -if command -v immich &> /dev/null; then - IMMICH_CMD="immich" - echo " ✓ Immich CLI found" -elif command -v npx &> /dev/null; then - echo " Immich CLI not installed. Will use npx (downloads temporarily)." - IMMICH_CMD="npx --yes @immich/cli" -elif command -v npm &> /dev/null; then - echo " Installing Immich CLI globally..." - if npm install -g @immich/cli 2>/dev/null; then - IMMICH_CMD="immich" - echo " ✓ Immich CLI installed" - else - echo " ✗ npm install failed. Trying npx..." - IMMICH_CMD="npx --yes @immich/cli" - fi -fi - -if [ -z "$IMMICH_CMD" ]; then - echo " ✗ Could not find npm or npx despite Node.js being installed." - echo " Install manually: npm install -g @immich/cli" - echo " Then re-run: $0 $API_KEY" - exit 1 -fi - -# ── Run the import ────────────────────────────────────────────── -echo "" -echo " Authenticating CLI with Immich..." -if ! $IMMICH_CMD login "$IMMICH_URL/api" "$API_KEY"; then - echo " ✗ CLI login failed" - exit 1 -fi - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " Starting import from: $SOURCE_DIR" -echo " Importing ~$PHOTO_COUNT files. This may take a while." -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -$IMMICH_CMD upload --recursive "$SOURCE_DIR" - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " Import complete!" -echo " View your photos at: $IMMICH_URL" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -IMPORT_SCRIPT_BODY - - chmod +x "$IMMICH_DIR/import-photos.sh" - chown "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR/import-photos.sh" - fi - - echo "" - echo "✓ Immich configured at $IMMICH_DIR" - if [ -n "$EXTERNAL_LIBRARY" ]; then - echo " Existing photos: $EXTERNAL_LIBRARY (read-only)" - echo " New uploads: $UPLOAD_LOCATION" - else - echo " Photo library: $UPLOAD_LOCATION" - fi - echo "" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Immich" "2283" "photos" - - # Ask to start container - prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH - if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then - echo " Starting Immich..." - docker compose up -d 2>/dev/null && echo " ✓ Immich started" || echo " ⚠ Failed to start" - fi - - # ── Post-setup instructions ───────────────────────────── - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ IMMICH SETUP STEPS │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - - if [ -n "$EXISTING_PHOTOS_SOURCE" ] && [ "$IMMICH_STRATEGY" != "2" ]; then - echo " Import your existing photos AFTER this script finishes:" - echo " $IMMICH_DIR/import-photos.sh" - echo "" - echo " The script handles everything automatically:" - echo " creates your admin account, generates an API key," - echo " configures the storage template, installs the CLI," - echo " and imports all photos from $EXISTING_PHOTOS_SOURCE" - echo " with EXIF dates preserved." - echo "" - echo " NOTE: Run import-photos.sh separately after this setup" - echo " script has completed. It may take a while for large" - echo " photo collections." - echo "" - echo " After import, open http://localhost:2283 to browse your photos." - elif [ "$IMMICH_STRATEGY" = "2" ]; then - echo " 1. Open http://localhost:2283 and create your admin account" - echo "" - echo " 2. Enable storage template (keeps new uploads organized):" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - echo "" - echo " 3. Set up your existing photo library:" - echo " Admin → External Libraries → Create Library" - echo " Import path: /usr/src/app/external" - echo " Click Scan to index your photos" - echo "" - echo " New uploads from the mobile app or web UI are stored" - echo " separately in: $UPLOAD_LOCATION" - else - echo " 1. Open http://localhost:2283 and create your admin account" - echo "" - echo " 2. Enable storage template (keeps files organized by date):" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - echo "" - echo " That's it. Upload photos from the mobile app or web UI." - echo " All photos are stored in: $PHOTOS_DIR" - fi - echo "" - fi - fi # End IMMICH_RECONFIGURE check - fi # End INSTALL_IMMICH check - - # ---- AUDIOBOOKSHELF ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUDIOBOOKSHELF" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ AUDIOBOOKSHELF - Audiobook & podcast server │" - echo "│ Stream audiobooks with progress sync across devices. │" - echo "│ Port: 13378 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Audiobookshelf? (y/n):" "n" INSTALL_AUDIOBOOKSHELF - fi - - if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then - ABS_DIR="$DOCKER_DIR/audiobookshelf" - - # Check if already installed and ask what to do - check_service_exists "AudioBookshelf" "$ABS_DIR" ABS_RECONFIGURE - - if [ "$ABS_RECONFIGURE" = "true" ]; then - echo "Installing Audiobookshelf..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $ABS_DIR" - else - mkdir -p "$ABS_DIR" - ensure_docker_dir_ownership "$ABS_DIR" - cd "$ABS_DIR" - - prompt_text "Path to audiobooks folder [default: $PRIMARY_DRIVE_PATH/audiobooks]:" "$PRIMARY_DRIVE_PATH/audiobooks" AUDIOBOOKS_PATH - - cat > docker-compose.yml << ABS_COMPOSE -name: audiobookshelf - -services: - audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf:latest - container_name: audiobookshelf - hostname: audiobookshelf - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - ./metadata:/metadata - - ${AUDIOBOOKS_PATH}:/audiobooks - - ${PODCASTS_PATH:-./podcasts}:/podcasts - ports: - - "13378:80" -ABS_COMPOSE - - cat > .env << ABS_ENV -AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH -PODCASTS_PATH=./podcasts -ABS_ENV - - mkdir -p config metadata podcasts - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR" - - echo "" - echo "✓ Audiobookshelf configured at $ABS_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks" - - prompt_yn "Start Audiobookshelf now? (y/n):" "y" START_ABS - if [ "$START_ABS" = "y" ] || [ "$START_ABS" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Audiobookshelf started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:13378" - echo "" - fi - fi # End ABS_RECONFIGURE check - fi # End INSTALL_AUDIOBOOKSHELF check - - # ---- EMBY ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_EMBY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ EMBY - Media server for movies, TV, music │" - echo "│ Stream your media library to any device. │" - echo "│ Port: 8096 (web), 8920 (https) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Emby? (y/n):" "n" INSTALL_EMBY - fi - - if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then - EMBY_DIR="$DOCKER_DIR/emby" - - # Check if already installed and ask what to do - check_service_exists "Emby" "$EMBY_DIR" EMBY_RECONFIGURE - - if [ "$EMBY_RECONFIGURE" = "true" ]; then - echo "Installing Emby..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $EMBY_DIR" - else - mkdir -p "$EMBY_DIR" - ensure_docker_dir_ownership "$EMBY_DIR" - cd "$EMBY_DIR" - - prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH - - cat > docker-compose.yml << EMBY_COMPOSE -name: emby - -services: - emby: - image: emby/embyserver:latest - container_name: emby - hostname: emby - restart: unless-stopped - environment: - - UID=$(id -u "$ACTUAL_USER") - - GID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - ${MEDIA_PATH}:/media - ports: - - "8096:8096" - - "8920:8920" - # Uncomment for hardware transcoding (Intel/AMD) - # devices: - # - /dev/dri:/dev/dri -EMBY_COMPOSE - - cat > .env << EMBY_ENV -MEDIA_PATH=$MEDIA_PATH -EMBY_ENV - - mkdir -p config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR" - - echo "" - echo "✓ Emby configured at $EMBY_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Emby" "8096" "emby" - - prompt_yn "Start Emby now? (y/n):" "y" START_EMBY - if [ "$START_EMBY" = "y" ] || [ "$START_EMBY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Emby started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8096" - echo "" - fi - fi # End EMBY_RECONFIGURE check - fi # End INSTALL_EMBY check - - # ---- A.R.M. (Automatic Ripping Machine) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ARM" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ A.R.M. - Automatic Ripping Machine │" - echo "│ Automatically rip DVDs, Blu-rays, and CDs. │" - echo "│ Port: 8080 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install A.R.M.? (y/n):" "n" INSTALL_ARM - fi - - if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then - ARM_DIR="$DOCKER_DIR/arm" - check_service_exists "A.R.M." "$ARM_DIR" ARM_RECONFIGURE - - if [ "$ARM_RECONFIGURE" = "true" ]; then - echo "Installing A.R.M...." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $ARM_DIR" - else - mkdir -p "$ARM_DIR" - ensure_docker_dir_ownership "$ARM_DIR" - cd "$ARM_DIR" - - prompt_text "Path for ripped media output [default: $PRIMARY_DRIVE_PATH/ripped]:" "$PRIMARY_DRIVE_PATH/ripped" ARM_OUTPUT - - # Detect optical drives - echo "" - echo "Detecting optical drives..." - OPTICAL_DRIVES=$(ls /dev/sr* 2>/dev/null || echo "") - if [ -n "$OPTICAL_DRIVES" ]; then - echo "Found: $OPTICAL_DRIVES" - else - echo "No optical drives detected. You can add them later." - OPTICAL_DRIVES="/dev/sr0" - fi - - cat > docker-compose.yml << ARM_COMPOSE -name: arm - -services: - automatic-ripping-machine: - image: automaticrippingmachine/automatic-ripping-machine:latest - container_name: arm - hostname: arm - restart: unless-stopped - environment: - - ARM_UID=$(id -u "$ACTUAL_USER") - - ARM_GID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/etc/arm/config - - ./logs:/home/arm/logs - - ${ARM_OUTPUT}/movies:/home/arm/media/completed - - ${ARM_OUTPUT}/music:/home/arm/music - ports: - - "8080:8080" - devices: - - /dev/sr0:/dev/sr0 - # Add more drives as needed: - # - /dev/sr1:/dev/sr1 - privileged: true -ARM_COMPOSE - - cat > .env << ARM_ENV -ARM_OUTPUT=$ARM_OUTPUT -ARM_ENV - - mkdir -p config logs - mkdir -p "$ARM_OUTPUT/movies" "$ARM_OUTPUT/music" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_DIR" - - echo "" - echo "✓ A.R.M. configured at $ARM_DIR" - - prompt_yn "Start A.R.M. now? (y/n):" "y" START_ARM - if [ "$START_ARM" = "y" ] || [ "$START_ARM" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ A.R.M. started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8080" - echo " ⚠️ Complete setup in browser on first visit!" - echo " Note: Edit docker-compose.yml to add more optical drives" - echo "" - fi - fi # End ARM_RECONFIGURE check - fi # End INSTALL_ARM check - - # ---- FILEBROWSER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FILEBROWSER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FILEBROWSER - Web-based file manager │" - echo "│ Browse, upload, download files via web interface. │" - echo "│ Port: 8085 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Filebrowser? (y/n):" "n" INSTALL_FILEBROWSER - fi - - if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then - FB_DIR="$DOCKER_DIR/filebrowser" - check_service_exists "FileBrowser" "$FB_DIR" FB_RECONFIGURE - - if [ "$FB_RECONFIGURE" = "true" ]; then - echo "Installing Filebrowser..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FB_DIR" - else - mkdir -p "$FB_DIR" - ensure_docker_dir_ownership "$FB_DIR" - cd "$FB_DIR" - - prompt_text "Path to browse [default: $PRIMARY_DRIVE_PATH]:" "$PRIMARY_DRIVE_PATH" FB_PATH - - cat > docker-compose.yml << FB_COMPOSE -name: filebrowser - -services: - filebrowser: - image: filebrowser/filebrowser:s6 - container_name: filebrowser - hostname: filebrowser - restart: unless-stopped - environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ${FB_PATH}:/srv - - ./database/filebrowser.db:/database/filebrowser.db - - ./config/settings.json:/config/settings.json - ports: - - "8085:80" -FB_COMPOSE - - cat > .env << FB_ENV -FB_PATH=$FB_PATH -FB_ENV - - mkdir -p database config - touch database/filebrowser.db - cat > config/settings.json << 'FB_SETTINGS' -{ - "port": 80, - "baseURL": "", - "address": "", - "log": "stdout", - "database": "/database/filebrowser.db", - "root": "/srv" -} -FB_SETTINGS - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR" - - echo "" - echo "✓ Filebrowser configured at $FB_DIR" - - prompt_yn "Start Filebrowser now? (y/n):" "y" START_FB - if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Filebrowser started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8085" - echo " Default login: admin / admin (change immediately!)" - echo "" - fi - fi # End FB_RECONFIGURE check - fi # End INSTALL_FILEBROWSER check - - # ---- MAGIC MIRROR ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MAGICMIRROR" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MAGIC MIRROR - Smart mirror / dashboard display │" - echo "│ Modular smart mirror platform. Run up to 3 instances. │" - echo "│ Ports: 8081, 8082, 8083 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Magic Mirror? (y/n):" "n" INSTALL_MAGICMIRROR - fi - - if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then - echo "" - prompt_text "How many Magic Mirror instances? [1-3, default: 1]:" "1" MM_COUNT - MM_COUNT=${MM_COUNT:-1} - if [ "$MM_COUNT" -gt 3 ]; then MM_COUNT=3; fi - if [ "$MM_COUNT" -lt 1 ]; then MM_COUNT=1; fi - - echo "Installing $MM_COUNT Magic Mirror instance(s)..." - - for i in $(seq 1 $MM_COUNT); do - MM_PORT=$((8080 + i)) - MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MM_DIR (port $MM_PORT)" - else - mkdir -p "$MM_DIR" - ensure_docker_dir_ownership "$MM_DIR" - cd "$MM_DIR" - - cat > docker-compose.yml << MM_COMPOSE -name: mm-$MM_PORT - -services: - magicmirror: - image: karsten13/magicmirror:latest - container_name: magicmirror-$MM_PORT - hostname: magicmirror-$MM_PORT - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/opt/magic_mirror/config - - ./modules:/opt/magic_mirror/modules - - ./css:/opt/magic_mirror/css - ports: - - "$MM_PORT:8080" -MM_COMPOSE - - mkdir -p config modules css - - # Ask if user has existing config to copy - echo "" - echo " Config options:" - echo " [1] Use default config (basic modules)" - echo " [2] Copy existing config from path" - read -p " Choose [1]: " MM_CONFIG_CHOICE - MM_CONFIG_CHOICE=${MM_CONFIG_CHOICE:-1} - - if [ "$MM_CONFIG_CHOICE" = "2" ]; then - read -p " Path to config.js: " MM_CONFIG_PATH - if [ -f "$MM_CONFIG_PATH" ]; then - cp "$MM_CONFIG_PATH" config/config.js - echo " ✓ Copied config from $MM_CONFIG_PATH" - - # Also copy custom.css if exists in same directory - MM_CSS_PATH="${MM_CONFIG_PATH%/*}/custom.css" - if [ -f "$MM_CSS_PATH" ]; then - cp "$MM_CSS_PATH" css/custom.css - echo " ✓ Copied custom.css" - fi - - # Parse config for third-party modules and offer to download - echo "" - echo " Scanning for third-party modules..." - - # Extract module names starting with MMM- (third-party convention) - THIRD_PARTY_MODULES=$(grep -oP 'module:\s*["\x27]MMM-[^"\x27]+["\x27]' config/config.js 2>/dev/null | sed "s/module:\s*[\"']//g" | sed "s/[\"']//g" | sort -u) - - if [ -n "$THIRD_PARTY_MODULES" ]; then - echo " Found third-party modules:" - echo "$THIRD_PARTY_MODULES" | while read mod; do - echo " - $mod" - done - echo "" - prompt_yn " Download these modules from GitHub? (y/n):" "y" MM_DOWNLOAD_MODS - if [ "$MM_DOWNLOAD_MODS" = "y" ] || [ "$MM_DOWNLOAD_MODS" = "Y" ]; then - cd modules - echo "$THIRD_PARTY_MODULES" | while read mod; do - if [ -n "$mod" ] && [ ! -d "$mod" ]; then - echo " Downloading $mod..." - # Try common GitHub patterns - git clone --depth 1 "https://github.com/MichMich/$mod.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/bugsounet/$mod.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/MagicMirrorOrg/$mod.git" 2>/dev/null || \ - echo " ⚠ Could not find $mod - search at https://github.com/topics/magicmirror" - fi - done - cd .. - - # Run npm install for each downloaded module - # Note: This runs AFTER container is started, inside the container - echo "" - echo " Note: Module dependencies will be installed when container starts" - echo " If you need to manually install module dependencies, run:" - echo " docker exec magicmirror-$MM_PORT sh -c 'cd /opt/magic_mirror/modules/ && npm install --production'" - echo "" - fi - else - echo " No third-party modules (MMM-*) found in config" - fi - else - echo " ⚠ File not found: $MM_CONFIG_PATH" - echo " Using default config..." - MM_CONFIG_CHOICE="1" - fi - fi - - # Create default config if not copying - if [ "$MM_CONFIG_CHOICE" != "2" ]; then - # Create basic config.js - cat > config/config.js << 'MM_CONFIG' -let config = { - address: "0.0.0.0", - port: 8080, - ipWhitelist: [], - language: "en", - timeFormat: 12, - units: "imperial", - modules: [ - { - module: "alert", - }, - { - module: "clock", - position: "top_left" - }, - { - module: "calendar", - header: "Calendar", - position: "top_left", - config: { - calendars: [ - { - symbol: "calendar-check", - url: "webcal://www.calendarlabs.com/ical-calendar/ics/76/US_Holidays.ics" - } - ] - } - }, - { - module: "weather", - position: "top_right", - config: { - weatherProvider: "openmeteo", - type: "current", - lat: 40.7128, - lon: -74.0060 - } - }, - { - module: "weather", - position: "top_right", - header: "Weather Forecast", - config: { - weatherProvider: "openmeteo", - type: "forecast", - lat: 40.7128, - lon: -74.0060 - } - }, - { - module: "newsfeed", - position: "bottom_bar", - config: { - feeds: [ - { - title: "BBC", - url: "https://feeds.bbci.co.uk/news/rss.xml" - } - ], - showSourceTitle: true, - showPublishDate: true, - broadcastNewsFeeds: true, - broadcastNewsUpdates: true - } - }, - ] -}; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") {module.exports = config;} -MM_CONFIG - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MM_DIR" - - echo " ✓ Magic Mirror #$i configured at $MM_DIR (port $MM_PORT)" - fi - done - - if [ "$DRY_RUN" != true ]; then - echo "" - prompt_yn "Start Magic Mirror instance(s) now? (y/n):" "y" START_MM - if [ "$START_MM" = "y" ] || [ "$START_MM" = "Y" ]; then - for i in $(seq 1 $MM_COUNT); do - MM_PORT=$((8080 + i)) - MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT" - (cd "$MM_DIR" && docker compose up -d 2>/dev/null) && echo " ✓ Magic Mirror #$i started (port $MM_PORT)" || echo " ⚠ Failed to start Magic Mirror #$i" - - # Install npm dependencies for third-party modules inside container - if [ -d "$MM_DIR/modules" ]; then - echo " Installing module dependencies inside container..." - sleep 3 # Wait for container to fully start - for mod_dir in "$MM_DIR/modules"/MMM-*/; do - if [ -d "$mod_dir" ] && [ -f "$mod_dir/package.json" ]; then - mod_name=$(basename "$mod_dir") - echo " Installing $mod_name dependencies..." - docker exec magicmirror-$MM_PORT sh -c "cd /opt/magic_mirror/modules/$mod_name && npm install --production" 2>/dev/null && \ - echo " ✓ $mod_name dependencies installed" || \ - echo " ⚠ $mod_name - npm install failed (container may need restart)" - fi - done - fi - done - fi - - echo " Access at: http://localhost:808X" - echo " Edit config: ~/docker/magicmirror-808X/config/config.js" - echo "" - fi - fi - - # ---- ACTUALBUDGET ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ACTUALBUDGET" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ ACTUALBUDGET - Open-source Personal Finance Management │" - echo "│ Budget tracking with bank account synchronization via SimpleFIN│" - echo "│ Port: 5006 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ActualBudget? (y/n):" "n" INSTALL_ACTUALBUDGET - fi - - if [ "$INSTALL_ACTUALBUDGET" = "y" ] || [ "$INSTALL_ACTUALBUDGET" = "Y" ]; then - AB_DIR="$DOCKER_DIR/actualbudget" - check_service_exists "ActualBudget" "$AB_DIR" AB_RECONFIGURE - - if [ "$AB_RECONFIGURE" = "true" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $AB_DIR" - else - echo "Installing ActualBudget..." - mkdir -p "$AB_DIR/data" - ensure_docker_dir_ownership "$AB_DIR" - cd "$AB_DIR" - - # Create .env file for environment variables - cat > .env << 'AB_ENV' -# ActualBudget Environment Variables -TZ=UTC -AB_ENV - - cat > docker-compose.yml << 'AB_COMPOSE' -name: actualbudget - -services: - actualbudget: - image: actualbudget/actual-server:latest - container_name: actualbudget - restart: unless-stopped - ports: - - "5006:5006" - volumes: - - ./data:/data - env_file: - - .env - labels: - - "io.podman.annotations.label/crowdsec.enable=true" -AB_COMPOSE - - echo " ✓ ActualBudget configured at $AB_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "ActualBudget" "5006" "budget" - - prompt_yn "Start ActualBudget now? (y/n):" "y" START_AB - if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ActualBudget started" || echo " ⚠ Failed to start ActualBudget" - fi - - echo "" - echo " Access at: http://localhost:5006" - echo " Bank sync: https://simplefin.org/ (SimpleFIN account required)" - echo " Data dir: $AB_DIR/data" - echo "" - fi - fi # End AB_RECONFIGURE check - fi # End INSTALL_ACTUALBUDGET check - - # ---- CADDY WEB SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_CADDY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CADDY - Modern Web Server & Reverse Proxy │" - echo "│ Automatic HTTPS, reverse proxy for all your services │" - echo "│ Port: 80 (HTTP), 443 (HTTPS) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - CADDY_DIR="$DOCKER_DIR/caddy" - - # Check if Caddy is already installed - if [ -f "$CADDY_DIR/Caddyfile" ] || [ -f "$CADDY_DIR/docker-compose.yml" ]; then - echo "" - echo "⚠ Caddy appears to be already installed at $CADDY_DIR" - prompt_yn "Do you want to reconfigure it? (y/n):" "n" RECONFIGURE_CADDY - if [ "$RECONFIGURE_CADDY" != "y" ] && [ "$RECONFIGURE_CADDY" != "Y" ]; then - echo " Skipping Caddy installation" - INSTALL_CADDY="n" - fi - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $CADDY_DIR" - else - echo "Installing Caddy..." - mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config" - ensure_docker_dir_ownership "$CADDY_DIR" - - # Backup existing Caddyfile if it exists - if [ -f "$CADDY_DIR/Caddyfile" ]; then - mkdir -p "$CADDY_DIR/backups" - BACKUP_FILE="$CADDY_DIR/backups/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)" - cp "$CADDY_DIR/Caddyfile" "$BACKUP_FILE" - echo " ✓ Backed up existing Caddyfile to: $BACKUP_FILE" - fi - - cd "$CADDY_DIR" - - cat > docker-compose.yml << 'CADDY_COMPOSE' -name: caddy - -services: - caddy: - image: caddy:latest - container_name: caddy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - - "443:443/udp" # HTTP/3 - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - ./data:/data - - ./config:/config - - /var/log/caddy:/var/log/caddy - environment: - - ACME_AGREE=true - labels: - - "io.podman.annotations.label/crowdsec.enable=true" -CADDY_COMPOSE - - # Create Caddyfile if it doesn't exist - if [ ! -f "Caddyfile" ]; then - cat > Caddyfile << 'CADDYFILE' -{ - # Global options - admin off - # Email for Let's Encrypt notifications - # email admin@yourdomain.com -} - -# Example configuration - edit this for your services -# Uncomment and modify these examples: - -# ActualBudget -# budget.yourdomain.com { -# log { -# output file /var/log/caddy/actualbudget-access.log -# format json -# level INFO -# } -# reverse_proxy localhost:5006 -# header { -# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" -# X-Frame-Options "SAMEORIGIN" -# X-Content-Type-Options "nosniff" -# X-XSS-Protection "1; mode=block" -# Referrer-Policy "strict-origin-when-cross-origin" -# } -# } - -# Add more services here... -CADDYFILE - echo " ✓ Created example Caddyfile" - else - echo " ℹ Using existing Caddyfile" - fi - - echo " ✓ Caddy configured at $CADDY_DIR" - - prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY - if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start Caddy" - fi - - echo "" - echo " Configuration file: $CADDY_DIR/Caddyfile" - echo " Edit Caddyfile to add your domains and services" - echo " Reload config: cd $CADDY_DIR && docker exec -w /etc/caddy caddy caddy reload" - echo "" - echo " ⚠ IMPORTANT: Edit the Caddyfile to configure your domains!" - echo " - Uncomment and modify the example configurations" - echo " - Add your domain names" - echo " - Configure services you want to expose" - echo "" - fi - fi - fi - - # ---- AUTHELIA ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUTHELIA" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ AUTHELIA - SSO & Two-Factor Authentication Portal │" - echo "│ Protects any Caddy subdomain with a single login + TOTP │" - echo "│ Port: 9091 (internal only, accessed via Caddy) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Authelia? (y/n):" "n" INSTALL_AUTHELIA - fi - - if [ "$INSTALL_AUTHELIA" = "y" ] || [ "$INSTALL_AUTHELIA" = "Y" ]; then - AUTHELIA_DIR="$DOCKER_DIR/authelia" - check_service_exists "Authelia" "$AUTHELIA_DIR" AUTHELIA_RECONFIGURE - - if [ "$AUTHELIA_RECONFIGURE" = "true" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $AUTHELIA_DIR" - else - echo "Installing Authelia..." - mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data" - - # Collect configuration from user - echo "" - echo " Authelia needs a few details to configure." - echo "" - prompt_text " Your domain (e.g., example.com):" "example.com" AUTHELIA_DOMAIN - prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER - prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY - prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL - prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST - prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT - prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER - prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS - prompt_text " Timezone (e.g., America/New_York):" "America/New_York" AUTHELIA_TZ - - # Generate secrets - echo "" - echo " Generating secrets..." - JWT_SECRET=$(openssl rand -hex 32) - SESSION_SECRET=$(openssl rand -hex 32) - STORAGE_SECRET=$(openssl rand -hex 32) - - echo "$JWT_SECRET" > "$AUTHELIA_DIR/config/secrets/jwt_secret" - echo "$SESSION_SECRET" > "$AUTHELIA_DIR/config/secrets/session_secret" - echo "$STORAGE_SECRET" > "$AUTHELIA_DIR/config/secrets/storage_secret" - echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password" - chmod 600 "$AUTHELIA_DIR/config/secrets/"* - echo " ✓ Secrets generated" - - # Generate password hash for admin user - echo "" - echo " Generating password hash for admin user..." - prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS - AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \ - authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \ - | grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH") - if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then - echo " ⚠ Could not generate hash automatically. Run this after install:" - echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'" - echo " Then update $AUTHELIA_DIR/config/users.yml" - else - echo " ✓ Password hash generated" - fi - - ensure_docker_dir_ownership "$AUTHELIA_DIR" - # Authelia requires its config/data owned by uid 1000 - chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" - - cd "$AUTHELIA_DIR" - - # .env file - cat > .env << AUTHELIA_ENV -MY_DOMAIN=${AUTHELIA_DOMAIN} -SMTP_USER=${AUTHELIA_SMTP_USER} -DOCKER_MY_NETWORK=caddy_net -TZ=${AUTHELIA_TZ} -AUTHELIA_ENV - - # docker-compose.yml - cat > docker-compose.yml << 'AUTHELIA_COMPOSE' -name: authelia - -services: - authelia: - image: authelia/authelia:4.39.20 - pull_policy: missing - container_name: authelia - user: "1000:1000" - volumes: - - ./config:/config - - ./data:/data - environment: - - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret - - AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret - - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret - - AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password - - AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER} - - AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}> - expose: - - 9091 - restart: unless-stopped - networks: - - caddy_net - -networks: - caddy_net: - external: true -AUTHELIA_COMPOSE - - # configuration.yml - cat > config/configuration.yml << AUTHELIA_CONFIG ---- -# Authelia configuration -# Secrets are injected via AUTHELIA_* environment variables in docker-compose.yml - -theme: dark - -server: - address: tcp://0.0.0.0:9091 - -log: - level: info - file_path: /data/authelia.log - -totp: - period: 30 - skew: 1 - -authentication_backend: - file: - path: /config/users.yml - password: - algorithm: argon2 - argon2: - variant: argon2id - iterations: 3 - memory: 65536 - parallelism: 4 - key_length: 32 - salt_length: 16 - -access_control: - default_policy: deny - rules: - - domain: "*.${AUTHELIA_DOMAIN}" - policy: two_factor - -session: - name: authelia_session - expiration: 12h - inactivity: 2h - remember_me: 7d - cookies: - - domain: ${AUTHELIA_DOMAIN} - authelia_url: https://auth.${AUTHELIA_DOMAIN} - default_redirection_url: https://${AUTHELIA_DOMAIN} - -storage: - local: - path: /data/db.sqlite3 - -notifier: - disable_startup_check: false - smtp: - address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT} - timeout: 10s - identifier: localhost - subject: "[Authelia] {title}" - startup_check_address: ${AUTHELIA_SMTP_USER} - disable_require_tls: false - disable_starttls: false -AUTHELIA_CONFIG - - # users.yml - cat > config/users.yml << AUTHELIA_USERS ---- -# Authelia users database -# To add users: copy a block, update username/email/displayname, restart authelia -# To generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword' -# Login with username (not email). Tell users to use "Forgot Password" to set their own password. - -users: - ${AUTHELIA_ADMIN_USER}: - displayname: "${AUTHELIA_ADMIN_DISPLAY}" - email: ${AUTHELIA_ADMIN_EMAIL} - password: "${AUTHELIA_HASH}" - groups: - - admins - - users -AUTHELIA_USERS - - chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" - - echo " ✓ Authelia configured at $AUTHELIA_DIR" - echo "" - - # Ensure caddy_net Docker network exists - if ! docker network ls --format '{{.Name}}' | grep -q "^caddy_net$"; then - echo " Creating Docker network caddy_net..." - docker network create caddy_net && echo " ✓ caddy_net created" || echo " ⚠ Failed to create caddy_net" - else - echo " ✓ Docker network caddy_net already exists" - fi - - # Inject Authelia snippet into Caddyfile if Caddy is installed - CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" - if [ -f "$CADDY_FILE" ]; then - echo "" - echo " Configuring Caddy for Authelia..." - - # Add (authelia) snippet at top if not already present - if ! grep -q "(authelia)" "$CADDY_FILE"; then - cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)" - AUTHELIA_SNIPPET=$(cat << 'SNIPPET_EOF' -# ── Authelia forward auth snippet ───────────────────────────────────────────── -(authelia) { - forward_auth authelia:9091 { - uri /api/authz/forward-auth - copy_headers Remote-User Remote-Groups Remote-Name Remote-Email - } -} - -SNIPPET_EOF -) - # Prepend snippet before existing content - echo "$AUTHELIA_SNIPPET" | cat - "$CADDY_FILE" > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE" - echo " ✓ Authelia snippet added to Caddyfile" - else - echo " ✓ Authelia snippet already in Caddyfile" - fi - - # Add auth portal site block if not already present - AUTH_DOMAIN="auth.${AUTHELIA_DOMAIN}" - if ! grep -q "$AUTH_DOMAIN" "$CADDY_FILE"; then - cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK - -# ── Authelia login portal ────────────────────────────────────────────────────── -${AUTH_DOMAIN} { - reverse_proxy authelia:9091 - log { - output file /var/log/caddy/auth.log - } -} -CADDY_AUTH_BLOCK - echo " ✓ Authelia portal block added for ${AUTH_DOMAIN}" - fi - - # Reload Caddy if it's running - if docker ps --format '{{.Names}}' | grep -q "^caddy$"; then - docker exec -w /etc/caddy caddy caddy reload 2>/dev/null \ - && echo " ✓ Caddy reloaded" \ - || echo " ⚠ Caddy reload failed — reload manually after fixing Caddyfile" - fi - else - echo " ℹ Caddy not yet installed. Install Caddy and add this to your Caddyfile:" - echo "" - echo ' (authelia) {' - echo ' forward_auth authelia:9091 {' - echo ' uri /api/authz/forward-auth' - echo ' copy_headers Remote-User Remote-Groups Remote-Name Remote-Email' - echo ' }' - echo ' }' - echo "" - echo " auth.${AUTHELIA_DOMAIN} {" - echo " reverse_proxy authelia:9091" - echo " }" - echo "" - fi - - prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA - if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Authelia started" || echo " ⚠ Failed to start Authelia" - sleep 2 - docker compose logs --tail=20 authelia 2>/dev/null || true - fi - - echo "" - echo " Auth portal: https://auth.${AUTHELIA_DOMAIN}" - echo " Config dir: $AUTHELIA_DIR/config" - echo " Users file: $AUTHELIA_DIR/config/users.yml" - echo " Data dir: $AUTHELIA_DIR/data" - echo "" - echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set real password)" - echo "" - echo " To protect a Caddy site, add 'import authelia' to its block:" - echo " myservice.${AUTHELIA_DOMAIN} {" - echo " import authelia" - echo " reverse_proxy localhost:PORT" - echo " }" - echo "" - echo " To add users:" - echo " Edit $AUTHELIA_DIR/config/users.yml then:" - echo " cd $AUTHELIA_DIR && docker compose restart authelia" - echo "" - fi - fi # End AUTHELIA_RECONFIGURE check - fi # End INSTALL_AUTHELIA check - - # ---- CROWDSEC ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_CROWDSEC" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CROWDSEC - Intrusion Prevention (fail2ban successor) │" - echo "│ Bans malicious IPs + geo-blocking + community IP reputation │" - echo "│ Protects SSH, Caddy, and other services │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install and configure CrowdSec? (y/n):" "n" INSTALL_CROWDSEC - fi - - if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install CrowdSec + Caddy acquisition + firewall bouncer" - else - echo "Installing CrowdSec..." - - # Install the CrowdSec agent if not already present - if command -v cscli &> /dev/null; then - echo " ✓ CrowdSec is already installed" - else - echo " Adding CrowdSec repository and installing agent..." - if curl -s https://install.crowdsec.net | sudo sh && sudo apt install -y crowdsec; then - echo " ✓ CrowdSec installed successfully" - else - echo " ⚠ Failed to install CrowdSec" - echo " See https://docs.crowdsec.net/ for manual installation" - fi - fi - - # Firewall bouncer (enforces bans via iptables/nftables) - echo " Installing firewall bouncer..." - sudo apt install -y crowdsec-firewall-bouncer-iptables 2>/dev/null || \ - echo " ⚠ Could not install firewall bouncer automatically" - - # Create log directory for Caddy - if [ ! -d "/var/log/caddy" ]; then - sudo mkdir -p /var/log/caddy - sudo chmod 755 /var/log/caddy - echo " ✓ Created /var/log/caddy directory" - fi - - # Detection collections: SSH, Caddy HTTP scenarios, base http - echo " Installing CrowdSec collections (sshd, caddy, base-http)..." - sudo cscli collections install crowdsecurity/sshd crowdsecurity/linux crowdsecurity/caddy crowdsecurity/base-http-scenarios 2>/dev/null || \ - echo " ⚠ Some collections may already be installed" - - # Tell CrowdSec to read Caddy's JSON access logs - ACQUIS_FILE="/etc/crowdsec/acquis.d/caddy.yaml" - if [ ! -f "$ACQUIS_FILE" ]; then - echo " Creating Caddy log acquisition for CrowdSec..." - sudo mkdir -p /etc/crowdsec/acquis.d - ACQUIS_CONTENT='filenames: - - /var/log/caddy/*.log - - /var/log/caddy/*-access.log -labels: - type: caddy' - if echo "$ACQUIS_CONTENT" | sudo tee "$ACQUIS_FILE" > /dev/null; then - echo " ✓ Created Caddy acquisition ($ACQUIS_FILE)" - else - echo " ⚠ Failed to create acquisition - create it manually" - fi - else - echo " ✓ Caddy acquisition already exists" - fi - - # Geo-blocking + reputation (the capability fail2ban/Authelia lack) - echo "" - echo " Geo-blocking & IP reputation (optional):" - echo " Enrich events with country/ASN data:" - echo " sudo cscli collections install crowdsecurity/geoip-enrich" - echo " Subscribe to community/3rd-party blocklists at:" - echo " https://app.crowdsec.net/" - - # Optional: push ban alerts to ntfy - prompt_yn "Send CrowdSec ban alerts to an ntfy topic? (y/n):" "n" CS_NTFY - if [ "$CS_NTFY" = "y" ] || [ "$CS_NTFY" = "Y" ]; then - prompt_text " ntfy topic URL (e.g. https://ntfy.sh/my-crowdsec):" "https://ntfy.sh/crowdsec-alerts" CS_NTFY_URL - sudo mkdir -p /etc/crowdsec/notifications - NTFY_FILE="/etc/crowdsec/notifications/ntfy.yaml" - NTFY_CONTENT="type: http -name: ntfy -log_level: info -format: | - {{range . -}} - {{range .Decisions -}} - {{.Value}} banned: {{.Scenario}} for {{.Duration}} - {{end -}} - {{end -}} -url: $CS_NTFY_URL -method: POST -headers: - Title: CrowdSec ban - Priority: high - Tags: rotating_light" - if echo "$NTFY_CONTENT" | sudo tee "$NTFY_FILE" > /dev/null; then - echo " ✓ Created ntfy notification ($NTFY_FILE)" - # Wire the notification into the default profile (only once) - if ! grep -qE "^\s*- ntfy" /etc/crowdsec/profiles.yaml 2>/dev/null; then - sudo awk '1; /^on_success:/ && !d {print "notifications:"; print " - ntfy"; d=1}' \ - /etc/crowdsec/profiles.yaml | sudo tee /etc/crowdsec/profiles.yaml.new > /dev/null \ - && sudo mv /etc/crowdsec/profiles.yaml.new /etc/crowdsec/profiles.yaml - echo " ✓ Enabled ntfy alerts in CrowdSec default profile" - else - echo " ✓ ntfy already referenced in CrowdSec profile" - fi - echo " ℹ Alerts fire when an IP is banned (after repeated failed attempts)," - echo " not on every individual failed login." - else - echo " ⚠ Failed to write ntfy notification config" - fi - fi - - # Restart services to apply - prompt_yn "Restart CrowdSec to apply changes? (y/n):" "y" RESTART_CS - if [ "$RESTART_CS" = "y" ] || [ "$RESTART_CS" = "Y" ]; then - sudo systemctl enable crowdsec 2>/dev/null || true - if sudo systemctl restart crowdsec; then - echo " ✓ CrowdSec restarted successfully" - sudo systemctl enable crowdsec-firewall-bouncer 2>/dev/null || true - sudo systemctl restart crowdsec-firewall-bouncer 2>/dev/null || true - sleep 2 - sudo cscli metrics 2>/dev/null | head -20 || true - else - echo " ⚠ Failed to restart CrowdSec" - echo " Check logs: sudo journalctl -u crowdsec -n 50" - fi - fi - - echo "" - echo " Useful commands:" - echo " List active bans: sudo cscli decisions list" - echo " List alerts: sudo cscli alerts list" - echo " Manually ban IP: sudo cscli decisions add --ip 1.2.3.4" - echo " Unban IP: sudo cscli decisions delete --ip 1.2.3.4" - echo " Show metrics: sudo cscli metrics" - echo "" - fi - fi - - # ---- LYRION MUSIC SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_LMS" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ LYRION MUSIC SERVER (LMS) - Music streaming server │" - echo "│ Stream music to Squeezebox devices, apps, and Chromecast. │" - echo "│ Port: 9000 (web), 9090 (CLI), 3483 (players) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Lyrion Music Server? (y/n):" "n" INSTALL_LMS - fi - - if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then - echo "Installing Lyrion Music Server..." - LMS_DIR="$DOCKER_DIR/lyrion" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $LMS_DIR" - else - mkdir -p "$LMS_DIR" - ensure_docker_dir_ownership "$LMS_DIR" - cd "$LMS_DIR" - - prompt_text "Path to music folder [default: $PRIMARY_DRIVE_PATH/music]:" "$PRIMARY_DRIVE_PATH/music" MUSIC_PATH - - cat > docker-compose.yml << LMS_COMPOSE -name: lyrion - -services: - lyrion: - image: lmscommunity/lyrionmusicserver:stable - container_name: lyrion - hostname: lyrion - restart: unless-stopped - network_mode: host - environment: - - HTTP_PORT=9000 - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config:rw - - ${MUSIC_PATH}:/music:ro - - ./playlists:/playlists:rw - - /etc/localtime:/etc/localtime:ro -LMS_COMPOSE - - cat > .env << LMS_ENV -MUSIC_PATH=$MUSIC_PATH -LMS_ENV - - mkdir -p config playlists - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LMS_DIR" - - echo "" - echo "✓ Lyrion Music Server configured at $LMS_DIR" - - prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS - if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Lyrion Music Server started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:9000" - echo " Note: Uses host networking for Chromecast support" - echo "" - fi - fi - - # ---- MEALIE ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MEALIE" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MEALIE - Recipe manager & meal planner │" - echo "│ Save recipes, plan meals, generate shopping lists. │" - echo "│ Port: 9925 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Mealie? (y/n):" "n" INSTALL_MEALIE - fi - - if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then - echo "Installing Mealie..." - MEALIE_DIR="$DOCKER_DIR/mealie" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MEALIE_DIR" - else - mkdir -p "$MEALIE_DIR" - ensure_docker_dir_ownership "$MEALIE_DIR" - cd "$MEALIE_DIR" - - cat > docker-compose.yml << MEALIE_COMPOSE -name: mealie - -services: - mealie: - image: ghcr.io/mealie-recipes/mealie:latest - container_name: mealie - hostname: mealie - restart: unless-stopped - environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - ALLOW_SIGNUP=true - - MAX_WORKERS=1 - - WEB_CONCURRENCY=1 - - BASE_URL=http://localhost:9925 - volumes: - - ./data:/app/data - ports: - - "9925:9000" -MEALIE_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" - - echo "" - echo "✓ Mealie configured at $MEALIE_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Mealie" "9925" "recipes" - - prompt_yn "Start Mealie now? (y/n):" "y" START_MEALIE - if [ "$START_MEALIE" = "y" ] || [ "$START_MEALIE" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Mealie started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:9925" - echo " Default: changeme@email.com / MyPassword" - echo "" - fi - fi - - # ---- HOME ASSISTANT ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_HOMEASSISTANT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ HOME ASSISTANT - Open-source home automation hub │" - echo "│ Smart-home control, automations, dashboards. │" - echo "│ Port: 8123 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Home Assistant? (y/n):" "n" INSTALL_HOMEASSISTANT - fi - - if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then - echo "Installing Home Assistant..." - HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR" - else - mkdir -p "$HOMEASSISTANT_DIR" - ensure_docker_dir_ownership "$HOMEASSISTANT_DIR" - cd "$HOMEASSISTANT_DIR" - - # Networking mode: bridge (published port) vs host networking. - echo "" - echo " Home Assistant networking mode:" - echo " 1) Bridge - container gets its own network; port 8123 is published" - echo " to the host. Works behind the Caddy reverse proxy and" - echo " keeps HA isolated. Recommended for most setups." - echo " 2) Host - HA shares the host's network directly. Needed for" - echo " auto-discovery of devices on your LAN (Chromecast/Cast," - echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)." - prompt_text " Choose networking mode [1]:" "1" HA_NETMODE - if [ "$HA_NETMODE" = "2" ]; then - HA_NET_LINES=" network_mode: host" - echo " → Host networking selected (best device discovery)." - else - HA_NET_LINES=" ports: - - \"8123:8123\"" - echo " → Bridge networking selected (port 8123 published)." - fi - - cat > docker-compose.yml << HOMEASSISTANT_COMPOSE -name: homeassistant - -services: - homeassistant: - image: ghcr.io/home-assistant/home-assistant:stable - container_name: homeassistant - hostname: homeassistant - restart: unless-stopped - privileged: true - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - /run/dbus:/run/dbus:ro -${HA_NET_LINES} -HOMEASSISTANT_COMPOSE - - mkdir -p config - - # Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy. - # Only written on a fresh install (never clobber an existing config). - if [ ! -f config/configuration.yaml ]; then - cat > config/configuration.yaml << 'HA_CONFIG' -# Loads default set of integrations. Do not remove. -default_config: - -# Allow access through a reverse proxy (e.g. Caddy) -http: - use_x_forwarded_for: true - trusted_proxies: - - 172.16.0.0/12 - - 192.168.0.0/16 - - 10.0.0.0/8 - - 127.0.0.1 - - ::1 -HA_CONFIG - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR" - - echo "" - echo "✓ Home Assistant configured at $HOMEASSISTANT_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Home Assistant" "8123" "home" - - prompt_yn "Start Home Assistant now? (y/n):" "y" START_HOMEASSISTANT - if [ "$START_HOMEASSISTANT" = "y" ] || [ "$START_HOMEASSISTANT" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Home Assistant started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8123" - echo " First run: open the URL and create your admin account (onboarding)." - echo " Note: first startup can take a minute while HA initializes." - echo "" - fi - fi - - # ---- MINECRAFT SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MINECRAFT SERVER - Game server with RAM limit │" - echo "│ Fabric server with configurable memory allocation. │" - echo "│ Port: 25565 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Minecraft Server? (y/n):" "n" INSTALL_MINECRAFT - fi - - if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then - echo "Installing Minecraft Server..." - MC_DIR="$DOCKER_DIR/minecraft" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MC_DIR" - else - mkdir -p "$MC_DIR" - ensure_docker_dir_ownership "$MC_DIR" - cd "$MC_DIR" - - echo "" - prompt_text "Maximum RAM for Minecraft (e.g., 2G, 4G) [default: 2G]:" "2G" MC_RAM - MC_RAM=${MC_RAM:-2G} - - cat > docker-compose.yml << MC_COMPOSE -name: minecraft - -services: - minecraft: - image: itzg/minecraft-server:latest - container_name: minecraft - hostname: minecraft - restart: unless-stopped - tty: true - stdin_open: true - environment: - - EULA=TRUE - - TYPE=FABRIC - - VERSION=LATEST - - MEMORY=${MC_RAM} - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - OPS= - - MOTD=A Minecraft Server - - DIFFICULTY=normal - - MODE=survival - volumes: - - ./data:/data - ports: - - "25565:25565" - deploy: - resources: - limits: - memory: ${MC_RAM} -MC_COMPOSE - - cat > .env << MC_ENV -MC_RAM=$MC_RAM -MC_ENV - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" - - echo "" - echo "✓ Minecraft Server configured at $MC_DIR" - - prompt_yn "Start Minecraft Server now? (y/n):" "y" START_MC - if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Minecraft Server started" || echo " ⚠ Failed to start" - fi - - echo " Connect: localhost:25565" - echo " RAM limit: $MC_RAM" - echo " Console: docker attach minecraft (Ctrl+P, Ctrl+Q to detach)" - echo "" - fi - fi - - # ---- LINUX-TO-SYNC (Private Repo) ---- - if [ "$WHIPTAIL_USED" != true ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ LINUX-TO-SYNC - Private sync repository │" - echo "│ Clone and set up your private linux-to-sync repository. │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - echo "Note: This requires access to github.com/outis1one/linux-to-sync" - echo "" - echo "To grant access, you need ONE of these:" - echo " 1. SSH key already added to your GitHub account" - echo " 2. GitHub Personal Access Token (PAT)" - echo " 3. GitHub CLI (gh) authenticated" - echo "" - prompt_yn "Set up linux-to-sync? (y/n):" "n" INSTALL_LINUXTOSYNC - - if [ "$INSTALL_LINUXTOSYNC" = "y" ] || [ "$INSTALL_LINUXTOSYNC" = "Y" ]; then - SYNC_DIR="$DOCKER_DIR/linux-to-sync" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would clone linux-to-sync to $SYNC_DIR" - else - echo "" - echo "Choose authentication method:" - echo " [1] SSH (if you have SSH key added to GitHub)" - echo " [2] HTTPS with token (requires Personal Access Token)" - echo "" - prompt_text "Enter 1 or 2 [default: 1]:" "1" AUTH_METHOD - - if [ "$AUTH_METHOD" = "2" ]; then - echo "" - echo "Create a Personal Access Token at:" - echo " https://github.com/settings/tokens/new" - echo " - Select 'repo' scope for full repository access" - echo "" - prompt_text "Enter your GitHub Personal Access Token:" "" GH_TOKEN - - if [ -n "$GH_TOKEN" ]; then - git clone "https://$GH_TOKEN@github.com/outis1one/linux-to-sync.git" "$SYNC_DIR" 2>/dev/null - if [ $? -eq 0 ]; then - # Remove token from remote URL for security - cd "$SYNC_DIR" - git remote set-url origin "https://github.com/outis1one/linux-to-sync.git" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" - echo "" - echo "✓ linux-to-sync cloned to $SYNC_DIR" - echo " Note: You'll need to enter token again for push/pull" - echo " Or set up: git config credential.helper store" - else - echo "✗ Clone failed. Check your token and try again." - fi - else - echo "No token provided. Skipping." - fi - else - echo "" - echo "Attempting SSH clone..." - echo "(Make sure your SSH key is added to GitHub)" - echo "" - git clone git@github.com:outis1one/linux-to-sync.git "$SYNC_DIR" 2>/dev/null - if [ $? -eq 0 ]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" - echo "" - echo "✓ linux-to-sync cloned to $SYNC_DIR" - else - echo "" - echo "✗ SSH clone failed." - echo "" - echo "To add your SSH key to GitHub:" - echo " 1. Copy your public key: cat ~/.ssh/id_rsa.pub" - echo " 2. Go to: https://github.com/settings/keys" - echo " 3. Click 'New SSH key' and paste your key" - echo "" - echo "Then retry this script or manually clone:" - echo " git clone git@github.com:outis1one/linux-to-sync.git ~/docker/linux-to-sync" - fi - fi - fi - fi - fi # End WHIPTAIL_USED check for linux-to-sync - - # ---- JELLYFIN (Alternative to Emby) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_JELLYFIN" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ JELLYFIN - Free media server (alternative to Emby) │" - echo "│ Stream movies, TV, music. No premium features locked. │" - echo "│ Port: 8096 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Jellyfin? (y/n):" "n" INSTALL_JELLYFIN - fi - - if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then - echo "Installing Jellyfin..." - JELLYFIN_DIR="$DOCKER_DIR/jellyfin" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $JELLYFIN_DIR" - else - mkdir -p "$JELLYFIN_DIR" - ensure_docker_dir_ownership "$JELLYFIN_DIR" - cd "$JELLYFIN_DIR" - - prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH - - # Get render group ID for hardware acceleration - RENDER_GID=$(getent group render | cut -d: -f3 2>/dev/null || echo "989") - - cat > docker-compose.yml << JELLYFIN_COMPOSE -name: jellyfin - -services: - jellyfin: - image: jellyfin/jellyfin:latest - container_name: jellyfin - hostname: jellyfin - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - devices: - - /dev/dri/renderD128:/dev/dri/renderD128 - group_add: - - "$RENDER_GID" - volumes: - - ./config:/config - - ./cache:/cache - - \${MEDIA_PATH}:/media:ro - ports: - - "8096:8096" - - "1900:1900/udp" - - "7359:7359/udp" -JELLYFIN_COMPOSE - - cat > .env << JELLYFIN_ENV -MEDIA_PATH=$MEDIA_PATH -JELLYFIN_ENV - - mkdir -p config cache - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" - - echo "" - echo "✓ Jellyfin configured at $JELLYFIN_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Jellyfin" "8096" "jellyfin" - - prompt_yn "Start Jellyfin now? (y/n):" "y" START_JELLYFIN - if [ "$START_JELLYFIN" = "y" ] || [ "$START_JELLYFIN" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Jellyfin started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8096" - echo " Note: Hardware acceleration enabled (Intel GPU)" - echo "" - fi - fi - - # ---- FRIGATE NVR ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FRIGATE - AI-powered NVR for security cameras │" - echo "│ Object detection, recordings, 24/7 monitoring. │" - echo "│ Port: 5000 (web), 8554 (RTSP), 8555 (WebRTC) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Frigate? (y/n):" "n" INSTALL_FRIGATE - fi - - if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then - echo "Installing Frigate..." - FRIGATE_DIR="$DOCKER_DIR/frigate" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FRIGATE_DIR" - else - # STEP 1: Create directory and install docker-compose - mkdir -p "$FRIGATE_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$FRIGATE_DIR" - cd "$FRIGATE_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Default path - FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate" - - cat > docker-compose.yml << FRIGATE_COMPOSE -name: frigate - -services: - frigate: - image: ghcr.io/blakeblackshear/frigate:stable - container_name: frigate - hostname: frigate - restart: unless-stopped - privileged: true - shm_size: "256mb" - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - devices: - - /dev/dri/renderD128:/dev/dri/renderD128 - volumes: - - ./config:/config - - \${FRIGATE_MEDIA}:/media/frigate - - type: tmpfs - target: /tmp/cache - tmpfs: - size: 1000000000 - ports: - - "5000:5000" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" -FRIGATE_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try to configure (uses defaults if fails) - prompt_text "Path for recordings [$FRIGATE_PATH]:" "$FRIGATE_PATH" FRIGATE_PATH 2>/dev/null || FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate" - - cat > .env << FRIGATE_ENV -FRIGATE_MEDIA=$FRIGATE_PATH -FRIGATE_ENV - - mkdir -p config 2>/dev/null || true - mkdir -p "$FRIGATE_PATH" 2>/dev/null || echo " ⚠ Could not create $FRIGATE_PATH - create manually" - - # Create template config - cat > config/config.yml << 'FRIGATE_CONFIG' -# Frigate Configuration -# Docs: https://docs.frigate.video -# -# ⚠️ YOU MUST EDIT THIS FILE to add your cameras! - -mqtt: - enabled: false - -cameras: - # EXAMPLE - Replace with your camera: - # front_door: - # ffmpeg: - # inputs: - # - path: rtsp://user:pass@192.168.1.100:554/stream - # roles: [detect, record] - # detect: - # width: 1280 - # height: 720 - # fps: 5 - -detectors: - default: - type: cpu - -record: - enabled: true - retain: - days: 7 - mode: motion - -snapshots: - enabled: true - retain: - default: 7 -FRIGATE_CONFIG - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_DIR" 2>/dev/null || true - - echo "" - echo "✓ Frigate installed at $FRIGATE_DIR" - echo "" - echo " ⚠️ Note: You should edit config/config.yml to add cameras before starting." - prompt_yn "Start Frigate now anyway? (y/n):" "n" START_FRIGATE - if [ "$START_FRIGATE" = "y" ] || [ "$START_FRIGATE" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Frigate started" || echo " ⚠ Failed to start" - fi - - echo " Access: http://localhost:5000" - echo " Docs: https://docs.frigate.video" - echo "" - fi - fi - - # ---- CADDY REVERSE PROXY (Legacy) ---- - # Note: This is the legacy Caddy installation - # The newer installation above includes CrowdSec support - # This section is kept for backwards compatibility - if [ "$WHIPTAIL_USED" != true ] && [ "$INSTALL_CADDY" != "y" ] && [ "$INSTALL_CADDY" != "Y" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CADDY - Automatic HTTPS reverse proxy (Legacy) │" - echo "│ Route domains to containers with automatic SSL certificates. │" - echo "│ Ports: 80, 443 │" - echo "└─────────────────────────────────────────────────────────────────┘" - if [ -z "$INSTALL_CADDY_LEGACY" ]; then - prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY_LEGACY - fi - INSTALL_CADDY="$INSTALL_CADDY_LEGACY" - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - echo "Installing Caddy..." - CADDY_DIR="$DOCKER_DIR/caddy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $CADDY_DIR" - else - # STEP 1: Create directory and install docker-compose - mkdir -p "$CADDY_DIR" 2>/dev/null || true - cd "$CADDY_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'CADDY_COMPOSE' -name: caddy - -services: - caddy: - image: caddy:latest - container_name: caddy - hostname: caddy - restart: unless-stopped - env_file: .env - ports: - - "80:80" - - "443:443" - - "443:443/udp" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - ./config:/config - - ./data:/data - - ./site:/srv - -networks: - default: - name: caddy_net - external: true -CADDY_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try configuration (uses defaults if fails) - CADDY_DOMAIN="" - prompt_text "Your domain (e.g., example.com) [blank for local]:" "" CADDY_DOMAIN 2>/dev/null || CADDY_DOMAIN="" - - cat > .env << CADDY_ENV -MY_DOMAIN=${CADDY_DOMAIN:-localhost} -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -CADDY_ENV - - # Create Docker network for Caddy (ignore if exists) - docker network create caddy_net 2>/dev/null || true - - # Create comprehensive Caddyfile with all services - cat > Caddyfile << 'CADDY_FILE' -# Caddy reverse proxy configuration -# Edit MY_DOMAIN in .env file, then uncomment services below -# -# To use: containers must be on 'caddy_net' network -# Add to each container's docker-compose.yml: -# networks: -# default: -# name: caddy_net -# external: true - -# ============================================================================ -# GLOBAL OPTIONS -# ============================================================================ -{ - # Uncomment for local-only (no domain/SSL): - # auto_https off -} - -# ============================================================================ -# MEDIA SERVERS -# ============================================================================ - -# Immich (photo backup) -# immich.{$MY_DOMAIN} { -# reverse_proxy immich_server:2283 -# } - -# Jellyfin (media server) -# jellyfin.{$MY_DOMAIN} { -# reverse_proxy jellyfin:8096 -# } - -# Emby (media server) -# emby.{$MY_DOMAIN} { -# reverse_proxy emby:8096 -# } - -# Audiobookshelf -# audiobooks.{$MY_DOMAIN} { -# reverse_proxy audiobookshelf:80 -# } - -# Lyrion Music Server -# music.{$MY_DOMAIN} { -# reverse_proxy lms:9000 -# } - -# ============================================================================ -# HOME AUTOMATION & MONITORING -# ============================================================================ - -# Frigate NVR -# frigate.{$MY_DOMAIN} { -# reverse_proxy frigate:5000 -# } - -# Uptime Kuma -# status.{$MY_DOMAIN} { -# reverse_proxy uptime-kuma:3001 -# } - -# Magic Mirror -# mirror.{$MY_DOMAIN} { -# reverse_proxy magicmirror:8080 -# } - -# Traccar GPS -# gps.{$MY_DOMAIN} { -# reverse_proxy traccar:8082 -# } - -# FindMyDevice -# fmd.{$MY_DOMAIN} { -# reverse_proxy fmd:8080 -# } - -# ============================================================================ -# UTILITIES -# ============================================================================ - -# Filebrowser -# files.{$MY_DOMAIN} { -# reverse_proxy filebrowser:80 -# } - -# Mealie (recipes) -# recipes.{$MY_DOMAIN} { -# reverse_proxy mealie:9000 -# } - -# Home Assistant (home) -# home.{$MY_DOMAIN} { -# reverse_proxy homeassistant:8123 -# } - -# ntfy (notifications) -# ntfy.{$MY_DOMAIN} { -# reverse_proxy ntfy:80 -# } - -# Portainer -# docker.{$MY_DOMAIN} { -# reverse_proxy portainer:9000 -# } - -# Kopia backup UI -# backup.{$MY_DOMAIN} { -# reverse_proxy kopia:51515 -# } - -# ============================================================================ -# TESTING / CATCH-ALL -# ============================================================================ - -# Local testing (always responds) -:80 { - respond "Caddy is running! Edit Caddyfile to enable your services." -} -CADDY_FILE - - mkdir -p config data site 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CADDY_DIR" 2>/dev/null || true - - echo "" - echo "✓ Caddy installed at $CADDY_DIR" - - prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY - if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start" - fi - - echo " Domain: ${CADDY_DOMAIN:-localhost} (edit .env)" - echo " Config: $CADDY_DIR/Caddyfile (uncomment services)" - echo "" - echo " ⚠️ Containers must be on 'caddy_net' network" - echo " Docs: https://caddyserver.com/docs/" - echo "" - fi - fi - - # ---- DDCLIENT DYNAMIC DNS ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_DDCLIENT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ DDCLIENT - Dynamic DNS updater │" - echo "│ Keep your domain pointing to your home IP. │" - echo "│ Supports: Cloudflare, DuckDNS, No-IP, and more. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ddclient? (y/n):" "n" INSTALL_DDCLIENT - fi - - if [ "$INSTALL_DDCLIENT" = "y" ] || [ "$INSTALL_DDCLIENT" = "Y" ]; then - echo "Installing ddclient..." - DDCLIENT_DIR="$DOCKER_DIR/ddclient" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $DDCLIENT_DIR" - else - # STEP 1: Install docker-compose - mkdir -p "$DDCLIENT_DIR" 2>/dev/null || true - cd "$DDCLIENT_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'DDCLIENT_COMPOSE' -name: ddclient - -services: - ddclient: - image: lscr.io/linuxserver/ddclient:latest - container_name: ddclient - hostname: ddclient - restart: unless-stopped - environment: - - PUID=1000 - - PGID=1000 - - TZ=${TZ} - volumes: - - ./config:/config -DDCLIENT_COMPOSE - echo "✓ Installed docker-compose.yml" - - cat > .env << DDCLIENT_ENV -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -DDCLIENT_ENV - - mkdir -p config 2>/dev/null || true - - # Create template config - cat > config/ddclient.conf << 'DDCLIENT_CONF' -# ddclient configuration -# ⚠️ YOU MUST EDIT THIS FILE! -# Uncomment and edit for your DNS provider - -daemon=300 -syslog=yes -pid=/var/run/ddclient/ddclient.pid -ssl=yes - -# Cloudflare example: -# use=web, web=cloudflare -# protocol=cloudflare -# zone=example.com -# login=token -# password=your-api-token -# example.com - -# DuckDNS example: -# use=web -# protocol=duckdns -# password=your-duckdns-token -# yourdomain.duckdns.org -DDCLIENT_CONF - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DDCLIENT_DIR" 2>/dev/null || true - - echo "" - echo "✓ ddclient installed at $DDCLIENT_DIR" - echo "" - echo " ⚠️ Note: You should edit config/ddclient.conf before starting." - prompt_yn "Start ddclient now anyway? (y/n):" "n" START_DDCLIENT - if [ "$START_DDCLIENT" = "y" ] || [ "$START_DDCLIENT" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ddclient started" || echo " ⚠ Failed to start" - fi - - echo " Docs: https://ddclient.net/" - echo "" - fi - fi - - # ---- NTFY NOTIFICATIONS ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_NTFY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ NTFY - Push notifications server │" - echo "│ Send notifications from scripts to your phone. │" - echo "│ Port: 8090 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ntfy? (y/n):" "n" INSTALL_NTFY - fi - - if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then - echo "Installing ntfy..." - NTFY_DIR="$DOCKER_DIR/ntfy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $NTFY_DIR" - else - mkdir -p "$NTFY_DIR" - ensure_docker_dir_ownership "$NTFY_DIR" - cd "$NTFY_DIR" - - cat > docker-compose.yml << 'NTFY_COMPOSE' -name: ntfy - -services: - ntfy: - image: binwiederhier/ntfy:latest - container_name: ntfy - hostname: ntfy - restart: unless-stopped - command: serve - environment: - - TZ=${TZ} - volumes: - - ./cache:/var/cache/ntfy - - ./config:/etc/ntfy - ports: - - "8090:80" -NTFY_COMPOSE - - cat > .env << NTFY_ENV -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -NTFY_ENV - - mkdir -p cache config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" - - echo "" - echo "✓ ntfy configured at $NTFY_DIR" - - prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY - if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ntfy started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8090" - echo "" - echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic" - echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic" - echo "" - fi - fi - - # ---- UPTIME KUMA ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_UPTIMEKUMA" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ UPTIME KUMA - Service monitoring dashboard │" - echo "│ Monitor websites, servers, Docker containers. │" - echo "│ Port: 3001 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Uptime Kuma? (y/n):" "n" INSTALL_UPTIMEKUMA - fi - - if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then - echo "Installing Uptime Kuma..." - UPTIME_DIR="$DOCKER_DIR/uptime-kuma" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $UPTIME_DIR" - else - mkdir -p "$UPTIME_DIR" - ensure_docker_dir_ownership "$UPTIME_DIR" - cd "$UPTIME_DIR" - - cat > docker-compose.yml << 'UPTIME_COMPOSE' -name: uptime-kuma - -services: - uptime-kuma: - image: louislam/uptime-kuma:1 - container_name: uptime-kuma - hostname: uptime-kuma - restart: unless-stopped - volumes: - - ./data:/app/data - - /var/run/docker.sock:/var/run/docker.sock:ro - ports: - - "3001:3001" -UPTIME_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPTIME_DIR" - - echo "" - echo "✓ Uptime Kuma configured at $UPTIME_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Uptime Kuma" "3001" "uptime" - - prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME - if [ "$START_UPTIME" = "y" ] || [ "$START_UPTIME" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Uptime Kuma started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:3001" - echo "" - fi - fi - - # ---- WG-EASY (WireGuard with Web UI) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WGEASY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ WG-EASY - WireGuard VPN with web management │" - echo "│ Easy WireGuard setup with QR codes for clients. │" - echo "│ Port: 51821 (web), 51820 (VPN) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install wg-easy? (y/n):" "n" INSTALL_WGEASY - fi - - if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then - echo "Installing wg-easy..." - WGEASY_DIR="$DOCKER_DIR/wg-easy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $WGEASY_DIR" - else - mkdir -p "$WGEASY_DIR" - ensure_docker_dir_ownership "$WGEASY_DIR" - cd "$WGEASY_DIR" - - # Get public IP or hostname - PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "your-public-ip") - WG_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) - - prompt_text "Public IP or hostname for VPN [default: $PUBLIC_IP]:" "$PUBLIC_IP" WG_HOST - - cat > docker-compose.yml << WGEASY_COMPOSE -name: wg-easy - -services: - wg-easy: - image: ghcr.io/wg-easy/wg-easy:latest - container_name: wg-easy - hostname: wg-easy - restart: unless-stopped - cap_add: - - NET_ADMIN - - SYS_MODULE - sysctls: - - net.ipv4.ip_forward=1 - - net.ipv4.conf.all.src_valid_mark=1 - environment: - - WG_HOST=\${WG_HOST} - - PASSWORD=\${WG_PASSWORD} - - WG_DEFAULT_DNS=1.1.1.1 - volumes: - - ./config:/etc/wireguard - ports: - - "51820:51820/udp" - - "51821:51821/tcp" -WGEASY_COMPOSE - - cat > .env << WGEASY_ENV -WG_HOST=$WG_HOST -WG_PASSWORD=$WG_PASSWORD -WGEASY_ENV - - mkdir -p config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WGEASY_DIR" - - echo "" - echo "✓ wg-easy configured at $WGEASY_DIR" - - prompt_yn "Start wg-easy now? (y/n):" "y" START_WGEASY - if [ "$START_WGEASY" = "y" ] || [ "$START_WGEASY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ wg-easy started" || echo " ⚠ Failed to start" - fi - - echo " Web UI: http://localhost:51821" - echo " Password: $WG_PASSWORD (saved in .env)" - echo " VPN Port: 51820/udp (forward this in your router)" - echo "" - fi - fi - - # ---- TRACCAR GPS TRACKING ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_TRACCAR" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ TRACCAR - GPS tracking server │" - echo "│ Track phones, vehicles, assets with OwnTracks/Traccar apps. │" - echo "│ Port: 8082 (web), 5055 (OsmAnd), 5000+ (devices) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Traccar? (y/n):" "n" INSTALL_TRACCAR - fi - - if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then - echo "Installing Traccar..." - TRACCAR_DIR="$DOCKER_DIR/traccar" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $TRACCAR_DIR" - else - mkdir -p "$TRACCAR_DIR" - ensure_docker_dir_ownership "$TRACCAR_DIR" - cd "$TRACCAR_DIR" - - cat > docker-compose.yml << 'TRACCAR_COMPOSE' -name: traccar - -services: - traccar: - image: traccar/traccar:latest - container_name: traccar - hostname: traccar - restart: unless-stopped - volumes: - - ./logs:/opt/traccar/logs:rw - - ./data:/opt/traccar/data:rw - - ./config/traccar.xml:/opt/traccar/conf/traccar.xml:ro - ports: - - "8082:8082" - - "5000-5150:5000-5150" - - "5000-5150:5000-5150/udp" -TRACCAR_COMPOSE - - mkdir -p logs data config - - # Create basic traccar.xml config - cat > config/traccar.xml << 'TRACCAR_XML' - - - - - - ./conf/default.xml - org.h2.Driver - jdbc:h2:/opt/traccar/data/database - sa - - -TRACCAR_XML - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR" - - echo "" - echo "✓ Traccar configured at $TRACCAR_DIR" - - prompt_yn "Start Traccar now? (y/n):" "y" START_TRACCAR - if [ "$START_TRACCAR" = "y" ] || [ "$START_TRACCAR" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Traccar started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8082" - echo " Default: admin@admin.com / admin (change immediately!)" - echo "" - fi - fi - - # ---- PORTAINER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_PORTAINER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ PORTAINER - Docker management web UI │" - echo "│ Manage containers, images, volumes via browser. │" - echo "│ Port: 9443 (https), 9000 (http) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Portainer? (y/n):" "n" INSTALL_PORTAINER - fi - - if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then - echo "Installing Portainer..." - PORTAINER_DIR="$DOCKER_DIR/portainer" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $PORTAINER_DIR" - else - mkdir -p "$PORTAINER_DIR" - ensure_docker_dir_ownership "$PORTAINER_DIR" - cd "$PORTAINER_DIR" - - cat > docker-compose.yml << 'PORTAINER_COMPOSE' -name: portainer - -services: - portainer: - image: portainer/portainer-ce:latest - container_name: portainer - hostname: portainer - restart: always - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./data:/data - ports: - - "9000:9000" - - "9443:9443" -PORTAINER_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR" - - echo "" - echo "✓ Portainer configured at $PORTAINER_DIR" - - prompt_yn "Start Portainer now? (y/n):" "y" START_PORTAINER - if [ "$START_PORTAINER" = "y" ] || [ "$START_PORTAINER" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Portainer started" || echo " ⚠ Failed to start" - fi - - echo " Access at: https://localhost:9443" - echo " Create admin account on first visit" - echo "" - fi - fi - - # ---- MESHCENTRAL SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MESHCENTRAL_SERVER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MESHCENTRAL SERVER - Self-hosted remote management │" - echo "│ Full MeshCentral server (not just agent). Manage all devices. │" - echo "│ Port: 4430 (https), 4433 (agent) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install MeshCentral Server? (y/n):" "n" INSTALL_MESHCENTRAL_SERVER - fi - - if [ "$INSTALL_MESHCENTRAL_SERVER" = "y" ] || [ "$INSTALL_MESHCENTRAL_SERVER" = "Y" ]; then - echo "Installing MeshCentral Server..." - MC_DIR="$DOCKER_DIR/meshcentral" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MC_DIR" - else - mkdir -p "$MC_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$MC_DIR" - cd "$MC_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'MC_COMPOSE' -name: meshcentral - -services: - meshcentral: - image: ghcr.io/ylianst/meshcentral:latest - container_name: meshcentral - hostname: meshcentral - restart: unless-stopped - environment: - - NODE_ENV=production - - HOSTNAME=${MC_HOSTNAME:-localhost} - - REVERSE_PROXY=${MC_REVERSE_PROXY:-false} - - REVERSE_PROXY_TLS_PORT=${MC_TLS_PORT:-443} - - IFRAME=false - - ALLOW_NEW_ACCOUNTS=true - - WEBRTC=true - volumes: - - ./data:/opt/meshcentral/meshcentral-data - - ./files:/opt/meshcentral/meshcentral-files - - ./backups:/opt/meshcentral/meshcentral-backups - ports: - - "4430:443" - - "4433:4433" -MC_COMPOSE - echo "✓ Installed docker-compose.yml" - - # Ask for hostname - echo "" - prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME 2>/dev/null || MC_HOSTNAME="localhost" - - cat > .env << MC_ENV -MC_HOSTNAME=$MC_HOSTNAME -MC_REVERSE_PROXY=false -MC_TLS_PORT=443 -MC_ENV - - mkdir -p data files backups 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" 2>/dev/null || true - - echo "" - echo "✓ MeshCentral Server installed at $MC_DIR" - - prompt_yn "Start MeshCentral now? (y/n):" "y" START_MESHCENTRAL - if [ "$START_MESHCENTRAL" = "y" ] || [ "$START_MESHCENTRAL" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ MeshCentral started" || echo " ⚠ Failed to start" - fi - - echo " Access: https://localhost:4430" - echo "" - echo " First visit: Create admin account" - echo " Then: Add devices → Download agent for each OS" - echo "" - echo " ⚠️ For remote access, set MC_HOSTNAME in .env to your domain/IP" - echo " Docs: https://meshcentral.com/docs/" - echo "" - fi - fi - - # ---- FINDMYDEVICE (FMD) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FMD" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FINDMYDEVICE - Self-hosted device tracking │" - echo "│ Track and locate Android devices. Alternative to Google Find. │" - echo "│ Port: 8084 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install FindMyDevice server? (y/n):" "n" INSTALL_FMD - fi - - if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then - echo "Installing FindMyDevice..." - FMD_DIR="$DOCKER_DIR/fmd" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FMD_DIR" - else - mkdir -p "$FMD_DIR" - ensure_docker_dir_ownership "$FMD_DIR" - cd "$FMD_DIR" - - # Generate random admin password - FMD_ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) - - cat > docker-compose.yml << FMD_COMPOSE -name: fmd - -services: - fmd: - image: nulide/findmydevice - container_name: fmd - hostname: fmd - restart: unless-stopped - environment: - - FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD} - volumes: - - ./data:/fmd/data - ports: - - "8084:8080" -FMD_COMPOSE - - cat > .env << FMD_ENV -FMD_ADMIN_PASSWORD=$FMD_ADMIN_PASS -FMD_ENV - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" - - echo "" - echo "✓ FindMyDevice configured at $FMD_DIR" - - prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD - if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ FindMyDevice started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8084" - echo " Admin password: $FMD_ADMIN_PASS (saved in .env)" - echo "" - echo " Mobile app: Install 'FindMyDevice' from F-Droid" - echo " Configure app to point to: http://YOUR-SERVER-IP:8084" - echo "" - fi - fi - - # ---- FRIGATE-NOTIFY ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE_NOTIFY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FRIGATE-NOTIFY - Push notifications for Frigate events │" - echo "│ Get alerts when Frigate detects people, cars, etc. │" - echo "│ Sends to: ntfy, Pushover, Discord, Gotify, and more. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Frigate-Notify? (y/n):" "n" INSTALL_FRIGATE_NOTIFY - fi - - if [ "$INSTALL_FRIGATE_NOTIFY" = "y" ] || [ "$INSTALL_FRIGATE_NOTIFY" = "Y" ]; then - echo "Installing Frigate-Notify..." - FN_DIR="$DOCKER_DIR/frigate-notify" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FN_DIR" - else - # STEP 1: Create directory and docker-compose (always succeeds) - mkdir -p "$FN_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$FN_DIR" - cd "$FN_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'FN_COMPOSE' -name: frigate-notify - -services: - frigate-notify: - image: ghcr.io/0x2142/frigate-notify:latest - container_name: frigate-notify - hostname: frigate-notify - restart: unless-stopped - volumes: - - ./config.yml:/app/config.yml:ro -FN_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try configuration (uses defaults if prompts fail) - echo "" - echo "Attempting auto-configuration..." - - # Set smart defaults based on what's installed - FRIGATE_URL="http://frigate:5000" - NTFY_URL="https://ntfy.sh" - NTFY_TOPIC="frigate-alerts" - - [ -d "$DOCKER_DIR/frigate" ] && echo " ✓ Frigate detected" || echo " ⚠ Frigate not found (using default URL)" - [ -d "$DOCKER_DIR/ntfy" ] && { NTFY_URL="http://ntfy:80"; echo " ✓ ntfy detected"; } || echo " ⚠ ntfy not found (using ntfy.sh)" - - # Try prompts, use defaults if they fail - echo "" - prompt_text "Frigate URL [$FRIGATE_URL]:" "$FRIGATE_URL" FRIGATE_URL 2>/dev/null || FRIGATE_URL="http://frigate:5000" - prompt_text "ntfy server [$NTFY_URL]:" "$NTFY_URL" NTFY_URL 2>/dev/null || NTFY_URL="https://ntfy.sh" - prompt_text "ntfy topic [frigate-alerts]:" "frigate-alerts" NTFY_TOPIC 2>/dev/null || NTFY_TOPIC="frigate-alerts" - - # Create config (template with user values or defaults) - cat > config.yml << FN_CONFIG -# Frigate-Notify Configuration -# Docs: https://frigate-notify.0x2142.com -# -# ⚠️ YOU MAY NEED TO EDIT THIS FILE! -# If notifications don't work, check: -# - Frigate server URL is correct -# - ntfy server is reachable -# - Containers are on same Docker network - -frigate: - server: $FRIGATE_URL - webapi: - enabled: true - interval: 30 - -alerts: - general: - send_startup_message: true - labels: - - person - - car - # - dog - # - package - -notifiers: - - name: ntfy - enabled: true - provider: ntfy - config: - server: $NTFY_URL - topic: $NTFY_TOPIC -FN_CONFIG - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FN_DIR" 2>/dev/null || true - - echo "" - echo "✓ Frigate-Notify installed at $FN_DIR" - - prompt_yn "Start Frigate-Notify now? (y/n):" "y" START_FN - if [ "$START_FN" = "y" ] || [ "$START_FN" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Frigate-Notify started" || echo " ⚠ Failed to start" - fi - - echo " Config: $FN_DIR/config.yml (edit if needed)" - echo " Docs: https://frigate-notify.0x2142.com" - echo "" - fi - fi - - # ---- WATCHTOWER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WATCHTOWER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ WATCHTOWER - Container update monitoring │" - echo "│ Monitor containers for updates. NOTIFY ONLY by default. │" - echo "│ Why notify-only? Apps like Immich have breaking DB migrations. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Watchtower? (y/n):" "n" INSTALL_WATCHTOWER - fi - - if [ "$INSTALL_WATCHTOWER" = "y" ] || [ "$INSTALL_WATCHTOWER" = "Y" ]; then - echo "Installing Watchtower..." - WT_DIR="$DOCKER_DIR/watchtower" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $WT_DIR" - else - mkdir -p "$WT_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$WT_DIR" - cd "$WT_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Ask about mode - echo "" - echo "Watchtower Mode:" - echo " [M] Monitor only - Get notifications about available updates (SAFE)" - echo " [A] Auto-update - Automatically pull and restart containers (RISKY)" - echo "" - echo " ⚠️ Auto-update can break apps like Immich that need DB migrations!" - echo " Recommendation: Use monitor mode, update manually when ready." - echo "" - WT_MODE="M" - prompt_text "Mode [M/A]:" "M" WT_MODE 2>/dev/null || WT_MODE="M" - WT_MODE=$(echo "$WT_MODE" | tr '[:lower:]' '[:upper:]') - - if [ "$WT_MODE" = "A" ]; then - MONITOR_ONLY="false" - echo " Mode: Auto-update (containers will be updated automatically)" - else - MONITOR_ONLY="true" - echo " Mode: Monitor only (you'll be notified of updates)" - fi - - # Check for ntfy - NTFY_URL="" - if [ -d "$DOCKER_DIR/ntfy" ]; then - echo " ✓ ntfy detected - configuring notifications" - NTFY_URL="http://ntfy/watchtower" - fi - - cat > docker-compose.yml << WT_COMPOSE -name: watchtower - -services: - watchtower: - image: containrrr/watchtower:latest - container_name: watchtower - hostname: watchtower - restart: unless-stopped - environment: - # Check for updates daily at 4 AM - - WATCHTOWER_SCHEDULE=0 0 4 * * * - # Monitor only - don't auto-update (change to false for auto-update) - - WATCHTOWER_MONITOR_ONLY=${MONITOR_ONLY} - # Cleanup old images after update - - WATCHTOWER_CLEANUP=true - # Include stopped containers - - WATCHTOWER_INCLUDE_STOPPED=true - # Notification URL (ntfy, Discord, Slack, etc.) - - WATCHTOWER_NOTIFICATION_URL=${NOTIFICATION_URL:-} - # Show debug info - - WATCHTOWER_DEBUG=false - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro -WT_COMPOSE - - # Create .env - cat > .env << WT_ENV -# Watchtower Configuration -# ========================= -# -# Monitor-only mode: Watchtower checks for updates but doesn't apply them. -# This is SAFER because some apps (Immich, Mealie) have database migrations -# that can break if you update without proper procedures. -# -# To update manually: -# cd ~/docker/{app} -# docker compose pull -# docker compose up -d - -# Set to "false" to enable auto-updates (RISKY!) -MONITOR_ONLY=$MONITOR_ONLY - -# Notification URL (optional) -# Examples: -# ntfy: ntfy://ntfy.example.com/watchtower -# Discord: discord://token@id -# Slack: slack://hook-url -# Gotify: gotify://hostname/token -# -# Full list: https://containrrr.dev/shoutrrr/services/overview/ -NOTIFICATION_URL=$NTFY_URL -WT_ENV - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WT_DIR" 2>/dev/null || true - - echo "" - echo "✓ Watchtower installed at $WT_DIR" - - prompt_yn "Start Watchtower now? (y/n):" "y" START_WATCHTOWER - if [ "$START_WATCHTOWER" = "y" ] || [ "$START_WATCHTOWER" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Watchtower started" || echo " ⚠ Failed to start" - fi - - echo " Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update")" - echo "" - echo " Checks for updates daily at 4 AM." - if [ -n "$NTFY_URL" ]; then - echo " Notifications: $NTFY_URL" - else - echo " Configure NOTIFICATION_URL in .env for alerts." - fi - echo "" - echo " To exclude a container from Watchtower:" - echo " Add label: com.centurylinklabs.watchtower.enable=false" - echo "" - fi - fi - - fi # End SKIP_DOCKER_INSTALLS check - - # ============================================================================ - # KOPIA BACKUP FOR DOCKER CONTAINERS - # ============================================================================ - if [ "$WHIPTAIL_USED" != true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DOCKER CONTAINER BACKUP (Kopia)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Backup all Docker containers (configs, databases, app data) to your" - echo "backup drives. Essential for disaster recovery - if your OS drive" - echo "fails, you can restore everything including:" - echo " • Immich memories, facial recognition data" - echo " • Emby/Jellyfin metadata, watch history" - echo " • Minecraft worlds, mods, permissions" - echo " • All app configs, users, and databases" - echo "" - prompt_yn "Set up Kopia container backup? (y/n):" "n" INSTALL_KOPIA - - if [ "$INSTALL_KOPIA" = "y" ] || [ "$INSTALL_KOPIA" = "Y" ]; then - echo "Installing Kopia backup..." - KOPIA_DIR="$DOCKER_DIR/kopia" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $KOPIA_DIR" - else - mkdir -p "$KOPIA_DIR" - ensure_docker_dir_ownership "$KOPIA_DIR" - cd "$KOPIA_DIR" - - KOPIA_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) - - echo "" - echo "Select backup destination(s):" - echo " Backups will be stored in ~/drives/{backup-drive}/kopia-repo/" - echo "" - - # List available backup drives - echo "Available mount points in ~/drives/:" - ls -1 "$ACTUAL_HOME/drives/" 2>/dev/null | grep -v "^primary$" || echo " (none found - set up drives first)" - echo "" - - prompt_text "Backup drive name [default: backup1]:" "backup1" KOPIA_BACKUP_DRIVE - - KOPIA_REPO="$ACTUAL_HOME/drives/$KOPIA_BACKUP_DRIVE/kopia-repo" - - cat > docker-compose.yml << KOPIA_COMPOSE -name: kopia - -services: - kopia: - image: kopia/kopia:latest - container_name: kopia - hostname: kopia - restart: unless-stopped - privileged: true - devices: - - /dev/fuse:/dev/fuse:rwm - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - KOPIA_PASSWORD=\${KOPIA_PASSWORD} - command: > - server start - --tls-generate-cert - --disable-csrf-token-checks - --address=0.0.0.0:51515 - --server-username=admin - --server-password=\${KOPIA_PASSWORD} - volumes: - - ./config:/app/config - - ./cache:/app/cache - - ./logs:/app/logs - - $DOCKER_DIR:/data/docker:ro - - $KOPIA_REPO:/repository - - ./tmp:/tmp:shared - ports: - - "51515:51515" -KOPIA_COMPOSE - - cat > .env << KOPIA_ENV -KOPIA_PASSWORD=$KOPIA_PASSWORD -KOPIA_ENV - - mkdir -p config cache logs tmp - mkdir -p "$KOPIA_REPO" - - # Create backup script - cat > backup-containers.sh << 'BACKUP_SCRIPT' -#!/bin/bash -# Backup all Docker containers using Kopia - -DOCKER_DIR="$HOME/docker" -TIMESTAMP=$(date +%Y%m%d-%H%M%S) - -echo "=== Docker Container Backup: $TIMESTAMP ===" -echo "" - -# Stop containers before backup for consistency (optional) -read -p "Stop containers during backup for consistency? (y/n): " STOP_CONTAINERS - -if [ "$STOP_CONTAINERS" = "y" ]; then - echo "Stopping containers..." - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ]; then - echo " Stopping $(basename $dir)..." - (cd "$dir" && docker compose stop) 2>/dev/null - fi - done -fi - -echo "" -echo "Running Kopia backup..." -docker exec kopia kopia snapshot create /data/docker --description "Container backup $TIMESTAMP" - -if [ "$STOP_CONTAINERS" = "y" ]; then - echo "" - echo "Restarting containers..." - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ]; then - echo " Starting $(basename $dir)..." - (cd "$dir" && docker compose start) 2>/dev/null - fi - done -fi - -echo "" -echo "=== Backup Complete ===" -docker exec kopia kopia snapshot list /data/docker --max-results 5 -BACKUP_SCRIPT - - # Create restore script - cat > restore-containers.sh << 'RESTORE_SCRIPT' -#!/bin/bash -# Restore Docker containers from Kopia backup - -echo "=== Docker Container Restore ===" -echo "" -echo "Available snapshots:" -docker exec kopia kopia snapshot list /data/docker - -echo "" -echo "To restore a specific snapshot:" -echo " docker exec kopia kopia restore /tmp/restore" -echo " Then copy files from ~/docker/kopia/tmp/restore/ to ~/docker/" -echo "" -echo "To mount snapshots for browsing:" -echo " docker exec kopia kopia mount all /tmp/mnt &" -echo " Then browse: ~/docker/kopia/tmp/mnt/" -RESTORE_SCRIPT - - chmod +x backup-containers.sh restore-containers.sh - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$KOPIA_DIR" - - echo "" - echo "✓ Kopia backup configured at $KOPIA_DIR" - - prompt_yn "Start Kopia now? (y/n):" "y" START_KOPIA - if [ "$START_KOPIA" = "y" ] || [ "$START_KOPIA" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Kopia started" || echo " ⚠ Failed to start" - fi - - echo " Web UI: https://localhost:51515" - echo " Username: admin" - echo " Password: $KOPIA_PASSWORD (saved in .env)" - echo "" - echo " Repository at: $KOPIA_REPO" - echo "" - echo " Backup now: cd $KOPIA_DIR && ./backup-containers.sh" - echo " Restore: cd $KOPIA_DIR && ./restore-containers.sh" - echo "" - echo " ⚠️ SAVE YOUR KOPIA PASSWORD! Without it, backups cannot be restored." - echo "" - fi - fi - fi # End WHIPTAIL_USED check for Kopia - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Docker applications configured in: $DOCKER_DIR" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To start an application:" - echo " cd ~/docker/{appname}" - echo " docker compose up -d" - echo "" - echo "To view logs:" - echo " docker compose logs -f" - echo "" - echo "To stop:" - echo " docker compose down" - echo "" -fi - -# Backup System (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "LOCAL BACKUP SYSTEM (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Set up rsync backup from primary drive(s) to backup drive(s)." -echo "" -echo "Why rsync instead of RAID?" -echo " • RAID mirrors corruption instantly - rsync gives you time to notice" -echo " • RAID requires identical drives - rsync works with any sizes" -echo " • RAID is complex to set up/recover - rsync is simple copy" -echo " • rsync can run on schedule - RAID is always-on (more wear)" -echo " • With rsync, backup drives can be disconnected for safety" -echo "" - -# Check if backup is already configured -BACKUP_CONFIGURED=false -if [ -f /usr/local/bin/backup-scripts/rsync-backup.sh ]; then - BACKUP_CONFIGURED=true - echo "Local backup system is already configured." - echo "" - prompt_yn "Reconfigure local backup system? (y/n):" "n" SETUP_BACKUP -else - prompt_yn "Set up local backup system? (y/n):" "n" SETUP_BACKUP -fi - -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Configure your drive mount points in ~/drives/" - echo "Default names: primary, backup1, backup2, etc." - echo "You can customize these (e.g., 'media', 'documents', 'photos-backup')" - echo "" - - # Install rsync if needed - if ! is_rsync_installed; then - run_cmd apt install -y rsync || echo "Warning: rsync installation failed" - fi - - # Create backup script directory - mkdir -p /usr/local/bin/backup-scripts - - # Ask for primary drive name - echo "PRIMARY DRIVE (source for backups):" - prompt_text " Mount point name [default: primary]:" "primary" PRIMARY_NAME - PRIMARY_NAME="${PRIMARY_NAME:-primary}" - - # Ask for number of backup drives - echo "" - echo "BACKUP DRIVES (destinations):" - prompt_text " How many backup drives? [1-4, default: 1]:" "1" NUM_BACKUPS - NUM_BACKUPS="${NUM_BACKUPS:-1}" - - # Validate number - case $NUM_BACKUPS in - 1|2|3|4) ;; - *) NUM_BACKUPS=1 ;; - esac - - # Collect backup drive names - declare -a BACKUP_NAMES - for i in $(seq 1 $NUM_BACKUPS); do - prompt_text " Backup drive $i name [default: backup$i]:" "backup$i" "BACKUP_NAME_$i" - eval "BACKUP_NAMES[$i]=\${BACKUP_NAME_$i:-backup$i}" - done - - # Create mount point directories - echo "" - echo "Creating mount point directories..." - mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME" - for i in $(seq 1 $NUM_BACKUPS); do - mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - done - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - echo "✓ Created mount points:" - echo " Primary: $ACTUAL_HOME/drives/$PRIMARY_NAME" - for i in $(seq 1 $NUM_BACKUPS); do - echo " Backup$i: $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - done - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE MOUNTING" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Available block devices:" - echo "" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - - prompt_yn "Mount drives now? (y/n):" "n" MOUNT_NOW - - if [ "$MOUNT_NOW" = "y" ] || [ "$MOUNT_NOW" = "Y" ]; then - echo "" - echo "Enter device paths (e.g., /dev/sdb1) or leave blank to skip" - echo "" - - read -p "Primary drive ($PRIMARY_NAME) device: " PRIMARY_DEV - - declare -a BACKUP_DEVS - for i in $(seq 1 $NUM_BACKUPS); do - read -p "Backup drive ${BACKUP_NAMES[$i]} device: " "BACKUP_DEV_$i" - eval "BACKUP_DEVS[$i]=\$BACKUP_DEV_$i" - done - - # Mount primary - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - echo "Mounting $PRIMARY_DEV to $ACTUAL_HOME/drives/$PRIMARY_NAME..." - mount "$PRIMARY_DEV" "$ACTUAL_HOME/drives/$PRIMARY_NAME" && echo "✓ $PRIMARY_NAME mounted" || echo "✗ Failed to mount $PRIMARY_NAME" - fi - - # Mount backups - for i in $(seq 1 $NUM_BACKUPS); do - if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then - echo "Mounting ${BACKUP_DEVS[$i]} to $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}..." - mount "${BACKUP_DEVS[$i]}" "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" && echo "✓ ${BACKUP_NAMES[$i]} mounted" || echo "✗ Failed to mount ${BACKUP_NAMES[$i]}" - fi - done - - echo "" - echo "Current mounts:" - df -h | grep "$ACTUAL_HOME/drives" || echo " (no drives currently mounted)" - - echo "" - prompt_yn "Add to /etc/fstab for auto-mount at boot? (y/n):" "n" ADD_FSTAB - - if [ "$ADD_FSTAB" = "y" ] || [ "$ADD_FSTAB" = "Y" ]; then - echo "" - echo "Adding entries to /etc/fstab..." - cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) - - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV") - if [ -n "$PRIMARY_UUID" ]; then - echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab - echo "✓ Added $PRIMARY_NAME to fstab" - fi - fi - - for i in $(seq 1 $NUM_BACKUPS); do - if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then - BACKUP_UUID=$(blkid -s UUID -o value "${BACKUP_DEVS[$i]}") - if [ -n "$BACKUP_UUID" ]; then - echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab - echo "✓ Added ${BACKUP_NAMES[$i]} to fstab" - fi - fi - done - fi - fi - - # Create rsync backup script - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CREATING BACKUP SCRIPT" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - - # Build backup destinations list - BACKUP_DESTS="" - for i in $(seq 1 $NUM_BACKUPS); do - BACKUP_DESTS="$BACKUP_DESTS \"$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}\"" - done - - cat > /usr/local/bin/backup-scripts/rsync-backup.sh << BACKUP_SCRIPT -#!/bin/bash -################################################################################ -# rsync Local Backup Script -# Backs up primary drive to all backup drives -################################################################################ - -PRIMARY="$ACTUAL_HOME/drives/$PRIMARY_NAME" -BACKUP_DRIVES=($BACKUP_DESTS) -LOG="/var/log/rsync-backup.log" - -echo "=== rsync Backup Started: \$(date) ===" | tee -a "\$LOG" -echo "Source: \$PRIMARY" | tee -a "\$LOG" -echo "" | tee -a "\$LOG" - -if [ ! -d "\$PRIMARY" ] || [ -z "\$(ls -A \$PRIMARY 2>/dev/null)" ]; then - echo "ERROR: Primary drive not mounted or empty at \$PRIMARY" | tee -a "\$LOG" - exit 1 -fi - -for BACKUP in "\${BACKUP_DRIVES[@]}"; do - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "\$LOG" - echo "Backing up to: \$BACKUP" | tee -a "\$LOG" - - if [ ! -d "\$BACKUP" ]; then - echo "⚠️ WARNING: \$BACKUP not mounted, skipping" | tee -a "\$LOG" - continue - fi - - # rsync options: - # -a = archive mode (preserves permissions, timestamps, etc.) - # -v = verbose - # -h = human-readable sizes - # --delete = remove files from backup that don't exist on primary - # --progress = show progress - - rsync -avh --delete --progress "\$PRIMARY/" "\$BACKUP/" 2>&1 | tee -a "\$LOG" - - if [ \$? -eq 0 ]; then - echo "✓ Backup to \$BACKUP completed" | tee -a "\$LOG" - else - echo "✗ Backup to \$BACKUP FAILED" | tee -a "\$LOG" - fi -done - -echo "" | tee -a "\$LOG" -echo "=== Backup Completed: \$(date) ===" | tee -a "\$LOG" -BACKUP_SCRIPT - - chmod +x /usr/local/bin/backup-scripts/rsync-backup.sh - - # Create systemd service - cat > /etc/systemd/system/rsync-backup.service << SERVICE -[Unit] -Description=rsync Local Backup Service -After=network.target - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/backup-scripts/rsync-backup.sh -User=root -SERVICE - - # Create systemd timer for daily backups at 2 AM - cat > /etc/systemd/system/rsync-backup.timer << TIMER -[Unit] -Description=Daily rsync Backup Timer -Requires=rsync-backup.service - -[Timer] -OnCalendar=*-*-* 02:00:00 -Persistent=true - -[Install] -WantedBy=timers.target -TIMER - - echo "" - echo "✓ Backup script created: /usr/local/bin/backup-scripts/rsync-backup.sh" - echo "✓ Systemd service/timer created (disabled by default)" - echo "" - echo "Quick start:" - echo " Test backup: sudo /usr/local/bin/backup-scripts/rsync-backup.sh" - echo " Enable daily: sudo systemctl enable --now rsync-backup.timer" - echo " View log: tail -f /var/log/rsync-backup.log" -else - echo "Skipping local backup setup." -fi -fi # End WHIPTAIL_USED check for local backup - -# Cloud Backup with rclone (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "CLOUD BACKUP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Set up encrypted cloud backup using rclone." -echo "Supports: Google Drive, OneDrive, Dropbox, and 40+ other providers." -echo "" -echo "Your files are encrypted BEFORE upload - the cloud provider cannot read them." -echo "" - -prompt_yn "Set up encrypted cloud backup? (y/n):" "n" SETUP_CLOUD_BACKUP - -if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then - echo "" - - # Install rclone if needed - if ! is_rclone_installed; then - echo "Installing rclone..." - run_cmd apt install -y rclone || echo "Warning: rclone installation failed" - fi - - if is_rclone_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CHOOSE CLOUD PROVIDER" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo " [1] Google Drive (15GB free)" - echo " [2] Microsoft OneDrive (5GB free, 1TB with Microsoft 365)" - echo " [3] Other (manual rclone config)" - echo "" - prompt_text "Select provider [1/2/3]:" "1" CLOUD_PROVIDER - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "RCLONE CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - case $CLOUD_PROVIDER in - 1) - echo "Setting up Google Drive..." - echo "" - echo "Step 1: Run 'rclone config' to create a Google Drive remote" - echo "Step 2: When prompted:" - echo " - Choose 'n' for new remote" - echo " - Name it 'gdrive'" - echo " - Choose 'drive' (Google Drive)" - echo " - Leave client_id and client_secret blank" - echo " - Choose scope '1' (full access)" - echo " - Leave root_folder_id blank" - echo " - Leave service_account_file blank" - echo " - Choose 'n' for advanced config" - echo " - Choose 'y' for auto config (opens browser)" - echo " - Choose 'n' for team drive" - echo " - Confirm with 'y'" - echo "" - CLOUD_REMOTE="gdrive" - ;; - 2) - echo "Setting up Microsoft OneDrive..." - echo "" - echo "Step 1: Run 'rclone config' to create a OneDrive remote" - echo "Step 2: When prompted:" - echo " - Choose 'n' for new remote" - echo " - Name it 'onedrive'" - echo " - Choose 'onedrive' (Microsoft OneDrive)" - echo " - Leave client_id and client_secret blank" - echo " - Choose region (usually 'global')" - echo " - Choose 'n' for advanced config" - echo " - Choose 'y' for auto config (opens browser)" - echo " - Choose 'onedrive' for account type" - echo " - Choose your drive from the list (usually option 0)" - echo " - Confirm with 'y'" - echo "" - CLOUD_REMOTE="onedrive" - ;; - *) - echo "Manual configuration selected." - echo "Run 'rclone config' to set up your cloud provider." - echo "" - prompt_text "Enter the remote name you will create:" "cloud" CLOUD_REMOTE - ;; - esac - - if [ "$UNATTENDED" != true ]; then - echo "Press Enter to launch rclone config..." - read - sudo -u "$ACTUAL_USER" rclone config - else - echo "Skipping interactive rclone config (unattended mode)" - echo "Run 'rclone config' manually to complete setup" - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "ENCRYPTION SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Now we'll create an encrypted wrapper around your cloud storage." - echo "This encrypts file names AND contents before upload." - echo "" - echo "⚠️ IMPORTANT: You will set a password. Without this password and" - echo " the rclone config file, your files CANNOT be recovered!" - echo "" - - if [ "$UNATTENDED" != true ]; then - echo "In rclone config:" - echo " - Choose 'n' for new remote" - echo " - Name it '${CLOUD_REMOTE}-crypt'" - echo " - Choose 'crypt' (Encrypt/Decrypt)" - echo " - Remote: '${CLOUD_REMOTE}:backup' (folder on cloud storage)" - echo " - Choose 'standard' for filename encryption" - echo " - Choose 'true' for directory name encryption" - echo " - Choose 'y' to enter your own password" - echo " - Enter a STRONG password (you'll need this to decrypt!)" - echo " - Choose 'y' for salt password (or 'n' to skip)" - echo " - Confirm with 'y'" - echo "" - echo "Press Enter to continue rclone config..." - read - sudo -u "$ACTUAL_USER" rclone config - fi - - # Backup the rclone config to local drives - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "BACKUP YOUR RCLONE CONFIG" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Your rclone config contains your encryption keys." - echo "WITHOUT IT, YOUR ENCRYPTED CLOUD FILES CANNOT BE DECRYPTED!" - echo "" - echo "The config file is at: $ACTUAL_HOME/.config/rclone/rclone.conf" - echo "" - - # Copy config to any mounted backup drives - if [ -d "$ACTUAL_HOME/drives" ]; then - for drive_dir in "$ACTUAL_HOME/drives"/*/; do - if [ -d "$drive_dir" ] && mountpoint -q "$drive_dir" 2>/dev/null; then - mkdir -p "${drive_dir}.rclone-config-backup" - if [ -f "$ACTUAL_HOME/.config/rclone/rclone.conf" ]; then - cp "$ACTUAL_HOME/.config/rclone/rclone.conf" "${drive_dir}.rclone-config-backup/rclone.conf.backup" - echo "✓ Config backed up to: ${drive_dir}.rclone-config-backup/" - fi - fi - done - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "SECURE OFF-SITE BACKUP OF CONFIG" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "For disaster recovery, store a copy of rclone.conf OFF-SITE:" - echo "" - echo "Recommended secure methods:" - echo " • Signal (end-to-end encrypted, send to yourself or trusted contact)" - echo " • Box.com (better privacy policy than Dropbox)" - echo " • Password manager (1Password, Bitwarden, etc.)" - echo " • Encrypted USB drive stored at another location" - echo "" - echo "⚠️ Dropbox: Works but has broader data access policies." - echo " Consider encrypting the config file before uploading." - echo "" - echo "To use on another computer:" - echo " 1. Install rclone" - echo " 2. Copy rclone.conf to ~/.config/rclone/" - echo " 3. Run: rclone ls ${CLOUD_REMOTE}-crypt:" - echo "" - - # Create cloud backup script - mkdir -p /usr/local/bin/backup-scripts - - SOURCE_PATH="${PRIMARY_NAME:-primary}" - - cat > /usr/local/bin/backup-scripts/cloud-backup.sh << CLOUD_SCRIPT -#!/bin/bash -################################################################################ -# rclone Encrypted Cloud Backup Script -################################################################################ - -SOURCE="$ACTUAL_HOME/drives/$SOURCE_PATH" -REMOTE="${CLOUD_REMOTE:-gdrive}-crypt" -LOG="/var/log/cloud-backup.log" - -echo "=== Cloud Backup Started: \$(date) ===" | tee -a "\$LOG" -echo "Source: \$SOURCE" | tee -a "\$LOG" -echo "Destination: \$REMOTE:" | tee -a "\$LOG" -echo "" | tee -a "\$LOG" - -if [ ! -d "\$SOURCE" ] || [ -z "\$(ls -A \$SOURCE 2>/dev/null)" ]; then - echo "ERROR: Source not mounted or empty at \$SOURCE" | tee -a "\$LOG" - exit 1 -fi - -# Sync to encrypted cloud storage -rclone sync "\$SOURCE" "\$REMOTE:" \\ - --progress \\ - --stats=30s \\ - --log-file="\$LOG" \\ - --log-level INFO - -if [ \$? -eq 0 ]; then - echo "" | tee -a "\$LOG" - echo "✓ Cloud backup completed: \$(date)" | tee -a "\$LOG" -else - echo "" | tee -a "\$LOG" - echo "✗ Cloud backup FAILED: \$(date)" | tee -a "\$LOG" -fi -CLOUD_SCRIPT - - chmod +x /usr/local/bin/backup-scripts/cloud-backup.sh - chown "$ACTUAL_USER:$ACTUAL_USER" /usr/local/bin/backup-scripts/cloud-backup.sh - - echo "" - echo "✓ Cloud backup script created: /usr/local/bin/backup-scripts/cloud-backup.sh" - echo "" - echo "Quick start:" - echo " Test backup: sudo /usr/local/bin/backup-scripts/cloud-backup.sh" - echo " View log: tail -f /var/log/cloud-backup.log" - else - echo "rclone installation failed. Skipping cloud backup setup." - fi -else - echo "Skipping cloud backup setup." -fi -fi # End WHIPTAIL_USED check for cloud backup - -# UFW Firewall Configuration (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "UFW FIREWALL (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "UFW (Uncomplicated Firewall) provides a simple interface for managing" -echo "iptables firewall rules." -echo "" - -UFW_ACTIVE=false -if is_ufw_installed && ufw status 2>/dev/null | grep -q "Status: active"; then - UFW_ACTIVE=true - echo "UFW is already enabled." - ufw status 2>/dev/null | head -20 - echo "" - prompt_yn "Reconfigure UFW? (y/n):" "n" CONFIGURE_UFW -else - if is_ufw_installed; then - echo "UFW is installed but not enabled." - else - echo "UFW is not installed." - fi - echo "" - prompt_yn "Enable and configure UFW firewall? (y/n):" "y" CONFIGURE_UFW -fi - -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo "" - echo "Configuring UFW firewall..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install ufw if needed" - echo "[DRY-RUN] Would allow SSH (port 22)" - if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo "[DRY-RUN] Would allow Samba" - fi - echo "[DRY-RUN] Would enable UFW" - else - # Install UFW if not present - if ! is_ufw_installed; then - apt install -y ufw || echo "Warning: UFW installation failed" - fi - - if is_ufw_installed; then - # Always allow SSH first (before enabling!) - ufw allow ssh - echo "✓ Allowed SSH (port 22)" - - # Allow Samba if installed - if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ] || is_samba_installed; then - ufw allow samba - echo "✓ Allowed Samba" - fi - - # Allow Docker service ports (only if Docker was installed) - if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo "" - echo "Opening firewall ports for Docker services..." - - prompt_yn "Open firewall ports for installed Docker services? (y/n):" "y" OPEN_DOCKER_PORTS - if [ "$OPEN_DOCKER_PORTS" = "y" ] || [ "$OPEN_DOCKER_PORTS" = "Y" ]; then - - # Reverse proxies (NPM or Caddy) - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - ufw allow 80/tcp comment 'HTTP' 2>/dev/null - ufw allow 443/tcp comment 'HTTPS' 2>/dev/null - echo " ✓ Allowed HTTP/HTTPS (80, 443)" - fi - - # Media servers - if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then - ufw allow 2283/tcp comment 'Immich' 2>/dev/null - echo " ✓ Allowed Immich (2283)" - fi - if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then - ufw allow 8096/tcp comment 'Jellyfin' 2>/dev/null - echo " ✓ Allowed Jellyfin (8096)" - fi - if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then - ufw allow 8096/tcp comment 'Emby' 2>/dev/null - echo " ✓ Allowed Emby (8096)" - fi - - # NVR - if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then - ufw allow 5000/tcp comment 'Frigate' 2>/dev/null - ufw allow 8554/tcp comment 'Frigate RTSP' 2>/dev/null - ufw allow 8555/tcp comment 'Frigate WebRTC' 2>/dev/null - ufw allow 8555/udp comment 'Frigate WebRTC UDP' 2>/dev/null - echo " ✓ Allowed Frigate (5000, 8554, 8555)" - fi - - # Utilities - if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then - ufw allow 9000/tcp comment 'Portainer HTTP' 2>/dev/null - ufw allow 9443/tcp comment 'Portainer HTTPS' 2>/dev/null - echo " ✓ Allowed Portainer (9000, 9443)" - fi - if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then - ufw allow 3001/tcp comment 'Uptime Kuma' 2>/dev/null - echo " ✓ Allowed Uptime Kuma (3001)" - fi - - # VPN - if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then - ufw allow 51820/udp comment 'WireGuard VPN' 2>/dev/null - ufw allow 51821/tcp comment 'WG-Easy Web UI' 2>/dev/null - echo " ✓ Allowed WireGuard (51820/udp, 51821)" - fi - - # GPS Tracking - if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then - ufw allow 8082/tcp comment 'Traccar' 2>/dev/null - ufw allow 5055/tcp comment 'Traccar OsmAnd' 2>/dev/null - echo " ✓ Allowed Traccar (8082, 5055)" - fi - - # Music server - if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then - ufw allow 9000/tcp comment 'Lyrion Music Server' 2>/dev/null - ufw allow 3483/tcp comment 'LMS Players' 2>/dev/null - ufw allow 3483/udp comment 'LMS Players UDP' 2>/dev/null - echo " ✓ Allowed Lyrion Music Server (9000, 3483)" - fi - - # Notifications - if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then - ufw allow 8090/tcp comment 'ntfy' 2>/dev/null - echo " ✓ Allowed ntfy (8090)" - fi - - # Minecraft - if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then - ufw allow 25565/tcp comment 'Minecraft' 2>/dev/null - echo " ✓ Allowed Minecraft (25565)" - fi - - # Other services - if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then - ufw allow 8085/tcp comment 'Filebrowser' 2>/dev/null - echo " ✓ Allowed Filebrowser (8085)" - fi - if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then - ufw allow 8084/tcp comment 'FindMyDevice' 2>/dev/null - echo " ✓ Allowed FindMyDevice (8084)" - fi - if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then - ufw allow 9925/tcp comment 'Mealie' 2>/dev/null - echo " ✓ Allowed Mealie (9925)" - fi - if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then - ufw allow 8123/tcp comment 'Home Assistant' 2>/dev/null - echo " ✓ Allowed Home Assistant (8123)" - fi - if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then - ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null - echo " ✓ Allowed MagicMirror (8081-8083)" - fi - if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then - ufw allow 8080/tcp comment 'A.R.M.' 2>/dev/null - echo " ✓ Allowed A.R.M. (8080)" - fi - if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then - ufw allow 13378/tcp comment 'Audiobookshelf' 2>/dev/null - echo " ✓ Allowed Audiobookshelf (13378)" - fi - - echo "" - fi - fi - - # Enable UFW (with --force to avoid prompt) - ufw --force enable - echo "✓ UFW firewall enabled" - - echo "" - echo "Current UFW status:" - ufw status - else - echo "✗ UFW installation failed, skipping configuration" - fi - fi -else - echo "Skipping UFW configuration." -fi - -CONFIGURE_UFW="${CONFIGURE_UFW:-n}" -fi # End WHIPTAIL_USED check for UFW - -# Full system upgrade (optional) -echo "" -prompt_yn "Perform full system upgrade? (y/n):" "n" DO_SYSTEM_UPGRADE -if [ "$DO_SYSTEM_UPGRADE" = "y" ] || [ "$DO_SYSTEM_UPGRADE" = "Y" ]; then - echo "Performing full system upgrade..." - run_cmd apt upgrade -y -else - echo "Skipping system upgrade." -fi - -# Clean up -echo "" -echo "Cleaning up..." -run_cmd apt autoremove -y -run_cmd apt autoclean - -echo "" -echo "=== Installation Complete! ===" -echo "" -echo "Installed Software:" -echo " ✓ net-tools, ncdu, git, curl, wget, htop, tree, zip/unzip" -echo " ✓ OpenSSH Server - SSH remote access" -if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then - echo " ✓ CrowdSec - intrusion prevention (SSH/Caddy, geo + IP reputation)" -fi -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo " ✓ Docker Engine + Docker Compose" -fi -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo " ✓ Samba - File sharing (Primary drive shared)" -fi -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo " ✓ NetBird - Mesh VPN" -fi -if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then - echo " ✓ WireGuard - VPN" -fi -if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then - echo " ✓ Tailscale - Mesh VPN" -fi -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo " ✓ RustDesk - Remote desktop" -fi -if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then - echo " ✓ TeamViewer - Remote desktop" -fi -if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then - echo " ✓ MeshCentral Agent - Remote management" -fi -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then - echo " ✓ Local backup configured (rsync)" -fi -if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then - echo " ✓ Cloud backup configured (rclone)" -fi -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo " ✓ UFW Firewall - enabled" -fi -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SSH AUTHENTICATION SETUP" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " SSH Key for This Computer:" -if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - if [ -f "$ACTUAL_HOME/.ssh/id_rsa.pub" ]; then -echo " ✓ 4096-bit RSA key generated" -echo " Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" -echo " View with: cat ~/.ssh/id_rsa.pub" - else -echo " ✗ Key generation was attempted but may have failed" - fi -else -echo " Not generated (skipped during install)" -echo " Generate later: ssh-keygen -t rsa -b 4096 -C \"your@email.com\"" -fi -echo "" -echo " SSH Server Status:" -if [ "$KEYS_IMPORTED" = true ]; then -echo " Password authentication: DISABLED (key-only access)" -echo " Imported SSH keys: ~/.ssh/authorized_keys" -else -echo " Password authentication: ENABLED" -fi -echo "" -echo " Traditional SSH Access:" -echo " - Uses keys from GitHub/Launchpad (if imported)" -echo " - Connect with: ssh user@hostname" -if [ "$KEYS_IMPORTED" = true ]; then -echo " - Password login: DISABLED (keys required)" -else -echo " - Password login: ENABLED" -fi -echo "" -echo " NetBird SSH Access (v0.60.0+ method):" -echo " - Requires openssh-server running (installed by this script)" -echo " - Enable per-peer in NetBird dashboard: Peers > [peer] > SSH" -echo " - NetBird injects /etc/ssh/sshd_config.d/99-netbird.conf" -echo " - Connect from another peer: ssh user@" -echo " - Get peer IPs with: netbird status" -echo " - Note: old 'netbird ssh ' command no longer works" -echo "" -echo " You can use ANY combination:" -echo " ✓ GitHub + Launchpad + NetBird SSH" -echo " ✓ GitHub + NetBird SSH" -echo " ✓ Launchpad + NetBird SSH" -echo " ✓ GitHub + Launchpad (no NetBird)" -echo " ✓ Just GitHub or just Launchpad" -echo " ✓ Just NetBird SSH" -echo " ✓ None (password auth only - if no keys imported)" -echo "" -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "BACKUP SYSTEM - $BACKUP_TOOL ($BACKUP_MODE mode)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " Mount points:" -echo " $ACTUAL_HOME/drives/primary" -echo " $ACTUAL_HOME/drives/backup1" -if [ "$BACKUP_MODE" = "split" ]; then -echo " $ACTUAL_HOME/drives/backup2" -fi -echo "" -echo " Backup script: /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo " Log file: /var/log/${BACKUP_TOOL}-backup.log" -echo "" -if [ "$BACKUP_MODE" = "split" ]; then -echo " ⚠️ CONFIGURE BEFORE RUNNING:" -echo " Edit the script to set which folders go to which backup drive." -echo " sudo nano /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo "" -fi -echo " Quick start:" -echo " 1. Test (dry-run): sudo ${BACKUP_TOOL}-backup.sh --dry-run # (edit script first)" -echo " 2. Run manually: sudo /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo " 3. Enable auto: sudo systemctl enable ${BACKUP_TOOL}-backup.timer" -echo " sudo systemctl start ${BACKUP_TOOL}-backup.timer" -echo "" -echo " Monitor: tail -f /var/log/${BACKUP_TOOL}-backup.log" -fi - -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "OTHER IMPORTANT NOTES" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo " Samba File Sharing:" - if command -v smbd &> /dev/null && grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then - echo " ✓ Share 'Primary' is accessible at:" - echo " Windows: \\\\$(hostname)\\Primary" - echo " Mac/Linux: smb://$(hostname)/Primary" - echo " • Username: $ACTUAL_USER" - echo " • Use the Samba password you just set" - echo "" - else - echo " ✗ Installation may have failed - check 'systemctl status smbd'" - echo "" - fi -fi -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo " Docker: Log out and back in for group membership to take effect" - echo "" -fi -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo " NetBird:" - echo " 1. Run 'netbird up' (opens browser for authentication)" - echo " 2. View connected peers: netbird status" - echo " 3. Configure ACLs in dashboard: https://app.netbird.io" - echo " 4. Enable SSH per-peer: Peers > [peer] > SSH (in dashboard)" - echo " 5. Connect via SSH: ssh user@" - echo " Note: --allow-server-ssh is pre-configured in systemd override" - echo " (/etc/systemd/system/netbird.service.d/ssh-server.conf)" - echo " so SSH works without re-authenticating on every connection" - echo "" -fi -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo " RustDesk: Launch from applications menu or run 'rustdesk'" - echo "" -fi -if [ "$INSTALL_CROWDSEC" = "y" ] || [ "$INSTALL_CROWDSEC" = "Y" ]; then - echo " CrowdSec:" - echo " • Check metrics: sudo cscli metrics" - echo " • View active bans: sudo cscli decisions list" - echo " • Unban IP: sudo cscli decisions delete --ip " - echo "" -fi -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo " UFW Firewall:" - echo " • Check status: sudo ufw status" - echo " • Allow port: sudo ufw allow " - echo " • Deny port: sudo ufw deny " - echo " • Disable: sudo ufw disable" - echo "" -fi \ No newline at end of file diff --git a/ubuntu-post-install-26.04-no-keycloak.sh b/ubuntu-post-install-26.04-no-keycloak.sh deleted file mode 100644 index 8113aa1..0000000 --- a/ubuntu-post-install-26.04-no-keycloak.sh +++ /dev/null @@ -1,7341 +0,0 @@ -#!/bin/bash - -# Ubuntu 26.04 Post-Installation Script -# Run with: sudo bash post-install.sh -# This script is rerunnable - it detects existing installations - -# ============================================================================ -# COMMAND-LINE ARGUMENT PARSING -# ============================================================================ - -DRY_RUN=false -UNATTENDED=false -LOG_FILE="/var/log/post-install.log" -WHIPTAIL_USED=false - -show_help() { - echo "Ubuntu 26.04 Post-Installation Script" - echo "" - echo "Usage: sudo ./post-install.sh [OPTIONS]" - echo "" - echo "Options:" - echo " --dry-run Preview what would be installed without making changes" - echo " --unattended Run with default options (no prompts)" - echo " Defaults: skip key generation, no SSH imports," - echo " install Docker, skip VPNs/Remote Desktop/Backup" - echo " --restore Disaster recovery mode - restore from backup" - echo " --help Show this help message" - echo "" - echo "Examples:" - echo " sudo ./post-install.sh # Interactive mode" - echo " sudo ./post-install.sh --dry-run # Preview installations" - echo " sudo ./post-install.sh --unattended # Automated install" - echo " sudo ./post-install.sh --restore # Disaster recovery" - echo "" - exit 0 -} - -RESTORE_MODE=false - -for arg in "$@"; do - case $arg in - --dry-run) - DRY_RUN=true - ;; - --unattended) - UNATTENDED=true - ;; - --restore) - RESTORE_MODE=true - ;; - --help|-h) - show_help - ;; - *) - echo "Unknown option: $arg" - echo "Use --help for usage information" - exit 1 - ;; - esac -done - -# ============================================================================ -# LOGGING SETUP -# ============================================================================ - -# Create log file and tee all output -exec > >(tee -a "$LOG_FILE") 2>&1 -echo "" -echo "=== Post-Install Log Started: $(date) ===" >> "$LOG_FILE" - -echo "=== Ubuntu 26.04 Post-Installation Script ===" -echo "" - -if [ "$DRY_RUN" = true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRY RUN MODE - No changes will be made" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" -fi - -if [ "$UNATTENDED" = true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "UNATTENDED MODE - Using default options" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" -fi - -# Check if running as root -if [ "$EUID" -ne 0 ]; then - echo "Please run as root (use sudo)" - exit 1 -fi - -# Get the actual user (not root) -ACTUAL_USER="${SUDO_USER:-$USER}" -ACTUAL_HOME=$(getent passwd "$ACTUAL_USER" | cut -d: -f6) -DOCKER_DIR="$ACTUAL_HOME/docker" - -echo "Note: Script will continue even if individual packages fail to install" -echo "Log file: $LOG_FILE" -echo "" - -# ============================================================================ -# INSTALLATION MODE SELECTOR -# ============================================================================ - -INSTALL_MODE="normal" - -# Disaster recovery function -run_disaster_recovery() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DISASTER RECOVERY MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This will restore your system and Docker apps from a Kopia backup." - echo "" - echo "Requirements:" - echo " • Backup drive connected (with kopia-repo folder)" - echo " • Kopia password (saved in .env or you remember it)" - echo "" - - # Step 1: Install core utilities - echo "Step 1: Installing core utilities" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Installing essential packages..." - apt-get update - apt-get install -y \ - net-tools \ - openssh-server \ - git \ - curl \ - wget \ - htop \ - ncdu \ - tree \ - zip \ - unzip \ - whiptail \ - 2>/dev/null || echo " ⚠ Some packages may have failed" - - # Install Kopia for backup management - echo "Installing Kopia..." - if ! command -v kopia &> /dev/null; then - curl -s https://kopia.io/signing-key | gpg --dearmor -o /usr/share/keyrings/kopia-keyring.gpg 2>/dev/null - echo "deb [signed-by=/usr/share/keyrings/kopia-keyring.gpg] https://packages.kopia.io/apt/ stable main" | tee /etc/apt/sources.list.d/kopia.list - apt-get update - apt-get install -y kopia 2>/dev/null || echo " ⚠ Kopia install failed" - fi - - # Start SSH - systemctl enable ssh 2>/dev/null || true - systemctl start ssh 2>/dev/null || true - echo "✓ Core utilities installed" - echo "" - - # Step 2: Find/mount backup drive - echo "Step 2: Locate backup drive" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Available block devices:" - lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null || lsblk - echo "" - - # Check if drives are already mounted - if [ -d "$ACTUAL_HOME/drives" ]; then - echo "Existing mount points in ~/drives/:" - ls -la "$ACTUAL_HOME/drives/" 2>/dev/null || echo " (none)" - echo "" - fi - - read -p "Enter path to backup drive or Kopia repo (e.g., /dev/sdb1 or ~/drives/backup1): " BACKUP_PATH - - # If it's a device, mount it - if [[ "$BACKUP_PATH" == /dev/* ]]; then - MOUNT_POINT="$ACTUAL_HOME/drives/restore-backup" - mkdir -p "$MOUNT_POINT" 2>/dev/null - echo "Mounting $BACKUP_PATH to $MOUNT_POINT..." - mount "$BACKUP_PATH" "$MOUNT_POINT" || { echo "Failed to mount. Check device path."; return 1; } - BACKUP_PATH="$MOUNT_POINT" - fi - - # Expand ~ to home directory - BACKUP_PATH="${BACKUP_PATH/#\~/$ACTUAL_HOME}" - - # Look for kopia-repo - KOPIA_REPO="" - if [ -d "$BACKUP_PATH/kopia-repo" ]; then - KOPIA_REPO="$BACKUP_PATH/kopia-repo" - elif [ -d "$BACKUP_PATH/kopia.repository" ] || [ -f "$BACKUP_PATH/kopia.repository.f" ]; then - KOPIA_REPO="$BACKUP_PATH" - else - echo "" - echo "Looking for Kopia repository..." - FOUND_REPO=$(find "$BACKUP_PATH" -maxdepth 3 -name "kopia.repository*" -type f 2>/dev/null | head -1) - if [ -n "$FOUND_REPO" ]; then - KOPIA_REPO=$(dirname "$FOUND_REPO") - fi - fi - - if [ -z "$KOPIA_REPO" ] || [ ! -d "$KOPIA_REPO" ]; then - echo "❌ Could not find Kopia repository at $BACKUP_PATH" - echo " Look for a folder containing 'kopia.repository' files" - return 1 - fi - - echo "✓ Found Kopia repository at: $KOPIA_REPO" - echo "" - - # Step 3: Get Kopia password - echo "Step 3: Kopia password" - echo "━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Try to find password in backed-up .env - if [ -f "$BACKUP_PATH/kopia/.env" ]; then - KOPIA_PASS=$(grep "KOPIA_PASSWORD" "$BACKUP_PATH/kopia/.env" 2>/dev/null | cut -d= -f2) - if [ -n "$KOPIA_PASS" ]; then - echo "Found password in backup. Use this? (y/n)" - read -p "> " USE_FOUND_PASS - [ "$USE_FOUND_PASS" != "y" ] && KOPIA_PASS="" - fi - fi - - if [ -z "$KOPIA_PASS" ]; then - read -s -p "Enter Kopia repository password: " KOPIA_PASS - echo "" - fi - - # Step 4: Install Docker if needed - echo "" - echo "Step 4: Ensure Docker is installed" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - if ! command -v docker &> /dev/null; then - echo "Installing Docker..." - apt-get update - apt-get install -y ca-certificates curl gnupg - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg - chmod a+r /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null - apt-get update - apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - usermod -aG docker "$ACTUAL_USER" - echo "✓ Docker installed" - else - echo "✓ Docker already installed" - fi - echo "" - - # Step 5: List snapshots and let user choose - echo "Step 5: Select snapshot to restore" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Connecting to repository and listing snapshots..." - echo "" - - # Use docker to run kopia - SNAPSHOT_LIST=$(docker run --rm \ - -v "$KOPIA_REPO:/repository" \ - -e KOPIA_PASSWORD="$KOPIA_PASS" \ - kopia/kopia:latest \ - snapshot list --all 2>&1) - - if echo "$SNAPSHOT_LIST" | grep -q "invalid password"; then - echo "❌ Invalid password" - return 1 - fi - - echo "$SNAPSHOT_LIST" - echo "" - read -p "Enter snapshot ID to restore (or 'latest' for most recent): " SNAPSHOT_ID - - if [ "$SNAPSHOT_ID" = "latest" ]; then - SNAPSHOT_ID=$(echo "$SNAPSHOT_LIST" | grep -oE "^[a-f0-9]+" | tail -1) - echo "Using latest snapshot: $SNAPSHOT_ID" - fi - - # Step 6: Restore to temp location - echo "" - echo "Step 6: Restoring from backup" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - RESTORE_TEMP="$ACTUAL_HOME/docker-restore-temp" - mkdir -p "$RESTORE_TEMP" - - echo "Restoring to $RESTORE_TEMP..." - docker run --rm \ - -v "$KOPIA_REPO:/repository" \ - -v "$RESTORE_TEMP:/restore" \ - -e KOPIA_PASSWORD="$KOPIA_PASS" \ - kopia/kopia:latest \ - restore "$SNAPSHOT_ID" /restore - - if [ $? -ne 0 ]; then - echo "❌ Restore failed" - return 1 - fi - echo "✓ Snapshot restored to temp location" - echo "" - - # Step 7: Detect and select services to restore - echo "Step 7: Select services to restore" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - mkdir -p "$DOCKER_DIR" - ensure_docker_dir_ownership "$DOCKER_DIR" - - # Find all docker-compose.yml files in restored backup - declare -A SERVICE_DIRS - SERVICES_FOUND=() - for compose_file in $(find "$RESTORE_TEMP" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null); do - SERVICE_DIR=$(dirname "$compose_file") - SERVICE_NAME=$(basename "$SERVICE_DIR") - SERVICES_FOUND+=("$SERVICE_NAME") - SERVICE_DIRS["$SERVICE_NAME"]="$SERVICE_DIR" - done - - if [ ${#SERVICES_FOUND[@]} -eq 0 ]; then - echo "No Docker services found in backup" - else - echo "Found ${#SERVICES_FOUND[@]} services in backup." - echo "" - - # Check if whiptail is available for nice UI - if command -v whiptail &> /dev/null; then - # Build whiptail checklist arguments - CHECKLIST_ARGS=() - for svc in "${SERVICES_FOUND[@]}"; do - CHECKLIST_ARGS+=("$svc" "" "ON") - done - - # Show checklist - returns selected items - SELECTED=$(whiptail --title "Select Services to Restore" \ - --checklist "Use SPACE to select/deselect, ENTER to confirm:" \ - 20 60 12 \ - "${CHECKLIST_ARGS[@]}" \ - 3>&1 1>&2 2>&3) - - # Parse selected services (whiptail returns quoted strings) - SELECTED_SERVICES=() - for item in $SELECTED; do - # Remove quotes - svc=$(echo "$item" | tr -d '"') - SELECTED_SERVICES+=("$svc") - done - else - # Fallback: text-based selection - echo "Select services to restore:" - echo " [A] All services" - echo " [N] None (skip restore)" - echo " [S] Select individually" - echo "" - read -p "Choice (A/N/S) [A]: " RESTORE_CHOICE - - case "${RESTORE_CHOICE^^}" in - N) - SELECTED_SERVICES=() - ;; - S) - SELECTED_SERVICES=() - for svc in "${SERVICES_FOUND[@]}"; do - read -p " Restore $svc? (y/n) [y]: " RESTORE_SVC - if [ "$RESTORE_SVC" != "n" ] && [ "$RESTORE_SVC" != "N" ]; then - SELECTED_SERVICES+=("$svc") - fi - done - ;; - *) - SELECTED_SERVICES=("${SERVICES_FOUND[@]}") - ;; - esac - fi - - # Restore selected services - echo "" - if [ ${#SELECTED_SERVICES[@]} -eq 0 ]; then - echo "No services selected for restore." - else - echo "Restoring ${#SELECTED_SERVICES[@]} services..." - echo "" - for svc in "${SELECTED_SERVICES[@]}"; do - SERVICE_DIR="${SERVICE_DIRS[$svc]}" - TARGET_DIR="$DOCKER_DIR/$svc" - - echo " Restoring $svc..." - - # Copy entire service directory - cp -r "$SERVICE_DIR" "$TARGET_DIR" 2>/dev/null || true - - # Fix ownership - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TARGET_DIR" 2>/dev/null || true - - echo " ✓ $svc restored to $TARGET_DIR" - done - fi - fi - - # Step 8: Start services - echo "" - echo "Step 8: Starting services" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Count restored services - RESTORED_COUNT=0 - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - ((RESTORED_COUNT++)) - fi - done - - if [ $RESTORED_COUNT -eq 0 ]; then - echo "No services to start." - else - echo "Start restored services?" - echo " [A] All services" - echo " [S] Select which to start" - echo " [N] None (start manually later)" - echo "" - read -p "Choice (A/S/N) [A]: " START_CHOICE - - case "${START_CHOICE^^}" in - N) - echo "Services not started. Start manually with:" - echo " cd ~/docker/{service} && docker compose up -d" - ;; - S) - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - SERVICE_NAME=$(basename "$dir") - read -p " Start $SERVICE_NAME? (y/n) [y]: " START_SVC - if [ "$START_SVC" != "n" ] && [ "$START_SVC" != "N" ]; then - echo " Starting $SERVICE_NAME..." - (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME" - fi - fi - done - ;; - *) - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/compose.yml" ]; then - SERVICE_NAME=$(basename "$dir") - echo " Starting $SERVICE_NAME..." - (cd "$dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $SERVICE_NAME" - fi - done - ;; - esac - fi - - # Cleanup - echo "" - read -p "Remove temporary restore files? (y/n): " CLEANUP - if [ "$CLEANUP" = "y" ]; then - rm -rf "$RESTORE_TEMP" - echo "✓ Cleaned up temp files" - fi - - # Step 9: Reconnect Kopia for future backups - echo "" - echo "Step 9: Setting up future backups" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Reconnecting Kopia to backup repository for future backups..." - - if command -v kopia &> /dev/null && [ -n "$KOPIA_REPO" ]; then - # Reconnect to the repository - if kopia repository connect filesystem --path="$KOPIA_REPO" --password="$KOPIA_PASSWORD" 2>/dev/null; then - echo "✓ Kopia reconnected to repository" - echo " Repository: $KOPIA_REPO" - - # Verify backup scripts exist - if [ -f "$DOCKER_DIR/kopia/backup-containers.sh" ]; then - chmod +x "$DOCKER_DIR/kopia/backup-containers.sh" 2>/dev/null || true - echo "✓ Backup script ready: $DOCKER_DIR/kopia/backup-containers.sh" - else - echo " ⚠ Backup script not found - run normal install to set up" - fi - else - echo " ⚠ Could not reconnect to repository" - echo " Run manually: kopia repository connect filesystem --path=\"$KOPIA_REPO\"" - fi - else - echo " ⚠ Kopia not available or repository not found" - echo " Run the normal install script to set up backups" - fi - echo "" - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DISASTER RECOVERY COMPLETE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Services restored to: $DOCKER_DIR" - echo "" - echo "Backups: $(command -v kopia &>/dev/null && kopia repository status &>/dev/null && echo "✓ Connected" || echo "⚠ Not connected")" - echo " Run backups: $DOCKER_DIR/kopia/backup-containers.sh" - echo "" - echo "To check running containers:" - echo " docker ps" - echo "" - echo "To view logs:" - echo " docker compose logs -f" - echo "" - echo "⚠️ Some services may need manual configuration:" - echo " • Frigate: Edit config/config.yml with camera URLs" - echo " • Caddy: Update Caddyfile with your domain" - echo " • ddclient: Edit config/ddclient.conf with DNS credentials" - echo "" - - return 0 -} - -# Migration function - import existing Docker containers -run_migration() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "MIGRATION MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This will migrate existing Docker containers to this script's structure." - echo "Containers will be copied (not moved) and versions preserved." - echo "" - - # Step 1: Get source Docker directory - echo "Step 1: Locate existing containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Try to auto-detect Docker directories - DETECTED_DIRS=() - - # Check common locations - for check_dir in /var/docker /opt/docker "$HOME/docker" /home/*/docker; do - if [ -d "$check_dir" ] && [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then - DETECTED_DIRS+=("$check_dir") - fi - done - - # Check mounted drives: ~/drives/*, /mnt/*, /media/* - for mount_base in "$HOME/drives" "$HOME_DIR/drives" /mnt /media; do - if [ -d "$mount_base" ]; then - for mount_dir in "$mount_base"/*; do - if [ -d "$mount_dir/docker" ]; then - check_dir="$mount_dir/docker" - if [ "$(find "$check_dir" -maxdepth 2 -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/null | head -1)" ]; then - DETECTED_DIRS+=("$check_dir") - fi - fi - done - fi - done - - if [ ${#DETECTED_DIRS[@]} -gt 0 ]; then - echo "Auto-detected Docker directories:" - for i in "${!DETECTED_DIRS[@]}"; do - dir="${DETECTED_DIRS[$i]}" - count=$(find "$dir" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null | wc -l) - echo " [$((i+1))] $dir ($count containers)" - done - echo "" - echo "Enter a number to select, or type a custom path:" - else - echo "No Docker directories auto-detected." - echo "" - echo "Common locations:" - echo " • ~/docker" - echo " • ~/drives/primary/docker" - echo " • /var/docker" - echo " • /opt/docker" - echo " • /mnt/data/docker" - echo "" - echo "Enter the full path to your Docker directory:" - fi - echo "" - read -p "Source directory: " SOURCE_DOCKER_DIR - - # Check if user entered a number (to select from detected list) - if [[ "$SOURCE_DOCKER_DIR" =~ ^[0-9]+$ ]] && [ ${#DETECTED_DIRS[@]} -gt 0 ]; then - idx=$((SOURCE_DOCKER_DIR - 1)) - if [ $idx -ge 0 ] && [ $idx -lt ${#DETECTED_DIRS[@]} ]; then - SOURCE_DOCKER_DIR="${DETECTED_DIRS[$idx]}" - echo "Selected: $SOURCE_DOCKER_DIR" - fi - fi - - # Expand ~ if used - SOURCE_DOCKER_DIR="${SOURCE_DOCKER_DIR/#\~/$HOME}" - - if [ ! -d "$SOURCE_DOCKER_DIR" ]; then - echo "❌ Directory not found: $SOURCE_DOCKER_DIR" - return 1 - fi - - # Step 2: Scan for containers - echo "" - echo "Step 2: Scanning for containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - CONTAINERS_FOUND=() - CONTAINER_PATHS=() - - while IFS= read -r compose_file; do - if [ -n "$compose_file" ]; then - container_dir=$(dirname "$compose_file") - container_name=$(basename "$container_dir") - CONTAINERS_FOUND+=("$container_name") - CONTAINER_PATHS+=("$container_dir") - fi - done < <(find "$SOURCE_DOCKER_DIR" -maxdepth 2 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null) - - if [ ${#CONTAINERS_FOUND[@]} -eq 0 ]; then - echo "❌ No Docker containers found in $SOURCE_DOCKER_DIR" - return 1 - fi - - echo "Found ${#CONTAINERS_FOUND[@]} container(s):" - for i in "${!CONTAINERS_FOUND[@]}"; do - container="${CONTAINERS_FOUND[$i]}" - path="${CONTAINER_PATHS[$i]}" - size=$(du -sh "$path" 2>/dev/null | cut -f1) - echo " [$((i+1))] $container ($size)" - done - echo "" - - # Step 3: Select containers to migrate - echo "Step 3: Select containers to migrate" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - SELECTED_CONTAINERS=() - - if command -v whiptail &> /dev/null && [ ${#CONTAINERS_FOUND[@]} -gt 3 ]; then - # Use whiptail for many containers - CHECKLIST_ARGS=() - for container in "${CONTAINERS_FOUND[@]}"; do - CHECKLIST_ARGS+=("$container" "" "ON") - done - - SELECTED=$(whiptail --title "Select Containers to Migrate" \ - --checklist "Use SPACE to select/deselect, ENTER to confirm:" \ - 20 60 12 \ - "${CHECKLIST_ARGS[@]}" \ - 3>&1 1>&2 2>&3) - - if [ $? -eq 0 ] && [ -n "$SELECTED" ]; then - # Parse whiptail output - for container in $SELECTED; do - # Remove quotes - container="${container//\"/}" - SELECTED_CONTAINERS+=("$container") - done - fi - else - # Text-based selection - echo "Select containers to migrate:" - echo " [A] All containers" - echo " [S] Select individually" - echo " [N] None (cancel)" - echo "" - read -p "Choice (A/S/N) [A]: " MIGRATE_CHOICE - - case "${MIGRATE_CHOICE^^}" in - N) - echo "Migration cancelled." - return 0 - ;; - S) - for container in "${CONTAINERS_FOUND[@]}"; do - read -p " Migrate $container? (y/n) [y]: " MIGRATE_THIS - if [ "$MIGRATE_THIS" != "n" ] && [ "$MIGRATE_THIS" != "N" ]; then - SELECTED_CONTAINERS+=("$container") - fi - done - ;; - *) - SELECTED_CONTAINERS=("${CONTAINERS_FOUND[@]}") - ;; - esac - fi - - if [ ${#SELECTED_CONTAINERS[@]} -eq 0 ]; then - echo "No containers selected." - return 0 - fi - - echo "" - echo "Will migrate ${#SELECTED_CONTAINERS[@]} container(s):" - for container in "${SELECTED_CONTAINERS[@]}"; do - echo " • $container" - done - echo "" - - # Step 4: Stop running containers (optional) - echo "Step 4: Container status" - echo "━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Should running containers be stopped before migration?" - echo " • Stopping ensures clean copy of data/databases" - echo " • Not stopping may result in inconsistent state" - echo "" - read -p "Stop containers before copying? (y/n) [y]: " STOP_CONTAINERS - - if [ "$STOP_CONTAINERS" != "n" ] && [ "$STOP_CONTAINERS" != "N" ]; then - echo "Stopping containers..." - for container in "${SELECTED_CONTAINERS[@]}"; do - for i in "${!CONTAINERS_FOUND[@]}"; do - if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then - container_dir="${CONTAINER_PATHS[$i]}" - if [ -f "$container_dir/docker-compose.yml" ] || [ -f "$container_dir/compose.yml" ]; then - echo " Stopping $container..." - (cd "$container_dir" && docker compose down 2>/dev/null) || true - fi - fi - done - done - echo "" - fi - - # Step 5: Decide migration method - echo "Step 5: Migration method" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Check if source is on a different drive (mounted drive, not home on OS) - SOURCE_ON_EXTERNAL=false - if [[ "$SOURCE_DOCKER_DIR" == /mnt/* ]] || [[ "$SOURCE_DOCKER_DIR" == /media/* ]] || [[ "$SOURCE_DOCKER_DIR" == */drives/* ]]; then - SOURCE_ON_EXTERNAL=true - fi - - MIGRATION_METHOD="copy" - if [ "$SOURCE_ON_EXTERNAL" = true ]; then - echo "Source is on a mounted drive: $SOURCE_DOCKER_DIR" - echo "" - echo "How would you like to migrate?" - echo "" - echo " [C] Copy - Copy containers to ~/docker on OS drive" - echo " Best for: old OS drive mounted temporarily" - echo "" - echo " [S] Symlink - Create ~/docker as symlink to source location" - echo " Best for: data drive you'll keep using" - echo "" - echo " [U] Use in-place - Use source location directly, no copy" - echo " Best for: already on your data drive" - echo "" - read -p "Method (C/S/U) [C]: " MIGRATION_METHOD - - case "${MIGRATION_METHOD^^}" in - S) - MIGRATION_METHOD="symlink" - ;; - U) - MIGRATION_METHOD="use" - ;; - *) - MIGRATION_METHOD="copy" - ;; - esac - else - echo "Source: $SOURCE_DOCKER_DIR" - echo "Target: $DOCKER_DIR" - echo "" - echo "Will copy containers to $DOCKER_DIR" - fi - - echo "" - - # Handle different migration methods - case "$MIGRATION_METHOD" in - symlink) - echo "Creating symlink: $DOCKER_DIR → $SOURCE_DOCKER_DIR" - echo "" - - # Check if target exists - if [ -e "$DOCKER_DIR" ] || [ -L "$DOCKER_DIR" ]; then - echo " ⚠ $DOCKER_DIR already exists" - read -p " Remove and create symlink? (y/n) [n]: " REMOVE_EXISTING - if [ "$REMOVE_EXISTING" = "y" ] || [ "$REMOVE_EXISTING" = "Y" ]; then - rm -rf "$DOCKER_DIR" - else - echo " Cancelled." - return 1 - fi - fi - - ln -s "$SOURCE_DOCKER_DIR" "$DOCKER_DIR" - chown -h "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - - echo "✓ Symlink created" - echo " Containers stay at: $SOURCE_DOCKER_DIR" - echo " Accessible via: $DOCKER_DIR → $SOURCE_DOCKER_DIR" - MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]} - ;; - - use) - echo "Using source location directly" - echo "" - DOCKER_DIR="$SOURCE_DOCKER_DIR" - echo "✓ DOCKER_DIR set to: $DOCKER_DIR" - echo " No files copied - containers remain in place" - MIGRATED_COUNT=${#SELECTED_CONTAINERS[@]} - ;; - - copy|*) - echo "Copying containers to $DOCKER_DIR" - echo "" - - # Create target directory - mkdir -p "$DOCKER_DIR" - chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - - MIGRATED_COUNT=0 - for container in "${SELECTED_CONTAINERS[@]}"; do - for i in "${!CONTAINERS_FOUND[@]}"; do - if [ "${CONTAINERS_FOUND[$i]}" = "$container" ]; then - source_dir="${CONTAINER_PATHS[$i]}" - target_dir="$DOCKER_DIR/$container" - - # Check if already exists - if [ -d "$target_dir" ]; then - echo " ⚠ $container already exists at $target_dir" - read -p " Overwrite? (y/n) [n]: " OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Skipping $container" - continue - fi - rm -rf "$target_dir" - fi - - echo " Copying $container..." - cp -a "$source_dir" "$target_dir" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$target_dir" - ((MIGRATED_COUNT++)) - echo " ✓ $container copied" - fi - done - done - ;; - esac - - echo "" - echo "✓ Migrated $MIGRATED_COUNT container(s)" - echo "" - - # Step 6: Update volume paths - echo "Step 6: Update volume paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Scanning docker-compose files for volume mounts that may need updating..." - echo "" - - # Find all absolute paths in volume mounts (excluding relative paths like ./ or named volumes) - PATHS_FOUND=() - PATHS_FILES=() - - for container in "${SELECTED_CONTAINERS[@]}"; do - compose_file="$DOCKER_DIR/$container/docker-compose.yml" - [ -f "$compose_file" ] || compose_file="$DOCKER_DIR/$container/compose.yml" - [ -f "$compose_file" ] || continue - - # Extract volume mount paths (lines with : that look like /path:/container/path) - while IFS= read -r line; do - # Match absolute paths in volume mounts (starting with /) - if [[ "$line" =~ ^[[:space:]]*-[[:space:]]*(/[^:]+): ]]; then - host_path="${BASH_REMATCH[1]}" - # Skip if it's a relative path or standard paths - if [[ "$host_path" != "./"* ]] && [[ "$host_path" != "/etc/"* ]] && [[ "$host_path" != "/var/run/"* ]]; then - # Check if this path doesn't exist on current system - if [ ! -e "$host_path" ]; then - # Avoid duplicates - if [[ ! " ${PATHS_FOUND[*]} " =~ " ${host_path} " ]]; then - PATHS_FOUND+=("$host_path") - PATHS_FILES+=("$compose_file") - fi - fi - fi - fi - done < "$compose_file" - done - - if [ ${#PATHS_FOUND[@]} -eq 0 ]; then - echo "✓ No volume paths need updating (all paths exist or are relative)" - echo "" - else - echo "Found ${#PATHS_FOUND[@]} volume path(s) that don't exist on this system:" - echo "" - - # Default new base path - DEFAULT_NEW_BASE="$HOME_DIR/drives/primary" - - for i in "${!PATHS_FOUND[@]}"; do - old_path="${PATHS_FOUND[$i]}" - compose_file="${PATHS_FILES[$i]}" - container_name=$(basename "$(dirname "$compose_file")") - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Container: $container_name" - echo "Old path: $old_path" - - # Suggest a new path based on the old path structure - # Extract the last part of the path for suggestion - path_tail=$(basename "$old_path") - suggested_path="$DEFAULT_NEW_BASE/$path_tail" - - echo "Suggested: $suggested_path" - echo "" - echo "Options:" - echo " [Enter] Accept suggested path" - echo " [S] Skip - keep original path" - echo " [path] Enter custom path" - echo "" - read -p "New path: " NEW_PATH_INPUT - - case "$NEW_PATH_INPUT" in - ""|" ") - new_path="$suggested_path" - ;; - [Ss]) - echo " Skipping - keeping original path" - continue - ;; - *) - new_path="${NEW_PATH_INPUT/#\~/$HOME_DIR}" - ;; - esac - - # Update the compose file - echo " Updating: $old_path → $new_path" - - # Escape paths for sed (handle slashes) - old_escaped=$(printf '%s\n' "$old_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g') - new_escaped=$(printf '%s\n' "$new_path" | sed 's/[[\.*^$()+?{|]/\\&/g; s/\//\\\//g') - - sed -i "s|$old_path|$new_path|g" "$compose_file" - - # Create directory if it doesn't exist - if [ ! -d "$new_path" ]; then - echo " Creating directory: $new_path" - mkdir -p "$new_path" 2>/dev/null || echo " ⚠ Could not create (may need manual creation)" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$new_path" 2>/dev/null || true - fi - - echo " ✓ Updated" - echo "" - done - - echo "✓ Volume paths updated" - echo "" - fi - - # Step 7: Start migrated containers (optional) - echo "Step 7: Start containers" - echo "━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - read -p "Start migrated containers? (y/n) [y]: " START_MIGRATED - - if [ "$START_MIGRATED" != "n" ] && [ "$START_MIGRATED" != "N" ]; then - for container in "${SELECTED_CONTAINERS[@]}"; do - target_dir="$DOCKER_DIR/$container" - if [ -d "$target_dir" ]; then - echo " Starting $container..." - (cd "$target_dir" && docker compose up -d 2>/dev/null) || echo " ⚠ Failed to start $container" - fi - done - echo "" - fi - - # Step 8: Offer additional services - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "MIGRATION COMPLETE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Containers migrated to: $DOCKER_DIR" - echo "" - echo "Would you like to install additional services not in your migration?" - echo "This will continue with the normal install process where you can" - echo "choose which additional apps to install (Immich, Frigate, etc.)" - echo "" - read -p "Continue to install additional services? (y/n) [y]: " INSTALL_MORE - - if [ "$INSTALL_MORE" = "n" ] || [ "$INSTALL_MORE" = "N" ]; then - echo "" - echo "Migration complete. You can run this script again to install more services." - echo "" - echo "To check running containers:" - echo " docker ps" - echo "" - return 0 - fi - - # Return to continue with normal install - INSTALL_MODE="post-migration" - return 0 -} - -# Check for restore mode flag -if [ "$RESTORE_MODE" = true ]; then - run_disaster_recovery - exit $? -fi - -# Interactive mode selector (if not unattended) -if [ "$UNATTENDED" != true ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "INSTALLATION MODE" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo " [N] Normal install - Fresh install or modify existing" - echo " [M] Migration - Import existing Docker containers" - echo " [R] Disaster recovery - Restore from Kopia backup" - echo "" - read -p "Select mode (N/M/R) [N]: " MODE_SELECT - - case "${MODE_SELECT^^}" in - R) - run_disaster_recovery - exit $? - ;; - M) - run_migration - # If migration returns 0 and INSTALL_MODE is post-migration, continue - if [ $? -ne 0 ]; then - exit 1 - fi - if [ "$INSTALL_MODE" != "post-migration" ]; then - exit 0 - fi - echo "" - echo "Continuing with additional service installation..." - echo "" - ;; - *) - INSTALL_MODE="normal" - ;; - esac - echo "" -fi - -# ============================================================================ -# DRIVE SETUP (Runs before everything else) -# ============================================================================ - -setup_drives() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "This script uses ~/drives/ for data storage:" - echo " ~/drives/primary - Main data drive (media, photos, etc.)" - echo " ~/drives/backup1 - Backup drive(s)" - echo "" - - # Create base drives directory - mkdir -p "$ACTUAL_HOME/drives" - chown "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - # Check for existing mounts - if mount | grep -q "$ACTUAL_HOME/drives/"; then - echo "Existing mounted drives:" - df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " (" $2 " total, " $4 " free)"}' - echo "" - prompt_yn "Configure additional drives? (y/n):" "n" SETUP_DRIVES - else - echo "No drives currently mounted in ~/drives/" - echo "" - prompt_yn "Set up drives now? (y/n):" "y" SETUP_DRIVES - fi - - if [ "$SETUP_DRIVES" != "y" ] && [ "$SETUP_DRIVES" != "Y" ]; then - echo "Skipping drive setup. You can run this script again later." - return 0 - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "AVAILABLE BLOCK DEVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - - # Detect drives without partition tables (new drives) - echo "Checking for new/unpartitioned drives..." - NEW_DRIVES=() - for disk in /dev/sd? /dev/nvme?n?; do - [ -b "$disk" ] || continue - # Check if disk has no partitions - if ! lsblk -n "$disk" | grep -q "part"; then - # Check if it has no partition table - if ! blkid "$disk" &>/dev/null; then - size=$(lsblk -n -d -o SIZE "$disk" 2>/dev/null) - NEW_DRIVES+=("$disk ($size)") - fi - fi - done - - if [ ${#NEW_DRIVES[@]} -gt 0 ]; then - echo "" - echo "⚠️ Found unpartitioned drives (no filesystem):" - for drive in "${NEW_DRIVES[@]}"; do - echo " • $drive" - done - echo "" - prompt_yn "Would you like to partition and format any of these? (y/n):" "n" FORMAT_DRIVES - - if [ "$FORMAT_DRIVES" = "y" ] || [ "$FORMAT_DRIVES" = "Y" ]; then - for drive_info in "${NEW_DRIVES[@]}"; do - drive=$(echo "$drive_info" | cut -d' ' -f1) - echo "" - prompt_yn "Format $drive_info as ext4? (ALL DATA WILL BE ERASED) (y/n):" "n" FORMAT_THIS - - if [ "$FORMAT_THIS" = "y" ] || [ "$FORMAT_THIS" = "Y" ]; then - echo " Creating partition table on $drive..." - parted -s "$drive" mklabel gpt - parted -s "$drive" mkpart primary ext4 0% 100% - - # Wait for partition to appear - sleep 2 - - # Format the partition - part="${drive}1" - [ -b "${drive}p1" ] && part="${drive}p1" # nvme drives - - if [ -b "$part" ]; then - echo " Formatting $part as ext4..." - mkfs.ext4 -F "$part" - echo " ✓ Formatted $part" - else - echo " ⚠ Could not find partition after creation" - fi - fi - done - echo "" - echo "Updated block devices:" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - fi - fi - - # Configure mount points - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CONFIGURE MOUNT POINTS" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Primary drive - echo "PRIMARY DRIVE (for media, photos, docker data):" - prompt_text " Mount point name [primary]:" "primary" PRIMARY_NAME - PRIMARY_NAME="${PRIMARY_NAME:-primary}" - mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME" - - # Check if already mounted - if mount | grep -q "$ACTUAL_HOME/drives/$PRIMARY_NAME"; then - echo " ✓ Already mounted" - else - echo " Enter device (e.g., /dev/sdb1) or leave blank to skip:" - read -p " Device for $PRIMARY_NAME: " PRIMARY_DEV - - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - # Add to fstab - PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV" 2>/dev/null) - if [ -n "$PRIMARY_UUID" ]; then - # Check if not already in fstab - if ! grep -q "$PRIMARY_UUID" /etc/fstab 2>/dev/null; then - cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true - echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab - echo " ✓ Added to fstab" - fi - fi - fi - fi - - # Backup drives - echo "" - echo "BACKUP DRIVES (optional, for rsync backups):" - prompt_text " How many backup drives? [0-4, default: 0]:" "0" NUM_BACKUPS - NUM_BACKUPS="${NUM_BACKUPS:-0}" - - # Validate number - case $NUM_BACKUPS in - 0|1|2|3|4) ;; - *) NUM_BACKUPS=0 ;; - esac - - declare -a BACKUP_NAMES - declare -a BACKUP_DEVS - for i in $(seq 1 $NUM_BACKUPS); do - echo "" - prompt_text " Backup drive $i name [backup$i]:" "backup$i" "BACKUP_NAME" - BACKUP_NAMES[$i]="${BACKUP_NAME:-backup$i}" - mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - - if ! mount | grep -q "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}"; then - read -p " Device for ${BACKUP_NAMES[$i]}: " "BACKUP_DEV" - BACKUP_DEVS[$i]="$BACKUP_DEV" - - if [ -n "$BACKUP_DEV" ] && [ -b "$BACKUP_DEV" ]; then - BACKUP_UUID=$(blkid -s UUID -o value "$BACKUP_DEV" 2>/dev/null) - if [ -n "$BACKUP_UUID" ]; then - if ! grep -q "$BACKUP_UUID" /etc/fstab 2>/dev/null; then - echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab - echo " ✓ Added ${BACKUP_NAMES[$i]} to fstab" - fi - fi - fi - else - echo " ✓ Already mounted" - fi - done - - # Set permissions - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - # Mount all from fstab - echo "" - echo "Mounting drives from fstab..." - mount -a 2>/dev/null || true - - echo "" - echo "Current mounts in ~/drives/:" - df -h | grep "$ACTUAL_HOME/drives" | awk '{print " " $6 " - " $2 " total, " $4 " free"}' || echo " (none)" - echo "" - - # Export drive names for later use - export PRIMARY_DRIVE_NAME="$PRIMARY_NAME" - export BACKUP_DRIVE_COUNT="$NUM_BACKUPS" - for i in $(seq 1 $NUM_BACKUPS); do - export "BACKUP_DRIVE_${i}_NAME=${BACKUP_NAMES[$i]}" - done - - echo "✓ Drive setup complete" - echo "" -} - -# Run drive setup for normal installs (not migration/recovery) -if [ "$INSTALL_MODE" = "normal" ] && [ "$UNATTENDED" != true ]; then - setup_drives -fi - -# ============================================================================ -# SOFTWARE DETECTION FUNCTIONS -# ============================================================================ - -is_docker_installed() { - command -v docker &> /dev/null && systemctl is-active --quiet docker 2>/dev/null -} - -is_samba_installed() { - command -v smbd &> /dev/null && systemctl is-active --quiet smbd 2>/dev/null -} - -is_netbird_installed() { - command -v netbird &> /dev/null -} - -is_rustdesk_installed() { - command -v rustdesk &> /dev/null || dpkg -l rustdesk &> /dev/null -} - -is_rclone_installed() { - command -v rclone &> /dev/null -} - -is_rsync_installed() { - command -v rsync &> /dev/null -} - -is_ufw_installed() { - command -v ufw &> /dev/null -} - -is_wireguard_installed() { - command -v wg &> /dev/null -} - -is_tailscale_installed() { - command -v tailscale &> /dev/null -} - -is_teamviewer_installed() { - command -v teamviewer &> /dev/null || dpkg -l teamviewer &> /dev/null 2>&1 -} - -is_meshcentral_installed() { - # MeshCentral agent is typically installed as meshagent - command -v meshagent &> /dev/null || [ -f /usr/local/mesh_services/meshagent/meshagent ] -} - -# ============================================================================ -# DRY-RUN AND UNATTENDED HELPER FUNCTIONS -# ============================================================================ - -# Run a command, but skip it in dry-run mode -run_cmd() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would execute: $*" - return 0 - else - "$@" - fi -} - -# Ensure Docker directories are owned by the actual user (not root) -# Usage: ensure_docker_dir_ownership /path/to/dir [additional_paths...] -ensure_docker_dir_ownership() { - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER" - return 0 - fi - - for dir in "$@"; do - if [ -d "$dir" ]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true - fi - done -} - -# Generate a secure random password with alphanumeric characters only (no special chars) -# Usage: generate_password [length] -# Default length: 32 -generate_password() { - local length="${1:-32}" - openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" -} - -# Validate password (alphanumeric only, minimum length) -# Usage: validate_password "password" [min_length] -# Returns 0 if valid, 1 if invalid -validate_password() { - local password="$1" - local min_length="${2:-12}" - - # Check length - if [ ${#password} -lt "$min_length" ]; then - echo " ⚠ Password must be at least $min_length characters long" - return 1 - fi - - # Check for special characters (not allowed) - if echo "$password" | grep -q '[^a-zA-Z0-9]'; then - echo " ⚠ Password must contain only letters and numbers (no special characters)" - return 1 - fi - - return 0 -} - -# Prompt for yes/no, with unattended default -# Usage: prompt_yn "Question?" "default" VARNAME -# default can be "y" or "n" -prompt_yn() { - local question="$1" - local default="$2" - local varname="$3" - - if [ "$UNATTENDED" = true ]; then - eval "$varname='$default'" - echo "$question [auto: $default]" - return - fi - - read -p "$question " response - eval "$varname='$response'" -} - -# Prompt for text input, with unattended default -# Usage: prompt_text "Question?" "default" VARNAME -prompt_text() { - local question="$1" - local default="$2" - local varname="$3" - - if [ "$UNATTENDED" = true ]; then - eval "$varname='$default'" - echo "$question [auto: $default]" - return - fi - - read -p "$question " response - eval "$varname='$response'" -} - -# ============================================================================ -# CADDY CONFIGURATION HELPER -# ============================================================================ -configure_caddy_for_service() { - local SERVICE_NAME="$1" - local SERVICE_PORT="$2" - local DEFAULT_SUBDOMAIN="$3" - local EXTRA_CONFIG="${4:-}" # Optional extra Caddy directives - - # Check if Caddy is installed - if [ ! -d "$DOCKER_DIR/caddy" ]; then - return 0 # Caddy not installed, skip configuration - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " CADDY REVERSE PROXY CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Caddy is installed. You can configure reverse proxy for $SERVICE_NAME." - echo "" - - local CONFIGURE_CADDY="" - 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" - return 0 - fi - - # Get domain/subdomain - echo "" - echo "Enter the full domain for $SERVICE_NAME:" - echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" - echo "" - local SERVICE_DOMAIN="" - prompt_text "Domain:" "" SERVICE_DOMAIN - - if [ -z "$SERVICE_DOMAIN" ]; then - echo " ⚠ No domain provided, skipping Caddy configuration." - return 0 - fi - - # Backup Caddyfile - local CADDY_DIR="$DOCKER_DIR/caddy" - local CADDYFILE="$CADDY_DIR/Caddyfile" - local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - - if [ -f "$CADDYFILE" ]; then - echo " Backing up Caddyfile to: $(basename $BACKUP_FILE)" - cp "$CADDYFILE" "$BACKUP_FILE" - else - echo " Creating new Caddyfile" - touch "$CADDYFILE" - fi - - # Check if service already configured - if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then - echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile" - local OVERWRITE="" - prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Keeping existing configuration." - return 0 - fi - # Remove existing configuration - sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE" - fi - - # Add service configuration to Caddyfile - echo " Adding $SERVICE_NAME configuration to Caddyfile..." - - cat >> "$CADDYFILE" << CADDY_BLOCK - -# $SERVICE_NAME -$SERVICE_DOMAIN { - reverse_proxy localhost:$SERVICE_PORT - - # Security headers - 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" - } - - # Logging for fail2ban - log { - output file /var/log/caddy/${SERVICE_DOMAIN}.log - format json - } -$EXTRA_CONFIG -} -CADDY_BLOCK - - echo " ✓ Configuration added to Caddyfile" - - # Reload Caddy - echo " Reloading Caddy configuration..." - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddy reloaded successfully" - else - echo " ⚠ Failed to reload Caddy (will retry after formatting)" - fi - - # Format Caddyfile - echo " Formatting Caddyfile..." - if docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddyfile formatted" - else - echo " ⚠ Failed to format Caddyfile" - fi - - # Final reload after formatting - echo " Final reload..." - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ Caddy configuration active" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $SERVICE_NAME is now accessible at:" - echo " https://$SERVICE_DOMAIN" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - else - echo " ⚠ Failed final reload. Check Caddy logs:" - echo " docker logs caddy" - echo " You can restore from backup: $BACKUP_FILE" - fi - - echo "" -} - -# ============================================================================ -# CHECK IF SERVICE EXISTS AND ASK USER ACTION -# ============================================================================ -check_service_exists() { - local SERVICE_NAME="$1" - local SERVICE_DIR="$2" - local RETURN_VAR="$3" # Variable name to set (true/false) - - if [ -f "$SERVICE_DIR/docker-compose.yml" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $SERVICE_NAME is already installed at $SERVICE_DIR" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Options:" - echo " 1. Skip (keep existing configuration)" - echo " 2. Reconfigure (will backup existing config)" - echo " 3. Restart containers only" - echo "" - local ACTION="" - prompt_text "Choose option [1/2/3]:" "1" ACTION - - case "$ACTION" in - 2) - echo " Backing up existing configuration..." - local BACKUP_NAME=$(echo "$SERVICE_NAME" | tr '[:upper:] ' '[:lower:]-') - BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$BACKUP_NAME" - mkdir -p "$BACKUP_DIR" - cp -r "$SERVICE_DIR" "$BACKUP_DIR/" - echo " ✓ Backup saved to: $BACKUP_DIR" - eval "$RETURN_VAR=true" - return 0 - ;; - 3) - echo " Restarting $SERVICE_NAME containers..." - cd "$SERVICE_DIR" - docker compose restart 2>/dev/null && echo " ✓ $SERVICE_NAME restarted" || echo " ⚠ Failed to restart" - eval "$RETURN_VAR=false" - return 0 - ;; - *) - echo " Skipping $SERVICE_NAME (already configured)" - eval "$RETURN_VAR=false" - return 0 - ;; - esac - else - # Service doesn't exist, proceed with installation - eval "$RETURN_VAR=true" - return 0 - fi -} - -# ============================================================================ -# DETECT AVAILABLE DRIVES (RUN ONCE) -# ============================================================================ -detect_drives() { - # Global variables set by this function: - # - DRIVES_DETECTED: true/false - # - PRIMARY_DRIVE: name of first drive (e.g., "storage1") - # - PRIMARY_DRIVE_PATH: full path to first drive - # - DRIVES_DIR: base drives directory - # - AVAILABLE_DRIVES_COUNT: number of drives found - - DRIVES_DIR="$ACTUAL_HOME/drives" - DRIVES_DETECTED=false - PRIMARY_DRIVE="" - PRIMARY_DRIVE_PATH="" - AVAILABLE_DRIVES_COUNT=0 - - if [ -d "$DRIVES_DIR" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " DETECTED DRIVES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - for drive in "$DRIVES_DIR"/*; do - if [ -d "$drive" ]; then - drive_name=$(basename "$drive") - drive_size=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $2}') - drive_used=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $3}') - drive_avail=$(df -h "$drive" 2>/dev/null | awk 'NR==2 {print $4}') - - echo " ✓ $drive_name" - if [ -n "$drive_size" ]; then - echo " Path: $drive" - echo " Size: $drive_size (Used: $drive_used, Available: $drive_avail)" - else - echo " Path: $drive" - fi - - # Set PRIMARY_DRIVE to the first drive found - if [ -z "$PRIMARY_DRIVE" ]; then - PRIMARY_DRIVE="$drive_name" - PRIMARY_DRIVE_PATH="$drive" - fi - - AVAILABLE_DRIVES_COUNT=$((AVAILABLE_DRIVES_COUNT + 1)) - fi - done - - if [ $AVAILABLE_DRIVES_COUNT -gt 0 ]; then - DRIVES_DETECTED=true - echo "" - echo " Using '$PRIMARY_DRIVE' as primary drive for default paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - fi - fi - - # If no drives detected, use home directory - if [ "$DRIVES_DETECTED" = "false" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " No ~/drives directory found" - echo " Using $ACTUAL_HOME for default paths" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - PRIMARY_DRIVE_PATH="$ACTUAL_HOME" - fi -} - -# ============================================================================ -# SHOW CURRENT INSTALLATION STATUS -# ============================================================================ -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "CURRENT SYSTEM STATUS" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -if is_docker_installed; then - echo " ✓ Docker: Installed ($(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ','))" -else - echo " ○ Docker: Not installed" -fi -if is_samba_installed; then - echo " ✓ Samba: Installed and running" -else - echo " ○ Samba: Not installed" -fi -if is_netbird_installed; then - echo " ✓ NetBird: Installed" -else - echo " ○ NetBird: Not installed" -fi -if is_rustdesk_installed; then - echo " ✓ RustDesk: Installed" -else - echo " ○ RustDesk: Not installed" -fi -if is_teamviewer_installed; then - echo " ✓ TeamViewer: Installed" -else - echo " ○ TeamViewer: Not installed" -fi -if is_meshcentral_installed; then - echo " ✓ MeshCentral Agent: Installed" -else - echo " ○ MeshCentral Agent: Not installed" -fi -if is_wireguard_installed; then - echo " ✓ WireGuard: Installed" -else - echo " ○ WireGuard: Not installed" -fi -if is_tailscale_installed; then - echo " ✓ Tailscale: Installed" -else - echo " ○ Tailscale: Not installed" -fi -if is_rclone_installed; then - echo " ✓ rclone: Installed" -else - echo " ○ rclone: Not installed" -fi -if is_rsync_installed; then - echo " ✓ rsync: Installed" -else - echo " ○ rsync: Not installed" -fi -if is_ufw_installed; then - if ufw status 2>/dev/null | grep -q "Status: active"; then - echo " ✓ UFW Firewall: Enabled" - else - echo " ○ UFW Firewall: Installed but disabled" - fi -else - echo " ○ UFW Firewall: Not installed" -fi -echo "" -echo "This script can reinstall/reconfigure any component." -echo "" - -# Update package list -echo "Updating package lists..." -run_cmd apt update - -# Install basic utilities -echo "" -echo "Installing basic utilities..." -echo " - net-tools: Network configuration tools (ifconfig, netstat, etc.)" -echo " - ncdu: Disk usage analyzer with ncurses interface" -echo " - git: Version control system" -echo " - curl: Command-line tool for transferring data with URLs" -echo " - wget: Network downloader" -echo " - htop: Interactive process viewer" -echo " - tree: Display directory structure in tree format" -echo " - zip/unzip: Archive compression utilities" -echo " - rclone: Rsync for cloud storage and local drives (backup tool)" -echo "" - -run_cmd apt install -y \ - net-tools \ - ncdu \ - git \ - curl \ - wget \ - htop \ - tree \ - zip \ - unzip \ - rclone || echo "Warning: Some utilities failed to install, continuing..." - -# Install OpenSSH Server -echo "" -echo "Installing OpenSSH Server..." -echo " - openssh-server: SSH server for remote access" -echo "" - -apt install -y openssh-server || echo "Warning: OpenSSH server installation failed, continuing..." - -# Start and enable SSH service -systemctl start ssh || echo "Warning: Failed to start SSH" -systemctl enable ssh || echo "Warning: Failed to enable SSH" - -# On rerun, skip SSH configuration if already set up -SKIP_SSH_CONFIG=false -if (systemctl is-active ssh &>/dev/null || systemctl is-active sshd &>/dev/null) && \ - { [ -f "$ACTUAL_HOME/.ssh/authorized_keys" ] && [ -s "$ACTUAL_HOME/.ssh/authorized_keys" ]; } || \ - [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then - echo "" - echo "SSH is already configured on this system." - prompt_yn "Reconfigure SSH keys and authentication? (y/n):" "n" RECONFIG_SSH - if [ "$RECONFIG_SSH" != "y" ] && [ "$RECONFIG_SSH" != "Y" ]; then - SKIP_SSH_CONFIG=true - fi -fi - -if [ "$SKIP_SSH_CONFIG" != true ]; then - -# Generate SSH key for this computer -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SSH KEY GENERATION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -prompt_yn "Generate a new SSH key for this computer? (y/n):" "n" GENERATE_KEY - -if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - echo "" - read -p "Enter a label/comment for the key (e.g., email or hostname) [default: $ACTUAL_USER@$(hostname)]: " KEY_COMMENT - - if [ -z "$KEY_COMMENT" ]; then - KEY_COMMENT="$ACTUAL_USER@$(hostname)" - fi - - # Check if key already exists - if [ -f "$ACTUAL_HOME/.ssh/id_rsa" ]; then - echo "" - echo "⚠️ WARNING: SSH key already exists at $ACTUAL_HOME/.ssh/id_rsa" - read -p "Overwrite existing key? This cannot be undone! (y/n): " OVERWRITE_KEY - - if [ "$OVERWRITE_KEY" != "y" ] && [ "$OVERWRITE_KEY" != "Y" ]; then - echo "Skipping key generation." - GENERATE_KEY="n" - fi - fi - - if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - echo "" - echo "Generating 4096-bit RSA key pair..." - echo "This may take a moment..." - - # Generate key as the actual user, not root - sudo -u "$ACTUAL_USER" ssh-keygen -t rsa -b 4096 -C "$KEY_COMMENT" -f "$ACTUAL_HOME/.ssh/id_rsa" -N "" - - if [ $? -eq 0 ]; then - echo "" - echo "✓ SSH key generated successfully!" - echo "" - echo "Private key: $ACTUAL_HOME/.ssh/id_rsa (keep this secret!)" - echo "Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Your PUBLIC key (safe to share):" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - cat "$ACTUAL_HOME/.ssh/id_rsa.pub" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To use this key:" - echo " • Add to GitHub: Settings → SSH and GPG keys → New SSH key" - echo " • Add to servers: Copy above key to remote ~/.ssh/authorized_keys" - echo " • View anytime: cat ~/.ssh/id_rsa.pub" - echo "" - else - echo "✗ Failed to generate SSH key" - fi - fi -else - echo "Skipping SSH key generation." -fi - -# Import SSH keys from GitHub/Launchpad -echo "" -echo "You can import SSH public keys from GitHub or Launchpad for easier SSH access." -echo "" -prompt_yn "Import SSH keys from GitHub or Launchpad? (y/n):" "n" IMPORT_SSH_KEYS - -KEYS_IMPORTED=false -GITHUB_USER="" -LAUNCHPAD_USER="" - -if [ "$IMPORT_SSH_KEYS" = "y" ] || [ "$IMPORT_SSH_KEYS" = "Y" ]; then - prompt_text "GitHub username (or leave blank to skip):" "" GITHUB_USER - prompt_text "Launchpad username (or leave blank to skip):" "" LAUNCHPAD_USER -fi - -# Create .ssh directory if it doesn't exist -mkdir -p "$ACTUAL_HOME/.ssh" -touch "$ACTUAL_HOME/.ssh/authorized_keys" -chmod 700 "$ACTUAL_HOME/.ssh" -chmod 600 "$ACTUAL_HOME/.ssh/authorized_keys" - -if [ -n "$GITHUB_USER" ]; then - echo "Importing SSH keys from GitHub user: $GITHUB_USER" - if curl -fsSL "https://github.com/$GITHUB_USER.keys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then - echo "✓ GitHub keys imported successfully" - KEYS_IMPORTED=true - else - echo "✗ Failed to import GitHub keys" - fi -fi - -if [ -n "$LAUNCHPAD_USER" ]; then - echo "Importing SSH keys from Launchpad user: $LAUNCHPAD_USER" - if curl -fsSL "https://launchpad.net/~$LAUNCHPAD_USER/+sshkeys" >> "$ACTUAL_HOME/.ssh/authorized_keys" 2>/dev/null; then - echo "✓ Launchpad keys imported successfully" - KEYS_IMPORTED=true - else - echo "✗ Failed to import Launchpad keys" - fi -fi - -# Fix ownership -chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/.ssh" - -# Disable password authentication if keys were imported -if [ "$KEYS_IMPORTED" = true ]; then - echo "" - echo "SSH keys imported. Disabling password authentication..." - - # Backup sshd_config - cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup - - # Disable password authentication - sed -i 's/^#*PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config - sed -i 's/^#*PasswordAuthentication no/PasswordAuthentication no/' /etc/ssh/sshd_config - - # Ensure these settings are also set - grep -q "^PasswordAuthentication" /etc/ssh/sshd_config || echo "PasswordAuthentication no" >> /etc/ssh/sshd_config - - # Restart SSH service to apply changes - systemctl restart ssh - - echo "✓ Password authentication disabled. SSH key authentication required." - echo "✓ Backup saved to /etc/ssh/sshd_config.backup" -else - echo "" - echo "No SSH keys imported. Password authentication remains enabled." - - # Offer fail2ban since password auth is still enabled - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "FAIL2BAN (Recommended with password SSH)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Since password authentication is enabled, fail2ban is recommended" - echo "to protect against brute-force SSH attacks." - echo "" - - prompt_yn "Install and enable fail2ban? (y/n):" "y" INSTALL_FAIL2BAN - - if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then - echo "" - echo "Installing fail2ban..." - run_cmd apt install -y fail2ban - - if [ "$DRY_RUN" != true ]; then - # Create local config to protect SSH - cat > /etc/fail2ban/jail.local << 'FAIL2BAN_CONFIG' -[sshd] -enabled = true -port = ssh -filter = sshd -logpath = /var/log/auth.log -maxretry = 5 -bantime = 3600 -findtime = 600 -FAIL2BAN_CONFIG - - run_cmd systemctl enable fail2ban - run_cmd systemctl restart fail2ban - echo "✓ fail2ban installed and configured" - echo " - 5 failed attempts = 1 hour ban" - echo " - View banned IPs: sudo fail2ban-client status sshd" - fi - else - echo "Skipping fail2ban installation." - fi -fi - -INSTALL_FAIL2BAN="${INSTALL_FAIL2BAN:-n}" - -fi # End SKIP_SSH_CONFIG - -# Docker Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "DOCKER INSTALLATION" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -if is_docker_installed; then - echo "Docker is already installed: $(docker --version 2>/dev/null)" - prompt_yn "Reinstall Docker? (y/n):" "n" INSTALL_DOCKER -else - prompt_yn "Install Docker? (y/n):" "y" INSTALL_DOCKER -fi - -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo "" - echo "Installing Docker prerequisites..." - echo " - ca-certificates: SSL/TLS certificates for secure connections" - echo " - gnupg: GNU Privacy Guard for package verification" - echo " - lsb-release: Provides Ubuntu version information" - echo "" - - run_cmd apt install -y \ - ca-certificates \ - gnupg \ - lsb-release || echo "Warning: Some prerequisites failed to install, continuing..." - - echo "" - echo "Installing Docker..." - echo " - Docker Engine: Container runtime platform" - echo " - Docker Compose: Multi-container application orchestration" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would remove old Docker packages" - echo "[DRY-RUN] Would add Docker GPG key and repository" - echo "[DRY-RUN] Would install docker-ce, docker-ce-cli, containerd.io, plugins" - echo "[DRY-RUN] Would start and enable Docker service" - echo "[DRY-RUN] Would add $SUDO_USER to docker group" - else - # Remove old Docker packages if they exist - apt remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true - - # Add Docker's official GPG key - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc - chmod a+r /etc/apt/keyrings/docker.asc - - # Add Docker repository - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ - tee /etc/apt/sources.list.d/docker.list > /dev/null - - # Update package list with Docker repo - apt update - - # Install Docker Engine, CLI, containerd, and Docker Compose plugin - apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin || echo "Warning: Docker installation failed, continuing..." - - # Start and enable Docker service - systemctl start docker || echo "Warning: Failed to start Docker" - systemctl enable docker || echo "Warning: Failed to enable Docker" - - # Add current user to docker group (if not root) - if [ -n "$SUDO_USER" ]; then - usermod -aG docker "$SUDO_USER" - echo "User $SUDO_USER added to docker group" - fi - - # Verify Docker installation - echo "" - echo "Verifying Docker installation..." - docker --version || echo "Warning: Docker verification failed" - docker compose version || echo "Warning: Docker Compose verification failed" - fi -else - echo "Skipping Docker installation." -fi - -# Samba File Sharing (Optional) -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SAMBA FILE SHARING (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Samba allows you to share folders over the network to Windows, Mac, and Linux." -echo "The script will share your primary drive at ~/drives/primary" -echo "" - -if is_samba_installed; then - echo "Samba is already installed and running." - if grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then - echo " Share 'Primary' is configured at: $ACTUAL_HOME/drives/primary" - fi - echo "" - prompt_yn "Reconfigure Samba? (y/n):" "n" INSTALL_SAMBA -else - prompt_yn "Install and configure Samba file sharing? (y/n):" "n" INSTALL_SAMBA -fi - -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo "" - echo "Installing Samba file sharing..." - echo " - Samba: SMB/CIFS file server for network file sharing" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install samba" - echo "[DRY-RUN] Would configure [Primary] share at $ACTUAL_HOME/drives/primary" - echo "[DRY-RUN] Would prompt for Samba password" - echo "[DRY-RUN] Would enable and start smbd/nmbd services" - else - run_cmd apt install -y samba || echo "Warning: Samba installation failed, continuing..." - - # Configure Samba share for primary drive - if command -v smbd &> /dev/null; then - echo "" - echo "Configuring Samba share for primary drive..." - - # Backup existing config - cp /etc/samba/smb.conf /etc/samba/smb.conf.backup-$(date +%Y%m%d-%H%M%S) - - # Add Primary share configuration - if ! grep -q "\[Primary\]" /etc/samba/smb.conf; then - cat >> /etc/samba/smb.conf << SAMBA_CONFIG - -# Primary drive share - added by post-install script -[Primary] - comment = Primary Drive - path = $ACTUAL_HOME/drives/primary - browseable = yes - read only = no - writable = yes - valid users = $ACTUAL_USER - create mask = 0775 - directory mask = 0775 -SAMBA_CONFIG - echo "✓ Added [Primary] share to Samba configuration" - else - echo "Samba [Primary] share already configured, skipping..." - fi - - # Add Samba user (skip in unattended mode - user must set password manually) - if [ "$UNATTENDED" != true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "SAMBA PASSWORD SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Set a password for Samba file sharing access." - echo "Tip: Using the same password as your system login is convenient." - echo "" - smbpasswd -a "$ACTUAL_USER" - else - echo "Skipping Samba password setup (unattended mode)" - echo "Set password later with: sudo smbpasswd -a $ACTUAL_USER" - fi - - # Enable and restart Samba services - systemctl enable smbd nmbd || echo "Warning: Failed to enable Samba services" - systemctl restart smbd nmbd || echo "Warning: Failed to restart Samba services" - - echo "" - echo "✓ Samba configured successfully" - echo " Share name: Primary" - echo " Path: $ACTUAL_HOME/drives/primary" - echo " Access: \\\\$(hostname)\\Primary (Windows) or smb://$(hostname)/Primary (Mac/Linux)" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "ADDING MORE SAMBA SHARES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To add another share, edit the Samba config file:" - echo "" - echo " sudo nano /etc/samba/smb.conf" - echo "" - echo "Add a new section at the end:" - echo "" - echo " [ShareName]" - echo " comment = Description of share" - echo " path = /path/to/folder" - echo " browseable = yes" - echo " read only = no" - echo " writable = yes" - echo " valid users = $ACTUAL_USER" - echo " create mask = 0775" - echo " directory mask = 0775" - echo "" - echo "Save (Ctrl+O, Enter) and exit (Ctrl+X), then restart Samba:" - echo "" - echo " sudo systemctl restart smbd nmbd" - echo "" - echo "Verify the share is active:" - echo "" - echo " testparm -s" - echo "" - else - echo "✗ Samba installation failed, skipping configuration" - fi - fi -else - echo "Skipping Samba installation." -fi - -# On rerun, skip VPN configuration if already set up -SKIP_VPN_CONFIG=false -if is_netbird_installed || is_wireguard_installed || is_tailscale_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "VPN SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "VPN services already configured:" - is_netbird_installed && echo " ✓ NetBird" - is_wireguard_installed && echo " ✓ WireGuard" - is_tailscale_installed && echo " ✓ Tailscale" - echo "" - prompt_yn "Reconfigure VPN services? (y/n):" "n" RECONFIG_VPN - if [ "$RECONFIG_VPN" != "y" ] && [ "$RECONFIG_VPN" != "Y" ]; then - SKIP_VPN_CONFIG=true - fi -fi - -if [ "$SKIP_VPN_CONFIG" != true ]; then - -# NetBird Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "NETBIRD MESH VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "NetBird is a secure mesh VPN for connecting devices across networks." -echo "" - -if is_netbird_installed; then - echo "NetBird is already installed." - netbird status 2>/dev/null || true - echo "" - prompt_yn "Reinstall NetBird? (y/n):" "n" INSTALL_NETBIRD -else - prompt_yn "Install NetBird? (y/n):" "n" INSTALL_NETBIRD -fi - -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo "" - echo "Installing NetBird..." - echo " - NetBird: Secure mesh VPN for connecting devices" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download and run NetBird install script" - echo "[DRY-RUN] Would ensure openssh-server is installed (required for NetBird SSH)" - echo "[DRY-RUN] Would configure netbird systemd service with --allow-server-ssh" - else - # NetBird v0.60.0+ requires openssh-server for SSH access. - # It injects /etc/ssh/sshd_config.d/99-netbird.conf and listens on port 22022. - echo "Ensuring openssh-server is installed (required for NetBird SSH)..." - apt install -y openssh-server 2>/dev/null || echo "Warning: openssh-server install failed, continuing..." - systemctl enable ssh 2>/dev/null || true - systemctl start ssh 2>/dev/null || true - - curl -fsSL https://pkgs.netbird.io/install.sh | sh || echo "Warning: NetBird installation failed, continuing..." - - # Persist --allow-server-ssh so this machine accepts NetBird SSH connections - # without requiring interactive re-authentication on every connection. - echo "Configuring NetBird to allow SSH server (persistent across reboots)..." - mkdir -p /etc/systemd/system/netbird.service.d - cat > /etc/systemd/system/netbird.service.d/ssh-server.conf << 'NETBIRD_OVERRIDE' -[Service] -ExecStart= -ExecStart=/usr/bin/netbird service run --allow-server-ssh -NETBIRD_OVERRIDE - systemctl daemon-reload 2>/dev/null || true - echo " ✓ NetBird will start with --allow-server-ssh on every boot" - - echo "" - echo "NetBird installed. Setup instructions:" - echo " 1. Create account at https://app.netbird.io (or self-host)" - echo " 2. Run 'netbird up' and authenticate via browser" - echo "" - echo "For NetBird SSH functionality (v0.60.0+ method):" - echo " • openssh-server is installed and --allow-server-ssh is persisted" - echo " • Enable SSH per-peer in the NetBird dashboard (Peers > [peer] > SSH)" - echo " • NetBird injects /etc/ssh/sshd_config.d/99-netbird.conf automatically" - echo " • Connect from another NetBird peer: ssh user@" - echo " • Get peer IPs with: netbird status" - echo " • SSH will work without re-authenticating each connection" - echo "" - fi -else - echo "Skipping NetBird installation." -fi - -# WireGuard Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "WIREGUARD VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "WireGuard is a fast, modern VPN protocol." -echo " • Lightweight and high-performance" -echo " • Simple configuration via config files" -echo " • Built into Linux kernel" -echo "" - -if is_wireguard_installed; then - echo "WireGuard is already installed." - echo "" - prompt_yn "Reinstall WireGuard? (y/n):" "n" INSTALL_WIREGUARD -else - prompt_yn "Install WireGuard? (y/n):" "n" INSTALL_WIREGUARD -fi - -if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then - echo "" - echo "Installing WireGuard..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install wireguard wireguard-tools" - else - apt install -y wireguard wireguard-tools || echo "Warning: WireGuard installation failed, continuing..." - - echo "" - echo "WireGuard installed. Setup instructions:" - echo "" - echo "Generate keys:" - echo " wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey" - echo "" - echo "Create config at /etc/wireguard/wg0.conf:" - echo " [Interface]" - echo " PrivateKey = " - echo " Address = 10.0.0.1/24" - echo " ListenPort = 51820" - echo "" - echo " [Peer]" - echo " PublicKey = " - echo " AllowedIPs = 10.0.0.2/32" - echo " Endpoint = peer.example.com:51820" - echo "" - echo "Start WireGuard:" - echo " sudo wg-quick up wg0" - echo " sudo systemctl enable wg-quick@wg0 # Start on boot" - echo "" - fi -else - echo "Skipping WireGuard installation." -fi - -# Tailscale Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "TAILSCALE VPN (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Tailscale is a zero-config mesh VPN built on WireGuard." -echo " • Easy setup - just sign in" -echo " • Built on WireGuard for performance" -echo " • Automatic NAT traversal" -echo " • Built-in SSH (Tailscale SSH)" -echo "" - -if is_tailscale_installed; then - echo "Tailscale is already installed." - tailscale status 2>/dev/null || true - echo "" - prompt_yn "Reinstall Tailscale? (y/n):" "n" INSTALL_TAILSCALE -else - prompt_yn "Install Tailscale? (y/n):" "n" INSTALL_TAILSCALE -fi - -if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then - echo "" - echo "Installing Tailscale..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would add Tailscale apt repository" - echo "[DRY-RUN] Would install tailscale" - else - # Add Tailscale's package signing key and repository - curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/resolute.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null - curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/resolute.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list - - apt update - apt install -y tailscale || echo "Warning: Tailscale installation failed, continuing..." - - echo "" - echo "Tailscale installed. Setup instructions:" - echo "" - echo "Connect to Tailscale network:" - echo " sudo tailscale up" - echo "" - echo "This opens a browser to authenticate. After that:" - echo " tailscale status # View connected devices" - echo " tailscale ip # Show your Tailscale IP" - echo "" - echo "Tailscale SSH (optional - enable in admin console):" - echo " • Enable 'SSH' in Tailscale admin console for this machine" - echo " • Connect with: ssh user@device-name (uses Tailscale identity)" - echo " • No SSH keys needed - Tailscale handles authentication" - echo "" - fi -else - echo "Skipping Tailscale installation." -fi - -fi # End SKIP_VPN_CONFIG - -# On rerun, skip remote desktop configuration if already set up -SKIP_RD_CONFIG=false -if is_rustdesk_installed || is_teamviewer_installed || is_meshcentral_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "REMOTE DESKTOP SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Remote desktop services already configured:" - is_rustdesk_installed && echo " ✓ RustDesk" - is_teamviewer_installed && echo " ✓ TeamViewer" - is_meshcentral_installed && echo " ✓ MeshCentral Agent" - echo "" - prompt_yn "Reconfigure remote desktop services? (y/n):" "n" RECONFIG_RD - if [ "$RECONFIG_RD" != "y" ] && [ "$RECONFIG_RD" != "Y" ]; then - SKIP_RD_CONFIG=true - fi -fi - -if [ "$SKIP_RD_CONFIG" != true ]; then - -# RustDesk Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "RUSTDESK REMOTE DESKTOP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "RustDesk is an open-source remote desktop software." -echo "" - -if is_rustdesk_installed; then - echo "RustDesk is already installed." - echo "" - prompt_yn "Reinstall RustDesk? (y/n):" "n" INSTALL_RUSTDESK -else - prompt_yn "Install RustDesk? (y/n):" "n" INSTALL_RUSTDESK -fi - -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo "" - echo "Installing RustDesk..." - echo " - RustDesk: Open-source remote desktop software" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download latest RustDesk from GitHub" - echo "[DRY-RUN] Would install rustdesk .deb package" - else - # Download latest RustDesk .deb package - RUSTDESK_VERSION=$(curl -s https://api.github.com/repos/rustdesk/rustdesk/releases/latest | grep -oP '"tag_name": "\K(.*)(?=")') - RUSTDESK_URL="https://github.com/rustdesk/rustdesk/releases/download/${RUSTDESK_VERSION}/rustdesk-${RUSTDESK_VERSION}-x86_64.deb" - - wget -O /tmp/rustdesk.deb "$RUSTDESK_URL" || echo "Warning: RustDesk download failed, continuing..." - - if [ -f /tmp/rustdesk.deb ]; then - apt install -y /tmp/rustdesk.deb || echo "Warning: RustDesk installation failed, continuing..." - rm /tmp/rustdesk.deb - echo "✓ RustDesk installed" - fi - fi -else - echo "Skipping RustDesk installation." -fi - -# TeamViewer Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "TEAMVIEWER REMOTE DESKTOP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "TeamViewer is commercial remote desktop software with a free tier." -echo " • Cross-platform (Windows, Mac, Linux, mobile)" -echo " • Easy to use - no port forwarding needed" -echo " • Requires TeamViewer account for unattended access" -echo "" - -if is_teamviewer_installed; then - echo "TeamViewer is already installed." - echo "" - prompt_yn "Reinstall TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER -else - prompt_yn "Install TeamViewer? (y/n):" "n" INSTALL_TEAMVIEWER -fi - -if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then - echo "" - echo "Installing TeamViewer..." - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would download TeamViewer .deb package" - echo "[DRY-RUN] Would install teamviewer" - else - # Download TeamViewer .deb package - wget -O /tmp/teamviewer.deb "https://download.teamviewer.com/download/linux/teamviewer_amd64.deb" || echo "Warning: TeamViewer download failed, continuing..." - - if [ -f /tmp/teamviewer.deb ]; then - apt install -y /tmp/teamviewer.deb || echo "Warning: TeamViewer installation failed, continuing..." - rm /tmp/teamviewer.deb - - echo "" - echo "TeamViewer installed. Setup instructions:" - echo "" - echo "Start TeamViewer:" - echo " teamviewer" - echo "" - echo "For unattended access:" - echo " 1. Open TeamViewer" - echo " 2. Go to Extras → Options → Security" - echo " 3. Set a personal password for unattended access" - echo " 4. Note your TeamViewer ID (shown in main window)" - echo "" - echo "✓ TeamViewer installed" - fi - fi -else - echo "Skipping TeamViewer installation." -fi - -# MeshCentral Installation -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "MESHCENTRAL AGENT (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "MeshCentral is an open-source remote management solution." -echo " • Self-hosted or use public servers" -echo " • Web-based remote desktop (no client software needed)" -echo " • Terminal, file transfer, and remote desktop" -echo " • Requires a MeshCentral server to connect to" -echo "" - -if is_meshcentral_installed; then - echo "MeshCentral Agent is already installed." - echo "" - prompt_yn "Reinstall MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL -else - prompt_yn "Install MeshCentral Agent? (y/n):" "n" INSTALL_MESHCENTRAL -fi - -if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then - echo "" - echo "MeshCentral requires a server URL to connect to." - echo "" - echo "If you have a MeshCentral server, the agent install is typically done by:" - echo " 1. Log into your MeshCentral web interface" - echo " 2. Go to 'My Devices' → 'Add Agent'" - echo " 3. Download and run the Linux agent installer" - echo "" - echo "Example (replace with your server's URL):" - echo " wget -O meshagent https://your-meshcentral-server/meshagents?id=XXXXX" - echo " chmod +x meshagent" - echo " sudo ./meshagent -install" - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would prompt for MeshCentral server URL" - echo "[DRY-RUN] Would download and install meshagent" - else - prompt_text "Enter MeshCentral agent URL (or leave blank to skip):" "" MESHCENTRAL_URL - - if [ -n "$MESHCENTRAL_URL" ]; then - wget -O /tmp/meshagent "$MESHCENTRAL_URL" || echo "Warning: MeshCentral agent download failed" - - if [ -f /tmp/meshagent ]; then - chmod +x /tmp/meshagent - /tmp/meshagent -install || echo "Warning: MeshCentral agent installation failed" - rm /tmp/meshagent - echo "" - echo "✓ MeshCentral Agent installed" - echo " Check your MeshCentral server - this device should appear shortly." - fi - else - echo "" - echo "No URL provided. Skipping MeshCentral agent installation." - echo "You can install later by downloading the agent from your MeshCentral server." - fi - fi -else - echo "Skipping MeshCentral installation." -fi - -fi # End SKIP_RD_CONFIG - -# ============================================================================ -# SELF-HOSTED DOCKER APPLICATIONS (Optional) -# ============================================================================ -# These applications run in Docker containers using docker-compose -# Each app is installed to ~/docker/{appname}/ - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SELF-HOSTED DOCKER APPLICATIONS (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Install self-hosted applications using Docker Compose." -echo "Each app will be installed to ~/docker/{appname}/" -echo "" -echo "Note: Docker must be installed for these applications." -echo "" - -if ! is_docker_installed && [ "$INSTALL_DOCKER" != "y" ] && [ "$INSTALL_DOCKER" != "Y" ]; then - echo "Docker is not installed. Skipping self-hosted applications." - echo "Install Docker first, then rerun this script." -else - # Create docker apps directory - DOCKER_DIR="$ACTUAL_HOME/docker" - if [ "$DRY_RUN" != true ]; then - mkdir -p "$DOCKER_DIR" - chown "$ACTUAL_USER:$ACTUAL_USER" "$DOCKER_DIR" - fi - - # ============================================================================ - # DETECT DRIVES (ONCE FOR ALL SERVICES) - # ============================================================================ - detect_drives - - # ============================================================================ - # SERVICE SELECTION MENU - # ============================================================================ - - # Use whiptail for service selection if available - if command -v whiptail &> /dev/null; then - # Detect existing services - echo "Detecting existing services..." - declare -A EXISTING_SERVICES - [ -d "$DOCKER_DIR/immich" ] && EXISTING_SERVICES[IMMICH]="ON" - [ -d "$DOCKER_DIR/audiobookshelf" ] && EXISTING_SERVICES[AUDIOBOOKSHELF]="ON" - [ -d "$DOCKER_DIR/emby" ] && EXISTING_SERVICES[EMBY]="ON" - [ -d "$DOCKER_DIR/arm" ] && EXISTING_SERVICES[ARM]="ON" - [ -d "$DOCKER_DIR/filebrowser" ] && EXISTING_SERVICES[FILEBROWSER]="ON" - [ -d "$DOCKER_DIR/magicmirror" ] && EXISTING_SERVICES[MAGICMIRROR]="ON" - [ -d "$DOCKER_DIR/actualbudget" ] && EXISTING_SERVICES[ACTUALBUDGET]="ON" - [ -d "$DOCKER_DIR/authelia" ] && EXISTING_SERVICES[AUTHELIA]="ON" - [ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON" - [ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON" - [ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON" - [ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON" - [ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON" - [ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON" - [ -d "$DOCKER_DIR/ntfy" ] && EXISTING_SERVICES[NTFY]="ON" - [ -d "$DOCKER_DIR/uptime-kuma" ] && EXISTING_SERVICES[UPTIMEKUMA]="ON" - [ -d "$DOCKER_DIR/wg-easy" ] && EXISTING_SERVICES[WGEASY]="ON" - [ -d "$DOCKER_DIR/traccar" ] && EXISTING_SERVICES[TRACCAR]="ON" - [ -d "$DOCKER_DIR/portainer" ] && EXISTING_SERVICES[PORTAINER]="ON" - [ -d "$DOCKER_DIR/meshcentral" ] && EXISTING_SERVICES[MESHCENTRAL]="ON" - [ -d "$DOCKER_DIR/fmd" ] && EXISTING_SERVICES[FINDMYDEVICE]="ON" - [ -d "$DOCKER_DIR/frigate-notify" ] && EXISTING_SERVICES[FRIGATE_NOTIFY]="ON" - [ -d "$DOCKER_DIR/watchtower" ] && EXISTING_SERVICES[WATCHTOWER]="ON" - command -v fail2ban-client &> /dev/null && EXISTING_SERVICES[FAIL2BAN]="ON" - - # Ask user what action to perform - ACTION=$(whiptail --title "Service Management" --menu \ - "Choose an action:" 15 60 3 \ - "1" "Install new services" \ - "2" "Uninstall existing services" \ - "3" "Cancel and skip" \ - 3>&1 1>&2 2>&3) - - case "$ACTION" in - 1) - # INSTALL MODE - MENU_MODE="INSTALL" - # Build checklist - mark existing services as ON - SELECTED_SERVICES=$(whiptail --title "Select Docker Services to Install" \ - --checklist "Use SPACE to select, ENTER to confirm. [*] = already installed" 25 78 17 \ - "IMMICH" "Photo & video backup (like Google Photos)" ${EXISTING_SERVICES[IMMICH]:-OFF} \ - "AUDIOBOOKSHELF" "Audiobook & podcast server" ${EXISTING_SERVICES[AUDIOBOOKSHELF]:-OFF} \ - "EMBY" "Media server for movies, TV, music" ${EXISTING_SERVICES[EMBY]:-OFF} \ - "ARM" "Automatic Ripping Machine for DVDs/Blu-rays" ${EXISTING_SERVICES[ARM]:-OFF} \ - "FILEBROWSER" "Web-based file manager" ${EXISTING_SERVICES[FILEBROWSER]:-OFF} \ - "MAGICMIRROR" "Smart mirror / dashboard display" ${EXISTING_SERVICES[MAGICMIRROR]:-OFF} \ - "ACTUALBUDGET" "Personal finance management with bank sync" ${EXISTING_SERVICES[ACTUALBUDGET]:-OFF} \ - "AUTHELIA" "SSO + 2FA auth portal for Caddy" ${EXISTING_SERVICES[AUTHELIA]:-OFF} \ - "CADDY" "Reverse proxy with automatic HTTPS" ${EXISTING_SERVICES[CADDY]:-OFF} \ - "FAIL2BAN" "Intrusion prevention system" ${EXISTING_SERVICES[FAIL2BAN]:-OFF} \ - "LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \ - "MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \ - "MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \ - "JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \ - "FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \ - "NTFY" "Push notifications server" ${EXISTING_SERVICES[NTFY]:-OFF} \ - "UPTIMEKUMA" "Service monitoring dashboard" ${EXISTING_SERVICES[UPTIMEKUMA]:-OFF} \ - "WGEASY" "WireGuard VPN with web UI" ${EXISTING_SERVICES[WGEASY]:-OFF} \ - "TRACCAR" "GPS tracking server" ${EXISTING_SERVICES[TRACCAR]:-OFF} \ - "PORTAINER" "Docker management web UI" ${EXISTING_SERVICES[PORTAINER]:-OFF} \ - "MESHCENTRAL" "Remote management server" ${EXISTING_SERVICES[MESHCENTRAL]:-OFF} \ - "FINDMYDEVICE" "Device tracking (like Find My)" ${EXISTING_SERVICES[FINDMYDEVICE]:-OFF} \ - "FRIGATE_NOTIFY" "Push notifications for Frigate" ${EXISTING_SERVICES[FRIGATE_NOTIFY]:-OFF} \ - "WATCHTOWER" "Automatic container updates" ${EXISTING_SERVICES[WATCHTOWER]:-OFF} \ - 3>&1 1>&2 2>&3) - ;; - 2) - # UNINSTALL MODE - MENU_MODE="UNINSTALL" - # Only show services that exist - UNINSTALL_OPTIONS="" - [ -n "${EXISTING_SERVICES[IMMICH]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS IMMICH \"Photo & video backup\" ON" - [ -n "${EXISTING_SERVICES[AUDIOBOOKSHELF]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUDIOBOOKSHELF \"Audiobook server\" ON" - [ -n "${EXISTING_SERVICES[EMBY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS EMBY \"Media server\" ON" - [ -n "${EXISTING_SERVICES[ARM]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ARM \"Automatic Ripping Machine\" ON" - [ -n "${EXISTING_SERVICES[FILEBROWSER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FILEBROWSER \"Web file manager\" ON" - [ -n "${EXISTING_SERVICES[MAGICMIRROR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MAGICMIRROR \"Smart mirror\" ON" - [ -n "${EXISTING_SERVICES[ACTUALBUDGET]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS ACTUALBUDGET \"Personal finance\" ON" - [ -n "${EXISTING_SERVICES[AUTHELIA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS AUTHELIA \"SSO + 2FA auth portal\" ON" - [ -n "${EXISTING_SERVICES[CADDY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CADDY \"Reverse proxy\" ON" - [ -n "${EXISTING_SERVICES[FAIL2BAN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FAIL2BAN \"Intrusion prevention\" ON" - [ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON" - [ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON" - [ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON" - [ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON" - [ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON" - [ -n "${EXISTING_SERVICES[NTFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS NTFY \"Push notifications\" ON" - [ -n "${EXISTING_SERVICES[UPTIMEKUMA]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS UPTIMEKUMA \"Service monitoring\" ON" - [ -n "${EXISTING_SERVICES[WGEASY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WGEASY \"WireGuard VPN\" ON" - [ -n "${EXISTING_SERVICES[TRACCAR]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS TRACCAR \"GPS tracking\" ON" - [ -n "${EXISTING_SERVICES[PORTAINER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS PORTAINER \"Docker management\" ON" - [ -n "${EXISTING_SERVICES[MESHCENTRAL]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MESHCENTRAL \"Remote management\" ON" - [ -n "${EXISTING_SERVICES[FINDMYDEVICE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FINDMYDEVICE \"Device tracking\" ON" - [ -n "${EXISTING_SERVICES[FRIGATE_NOTIFY]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE_NOTIFY \"Frigate notifications\" ON" - [ -n "${EXISTING_SERVICES[WATCHTOWER]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS WATCHTOWER \"Container updates\" ON" - - if [ -z "$UNINSTALL_OPTIONS" ]; then - whiptail --title "No Services Found" --msgbox "No services detected to uninstall." 8 50 - SELECTED_SERVICES="" - else - SELECTED_SERVICES=$(eval "whiptail --title 'Select Services to Uninstall' \ - --checklist 'WARNING: Data will be backed up but services will be removed' 25 78 17 \ - $UNINSTALL_OPTIONS \ - 3>&1 1>&2 2>&3") - fi - ;; - 3|*) - # Cancel - SELECTED_SERVICES="" - MENU_MODE="CANCEL" - ;; - esac - - # Check if user cancelled - if [ $? -ne 0 ]; then - echo "Service selection cancelled. Skipping Docker applications." - SELECTED_SERVICES="" - fi - - # Parse selections (whiptail returns quoted strings) - # Only set to "n" if not already set (preserve any earlier choices) - : ${INSTALL_IMMICH:="n"} - : ${INSTALL_AUDIOBOOKSHELF:="n"} - : ${INSTALL_EMBY:="n"} - : ${INSTALL_ARM:="n"} - : ${INSTALL_FILEBROWSER:="n"} - : ${INSTALL_MAGICMIRROR:="n"} - : ${INSTALL_ACTUALBUDGET:="n"} - : ${INSTALL_AUTHELIA:="n"} - : ${INSTALL_CADDY:="n"} - : ${INSTALL_FAIL2BAN:="n"} - : ${INSTALL_LMS:="n"} - : ${INSTALL_MEALIE:="n"} - : ${INSTALL_MINECRAFT:="n"} - : ${INSTALL_JELLYFIN:="n"} - : ${INSTALL_FRIGATE:="n"} - : ${INSTALL_NTFY:="n"} - : ${INSTALL_UPTIMEKUMA:="n"} - : ${INSTALL_WGEASY:="n"} - : ${INSTALL_TRACCAR:="n"} - : ${INSTALL_PORTAINER:="n"} - : ${INSTALL_MESHCENTRAL_SERVER:="n"} - : ${INSTALL_FMD:="n"} - : ${INSTALL_FRIGATE_NOTIFY:="n"} - : ${INSTALL_WATCHTOWER:="n"} - - # Set installation flags based on selections - if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then INSTALL_IMMICH="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then INSTALL_AUDIOBOOKSHELF="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then INSTALL_EMBY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "ARM"; then INSTALL_ARM="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then INSTALL_FILEBROWSER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then INSTALL_MAGICMIRROR="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then INSTALL_ACTUALBUDGET="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUTHELIA"; then INSTALL_AUTHELIA="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then INSTALL_CADDY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FAIL2BAN"; then INSTALL_FAIL2BAN="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then INSTALL_NTFY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then INSTALL_UPTIMEKUMA="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then INSTALL_WGEASY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then INSTALL_TRACCAR="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then INSTALL_PORTAINER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then INSTALL_MESHCENTRAL_SERVER="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then INSTALL_FMD="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then INSTALL_FRIGATE_NOTIFY="y"; fi - if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then INSTALL_WATCHTOWER="y"; fi - - if [ "$MENU_MODE" = "UNINSTALL" ]; then - echo "" - echo "Services selected for uninstallation:" - echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /' - echo "" - - # Process uninstalls - if [ -n "$SELECTED_SERVICES" ]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " UNINSTALLING SERVICES" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Define uninstall function - uninstall_service() { - local SERVICE_NAME=$1 - local SERVICE_DIR=$2 - local CONTAINER_NAME=$3 - - echo "Uninstalling $SERVICE_NAME..." - - if [ -d "$SERVICE_DIR" ]; then - cd "$SERVICE_DIR" 2>/dev/null || return - - # Stop and remove containers - if [ -f "docker-compose.yml" ]; then - echo " Stopping containers..." - docker compose down 2>/dev/null || docker-compose down 2>/dev/null || true - fi - - # Backup data - BACKUP_DIR="$DOCKER_DIR/backups/$(date +%Y%m%d-%H%M%S)-$SERVICE_NAME" - mkdir -p "$BACKUP_DIR" - echo " Backing up to $BACKUP_DIR..." - cp -r "$SERVICE_DIR" "$BACKUP_DIR/" 2>/dev/null || true - - # Remove service directory - echo " Removing $SERVICE_DIR..." - rm -rf "$SERVICE_DIR" - - echo " ✓ $SERVICE_NAME uninstalled (backup: $BACKUP_DIR)" - else - echo " ⚠ $SERVICE_NAME directory not found, skipping" - fi - echo "" - } - - # Uninstall selected services - if echo "$SELECTED_SERVICES" | grep -q "IMMICH"; then uninstall_service "Immich" "$DOCKER_DIR/immich" "immich"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUDIOBOOKSHELF"; then uninstall_service "AudioBookshelf" "$DOCKER_DIR/audiobookshelf" "audiobookshelf"; fi - if echo "$SELECTED_SERVICES" | grep -q "EMBY"; then uninstall_service "Emby" "$DOCKER_DIR/emby" "emby"; fi - if echo "$SELECTED_SERVICES" | grep -q "ARM"; then uninstall_service "ARM" "$DOCKER_DIR/arm" "arm"; fi - if echo "$SELECTED_SERVICES" | grep -q "FILEBROWSER"; then uninstall_service "FileBrowser" "$DOCKER_DIR/filebrowser" "filebrowser"; fi - if echo "$SELECTED_SERVICES" | grep -q "MAGICMIRROR"; then uninstall_service "MagicMirror" "$DOCKER_DIR/magicmirror" "magicmirror"; fi - if echo "$SELECTED_SERVICES" | grep -q "ACTUALBUDGET"; then uninstall_service "ActualBudget" "$DOCKER_DIR/actualbudget" "actualbudget"; fi - if echo "$SELECTED_SERVICES" | grep -q "AUTHELIA"; then uninstall_service "Authelia" "$DOCKER_DIR/authelia" "authelia"; fi - if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi - if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi - if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi - if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi - if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi - if echo "$SELECTED_SERVICES" | grep -q "NTFY"; then uninstall_service "ntfy" "$DOCKER_DIR/ntfy" "ntfy"; fi - if echo "$SELECTED_SERVICES" | grep -q "UPTIMEKUMA"; then uninstall_service "Uptime Kuma" "$DOCKER_DIR/uptime-kuma" "uptime-kuma"; fi - if echo "$SELECTED_SERVICES" | grep -q "WGEASY"; then uninstall_service "wg-easy" "$DOCKER_DIR/wg-easy" "wg-easy"; fi - if echo "$SELECTED_SERVICES" | grep -q "TRACCAR"; then uninstall_service "Traccar" "$DOCKER_DIR/traccar" "traccar"; fi - if echo "$SELECTED_SERVICES" | grep -q "PORTAINER"; then uninstall_service "Portainer" "$DOCKER_DIR/portainer" "portainer"; fi - if echo "$SELECTED_SERVICES" | grep -q "MESHCENTRAL"; then uninstall_service "MeshCentral" "$DOCKER_DIR/meshcentral" "meshcentral"; fi - if echo "$SELECTED_SERVICES" | grep -q "FINDMYDEVICE"; then uninstall_service "FindMyDevice" "$DOCKER_DIR/fmd" "fmd"; fi - if echo "$SELECTED_SERVICES" | grep -q "FRIGATE_NOTIFY"; then uninstall_service "Frigate-Notify" "$DOCKER_DIR/frigate-notify" "frigate-notify"; fi - if echo "$SELECTED_SERVICES" | grep -q "WATCHTOWER"; then uninstall_service "Watchtower" "$DOCKER_DIR/watchtower" "watchtower"; fi - - # Special handling for fail2ban (system package) - if echo "$SELECTED_SERVICES" | grep -q "FAIL2BAN"; then - echo "Uninstalling fail2ban..." - echo " Stopping fail2ban service..." - systemctl stop fail2ban 2>/dev/null || true - systemctl disable fail2ban 2>/dev/null || true - echo " Removing fail2ban package..." - apt-get remove --purge -y fail2ban 2>/dev/null || true - echo " ✓ fail2ban uninstalled" - echo "" - fi - - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " Uninstallation complete!" - echo " Backups saved to: $DOCKER_DIR/backups/" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - fi - - # After uninstall, exit the Docker apps section - WHIPTAIL_USED=true - - elif [ "$MENU_MODE" = "INSTALL" ]; then - echo "" - echo "Selected services:" - echo "$SELECTED_SERVICES" | tr '"' '\n' | grep -v '^$' | sed 's/^/ - /' - echo "" - - # Mark that whiptail was used - skip all individual prompts - WHIPTAIL_USED=true - else - # Cancel or other mode - WHIPTAIL_USED=false - fi - else - # Whiptail not available - will use individual prompts - WHIPTAIL_USED=false - MENU_MODE="INSTALL" - fi - - # Skip installation section if we just did uninstalls - if [ "$MENU_MODE" = "UNINSTALL" ]; then - echo "Skipping installation section (uninstall mode was selected)." - # Jump to end of Docker section by setting a flag - SKIP_DOCKER_INSTALLS=true - else - SKIP_DOCKER_INSTALLS=false - fi - - # ============================================================================ - # DOCKER SERVICE INSTALLATIONS - # ============================================================================ - - if [ "$SKIP_DOCKER_INSTALLS" != true ]; then - # ---- IMMICH ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_IMMICH" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ IMMICH - Self-hosted photo & video backup │" - echo "│ Like Google Photos but private. Mobile app auto-uploads. │" - echo "│ Port: 2283 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Immich? (y/n):" "n" INSTALL_IMMICH - fi - - if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then - IMMICH_DIR="$DOCKER_DIR/immich" - - # Check if already installed and ask what to do - check_service_exists "Immich" "$IMMICH_DIR" IMMICH_RECONFIGURE - - if [ "$IMMICH_RECONFIGURE" = "true" ]; then - echo "Installing Immich..." - - # ── Photo library setup ───────────────────────────────────── - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ PHOTO LIBRARY SETUP │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - - DEFAULT_PHOTOS_DIR="$PRIMARY_DRIVE_PATH/photos" - IMMICH_STRATEGY="" - EXTERNAL_LIBRARY="" - EXISTING_PHOTOS_SOURCE="" - - prompt_yn " Do you have existing photos to include? (y/n):" "n" HAS_EXISTING_PHOTOS - - if [ "$HAS_EXISTING_PHOTOS" = "y" ] || [ "$HAS_EXISTING_PHOTOS" = "Y" ]; then - echo "" - echo " How should Immich handle your existing photos?" - echo "" - echo " [1] Import everything into Immich (recommended)" - echo " Immich manages all photos in one unified library." - echo " Dates preserved via EXIF metadata. Organized by date." - echo " Your original folder names are NOT kept on disk" - echo " (use Immich albums to organize instead)." - echo "" - echo " [2] Keep existing photos in place (read-only external library)" - echo " Immich indexes your existing photos without moving them." - echo " New uploads go to a separate folder." - echo " Your folder structure stays intact." - echo "" - - if [ "$UNATTENDED" = true ]; then - IMMICH_STRATEGY="1" - echo " Strategy: [auto: 1]" - else - read -p " Choose [1/2]: " IMMICH_STRATEGY - IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}" - fi - - if [ "$IMMICH_STRATEGY" = "2" ]; then - # Strategy 2: external library - ask where existing photos are - echo "" - echo " Where are your existing photos?" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Existing photos path:" "$DEFAULT_PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$DEFAULT_PHOTOS_DIR" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" - - UPLOAD_LOCATION="$PRIMARY_DRIVE_PATH/immich-uploads" - EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE" - - echo "" - echo " Setup:" - echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)" - echo " New uploads: $UPLOAD_LOCATION" - else - # Strategy 1 with existing photos: ask for library path, will import into it - echo "" - echo " Where should Immich store your photo library?" - echo " (Existing photos will be imported here)" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR" - PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}" - PHOTOS_DIR="${PHOTOS_DIR%/}" - - echo "" - echo " Where are your existing photos now?" - echo " Default: $PHOTOS_DIR" - prompt_text " Existing photos path:" "$PHOTOS_DIR" EXISTING_PHOTOS_SOURCE 2>/dev/null || EXISTING_PHOTOS_SOURCE="$PHOTOS_DIR" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE/#\~/$ACTUAL_HOME}" - EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" - - UPLOAD_LOCATION="$PHOTOS_DIR" - EXTERNAL_LIBRARY="" - - echo "" - echo " All photos (existing + new) will live in:" - echo " $PHOTOS_DIR" - echo " └── 2026/01/filename.jpg organized by date" - fi - else - # No existing photos: simplest path - just ask where to store - IMMICH_STRATEGY="1" - echo "" - echo " Where should Immich store your photo library?" - echo " Default: $DEFAULT_PHOTOS_DIR" - prompt_text " Photo library path:" "$DEFAULT_PHOTOS_DIR" PHOTOS_DIR 2>/dev/null || PHOTOS_DIR="$DEFAULT_PHOTOS_DIR" - PHOTOS_DIR="${PHOTOS_DIR/#\~/$ACTUAL_HOME}" - PHOTOS_DIR="${PHOTOS_DIR%/}" - - UPLOAD_LOCATION="$PHOTOS_DIR" - EXTERNAL_LIBRARY="" - - echo "" - echo " All photos will be stored in:" - echo " $PHOTOS_DIR" - echo " └── 2026/01/filename.jpg organized by date" - fi - echo "" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $IMMICH_DIR" - echo "[DRY-RUN] Would store uploads at $UPLOAD_LOCATION" - [ -n "$EXTERNAL_LIBRARY" ] && echo "[DRY-RUN] Would mount external library at $EXTERNAL_LIBRARY" - echo "[DRY-RUN] Would create docker-compose.yml and .env" - else - mkdir -p "$IMMICH_DIR" 2>/dev/null || true - mkdir -p "$UPLOAD_LOCATION" 2>/dev/null || true - [ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" 2>/dev/null || true - - # Create required subdirectories with .immich marker files - # Immich checks these on startup and fails if they're missing - for subdir in thumbs upload backups library profile encoded-video; do - mkdir -p "$UPLOAD_LOCATION/$subdir" 2>/dev/null || true - touch "$UPLOAD_LOCATION/$subdir/.immich" 2>/dev/null || true - done - cd "$IMMICH_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Create docker-compose.yml - if [ -n "$EXTERNAL_LIBRARY" ]; then - # Strategy 2: external library mount included - cat > docker-compose.yml << 'IMMICH_COMPOSE' -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - ${EXTERNAL_LIBRARY}:/usr/src/app/external:ro - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false - - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_DB: ${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -IMMICH_COMPOSE - else - # Strategy 1: unified library, no external mount - cat > docker-compose.yml << 'IMMICH_COMPOSE' -name: immich - -services: - immich-server: - container_name: immich_server - image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} - volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:2283 - depends_on: - - redis - - database - restart: always - healthcheck: - disable: false - - immich-machine-learning: - container_name: immich_machine_learning - image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} - volumes: - - model-cache:/cache - env_file: - - .env - restart: always - healthcheck: - disable: false - - redis: - container_name: immich_redis - image: docker.io/valkey/valkey:9-bookworm - healthcheck: - test: valkey-cli ping || exit 1 - restart: always - - database: - container_name: immich_postgres - image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 - environment: - POSTGRES_PASSWORD: ${DB_PASSWORD} - POSTGRES_USER: ${DB_USERNAME} - POSTGRES_DB: ${DB_DATABASE_NAME} - POSTGRES_INITDB_ARGS: '--data-checksums' - volumes: - - ${DB_DATA_LOCATION}:/var/lib/postgresql/data - restart: always - -volumes: - model-cache: -IMMICH_COMPOSE - fi - - # Generate random password - DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) - - # Create .env file with strategy-appropriate comments - if [ "$IMMICH_STRATEGY" = "2" ]; then - cat > .env << IMMICH_ENV -# ============================================================ -# IMMICH CONFIGURATION — External Library Mode -# ============================================================ -# -# STORAGE TEMPLATE (configure in Immich web UI): -# Admin → Settings → Storage Template → Enable -# Template: {{y}}/{{MM}}/{{filename}} -# This organizes new uploads into: immich-uploads/2026/01/filename.jpg -# -# EXTERNAL LIBRARY SETUP: -# Admin → External Libraries → Create Library -# Import path: /usr/src/app/external -# Click "Scan" to index your existing photos. -# -# ============================================================ - -# New uploads from phone/web -UPLOAD_LOCATION=$UPLOAD_LOCATION - -# Existing photos (read-only, indexed by Immich) -# Mounted at /usr/src/app/external inside the container -EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY - -# Database location (keep on fast storage) -DB_DATA_LOCATION=./postgres - -IMMICH_VERSION=release - -DB_PASSWORD=$DB_PASS -DB_USERNAME=postgres -DB_DATABASE_NAME=immich - -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -IMMICH_ENV - else - cat > .env << IMMICH_ENV -# ============================================================ -# IMMICH CONFIGURATION — Unified Library -# ============================================================ -# -# All photos (imported + new uploads) are stored in one location. -# Storage template organizes files by date automatically. -# -# To import existing photos, run: -# $IMMICH_DIR/import-photos.sh -# -# The import script handles storage template configuration, -# CLI installation, and uploads with EXIF date preservation. -# -# ============================================================ - -# All photos stored here (uploads + imported) -UPLOAD_LOCATION=$UPLOAD_LOCATION - -# Database location (keep on fast storage) -DB_DATA_LOCATION=./postgres - -IMMICH_VERSION=release - -DB_PASSWORD=$DB_PASS -DB_USERNAME=postgres -DB_DATABASE_NAME=immich - -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -IMMICH_ENV - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" 2>/dev/null || true - [ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true - - # Create import helper script for Strategy 1 with existing photos - if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then - cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_HEAD' -#!/bin/bash -################################################################################ -# Immich Photo Import Script -# Generated by ubuntu-post-install -# -# This script imports your existing photo collection into Immich with proper -# EXIF date preservation. Photos are uploaded through the API, which triggers -# Immich's metadata extraction pipeline — dates, GPS, camera info are all -# read from the original files. -# -# What this script does: -# 1. Creates admin account (if first run) or logs in -# 2. Generates an API key automatically -# 3. Configures the storage template (date-based organization) -# 4. Installs the Immich CLI (if needed) -# 5. Uploads all photos with EXIF metadata preserved -# -# Usage: -# ./import-photos.sh # interactive (prompts for everything) -# ./import-photos.sh # skip account setup, use existing key -################################################################################ - -IMPORT_SCRIPT_HEAD - - cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_SCRIPT_VARS -IMMICH_URL="http://localhost:2283" -SOURCE_DIR="$EXISTING_PHOTOS_SOURCE" -IMMICH_DIR="$IMMICH_DIR" -IMPORT_SCRIPT_VARS - - cat >> "$IMMICH_DIR/import-photos.sh" << 'IMPORT_SCRIPT_BODY' - -echo "" -echo "┌─────────────────────────────────────────────────────────────────┐" -echo "│ IMMICH PHOTO IMPORT │" -echo "└─────────────────────────────────────────────────────────────────┘" -echo "" - -# ── Preflight checks ──────────────────────────────────────────── -echo "Checking Immich server..." -if ! curl -s "$IMMICH_URL/api/server/ping" > /dev/null 2>&1; then - echo "" - echo " ✗ Immich is not running at $IMMICH_URL" - echo " Start it with: cd $IMMICH_DIR && docker compose up -d" - echo "" - exit 1 -fi -echo " ✓ Immich is running" - -# Check source directory -if [ ! -d "$SOURCE_DIR" ]; then - echo "" - echo " ✗ Source directory not found: $SOURCE_DIR" - echo " Update SOURCE_DIR in this script if your photos moved." - echo "" - exit 1 -fi -echo " ✓ Source directory: $SOURCE_DIR" - -echo -n " Scanning for photos/videos (this may take a while)..." -PHOTO_COUNT=$(find "$SOURCE_DIR" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" -o -iname "*.heif" -o -iname "*.webp" -o -iname "*.gif" -o -iname "*.tiff" -o -iname "*.bmp" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.webm" \) 2>/dev/null | wc -l) -echo " done" -echo " ✓ Found ~$PHOTO_COUNT photos/videos" - -# ── Get or create API key ─────────────────────────────────────── -API_KEY="${1:-}" - -if [ -z "$API_KEY" ]; then - echo "" - # Check if server needs initial setup - SERVER_CONFIG=$(curl -s "$IMMICH_URL/api/server/config" 2>/dev/null) - IS_INITIALIZED=$(echo "$SERVER_CONFIG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('isInitialized', True))" 2>/dev/null) - - if [ "$IS_INITIALIZED" = "False" ]; then - # ── First-time setup: create admin account ────────────── - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FIRST-TIME SETUP — Creating admin account │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - echo " Immich needs an admin account. Enter your details:" - echo "" - read -p " Admin email: " ADMIN_EMAIL - while [ -z "$ADMIN_EMAIL" ]; do - read -p " Admin email (required): " ADMIN_EMAIL - done - read -sp " Admin password: " ADMIN_PASS - echo "" - while [ ${#ADMIN_PASS} -lt 8 ]; do - echo " Password must be at least 8 characters." - read -sp " Admin password: " ADMIN_PASS - echo "" - done - read -p " Your name [Admin]: " ADMIN_NAME - ADMIN_NAME="${ADMIN_NAME:-Admin}" - - echo "" - echo " Creating admin account..." - SIGNUP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/auth/admin-sign-up" \ - -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"name\":\"$ADMIN_NAME\"}" 2>/dev/null) - SIGNUP_CODE=$(echo "$SIGNUP_RESPONSE" | tail -1) - SIGNUP_BODY=$(echo "$SIGNUP_RESPONSE" | sed '$d') - - if [ "$SIGNUP_CODE" = "201" ]; then - echo " ✓ Admin account created" - else - echo " ✗ Failed to create admin account (HTTP $SIGNUP_CODE)" - echo " Response: $SIGNUP_BODY" - echo "" - echo " Create your account manually at $IMMICH_URL" - echo " Then re-run this script with your API key:" - echo " $0 " - exit 1 - fi - else - # Server already initialized, need credentials to log in - echo " Immich is already set up. Log in to generate an API key." - echo "" - read -p " Admin email: " ADMIN_EMAIL - while [ -z "$ADMIN_EMAIL" ]; do - read -p " Admin email (required): " ADMIN_EMAIL - done - read -sp " Admin password: " ADMIN_PASS - echo "" - fi - - # ── Login to get bearer token ─────────────────────────────── - echo " Logging in..." - LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/auth/login" \ - -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" 2>/dev/null) - LOGIN_CODE=$(echo "$LOGIN_RESPONSE" | tail -1) - LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | sed '$d') - - if [ "$LOGIN_CODE" != "201" ]; then - echo " ✗ Login failed (HTTP $LOGIN_CODE)" - echo "" - echo " Check your email/password and try again, or pass an API key:" - echo " $0 " - exit 1 - fi - - ACCESS_TOKEN=$(echo "$LOGIN_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])" 2>/dev/null) - if [ -z "$ACCESS_TOKEN" ]; then - echo " ✗ Could not extract access token from login response" - exit 1 - fi - echo " ✓ Logged in" - - # ── Create API key ────────────────────────────────────────── - echo " Creating API key..." - APIKEY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $ACCESS_TOKEN" \ - "$IMMICH_URL/api/api-keys" \ - -d '{"name":"import-photos-script"}' 2>/dev/null) - APIKEY_CODE=$(echo "$APIKEY_RESPONSE" | tail -1) - APIKEY_BODY=$(echo "$APIKEY_RESPONSE" | sed '$d') - - if [ "$APIKEY_CODE" = "201" ]; then - API_KEY=$(echo "$APIKEY_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['secret'])" 2>/dev/null) - if [ -n "$API_KEY" ]; then - echo " ✓ API key created" - else - echo " ✗ Could not extract API key from response" - echo " Create one manually: $IMMICH_URL → Account Settings → API Keys" - echo " Then re-run: $0 " - exit 1 - fi - else - echo " ✗ Failed to create API key (HTTP $APIKEY_CODE)" - echo " Create one manually: $IMMICH_URL → Account Settings → API Keys" - echo " Then re-run: $0 " - exit 1 - fi -else - # API key provided as argument — verify it - echo "" - echo " Verifying API key..." - VERIFY_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ - -H "x-api-key: $API_KEY" "$IMMICH_URL/api/users/me" 2>/dev/null) - if [ "$VERIFY_CODE" != "200" ]; then - echo " ✗ Invalid API key (HTTP $VERIFY_CODE)" - echo " Double-check the key and try again." - exit 1 - fi - echo " ✓ API key valid" -fi - -# ── Configure storage template via API ────────────────────────── -echo "" -echo " Configuring storage template ({{y}}/{{MM}}/{{filename}})..." -CURRENT_CONFIG=$(curl -s -H "x-api-key: $API_KEY" "$IMMICH_URL/api/system-config" 2>/dev/null) -if [ -n "$CURRENT_CONFIG" ] && command -v python3 &> /dev/null; then - UPDATED_CONFIG=$(echo "$CURRENT_CONFIG" | python3 -c " -import sys, json -config = json.load(sys.stdin) -config['storageTemplate']['enabled'] = True -config['storageTemplate']['template'] = '{{y}}/{{MM}}/{{filename}}' -json.dump(config, sys.stdout) -" 2>/dev/null) - if [ -n "$UPDATED_CONFIG" ]; then - RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \ - -H "x-api-key: $API_KEY" \ - -H "Content-Type: application/json" \ - "$IMMICH_URL/api/system-config" \ - -d "$UPDATED_CONFIG" 2>/dev/null) - if [ "$RESULT" = "200" ]; then - echo " ✓ Storage template configured" - else - echo " ⚠ Could not set storage template (HTTP $RESULT)" - echo " Set manually: Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - fi - else - echo " ⚠ Could not parse config. Set storage template manually:" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - fi -else - echo " ⚠ python3 not found. Set storage template manually:" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" -fi - -# ── Install immich-cli if needed ──────────────────────────────── -echo "" -IMMICH_CMD="" - -# Immich CLI requires Node.js >= 20 (File global class) -NODE_OK=false -if command -v node &> /dev/null; then - NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then - NODE_OK=true - fi -fi - -if [ "$NODE_OK" = false ]; then - echo " Immich CLI requires Node.js >= 20 (found: $(node -v 2>/dev/null || echo 'none'))." - read -p " Install Node.js 22 LTS now? (y/n): " INSTALL_NODE_YN - if [ "$INSTALL_NODE_YN" = "y" ] || [ "$INSTALL_NODE_YN" = "Y" ]; then - echo " Installing Node.js 22 LTS from NodeSource..." - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - 2>/dev/null - sudo apt-get install -y -qq nodejs 2>/dev/null - NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then - NODE_OK=true - echo " ✓ Node.js $(node -v) installed" - else - echo " ✗ Installation failed." - echo " Install Node.js 20+ manually: https://nodejs.org/" - echo " Then re-run: $0 $API_KEY" - exit 1 - fi - else - echo "" - echo " Install Node.js 20+ later and re-run this script:" - echo " curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -" - echo " sudo apt-get install -y nodejs" - echo " $0 $API_KEY" - exit 0 - fi -fi - -if command -v immich &> /dev/null; then - IMMICH_CMD="immich" - echo " ✓ Immich CLI found" -elif command -v npx &> /dev/null; then - echo " Immich CLI not installed. Will use npx (downloads temporarily)." - IMMICH_CMD="npx --yes @immich/cli" -elif command -v npm &> /dev/null; then - echo " Installing Immich CLI globally..." - if npm install -g @immich/cli 2>/dev/null; then - IMMICH_CMD="immich" - echo " ✓ Immich CLI installed" - else - echo " ✗ npm install failed. Trying npx..." - IMMICH_CMD="npx --yes @immich/cli" - fi -fi - -if [ -z "$IMMICH_CMD" ]; then - echo " ✗ Could not find npm or npx despite Node.js being installed." - echo " Install manually: npm install -g @immich/cli" - echo " Then re-run: $0 $API_KEY" - exit 1 -fi - -# ── Run the import ────────────────────────────────────────────── -echo "" -echo " Authenticating CLI with Immich..." -if ! $IMMICH_CMD login "$IMMICH_URL/api" "$API_KEY"; then - echo " ✗ CLI login failed" - exit 1 -fi - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " Starting import from: $SOURCE_DIR" -echo " Importing ~$PHOTO_COUNT files. This may take a while." -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" - -$IMMICH_CMD upload --recursive "$SOURCE_DIR" - -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " Import complete!" -echo " View your photos at: $IMMICH_URL" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -IMPORT_SCRIPT_BODY - - chmod +x "$IMMICH_DIR/import-photos.sh" - chown "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR/import-photos.sh" - fi - - echo "" - echo "✓ Immich configured at $IMMICH_DIR" - if [ -n "$EXTERNAL_LIBRARY" ]; then - echo " Existing photos: $EXTERNAL_LIBRARY (read-only)" - echo " New uploads: $UPLOAD_LOCATION" - else - echo " Photo library: $UPLOAD_LOCATION" - fi - echo "" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Immich" "2283" "photos" - - # Ask to start container - prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH - if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then - echo " Starting Immich..." - docker compose up -d 2>/dev/null && echo " ✓ Immich started" || echo " ⚠ Failed to start" - fi - - # ── Post-setup instructions ───────────────────────────── - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ IMMICH SETUP STEPS │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - - if [ -n "$EXISTING_PHOTOS_SOURCE" ] && [ "$IMMICH_STRATEGY" != "2" ]; then - echo " Import your existing photos AFTER this script finishes:" - echo " $IMMICH_DIR/import-photos.sh" - echo "" - echo " The script handles everything automatically:" - echo " creates your admin account, generates an API key," - echo " configures the storage template, installs the CLI," - echo " and imports all photos from $EXISTING_PHOTOS_SOURCE" - echo " with EXIF dates preserved." - echo "" - echo " NOTE: Run import-photos.sh separately after this setup" - echo " script has completed. It may take a while for large" - echo " photo collections." - echo "" - echo " After import, open http://localhost:2283 to browse your photos." - elif [ "$IMMICH_STRATEGY" = "2" ]; then - echo " 1. Open http://localhost:2283 and create your admin account" - echo "" - echo " 2. Enable storage template (keeps new uploads organized):" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - echo "" - echo " 3. Set up your existing photo library:" - echo " Admin → External Libraries → Create Library" - echo " Import path: /usr/src/app/external" - echo " Click Scan to index your photos" - echo "" - echo " New uploads from the mobile app or web UI are stored" - echo " separately in: $UPLOAD_LOCATION" - else - echo " 1. Open http://localhost:2283 and create your admin account" - echo "" - echo " 2. Enable storage template (keeps files organized by date):" - echo " Admin → Settings → Storage Template → Enable" - echo " Template: {{y}}/{{MM}}/{{filename}}" - echo "" - echo " That's it. Upload photos from the mobile app or web UI." - echo " All photos are stored in: $PHOTOS_DIR" - fi - echo "" - fi - fi # End IMMICH_RECONFIGURE check - fi # End INSTALL_IMMICH check - - # ---- AUDIOBOOKSHELF ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUDIOBOOKSHELF" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ AUDIOBOOKSHELF - Audiobook & podcast server │" - echo "│ Stream audiobooks with progress sync across devices. │" - echo "│ Port: 13378 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Audiobookshelf? (y/n):" "n" INSTALL_AUDIOBOOKSHELF - fi - - if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then - ABS_DIR="$DOCKER_DIR/audiobookshelf" - - # Check if already installed and ask what to do - check_service_exists "AudioBookshelf" "$ABS_DIR" ABS_RECONFIGURE - - if [ "$ABS_RECONFIGURE" = "true" ]; then - echo "Installing Audiobookshelf..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $ABS_DIR" - else - mkdir -p "$ABS_DIR" - ensure_docker_dir_ownership "$ABS_DIR" - cd "$ABS_DIR" - - prompt_text "Path to audiobooks folder [default: $PRIMARY_DRIVE_PATH/audiobooks]:" "$PRIMARY_DRIVE_PATH/audiobooks" AUDIOBOOKS_PATH - - cat > docker-compose.yml << ABS_COMPOSE -name: audiobookshelf - -services: - audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf:latest - container_name: audiobookshelf - hostname: audiobookshelf - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - ./metadata:/metadata - - ${AUDIOBOOKS_PATH}:/audiobooks - - ${PODCASTS_PATH:-./podcasts}:/podcasts - ports: - - "13378:80" -ABS_COMPOSE - - cat > .env << ABS_ENV -AUDIOBOOKS_PATH=$AUDIOBOOKS_PATH -PODCASTS_PATH=./podcasts -ABS_ENV - - mkdir -p config metadata podcasts - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ABS_DIR" - - echo "" - echo "✓ Audiobookshelf configured at $ABS_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks" - - prompt_yn "Start Audiobookshelf now? (y/n):" "y" START_ABS - if [ "$START_ABS" = "y" ] || [ "$START_ABS" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Audiobookshelf started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:13378" - echo "" - fi - fi # End ABS_RECONFIGURE check - fi # End INSTALL_AUDIOBOOKSHELF check - - # ---- EMBY ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_EMBY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ EMBY - Media server for movies, TV, music │" - echo "│ Stream your media library to any device. │" - echo "│ Port: 8096 (web), 8920 (https) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Emby? (y/n):" "n" INSTALL_EMBY - fi - - if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then - EMBY_DIR="$DOCKER_DIR/emby" - - # Check if already installed and ask what to do - check_service_exists "Emby" "$EMBY_DIR" EMBY_RECONFIGURE - - if [ "$EMBY_RECONFIGURE" = "true" ]; then - echo "Installing Emby..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $EMBY_DIR" - else - mkdir -p "$EMBY_DIR" - ensure_docker_dir_ownership "$EMBY_DIR" - cd "$EMBY_DIR" - - prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH - - cat > docker-compose.yml << EMBY_COMPOSE -name: emby - -services: - emby: - image: emby/embyserver:latest - container_name: emby - hostname: emby - restart: unless-stopped - environment: - - UID=$(id -u "$ACTUAL_USER") - - GID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config - - ${MEDIA_PATH}:/media - ports: - - "8096:8096" - - "8920:8920" - # Uncomment for hardware transcoding (Intel/AMD) - # devices: - # - /dev/dri:/dev/dri -EMBY_COMPOSE - - cat > .env << EMBY_ENV -MEDIA_PATH=$MEDIA_PATH -EMBY_ENV - - mkdir -p config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EMBY_DIR" - - echo "" - echo "✓ Emby configured at $EMBY_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Emby" "8096" "emby" - - prompt_yn "Start Emby now? (y/n):" "y" START_EMBY - if [ "$START_EMBY" = "y" ] || [ "$START_EMBY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Emby started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8096" - echo "" - fi - fi # End EMBY_RECONFIGURE check - fi # End INSTALL_EMBY check - - # ---- A.R.M. (Automatic Ripping Machine) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ARM" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ A.R.M. - Automatic Ripping Machine │" - echo "│ Automatically rip DVDs, Blu-rays, and CDs. │" - echo "│ Port: 8080 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install A.R.M.? (y/n):" "n" INSTALL_ARM - fi - - if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then - ARM_DIR="$DOCKER_DIR/arm" - check_service_exists "A.R.M." "$ARM_DIR" ARM_RECONFIGURE - - if [ "$ARM_RECONFIGURE" = "true" ]; then - echo "Installing A.R.M...." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $ARM_DIR" - else - mkdir -p "$ARM_DIR" - ensure_docker_dir_ownership "$ARM_DIR" - cd "$ARM_DIR" - - prompt_text "Path for ripped media output [default: $PRIMARY_DRIVE_PATH/ripped]:" "$PRIMARY_DRIVE_PATH/ripped" ARM_OUTPUT - - # Detect optical drives - echo "" - echo "Detecting optical drives..." - OPTICAL_DRIVES=$(ls /dev/sr* 2>/dev/null || echo "") - if [ -n "$OPTICAL_DRIVES" ]; then - echo "Found: $OPTICAL_DRIVES" - else - echo "No optical drives detected. You can add them later." - OPTICAL_DRIVES="/dev/sr0" - fi - - cat > docker-compose.yml << ARM_COMPOSE -name: arm - -services: - automatic-ripping-machine: - image: automaticrippingmachine/automatic-ripping-machine:latest - container_name: arm - hostname: arm - restart: unless-stopped - environment: - - ARM_UID=$(id -u "$ACTUAL_USER") - - ARM_GID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/etc/arm/config - - ./logs:/home/arm/logs - - ${ARM_OUTPUT}/movies:/home/arm/media/completed - - ${ARM_OUTPUT}/music:/home/arm/music - ports: - - "8080:8080" - devices: - - /dev/sr0:/dev/sr0 - # Add more drives as needed: - # - /dev/sr1:/dev/sr1 - privileged: true -ARM_COMPOSE - - cat > .env << ARM_ENV -ARM_OUTPUT=$ARM_OUTPUT -ARM_ENV - - mkdir -p config logs - mkdir -p "$ARM_OUTPUT/movies" "$ARM_OUTPUT/music" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_DIR" - - echo "" - echo "✓ A.R.M. configured at $ARM_DIR" - - prompt_yn "Start A.R.M. now? (y/n):" "y" START_ARM - if [ "$START_ARM" = "y" ] || [ "$START_ARM" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ A.R.M. started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8080" - echo " ⚠️ Complete setup in browser on first visit!" - echo " Note: Edit docker-compose.yml to add more optical drives" - echo "" - fi - fi # End ARM_RECONFIGURE check - fi # End INSTALL_ARM check - - # ---- FILEBROWSER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FILEBROWSER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FILEBROWSER - Web-based file manager │" - echo "│ Browse, upload, download files via web interface. │" - echo "│ Port: 8085 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Filebrowser? (y/n):" "n" INSTALL_FILEBROWSER - fi - - if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then - FB_DIR="$DOCKER_DIR/filebrowser" - check_service_exists "FileBrowser" "$FB_DIR" FB_RECONFIGURE - - if [ "$FB_RECONFIGURE" = "true" ]; then - echo "Installing Filebrowser..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FB_DIR" - else - mkdir -p "$FB_DIR" - ensure_docker_dir_ownership "$FB_DIR" - cd "$FB_DIR" - - prompt_text "Path to browse [default: $PRIMARY_DRIVE_PATH]:" "$PRIMARY_DRIVE_PATH" FB_PATH - - cat > docker-compose.yml << FB_COMPOSE -name: filebrowser - -services: - filebrowser: - image: filebrowser/filebrowser:s6 - container_name: filebrowser - hostname: filebrowser - restart: unless-stopped - environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ${FB_PATH}:/srv - - ./database/filebrowser.db:/database/filebrowser.db - - ./config/settings.json:/config/settings.json - ports: - - "8085:80" -FB_COMPOSE - - cat > .env << FB_ENV -FB_PATH=$FB_PATH -FB_ENV - - mkdir -p database config - touch database/filebrowser.db - cat > config/settings.json << 'FB_SETTINGS' -{ - "port": 80, - "baseURL": "", - "address": "", - "log": "stdout", - "database": "/database/filebrowser.db", - "root": "/srv" -} -FB_SETTINGS - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FB_DIR" - - echo "" - echo "✓ Filebrowser configured at $FB_DIR" - - prompt_yn "Start Filebrowser now? (y/n):" "y" START_FB - if [ "$START_FB" = "y" ] || [ "$START_FB" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Filebrowser started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8085" - echo " Default login: admin / admin (change immediately!)" - echo "" - fi - fi # End FB_RECONFIGURE check - fi # End INSTALL_FILEBROWSER check - - # ---- MAGIC MIRROR ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MAGICMIRROR" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MAGIC MIRROR - Smart mirror / dashboard display │" - echo "│ Modular smart mirror platform. Run up to 3 instances. │" - echo "│ Ports: 8081, 8082, 8083 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Magic Mirror? (y/n):" "n" INSTALL_MAGICMIRROR - fi - - if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then - echo "" - prompt_text "How many Magic Mirror instances? [1-3, default: 1]:" "1" MM_COUNT - MM_COUNT=${MM_COUNT:-1} - if [ "$MM_COUNT" -gt 3 ]; then MM_COUNT=3; fi - if [ "$MM_COUNT" -lt 1 ]; then MM_COUNT=1; fi - - echo "Installing $MM_COUNT Magic Mirror instance(s)..." - - for i in $(seq 1 $MM_COUNT); do - MM_PORT=$((8080 + i)) - MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MM_DIR (port $MM_PORT)" - else - mkdir -p "$MM_DIR" - ensure_docker_dir_ownership "$MM_DIR" - cd "$MM_DIR" - - cat > docker-compose.yml << MM_COMPOSE -name: mm-$MM_PORT - -services: - magicmirror: - image: karsten13/magicmirror:latest - container_name: magicmirror-$MM_PORT - hostname: magicmirror-$MM_PORT - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/opt/magic_mirror/config - - ./modules:/opt/magic_mirror/modules - - ./css:/opt/magic_mirror/css - ports: - - "$MM_PORT:8080" -MM_COMPOSE - - mkdir -p config modules css - - # Ask if user has existing config to copy - echo "" - echo " Config options:" - echo " [1] Use default config (basic modules)" - echo " [2] Copy existing config from path" - read -p " Choose [1]: " MM_CONFIG_CHOICE - MM_CONFIG_CHOICE=${MM_CONFIG_CHOICE:-1} - - if [ "$MM_CONFIG_CHOICE" = "2" ]; then - read -p " Path to config.js: " MM_CONFIG_PATH - if [ -f "$MM_CONFIG_PATH" ]; then - cp "$MM_CONFIG_PATH" config/config.js - echo " ✓ Copied config from $MM_CONFIG_PATH" - - # Also copy custom.css if exists in same directory - MM_CSS_PATH="${MM_CONFIG_PATH%/*}/custom.css" - if [ -f "$MM_CSS_PATH" ]; then - cp "$MM_CSS_PATH" css/custom.css - echo " ✓ Copied custom.css" - fi - - # Parse config for third-party modules and offer to download - echo "" - echo " Scanning for third-party modules..." - - # Extract module names starting with MMM- (third-party convention) - THIRD_PARTY_MODULES=$(grep -oP 'module:\s*["\x27]MMM-[^"\x27]+["\x27]' config/config.js 2>/dev/null | sed "s/module:\s*[\"']//g" | sed "s/[\"']//g" | sort -u) - - if [ -n "$THIRD_PARTY_MODULES" ]; then - echo " Found third-party modules:" - echo "$THIRD_PARTY_MODULES" | while read mod; do - echo " - $mod" - done - echo "" - prompt_yn " Download these modules from GitHub? (y/n):" "y" MM_DOWNLOAD_MODS - if [ "$MM_DOWNLOAD_MODS" = "y" ] || [ "$MM_DOWNLOAD_MODS" = "Y" ]; then - cd modules - echo "$THIRD_PARTY_MODULES" | while read mod; do - if [ -n "$mod" ] && [ ! -d "$mod" ]; then - echo " Downloading $mod..." - # Try common GitHub patterns - git clone --depth 1 "https://github.com/MichMich/$mod.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/bugsounet/$mod.git" 2>/dev/null || \ - git clone --depth 1 "https://github.com/MagicMirrorOrg/$mod.git" 2>/dev/null || \ - echo " ⚠ Could not find $mod - search at https://github.com/topics/magicmirror" - fi - done - cd .. - - # Run npm install for each downloaded module - # Note: This runs AFTER container is started, inside the container - echo "" - echo " Note: Module dependencies will be installed when container starts" - echo " If you need to manually install module dependencies, run:" - echo " docker exec magicmirror-$MM_PORT sh -c 'cd /opt/magic_mirror/modules/ && npm install --production'" - echo "" - fi - else - echo " No third-party modules (MMM-*) found in config" - fi - else - echo " ⚠ File not found: $MM_CONFIG_PATH" - echo " Using default config..." - MM_CONFIG_CHOICE="1" - fi - fi - - # Create default config if not copying - if [ "$MM_CONFIG_CHOICE" != "2" ]; then - # Create basic config.js - cat > config/config.js << 'MM_CONFIG' -let config = { - address: "0.0.0.0", - port: 8080, - ipWhitelist: [], - language: "en", - timeFormat: 12, - units: "imperial", - modules: [ - { - module: "alert", - }, - { - module: "clock", - position: "top_left" - }, - { - module: "calendar", - header: "Calendar", - position: "top_left", - config: { - calendars: [ - { - symbol: "calendar-check", - url: "webcal://www.calendarlabs.com/ical-calendar/ics/76/US_Holidays.ics" - } - ] - } - }, - { - module: "weather", - position: "top_right", - config: { - weatherProvider: "openmeteo", - type: "current", - lat: 40.7128, - lon: -74.0060 - } - }, - { - module: "weather", - position: "top_right", - header: "Weather Forecast", - config: { - weatherProvider: "openmeteo", - type: "forecast", - lat: 40.7128, - lon: -74.0060 - } - }, - { - module: "newsfeed", - position: "bottom_bar", - config: { - feeds: [ - { - title: "BBC", - url: "https://feeds.bbci.co.uk/news/rss.xml" - } - ], - showSourceTitle: true, - showPublishDate: true, - broadcastNewsFeeds: true, - broadcastNewsUpdates: true - } - }, - ] -}; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") {module.exports = config;} -MM_CONFIG - fi - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MM_DIR" - - echo " ✓ Magic Mirror #$i configured at $MM_DIR (port $MM_PORT)" - fi - done - - if [ "$DRY_RUN" != true ]; then - echo "" - prompt_yn "Start Magic Mirror instance(s) now? (y/n):" "y" START_MM - if [ "$START_MM" = "y" ] || [ "$START_MM" = "Y" ]; then - for i in $(seq 1 $MM_COUNT); do - MM_PORT=$((8080 + i)) - MM_DIR="$DOCKER_DIR/magicmirror-$MM_PORT" - (cd "$MM_DIR" && docker compose up -d 2>/dev/null) && echo " ✓ Magic Mirror #$i started (port $MM_PORT)" || echo " ⚠ Failed to start Magic Mirror #$i" - - # Install npm dependencies for third-party modules inside container - if [ -d "$MM_DIR/modules" ]; then - echo " Installing module dependencies inside container..." - sleep 3 # Wait for container to fully start - for mod_dir in "$MM_DIR/modules"/MMM-*/; do - if [ -d "$mod_dir" ] && [ -f "$mod_dir/package.json" ]; then - mod_name=$(basename "$mod_dir") - echo " Installing $mod_name dependencies..." - docker exec magicmirror-$MM_PORT sh -c "cd /opt/magic_mirror/modules/$mod_name && npm install --production" 2>/dev/null && \ - echo " ✓ $mod_name dependencies installed" || \ - echo " ⚠ $mod_name - npm install failed (container may need restart)" - fi - done - fi - done - fi - - echo " Access at: http://localhost:808X" - echo " Edit config: ~/docker/magicmirror-808X/config/config.js" - echo "" - fi - fi - - # ---- ACTUALBUDGET ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_ACTUALBUDGET" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ ACTUALBUDGET - Open-source Personal Finance Management │" - echo "│ Budget tracking with bank account synchronization via SimpleFIN│" - echo "│ Port: 5006 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ActualBudget? (y/n):" "n" INSTALL_ACTUALBUDGET - fi - - if [ "$INSTALL_ACTUALBUDGET" = "y" ] || [ "$INSTALL_ACTUALBUDGET" = "Y" ]; then - AB_DIR="$DOCKER_DIR/actualbudget" - check_service_exists "ActualBudget" "$AB_DIR" AB_RECONFIGURE - - if [ "$AB_RECONFIGURE" = "true" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $AB_DIR" - else - echo "Installing ActualBudget..." - mkdir -p "$AB_DIR/data" - ensure_docker_dir_ownership "$AB_DIR" - cd "$AB_DIR" - - # Create .env file for environment variables - cat > .env << 'AB_ENV' -# ActualBudget Environment Variables -TZ=UTC -AB_ENV - - cat > docker-compose.yml << 'AB_COMPOSE' -name: actualbudget - -services: - actualbudget: - image: actualbudget/actual-server:latest - container_name: actualbudget - restart: unless-stopped - ports: - - "5006:5006" - volumes: - - ./data:/data - env_file: - - .env - labels: - - "io.podman.annotations.label/fail2ban.enable=true" - - "io.podman.annotations.label/fail2ban.filter=caddy-auth" -AB_COMPOSE - - echo " ✓ ActualBudget configured at $AB_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "ActualBudget" "5006" "budget" - - prompt_yn "Start ActualBudget now? (y/n):" "y" START_AB - if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ActualBudget started" || echo " ⚠ Failed to start ActualBudget" - fi - - echo "" - echo " Access at: http://localhost:5006" - echo " Bank sync: https://simplefin.org/ (SimpleFIN account required)" - echo " Data dir: $AB_DIR/data" - echo "" - fi - fi # End AB_RECONFIGURE check - fi # End INSTALL_ACTUALBUDGET check - - # ---- CADDY WEB SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_CADDY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CADDY - Modern Web Server & Reverse Proxy │" - echo "│ Automatic HTTPS, reverse proxy for all your services │" - echo "│ Port: 80 (HTTP), 443 (HTTPS) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - CADDY_DIR="$DOCKER_DIR/caddy" - - # Check if Caddy is already installed - if [ -f "$CADDY_DIR/Caddyfile" ] || [ -f "$CADDY_DIR/docker-compose.yml" ]; then - echo "" - echo "⚠ Caddy appears to be already installed at $CADDY_DIR" - prompt_yn "Do you want to reconfigure it? (y/n):" "n" RECONFIGURE_CADDY - if [ "$RECONFIGURE_CADDY" != "y" ] && [ "$RECONFIGURE_CADDY" != "Y" ]; then - echo " Skipping Caddy installation" - INSTALL_CADDY="n" - fi - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $CADDY_DIR" - else - echo "Installing Caddy..." - mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config" - ensure_docker_dir_ownership "$CADDY_DIR" - - # Backup existing Caddyfile if it exists - if [ -f "$CADDY_DIR/Caddyfile" ]; then - mkdir -p "$CADDY_DIR/backups" - BACKUP_FILE="$CADDY_DIR/backups/Caddyfile.backup.$(date +%Y%m%d_%H%M%S)" - cp "$CADDY_DIR/Caddyfile" "$BACKUP_FILE" - echo " ✓ Backed up existing Caddyfile to: $BACKUP_FILE" - fi - - cd "$CADDY_DIR" - - cat > docker-compose.yml << 'CADDY_COMPOSE' -name: caddy - -services: - caddy: - image: caddy:latest - container_name: caddy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - - "443:443/udp" # HTTP/3 - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile - - ./data:/data - - ./config:/config - - /var/log/caddy:/var/log/caddy - environment: - - ACME_AGREE=true - labels: - - "io.podman.annotations.label/fail2ban.enable=true" -CADDY_COMPOSE - - # Create Caddyfile if it doesn't exist - if [ ! -f "Caddyfile" ]; then - cat > Caddyfile << 'CADDYFILE' -{ - # Global options - admin off - # Email for Let's Encrypt notifications - # email admin@yourdomain.com -} - -# Example configuration - edit this for your services -# Uncomment and modify these examples: - -# ActualBudget -# budget.yourdomain.com { -# log { -# output file /var/log/caddy/actualbudget-access.log -# format json -# level INFO -# } -# reverse_proxy localhost:5006 -# header { -# Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" -# X-Frame-Options "SAMEORIGIN" -# X-Content-Type-Options "nosniff" -# X-XSS-Protection "1; mode=block" -# Referrer-Policy "strict-origin-when-cross-origin" -# } -# } - -# Add more services here... -CADDYFILE - echo " ✓ Created example Caddyfile" - else - echo " ℹ Using existing Caddyfile" - fi - - echo " ✓ Caddy configured at $CADDY_DIR" - - prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY - if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start Caddy" - fi - - echo "" - echo " Configuration file: $CADDY_DIR/Caddyfile" - echo " Edit Caddyfile to add your domains and services" - echo " Reload config: cd $CADDY_DIR && docker exec -w /etc/caddy caddy caddy reload" - echo "" - echo " ⚠ IMPORTANT: Edit the Caddyfile to configure your domains!" - echo " - Uncomment and modify the example configurations" - echo " - Add your domain names" - echo " - Configure services you want to expose" - echo "" - fi - fi - fi - - # ---- AUTHELIA ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_AUTHELIA" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ AUTHELIA - SSO & Two-Factor Authentication Portal │" - echo "│ Protects any Caddy subdomain with a single login + TOTP │" - echo "│ Port: 9091 (internal only, accessed via Caddy) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Authelia? (y/n):" "n" INSTALL_AUTHELIA - fi - - if [ "$INSTALL_AUTHELIA" = "y" ] || [ "$INSTALL_AUTHELIA" = "Y" ]; then - AUTHELIA_DIR="$DOCKER_DIR/authelia" - check_service_exists "Authelia" "$AUTHELIA_DIR" AUTHELIA_RECONFIGURE - - if [ "$AUTHELIA_RECONFIGURE" = "true" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $AUTHELIA_DIR" - else - echo "Installing Authelia..." - mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data" - - # Collect configuration from user - echo "" - echo " Authelia needs a few details to configure." - echo "" - prompt_text " Your domain (e.g., example.com):" "example.com" AUTHELIA_DOMAIN - prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER - prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY - prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL - prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST - prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT - prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER - prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS - prompt_text " Timezone (e.g., America/New_York):" "America/New_York" AUTHELIA_TZ - - # Generate secrets - echo "" - echo " Generating secrets..." - JWT_SECRET=$(openssl rand -hex 32) - SESSION_SECRET=$(openssl rand -hex 32) - STORAGE_SECRET=$(openssl rand -hex 32) - - echo "$JWT_SECRET" > "$AUTHELIA_DIR/config/secrets/jwt_secret" - echo "$SESSION_SECRET" > "$AUTHELIA_DIR/config/secrets/session_secret" - echo "$STORAGE_SECRET" > "$AUTHELIA_DIR/config/secrets/storage_secret" - echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password" - chmod 600 "$AUTHELIA_DIR/config/secrets/"* - echo " ✓ Secrets generated" - - # Generate password hash for admin user - echo "" - echo " Generating password hash for admin user..." - prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS - AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \ - authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \ - | grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH") - if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then - echo " ⚠ Could not generate hash automatically. Run this after install:" - echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'" - echo " Then update $AUTHELIA_DIR/config/users.yml" - else - echo " ✓ Password hash generated" - fi - - ensure_docker_dir_ownership "$AUTHELIA_DIR" - # Authelia requires its config/data owned by uid 1000 - chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" - - cd "$AUTHELIA_DIR" - - # .env file - cat > .env << AUTHELIA_ENV -MY_DOMAIN=${AUTHELIA_DOMAIN} -SMTP_USER=${AUTHELIA_SMTP_USER} -DOCKER_MY_NETWORK=caddy_net -TZ=${AUTHELIA_TZ} -AUTHELIA_ENV - - # docker-compose.yml - cat > docker-compose.yml << 'AUTHELIA_COMPOSE' -name: authelia - -services: - authelia: - image: authelia/authelia:4.39.20 - pull_policy: missing - container_name: authelia - user: "1000:1000" - volumes: - - ./config:/config - - ./data:/data - environment: - - AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret - - AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret - - AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret - - AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password - - AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER} - - AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}> - expose: - - 9091 - restart: unless-stopped - networks: - - caddy_net - -networks: - caddy_net: - external: true -AUTHELIA_COMPOSE - - # configuration.yml - cat > config/configuration.yml << AUTHELIA_CONFIG ---- -# Authelia configuration -# Secrets are injected via AUTHELIA_* environment variables in docker-compose.yml - -theme: dark - -server: - address: tcp://0.0.0.0:9091 - -log: - level: info - file_path: /data/authelia.log - -totp: - period: 30 - skew: 1 - -authentication_backend: - file: - path: /config/users.yml - password: - algorithm: argon2 - argon2: - variant: argon2id - iterations: 3 - memory: 65536 - parallelism: 4 - key_length: 32 - salt_length: 16 - -access_control: - default_policy: deny - rules: - - domain: "*.${AUTHELIA_DOMAIN}" - policy: two_factor - -session: - name: authelia_session - expiration: 12h - inactivity: 2h - remember_me: 7d - cookies: - - domain: ${AUTHELIA_DOMAIN} - authelia_url: https://auth.${AUTHELIA_DOMAIN} - default_redirection_url: https://${AUTHELIA_DOMAIN} - -storage: - local: - path: /data/db.sqlite3 - -notifier: - disable_startup_check: false - smtp: - address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT} - timeout: 10s - identifier: localhost - subject: "[Authelia] {title}" - startup_check_address: ${AUTHELIA_SMTP_USER} - disable_require_tls: false - disable_starttls: false -AUTHELIA_CONFIG - - # users.yml - cat > config/users.yml << AUTHELIA_USERS ---- -# Authelia users database -# To add users: copy a block, update username/email/displayname, restart authelia -# To generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword' -# Login with username (not email). Tell users to use "Forgot Password" to set their own password. - -users: - ${AUTHELIA_ADMIN_USER}: - displayname: "${AUTHELIA_ADMIN_DISPLAY}" - email: ${AUTHELIA_ADMIN_EMAIL} - password: "${AUTHELIA_HASH}" - groups: - - admins - - users -AUTHELIA_USERS - - chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data" - - echo " ✓ Authelia configured at $AUTHELIA_DIR" - echo "" - - # Ensure caddy_net Docker network exists - if ! docker network ls --format '{{.Name}}' | grep -q "^caddy_net$"; then - echo " Creating Docker network caddy_net..." - docker network create caddy_net && echo " ✓ caddy_net created" || echo " ⚠ Failed to create caddy_net" - else - echo " ✓ Docker network caddy_net already exists" - fi - - # Inject Authelia snippet into Caddyfile if Caddy is installed - CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile" - if [ -f "$CADDY_FILE" ]; then - echo "" - echo " Configuring Caddy for Authelia..." - - # Add (authelia) snippet at top if not already present - if ! grep -q "(authelia)" "$CADDY_FILE"; then - cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)" - AUTHELIA_SNIPPET=$(cat << 'SNIPPET_EOF' -# ── Authelia forward auth snippet ───────────────────────────────────────────── -(authelia) { - forward_auth authelia:9091 { - uri /api/authz/forward-auth - copy_headers Remote-User Remote-Groups Remote-Name Remote-Email - } -} - -SNIPPET_EOF -) - # Prepend snippet before existing content - echo "$AUTHELIA_SNIPPET" | cat - "$CADDY_FILE" > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE" - echo " ✓ Authelia snippet added to Caddyfile" - else - echo " ✓ Authelia snippet already in Caddyfile" - fi - - # Add auth portal site block if not already present - AUTH_DOMAIN="auth.${AUTHELIA_DOMAIN}" - if ! grep -q "$AUTH_DOMAIN" "$CADDY_FILE"; then - cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK - -# ── Authelia login portal ────────────────────────────────────────────────────── -${AUTH_DOMAIN} { - reverse_proxy authelia:9091 - log { - output file /var/log/caddy/auth.log - } -} -CADDY_AUTH_BLOCK - echo " ✓ Authelia portal block added for ${AUTH_DOMAIN}" - fi - - # Reload Caddy if it's running - if docker ps --format '{{.Names}}' | grep -q "^caddy$"; then - docker exec -w /etc/caddy caddy caddy reload 2>/dev/null \ - && echo " ✓ Caddy reloaded" \ - || echo " ⚠ Caddy reload failed — reload manually after fixing Caddyfile" - fi - else - echo " ℹ Caddy not yet installed. Install Caddy and add this to your Caddyfile:" - echo "" - echo ' (authelia) {' - echo ' forward_auth authelia:9091 {' - echo ' uri /api/authz/forward-auth' - echo ' copy_headers Remote-User Remote-Groups Remote-Name Remote-Email' - echo ' }' - echo ' }' - echo "" - echo " auth.${AUTHELIA_DOMAIN} {" - echo " reverse_proxy authelia:9091" - echo " }" - echo "" - fi - - prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA - if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Authelia started" || echo " ⚠ Failed to start Authelia" - sleep 2 - docker compose logs --tail=20 authelia 2>/dev/null || true - fi - - echo "" - echo " Auth portal: https://auth.${AUTHELIA_DOMAIN}" - echo " Config dir: $AUTHELIA_DIR/config" - echo " Users file: $AUTHELIA_DIR/config/users.yml" - echo " Data dir: $AUTHELIA_DIR/data" - echo "" - echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set real password)" - echo "" - echo " To protect a Caddy site, add 'import authelia' to its block:" - echo " myservice.${AUTHELIA_DOMAIN} {" - echo " import authelia" - echo " reverse_proxy localhost:PORT" - echo " }" - echo "" - echo " To add users:" - echo " Edit $AUTHELIA_DIR/config/users.yml then:" - echo " cd $AUTHELIA_DIR && docker compose restart authelia" - echo "" - fi - fi # End AUTHELIA_RECONFIGURE check - fi # End INSTALL_AUTHELIA check - - # ---- FAIL2BAN ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FAIL2BAN" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FAIL2BAN - Intrusion Prevention System │" - echo "│ Automatically ban IPs with failed auth attempts │" - echo "│ Protects SSH, Caddy, and other services │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install and configure fail2ban? (y/n):" "n" INSTALL_FAIL2BAN - fi - - if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install fail2ban" - else - echo "Installing fail2ban..." - - # Check if fail2ban is already installed - if command -v fail2ban-client &> /dev/null; then - echo " ✓ fail2ban is already installed" - else - echo " Installing fail2ban package..." - if sudo apt update && sudo apt install -y fail2ban; then - echo " ✓ fail2ban installed successfully" - else - echo " ⚠ Failed to install fail2ban" - echo " You may need to install it manually: sudo apt install fail2ban" - fi - fi - - # Create log directory for Caddy - if [ ! -d "/var/log/caddy" ]; then - sudo mkdir -p /var/log/caddy - sudo chmod 755 /var/log/caddy - echo " ✓ Created /var/log/caddy directory" - fi - - # Check if Caddy filter exists - FILTER_FILE="/etc/fail2ban/filter.d/caddy-auth.conf" - if [ ! -f "$FILTER_FILE" ]; then - echo " Creating fail2ban filter for Caddy..." - - FILTER_CONTENT='[Definition] -failregex = ^.*"remote_ip":"".*"status":(?:401|403|429).*$ - ^.*"remote_addr":".*"status":(?:401|403|429).*$ -ignoreregex = ^.*"remote_ip":"(?:127\.0\.0\.1|::1)".*$ -datepattern = "ts":%%s' - - if echo "$FILTER_CONTENT" | sudo tee "$FILTER_FILE" > /dev/null; then - echo " ✓ Created Caddy fail2ban filter" - else - echo " ⚠ Failed to create filter - you may need to create it manually" - fi - else - echo " ✓ Caddy fail2ban filter already exists" - fi - - # Check if Caddy jail exists - JAIL_FILE="/etc/fail2ban/jail.d/caddy.conf" - if [ ! -f "$JAIL_FILE" ]; then - echo " Creating fail2ban jail for Caddy..." - echo "" - echo " Configure fail2ban settings (press Enter for defaults):" - - prompt_text " Max retries before ban:" "5" F2B_MAXRETRY - prompt_text " Find time window (seconds):" "600" F2B_FINDTIME - prompt_text " Ban duration (seconds):" "3600" F2B_BANTIME - - JAIL_CONTENT="[caddy-auth] -enabled = true -port = http,https -filter = caddy-auth -logpath = /var/log/caddy/access.log - /var/log/caddy/*-access.log -maxretry = $F2B_MAXRETRY -findtime = $F2B_FINDTIME -bantime = $F2B_BANTIME -action = iptables-multiport[name=CaddyAuth, port=\"http,https\", protocol=tcp] -backend = auto" - - if echo "$JAIL_CONTENT" | sudo tee "$JAIL_FILE" > /dev/null; then - echo " ✓ Created Caddy fail2ban jail" - else - echo " ⚠ Failed to create jail - you may need to create it manually" - fi - else - echo " ✓ Caddy fail2ban jail already exists" - fi - - # Test fail2ban configuration - echo "" - echo " Testing fail2ban configuration..." - if sudo fail2ban-client -t &> /dev/null; then - echo " ✓ fail2ban configuration is valid" - else - echo " ⚠ fail2ban configuration has errors" - echo " Check with: sudo fail2ban-client -t" - fi - - # Restart fail2ban - prompt_yn "Restart fail2ban to apply changes? (y/n):" "y" RESTART_F2B - if [ "$RESTART_F2B" = "y" ] || [ "$RESTART_F2B" = "Y" ]; then - if sudo systemctl restart fail2ban; then - echo " ✓ fail2ban restarted successfully" - - # Wait for fail2ban to start - sleep 2 - - # Check jail status - if sudo fail2ban-client status caddy-auth &> /dev/null; then - echo " ✓ caddy-auth jail is active" - echo "" - sudo fail2ban-client status caddy-auth - else - echo " ⚠ caddy-auth jail is not active (may need Caddy logs to exist first)" - fi - else - echo " ⚠ Failed to restart fail2ban" - echo " Check logs: sudo journalctl -u fail2ban -n 50" - fi - fi - - echo "" - echo " Useful commands:" - echo " Check jail status: sudo fail2ban-client status caddy-auth" - echo " View banned IPs: sudo fail2ban-client get caddy-auth banip" - echo " Unban IP: sudo fail2ban-client set caddy-auth unbanip 1.2.3.4" - echo " View logs: sudo tail -f /var/log/fail2ban.log" - echo "" - fi - fi - - # ---- LYRION MUSIC SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_LMS" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ LYRION MUSIC SERVER (LMS) - Music streaming server │" - echo "│ Stream music to Squeezebox devices, apps, and Chromecast. │" - echo "│ Port: 9000 (web), 9090 (CLI), 3483 (players) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Lyrion Music Server? (y/n):" "n" INSTALL_LMS - fi - - if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then - echo "Installing Lyrion Music Server..." - LMS_DIR="$DOCKER_DIR/lyrion" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $LMS_DIR" - else - mkdir -p "$LMS_DIR" - ensure_docker_dir_ownership "$LMS_DIR" - cd "$LMS_DIR" - - prompt_text "Path to music folder [default: $PRIMARY_DRIVE_PATH/music]:" "$PRIMARY_DRIVE_PATH/music" MUSIC_PATH - - cat > docker-compose.yml << LMS_COMPOSE -name: lyrion - -services: - lyrion: - image: lmscommunity/lyrionmusicserver:stable - container_name: lyrion - hostname: lyrion - restart: unless-stopped - network_mode: host - environment: - - HTTP_PORT=9000 - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - volumes: - - ./config:/config:rw - - ${MUSIC_PATH}:/music:ro - - ./playlists:/playlists:rw - - /etc/localtime:/etc/localtime:ro -LMS_COMPOSE - - cat > .env << LMS_ENV -MUSIC_PATH=$MUSIC_PATH -LMS_ENV - - mkdir -p config playlists - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LMS_DIR" - - echo "" - echo "✓ Lyrion Music Server configured at $LMS_DIR" - - prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS - if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Lyrion Music Server started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:9000" - echo " Note: Uses host networking for Chromecast support" - echo "" - fi - fi - - # ---- MEALIE ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MEALIE" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MEALIE - Recipe manager & meal planner │" - echo "│ Save recipes, plan meals, generate shopping lists. │" - echo "│ Port: 9925 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Mealie? (y/n):" "n" INSTALL_MEALIE - fi - - if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then - echo "Installing Mealie..." - MEALIE_DIR="$DOCKER_DIR/mealie" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MEALIE_DIR" - else - mkdir -p "$MEALIE_DIR" - ensure_docker_dir_ownership "$MEALIE_DIR" - cd "$MEALIE_DIR" - - cat > docker-compose.yml << MEALIE_COMPOSE -name: mealie - -services: - mealie: - image: ghcr.io/mealie-recipes/mealie:latest - container_name: mealie - hostname: mealie - restart: unless-stopped - environment: - - PUID=$(id -u "$ACTUAL_USER") - - PGID=$(id -g "$ACTUAL_USER") - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - ALLOW_SIGNUP=true - - MAX_WORKERS=1 - - WEB_CONCURRENCY=1 - - BASE_URL=http://localhost:9925 - volumes: - - ./data:/app/data - ports: - - "9925:9000" -MEALIE_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" - - echo "" - echo "✓ Mealie configured at $MEALIE_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Mealie" "9925" "recipes" - - prompt_yn "Start Mealie now? (y/n):" "y" START_MEALIE - if [ "$START_MEALIE" = "y" ] || [ "$START_MEALIE" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Mealie started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:9925" - echo " Default: changeme@email.com / MyPassword" - echo "" - fi - fi - - # ---- MINECRAFT SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MINECRAFT SERVER - Game server with RAM limit │" - echo "│ Fabric server with configurable memory allocation. │" - echo "│ Port: 25565 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Minecraft Server? (y/n):" "n" INSTALL_MINECRAFT - fi - - if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then - echo "Installing Minecraft Server..." - MC_DIR="$DOCKER_DIR/minecraft" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MC_DIR" - else - mkdir -p "$MC_DIR" - ensure_docker_dir_ownership "$MC_DIR" - cd "$MC_DIR" - - echo "" - prompt_text "Maximum RAM for Minecraft (e.g., 2G, 4G) [default: 2G]:" "2G" MC_RAM - MC_RAM=${MC_RAM:-2G} - - cat > docker-compose.yml << MC_COMPOSE -name: minecraft - -services: - minecraft: - image: itzg/minecraft-server:latest - container_name: minecraft - hostname: minecraft - restart: unless-stopped - tty: true - stdin_open: true - environment: - - EULA=TRUE - - TYPE=FABRIC - - VERSION=LATEST - - MEMORY=${MC_RAM} - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - OPS= - - MOTD=A Minecraft Server - - DIFFICULTY=normal - - MODE=survival - volumes: - - ./data:/data - ports: - - "25565:25565" - deploy: - resources: - limits: - memory: ${MC_RAM} -MC_COMPOSE - - cat > .env << MC_ENV -MC_RAM=$MC_RAM -MC_ENV - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" - - echo "" - echo "✓ Minecraft Server configured at $MC_DIR" - - prompt_yn "Start Minecraft Server now? (y/n):" "y" START_MC - if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Minecraft Server started" || echo " ⚠ Failed to start" - fi - - echo " Connect: localhost:25565" - echo " RAM limit: $MC_RAM" - echo " Console: docker attach minecraft (Ctrl+P, Ctrl+Q to detach)" - echo "" - fi - fi - - # ---- LINUX-TO-SYNC (Private Repo) ---- - if [ "$WHIPTAIL_USED" != true ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ LINUX-TO-SYNC - Private sync repository │" - echo "│ Clone and set up your private linux-to-sync repository. │" - echo "└─────────────────────────────────────────────────────────────────┘" - echo "" - echo "Note: This requires access to github.com/outis1one/linux-to-sync" - echo "" - echo "To grant access, you need ONE of these:" - echo " 1. SSH key already added to your GitHub account" - echo " 2. GitHub Personal Access Token (PAT)" - echo " 3. GitHub CLI (gh) authenticated" - echo "" - prompt_yn "Set up linux-to-sync? (y/n):" "n" INSTALL_LINUXTOSYNC - - if [ "$INSTALL_LINUXTOSYNC" = "y" ] || [ "$INSTALL_LINUXTOSYNC" = "Y" ]; then - SYNC_DIR="$DOCKER_DIR/linux-to-sync" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would clone linux-to-sync to $SYNC_DIR" - else - echo "" - echo "Choose authentication method:" - echo " [1] SSH (if you have SSH key added to GitHub)" - echo " [2] HTTPS with token (requires Personal Access Token)" - echo "" - prompt_text "Enter 1 or 2 [default: 1]:" "1" AUTH_METHOD - - if [ "$AUTH_METHOD" = "2" ]; then - echo "" - echo "Create a Personal Access Token at:" - echo " https://github.com/settings/tokens/new" - echo " - Select 'repo' scope for full repository access" - echo "" - prompt_text "Enter your GitHub Personal Access Token:" "" GH_TOKEN - - if [ -n "$GH_TOKEN" ]; then - git clone "https://$GH_TOKEN@github.com/outis1one/linux-to-sync.git" "$SYNC_DIR" 2>/dev/null - if [ $? -eq 0 ]; then - # Remove token from remote URL for security - cd "$SYNC_DIR" - git remote set-url origin "https://github.com/outis1one/linux-to-sync.git" - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" - echo "" - echo "✓ linux-to-sync cloned to $SYNC_DIR" - echo " Note: You'll need to enter token again for push/pull" - echo " Or set up: git config credential.helper store" - else - echo "✗ Clone failed. Check your token and try again." - fi - else - echo "No token provided. Skipping." - fi - else - echo "" - echo "Attempting SSH clone..." - echo "(Make sure your SSH key is added to GitHub)" - echo "" - git clone git@github.com:outis1one/linux-to-sync.git "$SYNC_DIR" 2>/dev/null - if [ $? -eq 0 ]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" - echo "" - echo "✓ linux-to-sync cloned to $SYNC_DIR" - else - echo "" - echo "✗ SSH clone failed." - echo "" - echo "To add your SSH key to GitHub:" - echo " 1. Copy your public key: cat ~/.ssh/id_rsa.pub" - echo " 2. Go to: https://github.com/settings/keys" - echo " 3. Click 'New SSH key' and paste your key" - echo "" - echo "Then retry this script or manually clone:" - echo " git clone git@github.com:outis1one/linux-to-sync.git ~/docker/linux-to-sync" - fi - fi - fi - fi - fi # End WHIPTAIL_USED check for linux-to-sync - - # ---- JELLYFIN (Alternative to Emby) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_JELLYFIN" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ JELLYFIN - Free media server (alternative to Emby) │" - echo "│ Stream movies, TV, music. No premium features locked. │" - echo "│ Port: 8096 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Jellyfin? (y/n):" "n" INSTALL_JELLYFIN - fi - - if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then - echo "Installing Jellyfin..." - JELLYFIN_DIR="$DOCKER_DIR/jellyfin" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $JELLYFIN_DIR" - else - mkdir -p "$JELLYFIN_DIR" - ensure_docker_dir_ownership "$JELLYFIN_DIR" - cd "$JELLYFIN_DIR" - - prompt_text "Path to media folder [default: $PRIMARY_DRIVE_PATH/media]:" "$PRIMARY_DRIVE_PATH/media" MEDIA_PATH - - # Get render group ID for hardware acceleration - RENDER_GID=$(getent group render | cut -d: -f3 2>/dev/null || echo "989") - - cat > docker-compose.yml << JELLYFIN_COMPOSE -name: jellyfin - -services: - jellyfin: - image: jellyfin/jellyfin:latest - container_name: jellyfin - hostname: jellyfin - restart: unless-stopped - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - devices: - - /dev/dri/renderD128:/dev/dri/renderD128 - group_add: - - "$RENDER_GID" - volumes: - - ./config:/config - - ./cache:/cache - - \${MEDIA_PATH}:/media:ro - ports: - - "8096:8096" - - "1900:1900/udp" - - "7359:7359/udp" -JELLYFIN_COMPOSE - - cat > .env << JELLYFIN_ENV -MEDIA_PATH=$MEDIA_PATH -JELLYFIN_ENV - - mkdir -p config cache - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JELLYFIN_DIR" - - echo "" - echo "✓ Jellyfin configured at $JELLYFIN_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Jellyfin" "8096" "jellyfin" - - prompt_yn "Start Jellyfin now? (y/n):" "y" START_JELLYFIN - if [ "$START_JELLYFIN" = "y" ] || [ "$START_JELLYFIN" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Jellyfin started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8096" - echo " Note: Hardware acceleration enabled (Intel GPU)" - echo "" - fi - fi - - # ---- FRIGATE NVR ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FRIGATE - AI-powered NVR for security cameras │" - echo "│ Object detection, recordings, 24/7 monitoring. │" - echo "│ Port: 5000 (web), 8554 (RTSP), 8555 (WebRTC) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Frigate? (y/n):" "n" INSTALL_FRIGATE - fi - - if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then - echo "Installing Frigate..." - FRIGATE_DIR="$DOCKER_DIR/frigate" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FRIGATE_DIR" - else - # STEP 1: Create directory and install docker-compose - mkdir -p "$FRIGATE_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$FRIGATE_DIR" - cd "$FRIGATE_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Default path - FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate" - - cat > docker-compose.yml << FRIGATE_COMPOSE -name: frigate - -services: - frigate: - image: ghcr.io/blakeblackshear/frigate:stable - container_name: frigate - hostname: frigate - restart: unless-stopped - privileged: true - shm_size: "256mb" - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - devices: - - /dev/dri/renderD128:/dev/dri/renderD128 - volumes: - - ./config:/config - - \${FRIGATE_MEDIA}:/media/frigate - - type: tmpfs - target: /tmp/cache - tmpfs: - size: 1000000000 - ports: - - "5000:5000" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" -FRIGATE_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try to configure (uses defaults if fails) - prompt_text "Path for recordings [$FRIGATE_PATH]:" "$FRIGATE_PATH" FRIGATE_PATH 2>/dev/null || FRIGATE_PATH="$ACTUAL_HOME/drives/primary/frigate" - - cat > .env << FRIGATE_ENV -FRIGATE_MEDIA=$FRIGATE_PATH -FRIGATE_ENV - - mkdir -p config 2>/dev/null || true - mkdir -p "$FRIGATE_PATH" 2>/dev/null || echo " ⚠ Could not create $FRIGATE_PATH - create manually" - - # Create template config - cat > config/config.yml << 'FRIGATE_CONFIG' -# Frigate Configuration -# Docs: https://docs.frigate.video -# -# ⚠️ YOU MUST EDIT THIS FILE to add your cameras! - -mqtt: - enabled: false - -cameras: - # EXAMPLE - Replace with your camera: - # front_door: - # ffmpeg: - # inputs: - # - path: rtsp://user:pass@192.168.1.100:554/stream - # roles: [detect, record] - # detect: - # width: 1280 - # height: 720 - # fps: 5 - -detectors: - default: - type: cpu - -record: - enabled: true - retain: - days: 7 - mode: motion - -snapshots: - enabled: true - retain: - default: 7 -FRIGATE_CONFIG - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_DIR" 2>/dev/null || true - - echo "" - echo "✓ Frigate installed at $FRIGATE_DIR" - echo "" - echo " ⚠️ Note: You should edit config/config.yml to add cameras before starting." - prompt_yn "Start Frigate now anyway? (y/n):" "n" START_FRIGATE - if [ "$START_FRIGATE" = "y" ] || [ "$START_FRIGATE" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Frigate started" || echo " ⚠ Failed to start" - fi - - echo " Access: http://localhost:5000" - echo " Docs: https://docs.frigate.video" - echo "" - fi - fi - - # ---- CADDY REVERSE PROXY (Legacy) ---- - # Note: This is the legacy Caddy installation - # The newer installation above includes fail2ban support - # This section is kept for backwards compatibility - if [ "$WHIPTAIL_USED" != true ] && [ "$INSTALL_CADDY" != "y" ] && [ "$INSTALL_CADDY" != "Y" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ CADDY - Automatic HTTPS reverse proxy (Legacy) │" - echo "│ Route domains to containers with automatic SSL certificates. │" - echo "│ Ports: 80, 443 │" - echo "└─────────────────────────────────────────────────────────────────┘" - if [ -z "$INSTALL_CADDY_LEGACY" ]; then - prompt_yn "Install Caddy reverse proxy? (y/n):" "n" INSTALL_CADDY_LEGACY - fi - INSTALL_CADDY="$INSTALL_CADDY_LEGACY" - fi - - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - echo "Installing Caddy..." - CADDY_DIR="$DOCKER_DIR/caddy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $CADDY_DIR" - else - # STEP 1: Create directory and install docker-compose - mkdir -p "$CADDY_DIR" 2>/dev/null || true - cd "$CADDY_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'CADDY_COMPOSE' -name: caddy - -services: - caddy: - image: caddy:latest - container_name: caddy - hostname: caddy - restart: unless-stopped - env_file: .env - ports: - - "80:80" - - "443:443" - - "443:443/udp" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - ./config:/config - - ./data:/data - - ./site:/srv - -networks: - default: - name: caddy_net - external: true -CADDY_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try configuration (uses defaults if fails) - CADDY_DOMAIN="" - prompt_text "Your domain (e.g., example.com) [blank for local]:" "" CADDY_DOMAIN 2>/dev/null || CADDY_DOMAIN="" - - cat > .env << CADDY_ENV -MY_DOMAIN=${CADDY_DOMAIN:-localhost} -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -CADDY_ENV - - # Create Docker network for Caddy (ignore if exists) - docker network create caddy_net 2>/dev/null || true - - # Create comprehensive Caddyfile with all services - cat > Caddyfile << 'CADDY_FILE' -# Caddy reverse proxy configuration -# Edit MY_DOMAIN in .env file, then uncomment services below -# -# To use: containers must be on 'caddy_net' network -# Add to each container's docker-compose.yml: -# networks: -# default: -# name: caddy_net -# external: true - -# ============================================================================ -# GLOBAL OPTIONS -# ============================================================================ -{ - # Uncomment for local-only (no domain/SSL): - # auto_https off -} - -# ============================================================================ -# MEDIA SERVERS -# ============================================================================ - -# Immich (photo backup) -# immich.{$MY_DOMAIN} { -# reverse_proxy immich_server:2283 -# } - -# Jellyfin (media server) -# jellyfin.{$MY_DOMAIN} { -# reverse_proxy jellyfin:8096 -# } - -# Emby (media server) -# emby.{$MY_DOMAIN} { -# reverse_proxy emby:8096 -# } - -# Audiobookshelf -# audiobooks.{$MY_DOMAIN} { -# reverse_proxy audiobookshelf:80 -# } - -# Lyrion Music Server -# music.{$MY_DOMAIN} { -# reverse_proxy lms:9000 -# } - -# ============================================================================ -# HOME AUTOMATION & MONITORING -# ============================================================================ - -# Frigate NVR -# frigate.{$MY_DOMAIN} { -# reverse_proxy frigate:5000 -# } - -# Uptime Kuma -# status.{$MY_DOMAIN} { -# reverse_proxy uptime-kuma:3001 -# } - -# Magic Mirror -# mirror.{$MY_DOMAIN} { -# reverse_proxy magicmirror:8080 -# } - -# Traccar GPS -# gps.{$MY_DOMAIN} { -# reverse_proxy traccar:8082 -# } - -# FindMyDevice -# fmd.{$MY_DOMAIN} { -# reverse_proxy fmd:8080 -# } - -# ============================================================================ -# UTILITIES -# ============================================================================ - -# Filebrowser -# files.{$MY_DOMAIN} { -# reverse_proxy filebrowser:80 -# } - -# Mealie (recipes) -# recipes.{$MY_DOMAIN} { -# reverse_proxy mealie:9000 -# } - -# ntfy (notifications) -# ntfy.{$MY_DOMAIN} { -# reverse_proxy ntfy:80 -# } - -# Portainer -# docker.{$MY_DOMAIN} { -# reverse_proxy portainer:9000 -# } - -# Kopia backup UI -# backup.{$MY_DOMAIN} { -# reverse_proxy kopia:51515 -# } - -# ============================================================================ -# TESTING / CATCH-ALL -# ============================================================================ - -# Local testing (always responds) -:80 { - respond "Caddy is running! Edit Caddyfile to enable your services." -} -CADDY_FILE - - mkdir -p config data site 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CADDY_DIR" 2>/dev/null || true - - echo "" - echo "✓ Caddy installed at $CADDY_DIR" - - prompt_yn "Start Caddy now? (y/n):" "y" START_CADDY - if [ "$START_CADDY" = "y" ] || [ "$START_CADDY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Caddy started" || echo " ⚠ Failed to start" - fi - - echo " Domain: ${CADDY_DOMAIN:-localhost} (edit .env)" - echo " Config: $CADDY_DIR/Caddyfile (uncomment services)" - echo "" - echo " ⚠️ Containers must be on 'caddy_net' network" - echo " Docs: https://caddyserver.com/docs/" - echo "" - fi - fi - - # ---- DDCLIENT DYNAMIC DNS ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_DDCLIENT" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ DDCLIENT - Dynamic DNS updater │" - echo "│ Keep your domain pointing to your home IP. │" - echo "│ Supports: Cloudflare, DuckDNS, No-IP, and more. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ddclient? (y/n):" "n" INSTALL_DDCLIENT - fi - - if [ "$INSTALL_DDCLIENT" = "y" ] || [ "$INSTALL_DDCLIENT" = "Y" ]; then - echo "Installing ddclient..." - DDCLIENT_DIR="$DOCKER_DIR/ddclient" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $DDCLIENT_DIR" - else - # STEP 1: Install docker-compose - mkdir -p "$DDCLIENT_DIR" 2>/dev/null || true - cd "$DDCLIENT_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'DDCLIENT_COMPOSE' -name: ddclient - -services: - ddclient: - image: lscr.io/linuxserver/ddclient:latest - container_name: ddclient - hostname: ddclient - restart: unless-stopped - environment: - - PUID=1000 - - PGID=1000 - - TZ=${TZ} - volumes: - - ./config:/config -DDCLIENT_COMPOSE - echo "✓ Installed docker-compose.yml" - - cat > .env << DDCLIENT_ENV -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -DDCLIENT_ENV - - mkdir -p config 2>/dev/null || true - - # Create template config - cat > config/ddclient.conf << 'DDCLIENT_CONF' -# ddclient configuration -# ⚠️ YOU MUST EDIT THIS FILE! -# Uncomment and edit for your DNS provider - -daemon=300 -syslog=yes -pid=/var/run/ddclient/ddclient.pid -ssl=yes - -# Cloudflare example: -# use=web, web=cloudflare -# protocol=cloudflare -# zone=example.com -# login=token -# password=your-api-token -# example.com - -# DuckDNS example: -# use=web -# protocol=duckdns -# password=your-duckdns-token -# yourdomain.duckdns.org -DDCLIENT_CONF - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DDCLIENT_DIR" 2>/dev/null || true - - echo "" - echo "✓ ddclient installed at $DDCLIENT_DIR" - echo "" - echo " ⚠️ Note: You should edit config/ddclient.conf before starting." - prompt_yn "Start ddclient now anyway? (y/n):" "n" START_DDCLIENT - if [ "$START_DDCLIENT" = "y" ] || [ "$START_DDCLIENT" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ddclient started" || echo " ⚠ Failed to start" - fi - - echo " Docs: https://ddclient.net/" - echo "" - fi - fi - - # ---- NTFY NOTIFICATIONS ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_NTFY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ NTFY - Push notifications server │" - echo "│ Send notifications from scripts to your phone. │" - echo "│ Port: 8090 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install ntfy? (y/n):" "n" INSTALL_NTFY - fi - - if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then - echo "Installing ntfy..." - NTFY_DIR="$DOCKER_DIR/ntfy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $NTFY_DIR" - else - mkdir -p "$NTFY_DIR" - ensure_docker_dir_ownership "$NTFY_DIR" - cd "$NTFY_DIR" - - cat > docker-compose.yml << 'NTFY_COMPOSE' -name: ntfy - -services: - ntfy: - image: binwiederhier/ntfy:latest - container_name: ntfy - hostname: ntfy - restart: unless-stopped - command: serve - environment: - - TZ=${TZ} - volumes: - - ./cache:/var/cache/ntfy - - ./config:/etc/ntfy - ports: - - "8090:80" -NTFY_COMPOSE - - cat > .env << NTFY_ENV -TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") -NTFY_ENV - - mkdir -p cache config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" - - echo "" - echo "✓ ntfy configured at $NTFY_DIR" - - prompt_yn "Start ntfy now? (y/n):" "y" START_NTFY - if [ "$START_NTFY" = "y" ] || [ "$START_NTFY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ ntfy started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8090" - echo "" - echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic" - echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic" - echo "" - fi - fi - - # ---- UPTIME KUMA ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_UPTIMEKUMA" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ UPTIME KUMA - Service monitoring dashboard │" - echo "│ Monitor websites, servers, Docker containers. │" - echo "│ Port: 3001 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Uptime Kuma? (y/n):" "n" INSTALL_UPTIMEKUMA - fi - - if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then - echo "Installing Uptime Kuma..." - UPTIME_DIR="$DOCKER_DIR/uptime-kuma" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $UPTIME_DIR" - else - mkdir -p "$UPTIME_DIR" - ensure_docker_dir_ownership "$UPTIME_DIR" - cd "$UPTIME_DIR" - - cat > docker-compose.yml << 'UPTIME_COMPOSE' -name: uptime-kuma - -services: - uptime-kuma: - image: louislam/uptime-kuma:1 - container_name: uptime-kuma - hostname: uptime-kuma - restart: unless-stopped - volumes: - - ./data:/app/data - - /var/run/docker.sock:/var/run/docker.sock:ro - ports: - - "3001:3001" -UPTIME_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPTIME_DIR" - - echo "" - echo "✓ Uptime Kuma configured at $UPTIME_DIR" - - # Configure Caddy reverse proxy before starting - configure_caddy_for_service "Uptime Kuma" "3001" "uptime" - - prompt_yn "Start Uptime Kuma now? (y/n):" "y" START_UPTIME - if [ "$START_UPTIME" = "y" ] || [ "$START_UPTIME" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Uptime Kuma started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:3001" - echo "" - fi - fi - - # ---- WG-EASY (WireGuard with Web UI) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WGEASY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ WG-EASY - WireGuard VPN with web management │" - echo "│ Easy WireGuard setup with QR codes for clients. │" - echo "│ Port: 51821 (web), 51820 (VPN) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install wg-easy? (y/n):" "n" INSTALL_WGEASY - fi - - if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then - echo "Installing wg-easy..." - WGEASY_DIR="$DOCKER_DIR/wg-easy" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $WGEASY_DIR" - else - mkdir -p "$WGEASY_DIR" - ensure_docker_dir_ownership "$WGEASY_DIR" - cd "$WGEASY_DIR" - - # Get public IP or hostname - PUBLIC_IP=$(curl -s ifconfig.me 2>/dev/null || echo "your-public-ip") - WG_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) - - prompt_text "Public IP or hostname for VPN [default: $PUBLIC_IP]:" "$PUBLIC_IP" WG_HOST - - cat > docker-compose.yml << WGEASY_COMPOSE -name: wg-easy - -services: - wg-easy: - image: ghcr.io/wg-easy/wg-easy:latest - container_name: wg-easy - hostname: wg-easy - restart: unless-stopped - cap_add: - - NET_ADMIN - - SYS_MODULE - sysctls: - - net.ipv4.ip_forward=1 - - net.ipv4.conf.all.src_valid_mark=1 - environment: - - WG_HOST=\${WG_HOST} - - PASSWORD=\${WG_PASSWORD} - - WG_DEFAULT_DNS=1.1.1.1 - volumes: - - ./config:/etc/wireguard - ports: - - "51820:51820/udp" - - "51821:51821/tcp" -WGEASY_COMPOSE - - cat > .env << WGEASY_ENV -WG_HOST=$WG_HOST -WG_PASSWORD=$WG_PASSWORD -WGEASY_ENV - - mkdir -p config - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WGEASY_DIR" - - echo "" - echo "✓ wg-easy configured at $WGEASY_DIR" - - prompt_yn "Start wg-easy now? (y/n):" "y" START_WGEASY - if [ "$START_WGEASY" = "y" ] || [ "$START_WGEASY" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ wg-easy started" || echo " ⚠ Failed to start" - fi - - echo " Web UI: http://localhost:51821" - echo " Password: $WG_PASSWORD (saved in .env)" - echo " VPN Port: 51820/udp (forward this in your router)" - echo "" - fi - fi - - # ---- TRACCAR GPS TRACKING ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_TRACCAR" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ TRACCAR - GPS tracking server │" - echo "│ Track phones, vehicles, assets with OwnTracks/Traccar apps. │" - echo "│ Port: 8082 (web), 5055 (OsmAnd), 5000+ (devices) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Traccar? (y/n):" "n" INSTALL_TRACCAR - fi - - if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then - echo "Installing Traccar..." - TRACCAR_DIR="$DOCKER_DIR/traccar" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $TRACCAR_DIR" - else - mkdir -p "$TRACCAR_DIR" - ensure_docker_dir_ownership "$TRACCAR_DIR" - cd "$TRACCAR_DIR" - - cat > docker-compose.yml << 'TRACCAR_COMPOSE' -name: traccar - -services: - traccar: - image: traccar/traccar:latest - container_name: traccar - hostname: traccar - restart: unless-stopped - volumes: - - ./logs:/opt/traccar/logs:rw - - ./data:/opt/traccar/data:rw - - ./config/traccar.xml:/opt/traccar/conf/traccar.xml:ro - ports: - - "8082:8082" - - "5000-5150:5000-5150" - - "5000-5150:5000-5150/udp" -TRACCAR_COMPOSE - - mkdir -p logs data config - - # Create basic traccar.xml config - cat > config/traccar.xml << 'TRACCAR_XML' - - - - - - ./conf/default.xml - org.h2.Driver - jdbc:h2:/opt/traccar/data/database - sa - - -TRACCAR_XML - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$TRACCAR_DIR" - - echo "" - echo "✓ Traccar configured at $TRACCAR_DIR" - - prompt_yn "Start Traccar now? (y/n):" "y" START_TRACCAR - if [ "$START_TRACCAR" = "y" ] || [ "$START_TRACCAR" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Traccar started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8082" - echo " Default: admin@admin.com / admin (change immediately!)" - echo "" - fi - fi - - # ---- PORTAINER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_PORTAINER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ PORTAINER - Docker management web UI │" - echo "│ Manage containers, images, volumes via browser. │" - echo "│ Port: 9443 (https), 9000 (http) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Portainer? (y/n):" "n" INSTALL_PORTAINER - fi - - if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then - echo "Installing Portainer..." - PORTAINER_DIR="$DOCKER_DIR/portainer" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $PORTAINER_DIR" - else - mkdir -p "$PORTAINER_DIR" - ensure_docker_dir_ownership "$PORTAINER_DIR" - cd "$PORTAINER_DIR" - - cat > docker-compose.yml << 'PORTAINER_COMPOSE' -name: portainer - -services: - portainer: - image: portainer/portainer-ce:latest - container_name: portainer - hostname: portainer - restart: always - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./data:/data - ports: - - "9000:9000" - - "9443:9443" -PORTAINER_COMPOSE - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR" - - echo "" - echo "✓ Portainer configured at $PORTAINER_DIR" - - prompt_yn "Start Portainer now? (y/n):" "y" START_PORTAINER - if [ "$START_PORTAINER" = "y" ] || [ "$START_PORTAINER" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Portainer started" || echo " ⚠ Failed to start" - fi - - echo " Access at: https://localhost:9443" - echo " Create admin account on first visit" - echo "" - fi - fi - - # ---- MESHCENTRAL SERVER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MESHCENTRAL_SERVER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ MESHCENTRAL SERVER - Self-hosted remote management │" - echo "│ Full MeshCentral server (not just agent). Manage all devices. │" - echo "│ Port: 4430 (https), 4433 (agent) │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install MeshCentral Server? (y/n):" "n" INSTALL_MESHCENTRAL_SERVER - fi - - if [ "$INSTALL_MESHCENTRAL_SERVER" = "y" ] || [ "$INSTALL_MESHCENTRAL_SERVER" = "Y" ]; then - echo "Installing MeshCentral Server..." - MC_DIR="$DOCKER_DIR/meshcentral" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $MC_DIR" - else - mkdir -p "$MC_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$MC_DIR" - cd "$MC_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'MC_COMPOSE' -name: meshcentral - -services: - meshcentral: - image: ghcr.io/ylianst/meshcentral:latest - container_name: meshcentral - hostname: meshcentral - restart: unless-stopped - environment: - - NODE_ENV=production - - HOSTNAME=${MC_HOSTNAME:-localhost} - - REVERSE_PROXY=${MC_REVERSE_PROXY:-false} - - REVERSE_PROXY_TLS_PORT=${MC_TLS_PORT:-443} - - IFRAME=false - - ALLOW_NEW_ACCOUNTS=true - - WEBRTC=true - volumes: - - ./data:/opt/meshcentral/meshcentral-data - - ./files:/opt/meshcentral/meshcentral-files - - ./backups:/opt/meshcentral/meshcentral-backups - ports: - - "4430:443" - - "4433:4433" -MC_COMPOSE - echo "✓ Installed docker-compose.yml" - - # Ask for hostname - echo "" - prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME 2>/dev/null || MC_HOSTNAME="localhost" - - cat > .env << MC_ENV -MC_HOSTNAME=$MC_HOSTNAME -MC_REVERSE_PROXY=false -MC_TLS_PORT=443 -MC_ENV - - mkdir -p data files backups 2>/dev/null || true - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" 2>/dev/null || true - - echo "" - echo "✓ MeshCentral Server installed at $MC_DIR" - - prompt_yn "Start MeshCentral now? (y/n):" "y" START_MESHCENTRAL - if [ "$START_MESHCENTRAL" = "y" ] || [ "$START_MESHCENTRAL" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ MeshCentral started" || echo " ⚠ Failed to start" - fi - - echo " Access: https://localhost:4430" - echo "" - echo " First visit: Create admin account" - echo " Then: Add devices → Download agent for each OS" - echo "" - echo " ⚠️ For remote access, set MC_HOSTNAME in .env to your domain/IP" - echo " Docs: https://meshcentral.com/docs/" - echo "" - fi - fi - - # ---- FINDMYDEVICE (FMD) ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FMD" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FINDMYDEVICE - Self-hosted device tracking │" - echo "│ Track and locate Android devices. Alternative to Google Find. │" - echo "│ Port: 8084 │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install FindMyDevice server? (y/n):" "n" INSTALL_FMD - fi - - if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then - echo "Installing FindMyDevice..." - FMD_DIR="$DOCKER_DIR/fmd" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FMD_DIR" - else - mkdir -p "$FMD_DIR" - ensure_docker_dir_ownership "$FMD_DIR" - cd "$FMD_DIR" - - # Generate random admin password - FMD_ADMIN_PASS=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) - - cat > docker-compose.yml << FMD_COMPOSE -name: fmd - -services: - fmd: - image: nulide/findmydevice - container_name: fmd - hostname: fmd - restart: unless-stopped - environment: - - FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD} - volumes: - - ./data:/fmd/data - ports: - - "8084:8080" -FMD_COMPOSE - - cat > .env << FMD_ENV -FMD_ADMIN_PASSWORD=$FMD_ADMIN_PASS -FMD_ENV - - mkdir -p data - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" - - echo "" - echo "✓ FindMyDevice configured at $FMD_DIR" - - prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD - if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ FindMyDevice started" || echo " ⚠ Failed to start" - fi - - echo " Access at: http://localhost:8084" - echo " Admin password: $FMD_ADMIN_PASS (saved in .env)" - echo "" - echo " Mobile app: Install 'FindMyDevice' from F-Droid" - echo " Configure app to point to: http://YOUR-SERVER-IP:8084" - echo "" - fi - fi - - # ---- FRIGATE-NOTIFY ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_FRIGATE_NOTIFY" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ FRIGATE-NOTIFY - Push notifications for Frigate events │" - echo "│ Get alerts when Frigate detects people, cars, etc. │" - echo "│ Sends to: ntfy, Pushover, Discord, Gotify, and more. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Frigate-Notify? (y/n):" "n" INSTALL_FRIGATE_NOTIFY - fi - - if [ "$INSTALL_FRIGATE_NOTIFY" = "y" ] || [ "$INSTALL_FRIGATE_NOTIFY" = "Y" ]; then - echo "Installing Frigate-Notify..." - FN_DIR="$DOCKER_DIR/frigate-notify" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $FN_DIR" - else - # STEP 1: Create directory and docker-compose (always succeeds) - mkdir -p "$FN_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$FN_DIR" - cd "$FN_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - cat > docker-compose.yml << 'FN_COMPOSE' -name: frigate-notify - -services: - frigate-notify: - image: ghcr.io/0x2142/frigate-notify:latest - container_name: frigate-notify - hostname: frigate-notify - restart: unless-stopped - volumes: - - ./config.yml:/app/config.yml:ro -FN_COMPOSE - echo "✓ Installed docker-compose.yml" - - # STEP 2: Try configuration (uses defaults if prompts fail) - echo "" - echo "Attempting auto-configuration..." - - # Set smart defaults based on what's installed - FRIGATE_URL="http://frigate:5000" - NTFY_URL="https://ntfy.sh" - NTFY_TOPIC="frigate-alerts" - - [ -d "$DOCKER_DIR/frigate" ] && echo " ✓ Frigate detected" || echo " ⚠ Frigate not found (using default URL)" - [ -d "$DOCKER_DIR/ntfy" ] && { NTFY_URL="http://ntfy:80"; echo " ✓ ntfy detected"; } || echo " ⚠ ntfy not found (using ntfy.sh)" - - # Try prompts, use defaults if they fail - echo "" - prompt_text "Frigate URL [$FRIGATE_URL]:" "$FRIGATE_URL" FRIGATE_URL 2>/dev/null || FRIGATE_URL="http://frigate:5000" - prompt_text "ntfy server [$NTFY_URL]:" "$NTFY_URL" NTFY_URL 2>/dev/null || NTFY_URL="https://ntfy.sh" - prompt_text "ntfy topic [frigate-alerts]:" "frigate-alerts" NTFY_TOPIC 2>/dev/null || NTFY_TOPIC="frigate-alerts" - - # Create config (template with user values or defaults) - cat > config.yml << FN_CONFIG -# Frigate-Notify Configuration -# Docs: https://frigate-notify.0x2142.com -# -# ⚠️ YOU MAY NEED TO EDIT THIS FILE! -# If notifications don't work, check: -# - Frigate server URL is correct -# - ntfy server is reachable -# - Containers are on same Docker network - -frigate: - server: $FRIGATE_URL - webapi: - enabled: true - interval: 30 - -alerts: - general: - send_startup_message: true - labels: - - person - - car - # - dog - # - package - -notifiers: - - name: ntfy - enabled: true - provider: ntfy - config: - server: $NTFY_URL - topic: $NTFY_TOPIC -FN_CONFIG - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FN_DIR" 2>/dev/null || true - - echo "" - echo "✓ Frigate-Notify installed at $FN_DIR" - - prompt_yn "Start Frigate-Notify now? (y/n):" "y" START_FN - if [ "$START_FN" = "y" ] || [ "$START_FN" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Frigate-Notify started" || echo " ⚠ Failed to start" - fi - - echo " Config: $FN_DIR/config.yml (edit if needed)" - echo " Docs: https://frigate-notify.0x2142.com" - echo "" - fi - fi - - # ---- WATCHTOWER ---- - if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_WATCHTOWER" ]; then - echo "" - echo "┌─────────────────────────────────────────────────────────────────┐" - echo "│ WATCHTOWER - Container update monitoring │" - echo "│ Monitor containers for updates. NOTIFY ONLY by default. │" - echo "│ Why notify-only? Apps like Immich have breaking DB migrations. │" - echo "└─────────────────────────────────────────────────────────────────┘" - prompt_yn "Install Watchtower? (y/n):" "n" INSTALL_WATCHTOWER - fi - - if [ "$INSTALL_WATCHTOWER" = "y" ] || [ "$INSTALL_WATCHTOWER" = "Y" ]; then - echo "Installing Watchtower..." - WT_DIR="$DOCKER_DIR/watchtower" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $WT_DIR" - else - mkdir -p "$WT_DIR" 2>/dev/null || true - ensure_docker_dir_ownership "$WT_DIR" - cd "$WT_DIR" 2>/dev/null || cd "$DOCKER_DIR" - - # Ask about mode - echo "" - echo "Watchtower Mode:" - echo " [M] Monitor only - Get notifications about available updates (SAFE)" - echo " [A] Auto-update - Automatically pull and restart containers (RISKY)" - echo "" - echo " ⚠️ Auto-update can break apps like Immich that need DB migrations!" - echo " Recommendation: Use monitor mode, update manually when ready." - echo "" - WT_MODE="M" - prompt_text "Mode [M/A]:" "M" WT_MODE 2>/dev/null || WT_MODE="M" - WT_MODE=$(echo "$WT_MODE" | tr '[:lower:]' '[:upper:]') - - if [ "$WT_MODE" = "A" ]; then - MONITOR_ONLY="false" - echo " Mode: Auto-update (containers will be updated automatically)" - else - MONITOR_ONLY="true" - echo " Mode: Monitor only (you'll be notified of updates)" - fi - - # Check for ntfy - NTFY_URL="" - if [ -d "$DOCKER_DIR/ntfy" ]; then - echo " ✓ ntfy detected - configuring notifications" - NTFY_URL="http://ntfy/watchtower" - fi - - cat > docker-compose.yml << WT_COMPOSE -name: watchtower - -services: - watchtower: - image: containrrr/watchtower:latest - container_name: watchtower - hostname: watchtower - restart: unless-stopped - environment: - # Check for updates daily at 4 AM - - WATCHTOWER_SCHEDULE=0 0 4 * * * - # Monitor only - don't auto-update (change to false for auto-update) - - WATCHTOWER_MONITOR_ONLY=${MONITOR_ONLY} - # Cleanup old images after update - - WATCHTOWER_CLEANUP=true - # Include stopped containers - - WATCHTOWER_INCLUDE_STOPPED=true - # Notification URL (ntfy, Discord, Slack, etc.) - - WATCHTOWER_NOTIFICATION_URL=${NOTIFICATION_URL:-} - # Show debug info - - WATCHTOWER_DEBUG=false - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro -WT_COMPOSE - - # Create .env - cat > .env << WT_ENV -# Watchtower Configuration -# ========================= -# -# Monitor-only mode: Watchtower checks for updates but doesn't apply them. -# This is SAFER because some apps (Immich, Mealie) have database migrations -# that can break if you update without proper procedures. -# -# To update manually: -# cd ~/docker/{app} -# docker compose pull -# docker compose up -d - -# Set to "false" to enable auto-updates (RISKY!) -MONITOR_ONLY=$MONITOR_ONLY - -# Notification URL (optional) -# Examples: -# ntfy: ntfy://ntfy.example.com/watchtower -# Discord: discord://token@id -# Slack: slack://hook-url -# Gotify: gotify://hostname/token -# -# Full list: https://containrrr.dev/shoutrrr/services/overview/ -NOTIFICATION_URL=$NTFY_URL -WT_ENV - - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WT_DIR" 2>/dev/null || true - - echo "" - echo "✓ Watchtower installed at $WT_DIR" - - prompt_yn "Start Watchtower now? (y/n):" "y" START_WATCHTOWER - if [ "$START_WATCHTOWER" = "y" ] || [ "$START_WATCHTOWER" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Watchtower started" || echo " ⚠ Failed to start" - fi - - echo " Mode: $([ "$MONITOR_ONLY" = "true" ] && echo "Monitor only" || echo "Auto-update")" - echo "" - echo " Checks for updates daily at 4 AM." - if [ -n "$NTFY_URL" ]; then - echo " Notifications: $NTFY_URL" - else - echo " Configure NOTIFICATION_URL in .env for alerts." - fi - echo "" - echo " To exclude a container from Watchtower:" - echo " Add label: com.centurylinklabs.watchtower.enable=false" - echo "" - fi - fi - - fi # End SKIP_DOCKER_INSTALLS check - - # ============================================================================ - # KOPIA BACKUP FOR DOCKER CONTAINERS - # ============================================================================ - if [ "$WHIPTAIL_USED" != true ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DOCKER CONTAINER BACKUP (Kopia)" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Backup all Docker containers (configs, databases, app data) to your" - echo "backup drives. Essential for disaster recovery - if your OS drive" - echo "fails, you can restore everything including:" - echo " • Immich memories, facial recognition data" - echo " • Emby/Jellyfin metadata, watch history" - echo " • Minecraft worlds, mods, permissions" - echo " • All app configs, users, and databases" - echo "" - prompt_yn "Set up Kopia container backup? (y/n):" "n" INSTALL_KOPIA - - if [ "$INSTALL_KOPIA" = "y" ] || [ "$INSTALL_KOPIA" = "Y" ]; then - echo "Installing Kopia backup..." - KOPIA_DIR="$DOCKER_DIR/kopia" - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $KOPIA_DIR" - else - mkdir -p "$KOPIA_DIR" - ensure_docker_dir_ownership "$KOPIA_DIR" - cd "$KOPIA_DIR" - - KOPIA_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 24) - - echo "" - echo "Select backup destination(s):" - echo " Backups will be stored in ~/drives/{backup-drive}/kopia-repo/" - echo "" - - # List available backup drives - echo "Available mount points in ~/drives/:" - ls -1 "$ACTUAL_HOME/drives/" 2>/dev/null | grep -v "^primary$" || echo " (none found - set up drives first)" - echo "" - - prompt_text "Backup drive name [default: backup1]:" "backup1" KOPIA_BACKUP_DRIVE - - KOPIA_REPO="$ACTUAL_HOME/drives/$KOPIA_BACKUP_DRIVE/kopia-repo" - - cat > docker-compose.yml << KOPIA_COMPOSE -name: kopia - -services: - kopia: - image: kopia/kopia:latest - container_name: kopia - hostname: kopia - restart: unless-stopped - privileged: true - devices: - - /dev/fuse:/dev/fuse:rwm - environment: - - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") - - KOPIA_PASSWORD=\${KOPIA_PASSWORD} - command: > - server start - --tls-generate-cert - --disable-csrf-token-checks - --address=0.0.0.0:51515 - --server-username=admin - --server-password=\${KOPIA_PASSWORD} - volumes: - - ./config:/app/config - - ./cache:/app/cache - - ./logs:/app/logs - - $DOCKER_DIR:/data/docker:ro - - $KOPIA_REPO:/repository - - ./tmp:/tmp:shared - ports: - - "51515:51515" -KOPIA_COMPOSE - - cat > .env << KOPIA_ENV -KOPIA_PASSWORD=$KOPIA_PASSWORD -KOPIA_ENV - - mkdir -p config cache logs tmp - mkdir -p "$KOPIA_REPO" - - # Create backup script - cat > backup-containers.sh << 'BACKUP_SCRIPT' -#!/bin/bash -# Backup all Docker containers using Kopia - -DOCKER_DIR="$HOME/docker" -TIMESTAMP=$(date +%Y%m%d-%H%M%S) - -echo "=== Docker Container Backup: $TIMESTAMP ===" -echo "" - -# Stop containers before backup for consistency (optional) -read -p "Stop containers during backup for consistency? (y/n): " STOP_CONTAINERS - -if [ "$STOP_CONTAINERS" = "y" ]; then - echo "Stopping containers..." - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ]; then - echo " Stopping $(basename $dir)..." - (cd "$dir" && docker compose stop) 2>/dev/null - fi - done -fi - -echo "" -echo "Running Kopia backup..." -docker exec kopia kopia snapshot create /data/docker --description "Container backup $TIMESTAMP" - -if [ "$STOP_CONTAINERS" = "y" ]; then - echo "" - echo "Restarting containers..." - for dir in "$DOCKER_DIR"/*/; do - if [ -f "$dir/docker-compose.yml" ]; then - echo " Starting $(basename $dir)..." - (cd "$dir" && docker compose start) 2>/dev/null - fi - done -fi - -echo "" -echo "=== Backup Complete ===" -docker exec kopia kopia snapshot list /data/docker --max-results 5 -BACKUP_SCRIPT - - # Create restore script - cat > restore-containers.sh << 'RESTORE_SCRIPT' -#!/bin/bash -# Restore Docker containers from Kopia backup - -echo "=== Docker Container Restore ===" -echo "" -echo "Available snapshots:" -docker exec kopia kopia snapshot list /data/docker - -echo "" -echo "To restore a specific snapshot:" -echo " docker exec kopia kopia restore /tmp/restore" -echo " Then copy files from ~/docker/kopia/tmp/restore/ to ~/docker/" -echo "" -echo "To mount snapshots for browsing:" -echo " docker exec kopia kopia mount all /tmp/mnt &" -echo " Then browse: ~/docker/kopia/tmp/mnt/" -RESTORE_SCRIPT - - chmod +x backup-containers.sh restore-containers.sh - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$KOPIA_DIR" - - echo "" - echo "✓ Kopia backup configured at $KOPIA_DIR" - - prompt_yn "Start Kopia now? (y/n):" "y" START_KOPIA - if [ "$START_KOPIA" = "y" ] || [ "$START_KOPIA" = "Y" ]; then - docker compose up -d 2>/dev/null && echo " ✓ Kopia started" || echo " ⚠ Failed to start" - fi - - echo " Web UI: https://localhost:51515" - echo " Username: admin" - echo " Password: $KOPIA_PASSWORD (saved in .env)" - echo "" - echo " Repository at: $KOPIA_REPO" - echo "" - echo " Backup now: cd $KOPIA_DIR && ./backup-containers.sh" - echo " Restore: cd $KOPIA_DIR && ./restore-containers.sh" - echo "" - echo " ⚠️ SAVE YOUR KOPIA PASSWORD! Without it, backups cannot be restored." - echo "" - fi - fi - fi # End WHIPTAIL_USED check for Kopia - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Docker applications configured in: $DOCKER_DIR" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "To start an application:" - echo " cd ~/docker/{appname}" - echo " docker compose up -d" - echo "" - echo "To view logs:" - echo " docker compose logs -f" - echo "" - echo "To stop:" - echo " docker compose down" - echo "" -fi - -# Backup System (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "LOCAL BACKUP SYSTEM (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Set up rsync backup from primary drive(s) to backup drive(s)." -echo "" -echo "Why rsync instead of RAID?" -echo " • RAID mirrors corruption instantly - rsync gives you time to notice" -echo " • RAID requires identical drives - rsync works with any sizes" -echo " • RAID is complex to set up/recover - rsync is simple copy" -echo " • rsync can run on schedule - RAID is always-on (more wear)" -echo " • With rsync, backup drives can be disconnected for safety" -echo "" - -# Check if backup is already configured -BACKUP_CONFIGURED=false -if [ -f /usr/local/bin/backup-scripts/rsync-backup.sh ]; then - BACKUP_CONFIGURED=true - echo "Local backup system is already configured." - echo "" - prompt_yn "Reconfigure local backup system? (y/n):" "n" SETUP_BACKUP -else - prompt_yn "Set up local backup system? (y/n):" "n" SETUP_BACKUP -fi - -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Configure your drive mount points in ~/drives/" - echo "Default names: primary, backup1, backup2, etc." - echo "You can customize these (e.g., 'media', 'documents', 'photos-backup')" - echo "" - - # Install rsync if needed - if ! is_rsync_installed; then - run_cmd apt install -y rsync || echo "Warning: rsync installation failed" - fi - - # Create backup script directory - mkdir -p /usr/local/bin/backup-scripts - - # Ask for primary drive name - echo "PRIMARY DRIVE (source for backups):" - prompt_text " Mount point name [default: primary]:" "primary" PRIMARY_NAME - PRIMARY_NAME="${PRIMARY_NAME:-primary}" - - # Ask for number of backup drives - echo "" - echo "BACKUP DRIVES (destinations):" - prompt_text " How many backup drives? [1-4, default: 1]:" "1" NUM_BACKUPS - NUM_BACKUPS="${NUM_BACKUPS:-1}" - - # Validate number - case $NUM_BACKUPS in - 1|2|3|4) ;; - *) NUM_BACKUPS=1 ;; - esac - - # Collect backup drive names - declare -a BACKUP_NAMES - for i in $(seq 1 $NUM_BACKUPS); do - prompt_text " Backup drive $i name [default: backup$i]:" "backup$i" "BACKUP_NAME_$i" - eval "BACKUP_NAMES[$i]=\${BACKUP_NAME_$i:-backup$i}" - done - - # Create mount point directories - echo "" - echo "Creating mount point directories..." - mkdir -p "$ACTUAL_HOME/drives/$PRIMARY_NAME" - for i in $(seq 1 $NUM_BACKUPS); do - mkdir -p "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - done - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ACTUAL_HOME/drives" - - echo "✓ Created mount points:" - echo " Primary: $ACTUAL_HOME/drives/$PRIMARY_NAME" - for i in $(seq 1 $NUM_BACKUPS); do - echo " Backup$i: $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" - done - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "DRIVE MOUNTING" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Available block devices:" - echo "" - lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL - echo "" - - prompt_yn "Mount drives now? (y/n):" "n" MOUNT_NOW - - if [ "$MOUNT_NOW" = "y" ] || [ "$MOUNT_NOW" = "Y" ]; then - echo "" - echo "Enter device paths (e.g., /dev/sdb1) or leave blank to skip" - echo "" - - read -p "Primary drive ($PRIMARY_NAME) device: " PRIMARY_DEV - - declare -a BACKUP_DEVS - for i in $(seq 1 $NUM_BACKUPS); do - read -p "Backup drive ${BACKUP_NAMES[$i]} device: " "BACKUP_DEV_$i" - eval "BACKUP_DEVS[$i]=\$BACKUP_DEV_$i" - done - - # Mount primary - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - echo "Mounting $PRIMARY_DEV to $ACTUAL_HOME/drives/$PRIMARY_NAME..." - mount "$PRIMARY_DEV" "$ACTUAL_HOME/drives/$PRIMARY_NAME" && echo "✓ $PRIMARY_NAME mounted" || echo "✗ Failed to mount $PRIMARY_NAME" - fi - - # Mount backups - for i in $(seq 1 $NUM_BACKUPS); do - if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then - echo "Mounting ${BACKUP_DEVS[$i]} to $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}..." - mount "${BACKUP_DEVS[$i]}" "$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}" && echo "✓ ${BACKUP_NAMES[$i]} mounted" || echo "✗ Failed to mount ${BACKUP_NAMES[$i]}" - fi - done - - echo "" - echo "Current mounts:" - df -h | grep "$ACTUAL_HOME/drives" || echo " (no drives currently mounted)" - - echo "" - prompt_yn "Add to /etc/fstab for auto-mount at boot? (y/n):" "n" ADD_FSTAB - - if [ "$ADD_FSTAB" = "y" ] || [ "$ADD_FSTAB" = "Y" ]; then - echo "" - echo "Adding entries to /etc/fstab..." - cp /etc/fstab /etc/fstab.backup-$(date +%Y%m%d-%H%M%S) - - if [ -n "$PRIMARY_DEV" ] && [ -b "$PRIMARY_DEV" ]; then - PRIMARY_UUID=$(blkid -s UUID -o value "$PRIMARY_DEV") - if [ -n "$PRIMARY_UUID" ]; then - echo "UUID=$PRIMARY_UUID $ACTUAL_HOME/drives/$PRIMARY_NAME auto defaults,nofail 0 2" >> /etc/fstab - echo "✓ Added $PRIMARY_NAME to fstab" - fi - fi - - for i in $(seq 1 $NUM_BACKUPS); do - if [ -n "${BACKUP_DEVS[$i]}" ] && [ -b "${BACKUP_DEVS[$i]}" ]; then - BACKUP_UUID=$(blkid -s UUID -o value "${BACKUP_DEVS[$i]}") - if [ -n "$BACKUP_UUID" ]; then - echo "UUID=$BACKUP_UUID $ACTUAL_HOME/drives/${BACKUP_NAMES[$i]} auto defaults,nofail 0 2" >> /etc/fstab - echo "✓ Added ${BACKUP_NAMES[$i]} to fstab" - fi - fi - done - fi - fi - - # Create rsync backup script - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CREATING BACKUP SCRIPT" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - - # Build backup destinations list - BACKUP_DESTS="" - for i in $(seq 1 $NUM_BACKUPS); do - BACKUP_DESTS="$BACKUP_DESTS \"$ACTUAL_HOME/drives/${BACKUP_NAMES[$i]}\"" - done - - cat > /usr/local/bin/backup-scripts/rsync-backup.sh << BACKUP_SCRIPT -#!/bin/bash -################################################################################ -# rsync Local Backup Script -# Backs up primary drive to all backup drives -################################################################################ - -PRIMARY="$ACTUAL_HOME/drives/$PRIMARY_NAME" -BACKUP_DRIVES=($BACKUP_DESTS) -LOG="/var/log/rsync-backup.log" - -echo "=== rsync Backup Started: \$(date) ===" | tee -a "\$LOG" -echo "Source: \$PRIMARY" | tee -a "\$LOG" -echo "" | tee -a "\$LOG" - -if [ ! -d "\$PRIMARY" ] || [ -z "\$(ls -A \$PRIMARY 2>/dev/null)" ]; then - echo "ERROR: Primary drive not mounted or empty at \$PRIMARY" | tee -a "\$LOG" - exit 1 -fi - -for BACKUP in "\${BACKUP_DRIVES[@]}"; do - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" | tee -a "\$LOG" - echo "Backing up to: \$BACKUP" | tee -a "\$LOG" - - if [ ! -d "\$BACKUP" ]; then - echo "⚠️ WARNING: \$BACKUP not mounted, skipping" | tee -a "\$LOG" - continue - fi - - # rsync options: - # -a = archive mode (preserves permissions, timestamps, etc.) - # -v = verbose - # -h = human-readable sizes - # --delete = remove files from backup that don't exist on primary - # --progress = show progress - - rsync -avh --delete --progress "\$PRIMARY/" "\$BACKUP/" 2>&1 | tee -a "\$LOG" - - if [ \$? -eq 0 ]; then - echo "✓ Backup to \$BACKUP completed" | tee -a "\$LOG" - else - echo "✗ Backup to \$BACKUP FAILED" | tee -a "\$LOG" - fi -done - -echo "" | tee -a "\$LOG" -echo "=== Backup Completed: \$(date) ===" | tee -a "\$LOG" -BACKUP_SCRIPT - - chmod +x /usr/local/bin/backup-scripts/rsync-backup.sh - - # Create systemd service - cat > /etc/systemd/system/rsync-backup.service << SERVICE -[Unit] -Description=rsync Local Backup Service -After=network.target - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/backup-scripts/rsync-backup.sh -User=root -SERVICE - - # Create systemd timer for daily backups at 2 AM - cat > /etc/systemd/system/rsync-backup.timer << TIMER -[Unit] -Description=Daily rsync Backup Timer -Requires=rsync-backup.service - -[Timer] -OnCalendar=*-*-* 02:00:00 -Persistent=true - -[Install] -WantedBy=timers.target -TIMER - - echo "" - echo "✓ Backup script created: /usr/local/bin/backup-scripts/rsync-backup.sh" - echo "✓ Systemd service/timer created (disabled by default)" - echo "" - echo "Quick start:" - echo " Test backup: sudo /usr/local/bin/backup-scripts/rsync-backup.sh" - echo " Enable daily: sudo systemctl enable --now rsync-backup.timer" - echo " View log: tail -f /var/log/rsync-backup.log" -else - echo "Skipping local backup setup." -fi -fi # End WHIPTAIL_USED check for local backup - -# Cloud Backup with rclone (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "CLOUD BACKUP (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "Set up encrypted cloud backup using rclone." -echo "Supports: Google Drive, OneDrive, Dropbox, and 40+ other providers." -echo "" -echo "Your files are encrypted BEFORE upload - the cloud provider cannot read them." -echo "" - -prompt_yn "Set up encrypted cloud backup? (y/n):" "n" SETUP_CLOUD_BACKUP - -if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then - echo "" - - # Install rclone if needed - if ! is_rclone_installed; then - echo "Installing rclone..." - run_cmd apt install -y rclone || echo "Warning: rclone installation failed" - fi - - if is_rclone_installed; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "CHOOSE CLOUD PROVIDER" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo " [1] Google Drive (15GB free)" - echo " [2] Microsoft OneDrive (5GB free, 1TB with Microsoft 365)" - echo " [3] Other (manual rclone config)" - echo "" - prompt_text "Select provider [1/2/3]:" "1" CLOUD_PROVIDER - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "RCLONE CONFIGURATION" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - case $CLOUD_PROVIDER in - 1) - echo "Setting up Google Drive..." - echo "" - echo "Step 1: Run 'rclone config' to create a Google Drive remote" - echo "Step 2: When prompted:" - echo " - Choose 'n' for new remote" - echo " - Name it 'gdrive'" - echo " - Choose 'drive' (Google Drive)" - echo " - Leave client_id and client_secret blank" - echo " - Choose scope '1' (full access)" - echo " - Leave root_folder_id blank" - echo " - Leave service_account_file blank" - echo " - Choose 'n' for advanced config" - echo " - Choose 'y' for auto config (opens browser)" - echo " - Choose 'n' for team drive" - echo " - Confirm with 'y'" - echo "" - CLOUD_REMOTE="gdrive" - ;; - 2) - echo "Setting up Microsoft OneDrive..." - echo "" - echo "Step 1: Run 'rclone config' to create a OneDrive remote" - echo "Step 2: When prompted:" - echo " - Choose 'n' for new remote" - echo " - Name it 'onedrive'" - echo " - Choose 'onedrive' (Microsoft OneDrive)" - echo " - Leave client_id and client_secret blank" - echo " - Choose region (usually 'global')" - echo " - Choose 'n' for advanced config" - echo " - Choose 'y' for auto config (opens browser)" - echo " - Choose 'onedrive' for account type" - echo " - Choose your drive from the list (usually option 0)" - echo " - Confirm with 'y'" - echo "" - CLOUD_REMOTE="onedrive" - ;; - *) - echo "Manual configuration selected." - echo "Run 'rclone config' to set up your cloud provider." - echo "" - prompt_text "Enter the remote name you will create:" "cloud" CLOUD_REMOTE - ;; - esac - - if [ "$UNATTENDED" != true ]; then - echo "Press Enter to launch rclone config..." - read - sudo -u "$ACTUAL_USER" rclone config - else - echo "Skipping interactive rclone config (unattended mode)" - echo "Run 'rclone config' manually to complete setup" - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "ENCRYPTION SETUP" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Now we'll create an encrypted wrapper around your cloud storage." - echo "This encrypts file names AND contents before upload." - echo "" - echo "⚠️ IMPORTANT: You will set a password. Without this password and" - echo " the rclone config file, your files CANNOT be recovered!" - echo "" - - if [ "$UNATTENDED" != true ]; then - echo "In rclone config:" - echo " - Choose 'n' for new remote" - echo " - Name it '${CLOUD_REMOTE}-crypt'" - echo " - Choose 'crypt' (Encrypt/Decrypt)" - echo " - Remote: '${CLOUD_REMOTE}:backup' (folder on cloud storage)" - echo " - Choose 'standard' for filename encryption" - echo " - Choose 'true' for directory name encryption" - echo " - Choose 'y' to enter your own password" - echo " - Enter a STRONG password (you'll need this to decrypt!)" - echo " - Choose 'y' for salt password (or 'n' to skip)" - echo " - Confirm with 'y'" - echo "" - echo "Press Enter to continue rclone config..." - read - sudo -u "$ACTUAL_USER" rclone config - fi - - # Backup the rclone config to local drives - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "BACKUP YOUR RCLONE CONFIG" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Your rclone config contains your encryption keys." - echo "WITHOUT IT, YOUR ENCRYPTED CLOUD FILES CANNOT BE DECRYPTED!" - echo "" - echo "The config file is at: $ACTUAL_HOME/.config/rclone/rclone.conf" - echo "" - - # Copy config to any mounted backup drives - if [ -d "$ACTUAL_HOME/drives" ]; then - for drive_dir in "$ACTUAL_HOME/drives"/*/; do - if [ -d "$drive_dir" ] && mountpoint -q "$drive_dir" 2>/dev/null; then - mkdir -p "${drive_dir}.rclone-config-backup" - if [ -f "$ACTUAL_HOME/.config/rclone/rclone.conf" ]; then - cp "$ACTUAL_HOME/.config/rclone/rclone.conf" "${drive_dir}.rclone-config-backup/rclone.conf.backup" - echo "✓ Config backed up to: ${drive_dir}.rclone-config-backup/" - fi - fi - done - fi - - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "SECURE OFF-SITE BACKUP OF CONFIG" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "For disaster recovery, store a copy of rclone.conf OFF-SITE:" - echo "" - echo "Recommended secure methods:" - echo " • Signal (end-to-end encrypted, send to yourself or trusted contact)" - echo " • Box.com (better privacy policy than Dropbox)" - echo " • Password manager (1Password, Bitwarden, etc.)" - echo " • Encrypted USB drive stored at another location" - echo "" - echo "⚠️ Dropbox: Works but has broader data access policies." - echo " Consider encrypting the config file before uploading." - echo "" - echo "To use on another computer:" - echo " 1. Install rclone" - echo " 2. Copy rclone.conf to ~/.config/rclone/" - echo " 3. Run: rclone ls ${CLOUD_REMOTE}-crypt:" - echo "" - - # Create cloud backup script - mkdir -p /usr/local/bin/backup-scripts - - SOURCE_PATH="${PRIMARY_NAME:-primary}" - - cat > /usr/local/bin/backup-scripts/cloud-backup.sh << CLOUD_SCRIPT -#!/bin/bash -################################################################################ -# rclone Encrypted Cloud Backup Script -################################################################################ - -SOURCE="$ACTUAL_HOME/drives/$SOURCE_PATH" -REMOTE="${CLOUD_REMOTE:-gdrive}-crypt" -LOG="/var/log/cloud-backup.log" - -echo "=== Cloud Backup Started: \$(date) ===" | tee -a "\$LOG" -echo "Source: \$SOURCE" | tee -a "\$LOG" -echo "Destination: \$REMOTE:" | tee -a "\$LOG" -echo "" | tee -a "\$LOG" - -if [ ! -d "\$SOURCE" ] || [ -z "\$(ls -A \$SOURCE 2>/dev/null)" ]; then - echo "ERROR: Source not mounted or empty at \$SOURCE" | tee -a "\$LOG" - exit 1 -fi - -# Sync to encrypted cloud storage -rclone sync "\$SOURCE" "\$REMOTE:" \\ - --progress \\ - --stats=30s \\ - --log-file="\$LOG" \\ - --log-level INFO - -if [ \$? -eq 0 ]; then - echo "" | tee -a "\$LOG" - echo "✓ Cloud backup completed: \$(date)" | tee -a "\$LOG" -else - echo "" | tee -a "\$LOG" - echo "✗ Cloud backup FAILED: \$(date)" | tee -a "\$LOG" -fi -CLOUD_SCRIPT - - chmod +x /usr/local/bin/backup-scripts/cloud-backup.sh - chown "$ACTUAL_USER:$ACTUAL_USER" /usr/local/bin/backup-scripts/cloud-backup.sh - - echo "" - echo "✓ Cloud backup script created: /usr/local/bin/backup-scripts/cloud-backup.sh" - echo "" - echo "Quick start:" - echo " Test backup: sudo /usr/local/bin/backup-scripts/cloud-backup.sh" - echo " View log: tail -f /var/log/cloud-backup.log" - else - echo "rclone installation failed. Skipping cloud backup setup." - fi -else - echo "Skipping cloud backup setup." -fi -fi # End WHIPTAIL_USED check for cloud backup - -# UFW Firewall Configuration (Optional) -if [ "$WHIPTAIL_USED" != true ]; then -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "UFW FIREWALL (Optional)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo "UFW (Uncomplicated Firewall) provides a simple interface for managing" -echo "iptables firewall rules." -echo "" - -UFW_ACTIVE=false -if is_ufw_installed && ufw status 2>/dev/null | grep -q "Status: active"; then - UFW_ACTIVE=true - echo "UFW is already enabled." - ufw status 2>/dev/null | head -20 - echo "" - prompt_yn "Reconfigure UFW? (y/n):" "n" CONFIGURE_UFW -else - if is_ufw_installed; then - echo "UFW is installed but not enabled." - else - echo "UFW is not installed." - fi - echo "" - prompt_yn "Enable and configure UFW firewall? (y/n):" "y" CONFIGURE_UFW -fi - -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo "" - echo "Configuring UFW firewall..." - - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would install ufw if needed" - echo "[DRY-RUN] Would allow SSH (port 22)" - if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo "[DRY-RUN] Would allow Samba" - fi - echo "[DRY-RUN] Would enable UFW" - else - # Install UFW if not present - if ! is_ufw_installed; then - apt install -y ufw || echo "Warning: UFW installation failed" - fi - - if is_ufw_installed; then - # Always allow SSH first (before enabling!) - ufw allow ssh - echo "✓ Allowed SSH (port 22)" - - # Allow Samba if installed - if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ] || is_samba_installed; then - ufw allow samba - echo "✓ Allowed Samba" - fi - - # Allow Docker service ports (only if Docker was installed) - if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo "" - echo "Opening firewall ports for Docker services..." - - prompt_yn "Open firewall ports for installed Docker services? (y/n):" "y" OPEN_DOCKER_PORTS - if [ "$OPEN_DOCKER_PORTS" = "y" ] || [ "$OPEN_DOCKER_PORTS" = "Y" ]; then - - # Reverse proxies (NPM or Caddy) - if [ "$INSTALL_CADDY" = "y" ] || [ "$INSTALL_CADDY" = "Y" ]; then - ufw allow 80/tcp comment 'HTTP' 2>/dev/null - ufw allow 443/tcp comment 'HTTPS' 2>/dev/null - echo " ✓ Allowed HTTP/HTTPS (80, 443)" - fi - - # Media servers - if [ "$INSTALL_IMMICH" = "y" ] || [ "$INSTALL_IMMICH" = "Y" ]; then - ufw allow 2283/tcp comment 'Immich' 2>/dev/null - echo " ✓ Allowed Immich (2283)" - fi - if [ "$INSTALL_JELLYFIN" = "y" ] || [ "$INSTALL_JELLYFIN" = "Y" ]; then - ufw allow 8096/tcp comment 'Jellyfin' 2>/dev/null - echo " ✓ Allowed Jellyfin (8096)" - fi - if [ "$INSTALL_EMBY" = "y" ] || [ "$INSTALL_EMBY" = "Y" ]; then - ufw allow 8096/tcp comment 'Emby' 2>/dev/null - echo " ✓ Allowed Emby (8096)" - fi - - # NVR - if [ "$INSTALL_FRIGATE" = "y" ] || [ "$INSTALL_FRIGATE" = "Y" ]; then - ufw allow 5000/tcp comment 'Frigate' 2>/dev/null - ufw allow 8554/tcp comment 'Frigate RTSP' 2>/dev/null - ufw allow 8555/tcp comment 'Frigate WebRTC' 2>/dev/null - ufw allow 8555/udp comment 'Frigate WebRTC UDP' 2>/dev/null - echo " ✓ Allowed Frigate (5000, 8554, 8555)" - fi - - # Utilities - if [ "$INSTALL_PORTAINER" = "y" ] || [ "$INSTALL_PORTAINER" = "Y" ]; then - ufw allow 9000/tcp comment 'Portainer HTTP' 2>/dev/null - ufw allow 9443/tcp comment 'Portainer HTTPS' 2>/dev/null - echo " ✓ Allowed Portainer (9000, 9443)" - fi - if [ "$INSTALL_UPTIMEKUMA" = "y" ] || [ "$INSTALL_UPTIMEKUMA" = "Y" ]; then - ufw allow 3001/tcp comment 'Uptime Kuma' 2>/dev/null - echo " ✓ Allowed Uptime Kuma (3001)" - fi - - # VPN - if [ "$INSTALL_WGEASY" = "y" ] || [ "$INSTALL_WGEASY" = "Y" ]; then - ufw allow 51820/udp comment 'WireGuard VPN' 2>/dev/null - ufw allow 51821/tcp comment 'WG-Easy Web UI' 2>/dev/null - echo " ✓ Allowed WireGuard (51820/udp, 51821)" - fi - - # GPS Tracking - if [ "$INSTALL_TRACCAR" = "y" ] || [ "$INSTALL_TRACCAR" = "Y" ]; then - ufw allow 8082/tcp comment 'Traccar' 2>/dev/null - ufw allow 5055/tcp comment 'Traccar OsmAnd' 2>/dev/null - echo " ✓ Allowed Traccar (8082, 5055)" - fi - - # Music server - if [ "$INSTALL_LMS" = "y" ] || [ "$INSTALL_LMS" = "Y" ]; then - ufw allow 9000/tcp comment 'Lyrion Music Server' 2>/dev/null - ufw allow 3483/tcp comment 'LMS Players' 2>/dev/null - ufw allow 3483/udp comment 'LMS Players UDP' 2>/dev/null - echo " ✓ Allowed Lyrion Music Server (9000, 3483)" - fi - - # Notifications - if [ "$INSTALL_NTFY" = "y" ] || [ "$INSTALL_NTFY" = "Y" ]; then - ufw allow 8090/tcp comment 'ntfy' 2>/dev/null - echo " ✓ Allowed ntfy (8090)" - fi - - # Minecraft - if [ "$INSTALL_MINECRAFT" = "y" ] || [ "$INSTALL_MINECRAFT" = "Y" ]; then - ufw allow 25565/tcp comment 'Minecraft' 2>/dev/null - echo " ✓ Allowed Minecraft (25565)" - fi - - # Other services - if [ "$INSTALL_FILEBROWSER" = "y" ] || [ "$INSTALL_FILEBROWSER" = "Y" ]; then - ufw allow 8085/tcp comment 'Filebrowser' 2>/dev/null - echo " ✓ Allowed Filebrowser (8085)" - fi - if [ "$INSTALL_FMD" = "y" ] || [ "$INSTALL_FMD" = "Y" ]; then - ufw allow 8084/tcp comment 'FindMyDevice' 2>/dev/null - echo " ✓ Allowed FindMyDevice (8084)" - fi - if [ "$INSTALL_MEALIE" = "y" ] || [ "$INSTALL_MEALIE" = "Y" ]; then - ufw allow 9925/tcp comment 'Mealie' 2>/dev/null - echo " ✓ Allowed Mealie (9925)" - fi - if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then - ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null - echo " ✓ Allowed MagicMirror (8081-8083)" - fi - if [ "$INSTALL_ARM" = "y" ] || [ "$INSTALL_ARM" = "Y" ]; then - ufw allow 8080/tcp comment 'A.R.M.' 2>/dev/null - echo " ✓ Allowed A.R.M. (8080)" - fi - if [ "$INSTALL_AUDIOBOOKSHELF" = "y" ] || [ "$INSTALL_AUDIOBOOKSHELF" = "Y" ]; then - ufw allow 13378/tcp comment 'Audiobookshelf' 2>/dev/null - echo " ✓ Allowed Audiobookshelf (13378)" - fi - - echo "" - fi - fi - - # Enable UFW (with --force to avoid prompt) - ufw --force enable - echo "✓ UFW firewall enabled" - - echo "" - echo "Current UFW status:" - ufw status - else - echo "✗ UFW installation failed, skipping configuration" - fi - fi -else - echo "Skipping UFW configuration." -fi - -CONFIGURE_UFW="${CONFIGURE_UFW:-n}" -fi # End WHIPTAIL_USED check for UFW - -# Full system upgrade (optional) -echo "" -prompt_yn "Perform full system upgrade? (y/n):" "n" DO_SYSTEM_UPGRADE -if [ "$DO_SYSTEM_UPGRADE" = "y" ] || [ "$DO_SYSTEM_UPGRADE" = "Y" ]; then - echo "Performing full system upgrade..." - run_cmd apt upgrade -y -else - echo "Skipping system upgrade." -fi - -# Clean up -echo "" -echo "Cleaning up..." -run_cmd apt autoremove -y -run_cmd apt autoclean - -echo "" -echo "=== Installation Complete! ===" -echo "" -echo "Installed Software:" -echo " ✓ net-tools, ncdu, git, curl, wget, htop, tree, zip/unzip" -echo " ✓ OpenSSH Server - SSH remote access" -if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then - echo " ✓ fail2ban - SSH brute-force protection" -fi -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo " ✓ Docker Engine + Docker Compose" -fi -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo " ✓ Samba - File sharing (Primary drive shared)" -fi -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo " ✓ NetBird - Mesh VPN" -fi -if [ "$INSTALL_WIREGUARD" = "y" ] || [ "$INSTALL_WIREGUARD" = "Y" ]; then - echo " ✓ WireGuard - VPN" -fi -if [ "$INSTALL_TAILSCALE" = "y" ] || [ "$INSTALL_TAILSCALE" = "Y" ]; then - echo " ✓ Tailscale - Mesh VPN" -fi -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo " ✓ RustDesk - Remote desktop" -fi -if [ "$INSTALL_TEAMVIEWER" = "y" ] || [ "$INSTALL_TEAMVIEWER" = "Y" ]; then - echo " ✓ TeamViewer - Remote desktop" -fi -if [ "$INSTALL_MESHCENTRAL" = "y" ] || [ "$INSTALL_MESHCENTRAL" = "Y" ]; then - echo " ✓ MeshCentral Agent - Remote management" -fi -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then - echo " ✓ Local backup configured (rsync)" -fi -if [ "$SETUP_CLOUD_BACKUP" = "y" ] || [ "$SETUP_CLOUD_BACKUP" = "Y" ]; then - echo " ✓ Cloud backup configured (rclone)" -fi -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo " ✓ UFW Firewall - enabled" -fi -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "SSH AUTHENTICATION SETUP" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " SSH Key for This Computer:" -if [ "$GENERATE_KEY" = "y" ] || [ "$GENERATE_KEY" = "Y" ]; then - if [ -f "$ACTUAL_HOME/.ssh/id_rsa.pub" ]; then -echo " ✓ 4096-bit RSA key generated" -echo " Public key: $ACTUAL_HOME/.ssh/id_rsa.pub" -echo " View with: cat ~/.ssh/id_rsa.pub" - else -echo " ✗ Key generation was attempted but may have failed" - fi -else -echo " Not generated (skipped during install)" -echo " Generate later: ssh-keygen -t rsa -b 4096 -C \"your@email.com\"" -fi -echo "" -echo " SSH Server Status:" -if [ "$KEYS_IMPORTED" = true ]; then -echo " Password authentication: DISABLED (key-only access)" -echo " Imported SSH keys: ~/.ssh/authorized_keys" -else -echo " Password authentication: ENABLED" -fi -echo "" -echo " Traditional SSH Access:" -echo " - Uses keys from GitHub/Launchpad (if imported)" -echo " - Connect with: ssh user@hostname" -if [ "$KEYS_IMPORTED" = true ]; then -echo " - Password login: DISABLED (keys required)" -else -echo " - Password login: ENABLED" -fi -echo "" -echo " NetBird SSH Access (v0.60.0+ method):" -echo " - Requires openssh-server running (installed by this script)" -echo " - Enable per-peer in NetBird dashboard: Peers > [peer] > SSH" -echo " - NetBird injects /etc/ssh/sshd_config.d/99-netbird.conf" -echo " - Connect from another peer: ssh user@" -echo " - Get peer IPs with: netbird status" -echo " - Note: old 'netbird ssh ' command no longer works" -echo "" -echo " You can use ANY combination:" -echo " ✓ GitHub + Launchpad + NetBird SSH" -echo " ✓ GitHub + NetBird SSH" -echo " ✓ Launchpad + NetBird SSH" -echo " ✓ GitHub + Launchpad (no NetBird)" -echo " ✓ Just GitHub or just Launchpad" -echo " ✓ Just NetBird SSH" -echo " ✓ None (password auth only - if no keys imported)" -echo "" -if [ "$SETUP_BACKUP" = "y" ] || [ "$SETUP_BACKUP" = "Y" ]; then -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "BACKUP SYSTEM - $BACKUP_TOOL ($BACKUP_MODE mode)" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " Mount points:" -echo " $ACTUAL_HOME/drives/primary" -echo " $ACTUAL_HOME/drives/backup1" -if [ "$BACKUP_MODE" = "split" ]; then -echo " $ACTUAL_HOME/drives/backup2" -fi -echo "" -echo " Backup script: /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo " Log file: /var/log/${BACKUP_TOOL}-backup.log" -echo "" -if [ "$BACKUP_MODE" = "split" ]; then -echo " ⚠️ CONFIGURE BEFORE RUNNING:" -echo " Edit the script to set which folders go to which backup drive." -echo " sudo nano /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo "" -fi -echo " Quick start:" -echo " 1. Test (dry-run): sudo ${BACKUP_TOOL}-backup.sh --dry-run # (edit script first)" -echo " 2. Run manually: sudo /usr/local/bin/backup-scripts/${BACKUP_TOOL}-backup.sh" -echo " 3. Enable auto: sudo systemctl enable ${BACKUP_TOOL}-backup.timer" -echo " sudo systemctl start ${BACKUP_TOOL}-backup.timer" -echo "" -echo " Monitor: tail -f /var/log/${BACKUP_TOOL}-backup.log" -fi - -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "OTHER IMPORTANT NOTES" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -if [ "$INSTALL_SAMBA" = "y" ] || [ "$INSTALL_SAMBA" = "Y" ]; then - echo " Samba File Sharing:" - if command -v smbd &> /dev/null && grep -q "\[Primary\]" /etc/samba/smb.conf 2>/dev/null; then - echo " ✓ Share 'Primary' is accessible at:" - echo " Windows: \\\\$(hostname)\\Primary" - echo " Mac/Linux: smb://$(hostname)/Primary" - echo " • Username: $ACTUAL_USER" - echo " • Use the Samba password you just set" - echo "" - else - echo " ✗ Installation may have failed - check 'systemctl status smbd'" - echo "" - fi -fi -if [ "$INSTALL_DOCKER" = "y" ] || [ "$INSTALL_DOCKER" = "Y" ]; then - echo " Docker: Log out and back in for group membership to take effect" - echo "" -fi -if [ "$INSTALL_NETBIRD" = "y" ] || [ "$INSTALL_NETBIRD" = "Y" ]; then - echo " NetBird:" - echo " 1. Run 'netbird up' (opens browser for authentication)" - echo " 2. View connected peers: netbird status" - echo " 3. Configure ACLs in dashboard: https://app.netbird.io" - echo " 4. Enable SSH per-peer: Peers > [peer] > SSH (in dashboard)" - echo " 5. Connect via SSH: ssh user@" - echo " Note: --allow-server-ssh is pre-configured in systemd override" - echo " (/etc/systemd/system/netbird.service.d/ssh-server.conf)" - echo " so SSH works without re-authenticating on every connection" - echo "" -fi -if [ "$INSTALL_RUSTDESK" = "y" ] || [ "$INSTALL_RUSTDESK" = "Y" ]; then - echo " RustDesk: Launch from applications menu or run 'rustdesk'" - echo "" -fi -if [ "$INSTALL_FAIL2BAN" = "y" ] || [ "$INSTALL_FAIL2BAN" = "Y" ]; then - echo " fail2ban:" - echo " • Check status: sudo fail2ban-client status sshd" - echo " • View banned IPs: sudo fail2ban-client status sshd" - echo " • Unban IP: sudo fail2ban-client set sshd unbanip " - echo "" -fi -if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then - echo " UFW Firewall:" - echo " • Check status: sudo ufw status" - echo " • Allow port: sudo ufw allow " - echo " • Deny port: sudo ufw deny " - echo " • Disable: sudo ufw disable" - echo "" -fi \ No newline at end of file