diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c304ba..ed01653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,110 @@ All notable changes to this project. Versions follow `MAJOR.MINOR.PATCH`. The project is pre-1.0 while the modular system reaches parity with the monolithic `ubuntu-post-install-*.sh` scripts. +## [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 diff --git a/MODULAR.md b/MODULAR.md index 2ad5719..c36b58f 100644 --- a/MODULAR.md +++ b/MODULAR.md @@ -72,7 +72,10 @@ single shared compose file. ## Groups -`base` · `homelab` · `utilities` · `media` · `cameras` · `gaming` · `backup`. +`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 @@ -82,17 +85,17 @@ ordered first. ## Migration status -This is an incremental migration. The big `ubuntu-post-install-*-crowdsec.sh` -script remains the current "install everything" entry point until the modules -reach parity, at which point it is retired (like the `original` and -`-no-keycloak` tiers, which stay frozen as the evolution record). +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. -| Module | Status | -|--------|--------| -| `base` (incl. glow) | ✅ done | -| `homeassistant` | ✅ done | -| `minecraft` (multi-instance, rich) | ⏳ porting from `setupminecraft.sh` | -| `wolf` (gaming) | ⏳ porting from `setupwolf.sh` | -| `js99er` (gaming) | ⏳ porting from `setupjs99er.sh` | -| `backup` (Kopia, cross-cutting) | ⏳ porting from `setupbackup.sh` | -| remaining ~65 services | ⏳ migrate from the monolith incrementally | +| 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-notify` | +| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | +| `extras` | `linux-to-sync`, `silent-send` | +| `backup` | `backup` | diff --git a/VERSION b/VERSION index c81aa44..3eefcb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.7 +1.0.0 diff --git a/services/actualbudget.sh b/services/actualbudget.sh new file mode 100644 index 0000000..8fa71f9 --- /dev/null +++ b/services/actualbudget.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# services/actualbudget.sh — Open-source personal finance / budgeting (Actual Budget). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- ACTUALBUDGET ----). +# Own ~/docker/actualbudget/ with a standalone docker-compose.yml. + +register_service actualbudget utilities "Open-source personal finance & budgeting (Actual Budget)" 5006 + +install_actualbudget() { + require_docker || return 1 + + local AB_DIR="$DOCKER_DIR/actualbudget" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Actual Budget would:" + echo " - Create $AB_DIR with docker-compose.yml (data/)" + echo " - Expose port 5006" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + mkdir -p "$AB_DIR/data" + ensure_docker_dir_ownership "$AB_DIR" + cd "$AB_DIR" || return 1 + + local TZ_VAL; TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + 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 +AB_COMPOSE + + cat > .env << AB_ENV +TZ=$TZ_VAL +AB_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR" + log_success "Actual Budget configured at $AB_DIR" + + configure_caddy_for_service "ActualBudget" "5006" "budget" + + write_readme "$AB_DIR" << MD +# Actual Budget + +Open-source personal finance and budgeting tool. Supports bank sync via +SimpleFIN (requires a SimpleFIN account at simplefin.org). + +- Web UI: http://localhost:5006 +- App data: \`data/\` + +## Manage +\`\`\`bash +cd $AB_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Notes +- First launch: create a budget file or import an existing one. +- Bank sync requires a SimpleFIN bridge subscription (simplefin.org). +MD + + local START_AB="" + prompt_yn "Start Actual Budget now? (y/n):" "y" START_AB + if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then + docker compose up -d && log_success "Actual Budget started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:5006" + echo " Bank sync: simplefin.org (optional, paid)" + echo "" +} diff --git a/services/arm.sh b/services/arm.sh new file mode 100644 index 0000000..3d84efa --- /dev/null +++ b/services/arm.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# services/arm.sh — Automatic Ripping Machine: rip DVDs, Blu-rays, CDs. +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- A.R.M. ----). +# Own ~/docker/arm/ with a standalone docker-compose.yml + .env. Detects +# optical drives at install time; add more /dev/srN entries manually after. + +register_service arm media "Automatic Ripping Machine — rip DVDs, Blu-rays, CDs" 8080 + +install_arm() { + require_docker || return 1 + + local ARM_DIR="$DOCKER_DIR/arm" + local DEFAULT_OUTPUT="$ACTUAL_HOME/ripped" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] A.R.M. would:" + echo " - Create $ARM_DIR with docker-compose.yml + .env (config/ logs/)" + echo " - Detect optical drives (/dev/sr*) — defaults to /dev/sr0" + echo " - Create ripped output dirs (movies/ music/) under $DEFAULT_OUTPUT" + echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER") with privileged: true" + echo " - Expose port 8080" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local ARM_OUTPUT="" + prompt_text "Path for ripped media output [$DEFAULT_OUTPUT]:" "$DEFAULT_OUTPUT" ARM_OUTPUT + ARM_OUTPUT="${ARM_OUTPUT/#\~/$ACTUAL_HOME}"; ARM_OUTPUT="${ARM_OUTPUT%/}" + + echo "" + echo "Detecting optical drives..." + local OPTICAL_DRIVES + OPTICAL_DRIVES=$(ls /dev/sr* 2>/dev/null || true) + if [ -n "$OPTICAL_DRIVES" ]; then + echo " Found: $OPTICAL_DRIVES" + else + echo " No optical drives detected. Defaulting to /dev/sr0 — add more later." + OPTICAL_DRIVES="/dev/sr0" + fi + + mkdir -p "$ARM_DIR" + ensure_docker_dir_ownership "$ARM_DIR" + cd "$ARM_DIR" || return 1 + + local TZ_VAL UID_VAL GID_VAL + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + + 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=$UID_VAL + - ARM_GID=$GID_VAL + - TZ=$TZ_VAL + 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 optical 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" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$ARM_OUTPUT" + log_success "A.R.M. configured at $ARM_DIR" + + configure_caddy_for_service "A.R.M." "8080" "arm" + + write_readme "$ARM_DIR" << MD +# A.R.M. (Automatic Ripping Machine) + +Auto-rips DVDs, Blu-rays, and CDs when you insert them — identifies the disc, +fetches metadata, and transcodes to a usable format. + +- Web UI: http://localhost:8080 (complete setup on first visit) +- Ripped output: \`$ARM_OUTPUT\` → movies and music subdirs +- App data: \`config/\` and \`logs/\` + +## Manage +\`\`\`bash +cd $ARM_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Adding optical drives +Edit \`docker-compose.yml\` and add more \`devices:\` entries: +\`\`\`yaml + devices: + - /dev/sr0:/dev/sr0 + - /dev/sr1:/dev/sr1 +\`\`\` +Then \`docker compose up -d\` to apply. + +## Notes +- First launch: open the web UI and complete the setup wizard. +- \`privileged: true\` is required for ARM to control the optical drive. +- Change the output path in \`.env\` (\`ARM_OUTPUT=\`), then \`docker compose up -d\`. +MD + + local START_ARM="" + prompt_yn "Start A.R.M. now? (y/n):" "y" START_ARM + if [ "$START_ARM" = "y" ] || [ "$START_ARM" = "Y" ]; then + docker compose up -d && log_success "A.R.M. started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:8080" + echo " Complete setup in browser on first visit." + echo "" +} diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh new file mode 100644 index 0000000..6149f6f --- /dev/null +++ b/services/audiobookshelf.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# services/audiobookshelf.sh — Audiobook & podcast server (Audiobookshelf). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- AUDIOBOOKSHELF ----). +# Own ~/docker/audiobookshelf/ with a standalone docker-compose.yml + .env. + +register_service audiobookshelf media "Audiobook & podcast server (Audiobookshelf)" 13378 + +install_audiobookshelf() { + require_docker || return 1 + + local ABS_DIR="$DOCKER_DIR/audiobookshelf" + local DEFAULT_AUDIOBOOKS="$ACTUAL_HOME/audiobooks" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Audiobookshelf would:" + echo " - Create $ABS_DIR with docker-compose.yml + .env (config/ metadata/ podcasts/)" + echo " - Mount an audiobooks folder (default $DEFAULT_AUDIOBOOKS) at /audiobooks" + echo " - Expose port 13378" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local AUDIOBOOKS_PATH="" + prompt_text "Path to audiobooks folder [$DEFAULT_AUDIOBOOKS]:" "$DEFAULT_AUDIOBOOKS" AUDIOBOOKS_PATH + AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH/#\~/$ACTUAL_HOME}"; AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH%/}" + + mkdir -p "$ABS_DIR" + ensure_docker_dir_ownership "$ABS_DIR" + cd "$ABS_DIR" || return 1 + + local TZ_VAL; TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + 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=$TZ_VAL + 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" + log_success "Audiobookshelf configured at $ABS_DIR" + + configure_caddy_for_service "AudioBookshelf" "13378" "audiobooks" + + write_readme "$ABS_DIR" << MD +# Audiobookshelf + +Self-hosted audiobook and podcast server with progress sync across devices. + +- Web UI: http://localhost:13378 +- Audiobooks: \`$AUDIOBOOKS_PATH\` → mounted at /audiobooks +- Podcasts: \`podcasts/\` in this folder → /podcasts (change \`PODCASTS_PATH\` in .env) +- App data: \`config/\` and \`metadata/\` + +## Manage +\`\`\`bash +cd $ABS_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +First launch: open the web UI, create your admin account, then add libraries +pointing at /audiobooks and /podcasts. +MD + + local START_ABS="" + prompt_yn "Start Audiobookshelf now? (y/n):" "y" START_ABS + if [ "$START_ABS" = "y" ] || [ "$START_ABS" = "Y" ]; then + docker compose up -d && log_success "Audiobookshelf started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:13378" + echo "" +} diff --git a/services/ddclient.sh b/services/ddclient.sh new file mode 100644 index 0000000..49b9bd9 --- /dev/null +++ b/services/ddclient.sh @@ -0,0 +1,131 @@ +#!/bin/bash +# services/ddclient.sh — Dynamic DNS updater (ddclient). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- DDCLIENT ----). +# Own ~/docker/ddclient/ with a standalone docker-compose.yml + config. +# Supports Cloudflare, DuckDNS, No-IP, and many other providers. +# Edit config/ddclient.conf before starting — no web UI. + +register_service ddclient utilities "Dynamic DNS updater — keep your domain pointing at your home IP (ddclient)" + +install_ddclient() { + require_docker || return 1 + + local DDCLIENT_DIR="$DOCKER_DIR/ddclient" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] ddclient would:" + echo " - Create $DDCLIENT_DIR with docker-compose.yml + config/ddclient.conf template" + echo " - No web UI — edit config/ddclient.conf for your DNS provider before starting" + return 0 + fi + + mkdir -p "$DDCLIENT_DIR" + ensure_docker_dir_ownership "$DDCLIENT_DIR" + cd "$DDCLIENT_DIR" || return 1 + + local TZ_VAL; TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + 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 + + cat > .env << DDCLIENT_ENV +TZ=$TZ_VAL +DDCLIENT_ENV + + mkdir -p config + + cat > config/ddclient.conf << 'DDCLIENT_CONF' +# ddclient configuration +# Docs: https://ddclient.net/ +# +# ⚠️ YOU MUST EDIT THIS FILE before starting ddclient. +# Uncomment and fill in the block 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 + +# No-IP example: +# use=web +# protocol=noip +# login=your@email.com +# password=your-password +# yourhostname.ddns.net +DDCLIENT_CONF + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DDCLIENT_DIR" + log_success "ddclient configured at $DDCLIENT_DIR" + + write_readme "$DDCLIENT_DIR" << MD +# ddclient + +Dynamic DNS client — keeps your domain pointing at your home IP address +even when your ISP changes it. No web UI; runs as a background daemon. + +- Config: \`config/ddclient.conf\` — **edit before starting** +- Supported providers: Cloudflare, DuckDNS, No-IP, FreeDNS, and more + +## Setup +1. Edit \`config/ddclient.conf\` for your DNS provider. +2. Start: \`docker compose up -d\` +3. Check logs: \`docker compose logs -f\` + +## Manage +\`\`\`bash +cd $DDCLIENT_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Docs +- https://ddclient.net/ +- Cloudflare setup: https://ddclient.net/protocols/cloudflare.html +MD + + echo "" + log_warning "Edit config/ddclient.conf for your DNS provider before starting." + echo "" + local START_DDC="" + prompt_yn "Start ddclient now? (y/n):" "n" START_DDC + if [ "$START_DDC" = "y" ] || [ "$START_DDC" = "Y" ]; then + docker compose up -d && log_success "ddclient started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Config: $DDCLIENT_DIR/config/ddclient.conf" + echo " Docs: https://ddclient.net/" + echo "" +} diff --git a/services/emby.sh b/services/emby.sh new file mode 100644 index 0000000..280c31a --- /dev/null +++ b/services/emby.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# services/emby.sh — Media server for movies, TV, and music (Emby). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- EMBY ----). +# Own ~/docker/emby/ with a standalone docker-compose.yml + .env. Hardware +# transcoding is left commented in the compose (uncomment the /dev/dri block +# once you've confirmed your GPU) to match the original behavior. + +register_service emby media "Media server — movies, TV, music (Emby)" 8096 + +install_emby() { + require_docker || return 1 + + local EMBY_DIR="$DOCKER_DIR/emby" + local DEFAULT_MEDIA="$ACTUAL_HOME/media" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Emby would:" + echo " - Create $EMBY_DIR with docker-compose.yml + .env (config/)" + echo " - Mount a media folder (default $DEFAULT_MEDIA) at /media" + echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER")" + echo " - Expose ports 8096 (web) and 8920 (https)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local MEDIA_PATH="" + prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH + MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}" + + mkdir -p "$EMBY_DIR" + ensure_docker_dir_ownership "$EMBY_DIR" + cd "$EMBY_DIR" || return 1 + + local TZ_VAL UID_VAL GID_VAL + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + + cat > docker-compose.yml << EMBY_COMPOSE +name: emby + +services: + emby: + image: emby/embyserver:latest + container_name: emby + hostname: emby + restart: unless-stopped + environment: + - UID=$UID_VAL + - GID=$GID_VAL + - TZ=$TZ_VAL + 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" + log_success "Emby configured at $EMBY_DIR" + + configure_caddy_for_service "Emby" "8096" "emby" + + write_readme "$EMBY_DIR" << MD +# Emby + +Media server for movies, TV, and music. + +- Web UI: http://localhost:8096 (HTTPS on 8920) +- Media folder: \`$MEDIA_PATH\` → mounted at /media +- App data: \`config/\` in this folder +- Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`. + +## Manage +\`\`\`bash +cd $EMBY_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Hardware transcoding +Uncomment the \`devices: [/dev/dri:/dev/dri]\` block in \`docker-compose.yml\` +once you've confirmed your Intel/AMD GPU exposes a render node, then restart. +MD + + local START_EMBY="" + prompt_yn "Start Emby now? (y/n):" "y" START_EMBY + if [ "$START_EMBY" = "y" ] || [ "$START_EMBY" = "Y" ]; then + docker compose up -d && log_success "Emby started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:8096" + echo "" +} diff --git a/services/fmd.sh b/services/fmd.sh new file mode 100644 index 0000000..d5ba5d5 --- /dev/null +++ b/services/fmd.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# services/fmd.sh — FindMyDevice server for Android device tracking (FMD). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- FINDMYDEVICE ----). +# Own ~/docker/fmd/ with a standalone docker-compose.yml + .env. +# Mobile app: "FindMyDevice" on F-Droid — not the Play Store version. + +register_service fmd utilities "Android device tracking — alternative to Google Find My Device (FMD)" 8084 + +install_fmd() { + require_docker || return 1 + + local FMD_DIR="$DOCKER_DIR/fmd" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] FindMyDevice would:" + echo " - Create $FMD_DIR with docker-compose.yml + .env (data/)" + echo " - Generate a random admin password" + echo " - Expose port 8084" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + mkdir -p "$FMD_DIR" + ensure_docker_dir_ownership "$FMD_DIR" + cd "$FMD_DIR" || return 1 + + local FMD_PASS + FMD_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_PASS +FMD_ENV + + mkdir -p data + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FMD_DIR" + log_success "FindMyDevice configured at $FMD_DIR" + + configure_caddy_for_service "FindMyDevice" "8084" "fmd" + + write_readme "$FMD_DIR" << MD +# FindMyDevice (FMD) + +Self-hosted Android device tracking — locate, lock, or wipe your device +from the web UI. Alternative to Google's Find My Device. + +- Web UI: http://localhost:8084 +- Admin password: stored in \`.env\` (\`FMD_ADMIN_PASSWORD\`) +- App data: \`data/\` + +## Manage +\`\`\`bash +cd $FMD_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Mobile app +Install **FindMyDevice** from **F-Droid** (not the Play Store version): +1. Open the app → Settings → Server URL → \`http://YOUR-SERVER-IP:8084\` +2. Enter your admin password from \`.env\` +3. Grant location and accessibility permissions +MD + + local START_FMD="" + prompt_yn "Start FindMyDevice now? (y/n):" "y" START_FMD + if [ "$START_FMD" = "y" ] || [ "$START_FMD" = "Y" ]; then + docker compose up -d && log_success "FindMyDevice started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:8084" + echo " Password: $FMD_PASS (saved in .env)" + echo " Mobile app: FindMyDevice on F-Droid" + echo "" +} diff --git a/services/frigate-notify.sh b/services/frigate-notify.sh new file mode 100644 index 0000000..49c2214 --- /dev/null +++ b/services/frigate-notify.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# services/frigate-notify.sh — Push notification sidecar for Frigate events. +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- FRIGATE-NOTIFY ----). +# Own ~/docker/frigate-notify/ with a standalone docker-compose.yml + config.yml. +# Supports ntfy, Pushover, Discord, Gotify, Telegram, and more. No web UI. +# Auto-detects local Frigate and ntfy installs to pre-fill config defaults. + +register_service frigate-notify cameras "Push alerts for Frigate detection events (Frigate-Notify)" + +install_frigate-notify() { + require_docker || return 1 + + local FN_DIR="$DOCKER_DIR/frigate-notify" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Frigate-Notify would:" + echo " - Create $FN_DIR with docker-compose.yml + config.yml" + echo " - Auto-detect local Frigate and ntfy installs" + echo " - No web UI — configure via config.yml" + return 0 + fi + + mkdir -p "$FN_DIR" + ensure_docker_dir_ownership "$FN_DIR" + cd "$FN_DIR" || return 1 + + 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 + + # Smart defaults based on what's installed + local FRIGATE_URL="http://frigate:5000" + local NTFY_URL="https://ntfy.sh" + local NTFY_TOPIC="frigate-alerts" + + if [ -d "$DOCKER_DIR/frigate" ]; then + log_success "Local Frigate detected — using http://frigate:5000" + else + log_warning "Frigate not found locally — using default URL (update config.yml if needed)" + fi + + if [ -d "$DOCKER_DIR/ntfy" ]; then + NTFY_URL="http://ntfy:80" + log_success "Local ntfy detected — using http://ntfy:80" + else + log_warning "Local ntfy not found — using ntfy.sh (update config.yml for self-hosted)" + fi + + echo "" + prompt_text "Frigate URL [$FRIGATE_URL]:" "$FRIGATE_URL" FRIGATE_URL + prompt_text "ntfy server URL [$NTFY_URL]:" "$NTFY_URL" NTFY_URL + prompt_text "ntfy topic [frigate-alerts]:" "frigate-alerts" NTFY_TOPIC + + cat > config.yml << FN_CONFIG +# Frigate-Notify Configuration +# Docs: https://frigate-notify.0x2142.com +# +# Edit this file if notifications don't arrive — check Frigate URL, +# ntfy server, and that containers share a 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" + log_success "Frigate-Notify configured at $FN_DIR" + + write_readme "$FN_DIR" << MD +# Frigate-Notify + +Push notification sidecar for Frigate — sends alerts when Frigate detects +people, cars, animals, or custom objects. Supports ntfy, Pushover, Discord, +Gotify, Telegram, and more. No web UI. + +- Config: \`config.yml\` — edit notification targets here +- Frigate events polled every 30 seconds by default +- Docs: https://frigate-notify.0x2142.com + +## Manage +\`\`\`bash +cd $FN_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # check for delivery errors +docker compose pull && docker compose up -d # update +\`\`\` + +## Adding more notifiers +Edit \`config.yml\` and add entries under \`notifiers:\`. Supported providers: +ntfy, Pushover, Discord (webhook), Gotify, Telegram, SMTP, and more. +See: https://frigate-notify.0x2142.com/configuration/alerts/ +MD + + local START_FN="" + prompt_yn "Start Frigate-Notify now? (y/n):" "y" START_FN + if [ "$START_FN" = "y" ] || [ "$START_FN" = "Y" ]; then + docker compose up -d && log_success "Frigate-Notify started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Config: $FN_DIR/config.yml" + echo " Docs: https://frigate-notify.0x2142.com" + echo "" +} diff --git a/services/frigate.sh b/services/frigate.sh new file mode 100644 index 0000000..836961b --- /dev/null +++ b/services/frigate.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# services/frigate.sh — AI-powered NVR for security cameras (Frigate). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- FRIGATE NVR ----). +# Own ~/docker/frigate/ with a standalone docker-compose.yml + .env + config.yml. +# Auto-enables /dev/dri/renderD128 for hardware detection (Intel/AMD) when present. +# YOU MUST edit config/config.yml to add your camera RTSP streams before starting. + +register_service frigate cameras "AI-powered NVR — object detection on security cameras (Frigate)" 5000 + +install_frigate() { + require_docker || return 1 + + local FRIGATE_DIR="$DOCKER_DIR/frigate" + local DEFAULT_MEDIA="$ACTUAL_HOME/frigate" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Frigate would:" + echo " - Create $FRIGATE_DIR with docker-compose.yml + .env + config/config.yml" + echo " - Auto-enable /dev/dri/renderD128 for GPU-assisted detection if present" + echo " - Expose ports 5000 (web), 8554 (RTSP restream), 8555 (WebRTC)" + echo " - Write a starter config.yml — edit to add camera streams before starting" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local FRIGATE_MEDIA="" + prompt_text "Path for recordings/snapshots [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" FRIGATE_MEDIA + FRIGATE_MEDIA="${FRIGATE_MEDIA/#\~/$ACTUAL_HOME}"; FRIGATE_MEDIA="${FRIGATE_MEDIA%/}" + + mkdir -p "$FRIGATE_DIR" + ensure_docker_dir_ownership "$FRIGATE_DIR" + cd "$FRIGATE_DIR" || return 1 + + local TZ_VAL; TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + # Hardware detection: include /dev/dri only when a render node exists + local DEVICE_BLOCK="" + if [ -e /dev/dri/renderD128 ]; then + DEVICE_BLOCK=" devices: + - /dev/dri/renderD128:/dev/dri/renderD128" + log_success "Render node found — enabling hardware-accelerated detection" + else + log_warning "No /dev/dri/renderD128 — Frigate will use CPU detection." + fi + + 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=$TZ_VAL +$DEVICE_BLOCK + 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 + + cat > .env << FRIGATE_ENV +FRIGATE_MEDIA=$FRIGATE_MEDIA +FRIGATE_ENV + + mkdir -p config + mkdir -p "$FRIGATE_MEDIA" + + cat > config/config.yml << 'FRIGATE_CONFIG' +# Frigate Configuration — Docs: https://docs.frigate.video +# +# ⚠️ YOU MUST EDIT THIS FILE to add your cameras before starting Frigate. + +mqtt: + enabled: false # Set to true and configure if you use Home Assistant + +cameras: + # Example — replace with your camera details: + # 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 # Change to 'edgetpu' for Coral TPU or 'openvino' for Intel GPU + +record: + enabled: true + retain: + days: 7 + mode: motion + +snapshots: + enabled: true + retain: + default: 7 +FRIGATE_CONFIG + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_DIR" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true + log_success "Frigate configured at $FRIGATE_DIR" + + configure_caddy_for_service "Frigate" "5000" "frigate" + + write_readme "$FRIGATE_DIR" << MD +# Frigate NVR + +AI-powered network video recorder with real-time object detection for +security cameras. Detects people, cars, animals, and more. + +- Web UI: http://localhost:5000 +- RTSP restream: port 8554 +- WebRTC: port 8555 +- Recordings: \`$FRIGATE_MEDIA\` +- Config: \`config/config.yml\` — **add your camera RTSP streams here** + +## Manage +\`\`\`bash +cd $FRIGATE_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## First steps +1. Edit \`config/config.yml\` — add your camera RTSP URLs under \`cameras:\` +2. Start Frigate: \`docker compose up -d\` +3. Open http://localhost:5000 to view cameras and configure detection zones + +## Hardware acceleration +- Intel/AMD GPU: uncomment the \`devices: [/dev/dri/renderD128]\` block +- Google Coral TPU: set \`detectors.default.type: edgetpu\` + add USB device +- Docs: https://docs.frigate.video/configuration/hardware_acceleration +MD + + echo "" + log_warning "Edit config/config.yml to add your camera RTSP streams before starting." + echo "" + local START_FRIGATE="" + prompt_yn "Start Frigate now anyway? (y/n):" "n" START_FRIGATE + if [ "$START_FRIGATE" = "y" ] || [ "$START_FRIGATE" = "Y" ]; then + docker compose up -d && log_success "Frigate started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:5000" + echo " Config: $FRIGATE_DIR/config/config.yml (add cameras here)" + echo "" +} diff --git a/services/immich.sh b/services/immich.sh new file mode 100644 index 0000000..0bce477 --- /dev/null +++ b/services/immich.sh @@ -0,0 +1,610 @@ +#!/bin/bash +# services/immich.sh — Self-hosted photo & video backup (like Google Photos). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- IMMICH ----). +# Multi-container stack: immich-server + machine-learning + valkey + postgres. +# Two library strategies: +# 1) Unified — Immich manages all photos in one place (import-photos.sh helps) +# 2) External — Immich indexes your existing folder read-only; new uploads separate + +register_service immich media "Self-hosted photo & video backup — like Google Photos (Immich)" 2283 + +install_immich() { + require_docker || return 1 + + local IMMICH_DIR="$DOCKER_DIR/immich" + local DEFAULT_PHOTOS="$ACTUAL_HOME/photos" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Immich would:" + echo " - Create $IMMICH_DIR with docker-compose.yml + .env" + echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres" + echo " - Strategy 1 (unified): all photos in one folder, import-photos.sh helper" + echo " - Strategy 2 (external): existing photos indexed read-only, new uploads separate" + echo " - Expose port 2283" + echo " - Offer a Caddy reverse proxy and to start the stack" + return 0 + fi + + # ── Photo library setup ───────────────────────────────────────────────── + echo "" + echo " PHOTO LIBRARY SETUP" + echo "" + + local IMMICH_STRATEGY="1" UPLOAD_LOCATION="" EXTERNAL_LIBRARY="" EXISTING_PHOTOS_SOURCE="" + local HAS_EXISTING_PHOTOS="" + 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 into Immich (recommended)" + echo " Immich manages all photos in one unified library." + echo " Dates preserved via EXIF. Organized by date automatically." + 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 -r -p " Choose [1/2]: " IMMICH_STRATEGY + IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}" + fi + + if [ "$IMMICH_STRATEGY" = "2" ]; then + local EXISTING_PHOTOS_PATH="" + prompt_text "Existing photos path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" EXISTING_PHOTOS_PATH + EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_PATH/#\~/$ACTUAL_HOME}"; EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" + UPLOAD_LOCATION="$ACTUAL_HOME/immich-uploads" + EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE" + echo "" + echo " Setup:" + echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)" + echo " New uploads: $UPLOAD_LOCATION" + else + local PHOTOS_DIR_INPUT="" + prompt_text "Photo library path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" PHOTOS_DIR_INPUT + PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT/#\~/$ACTUAL_HOME}"; PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT%/}" + + local EXISTING_INPUT="" + prompt_text "Existing photos path [$PHOTOS_DIR_INPUT]:" "$PHOTOS_DIR_INPUT" EXISTING_INPUT + EXISTING_PHOTOS_SOURCE="${EXISTING_INPUT/#\~/$ACTUAL_HOME}"; EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}" + + UPLOAD_LOCATION="$PHOTOS_DIR_INPUT" + echo "" + echo " All photos (existing + new) will live in: $PHOTOS_DIR_INPUT" + fi + else + local PHOTOS_DIR_INPUT="" + prompt_text "Photo library path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" PHOTOS_DIR_INPUT + PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT/#\~/$ACTUAL_HOME}"; PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT%/}" + UPLOAD_LOCATION="$PHOTOS_DIR_INPUT" + echo "" + echo " Photos will be stored in: $PHOTOS_DIR_INPUT" + fi + + echo "" + + # ── Create directories ────────────────────────────────────────────────── + mkdir -p "$IMMICH_DIR" + ensure_docker_dir_ownership "$IMMICH_DIR" + mkdir -p "$UPLOAD_LOCATION" + [ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY" + + # Immich checks for these subdirs + .immich marker files on startup + local subdir + for subdir in thumbs upload backups library profile encoded-video; do + mkdir -p "$UPLOAD_LOCATION/$subdir" + touch "$UPLOAD_LOCATION/$subdir/.immich" + done + + cd "$IMMICH_DIR" || return 1 + + # ── Generate DB password ──────────────────────────────────────────────── + local DB_PASS TZ_VAL + DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + # ── Write docker-compose.yml ──────────────────────────────────────────── + if [ -n "$EXTERNAL_LIBRARY" ]; then + 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 + 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 + + # ── Write .env ────────────────────────────────────────────────────────── + if [ "$IMMICH_STRATEGY" = "2" ]; then + cat > .env << IMMICH_ENV +# IMMICH CONFIGURATION — External Library Mode +# +# STORAGE TEMPLATE (set in Immich web UI): +# Admin → Settings → Storage Template → Enable +# Template: {{y}}/{{MM}}/{{filename}} +# +# 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) +EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY + +DB_DATA_LOCATION=./postgres +IMMICH_VERSION=release +DB_PASSWORD=$DB_PASS +DB_USERNAME=postgres +DB_DATABASE_NAME=immich +TZ=$TZ_VAL +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 + +UPLOAD_LOCATION=$UPLOAD_LOCATION +DB_DATA_LOCATION=./postgres +IMMICH_VERSION=release +DB_PASSWORD=$DB_PASS +DB_USERNAME=postgres +DB_DATABASE_NAME=immich +TZ=$TZ_VAL +IMMICH_ENV + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION" + [ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true + + # ── import-photos.sh (strategy 1 + existing photos only) ─────────────── + if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then + cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_HEAD' +#!/bin/bash +################################################################################ +# Immich Photo Import Script — generated by ubuntu-post-install +# +# Imports your existing photo collection into Immich with EXIF date preservation. +# Photos are uploaded through the API so Immich extracts metadata (dates, GPS, +# camera info) from the originals. +# +# 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_HEAD + + cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS +IMMICH_URL="http://localhost:2283" +SOURCE_DIR="$EXISTING_PHOTOS_SOURCE" +IMMICH_DIR="$IMMICH_DIR" +IMPORT_VARS + + cat >> "$IMMICH_DIR/import-photos.sh" << 'IMPORT_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" + +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..." +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 "" + 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 + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ FIRST-TIME SETUP — Creating admin account │" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + read -r -p " Admin email: " ADMIN_EMAIL + while [ -z "$ADMIN_EMAIL" ]; do + read -r -p " Admin email (required): " ADMIN_EMAIL + done + read -r -sp " Admin password: " ADMIN_PASS; echo "" + while [ "${#ADMIN_PASS}" -lt 8 ]; do + echo " Password must be at least 8 characters." + read -r -sp " Admin password: " ADMIN_PASS; echo "" + done + read -r -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 " Create your account at $IMMICH_URL then re-run: $0 " + exit 1 + fi + else + echo " Immich is already set up. Log in to generate an API key." + echo "" + read -r -p " Admin email: " ADMIN_EMAIL + while [ -z "$ADMIN_EMAIL" ]; do + read -r -p " Admin email (required): " ADMIN_EMAIL + done + read -r -sp " Admin password: " ADMIN_PASS; echo "" + fi + + 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 " Check your email/password, or pass an API key: $0 " + exit 1 + fi + + ACCESS_TOKEN=$(echo "$LOGIN_BODY" | python3 -c \ + "import sys,json; print(json.load(sys.stdin)['accessToken'])" 2>/dev/null) + [ -z "$ACCESS_TOKEN" ] && { echo " ✗ Could not extract access token"; exit 1; } + echo " ✓ Logged in" + + 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" + echo " Create one at $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 at $IMMICH_URL → Account Settings → API Keys" + echo " Then re-run: $0 " + exit 1 + fi +else + 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) + [ "$VERIFY_CODE" != "200" ] && { echo " ✗ Invalid API key (HTTP $VERIFY_CODE)"; exit 1; } + echo " ✓ API key valid" +fi + +# ── Configure storage template ─────────────────────────────────────────────── +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) + [ "$RESULT" = "200" ] \ + && echo " ✓ Storage template configured" \ + || echo " ⚠ Could not set template (HTTP $RESULT) — set manually in Admin → Settings" + else + echo " ⚠ Could not parse config — set storage template manually in Admin → Settings" + fi +else + echo " ⚠ python3 not found — set storage template manually in Admin → Settings" +fi + +# ── Install immich-cli if needed ───────────────────────────────────────────── +echo "" +IMMICH_CMD="" +NODE_OK=false +if command -v node &>/dev/null; then + NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null && NODE_OK=true +fi + +if [ "$NODE_OK" = false ]; then + echo " Immich CLI requires Node.js >= 20 (found: $(node -v 2>/dev/null || echo 'none'))." + read -r -p " Install Node.js 22 LTS now? (y/n): " INSTALL_NODE_YN + if [ "$INSTALL_NODE_YN" = "y" ] || [ "$INSTALL_NODE_YN" = "Y" ]; then + 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 — install Node.js 20+ manually then re-run: $0 $API_KEY" + exit 1 + fi + else + echo " Install Node.js 20+ and re-run: $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." + 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 + IMMICH_CMD="npx --yes @immich/cli" + fi +fi + +[ -z "$IMMICH_CMD" ] && { echo " ✗ No npm/npx found — install manually: npm install -g @immich/cli"; exit 1; } + +# ── Run the import ─────────────────────────────────────────────────────────── +echo "" +echo " Authenticating CLI..." +$IMMICH_CMD login "$IMMICH_URL/api" "$API_KEY" || { echo " ✗ CLI login failed"; exit 1; } + +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! View your photos at: $IMMICH_URL" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +IMPORT_BODY + + chmod +x "$IMMICH_DIR/import-photos.sh" + chown "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR/import-photos.sh" + log_success "Import helper written: $IMMICH_DIR/import-photos.sh" + fi + + log_success "Immich configured at $IMMICH_DIR" + + configure_caddy_for_service "Immich" "2283" "immich" + + write_readme "$IMMICH_DIR" << MD +# Immich + +Self-hosted photo and video backup — like Google Photos but private. +Mobile apps (iOS/Android) auto-upload in the background. + +- Web UI: http://localhost:2283 +- Photo storage: \`$UPLOAD_LOCATION\` +- App data (postgres, model cache): inside this folder +- Edit paths in \`.env\`, then \`docker compose up -d\` to apply. + +## Manage +\`\`\`bash +cd $IMMICH_DIR +docker compose up -d # start all containers +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## First launch +1. Open http://localhost:2283 and create your admin account. +2. Install the Immich mobile app and point it at \`http://:2283\`. +3. (External library mode) Go to Admin → External Libraries → Create Library, + set import path to \`/usr/src/app/external\`, and click Scan. + +## Import existing photos (unified mode) +\`\`\`bash +./import-photos.sh # interactive +./import-photos.sh # skip login, use existing API key +\`\`\` + +## Notes +- Machine learning features (face recognition, CLIP search) require the + \`immich-machine-learning\` container — it pulls a large model on first run. +- The \`.immich\` marker files in the upload subdirs are required by Immich; + do not delete them. +MD + + local START_IMMICH="" + prompt_yn "Start Immich now? (y/n):" "y" START_IMMICH + if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then + docker compose up -d && log_success "Immich started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:2283" + echo " First launch: create your admin account in the web UI." + echo "" +} diff --git a/services/jellyfin.sh b/services/jellyfin.sh new file mode 100644 index 0000000..8211150 --- /dev/null +++ b/services/jellyfin.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# services/jellyfin.sh — Free media server for movies, TV, and music (Jellyfin). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- JELLYFIN ----). +# Lives in its own ~/docker/jellyfin/ with a standalone docker-compose.yml + .env. +# Hardware transcoding (Intel/AMD VAAPI) is auto-enabled when a render node +# (/dev/dri/renderD128) is present on the host. + +register_service jellyfin media "Free media server — movies, TV, music (Jellyfin)" 8096 + +install_jellyfin() { + require_docker || return 1 + + local JELLYFIN_DIR="$DOCKER_DIR/jellyfin" + local DEFAULT_MEDIA="$ACTUAL_HOME/media" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Jellyfin would:" + echo " - Create $JELLYFIN_DIR with docker-compose.yml + .env (config/ cache/)" + echo " - Mount a media folder (default $DEFAULT_MEDIA) read-only at /media" + echo " - Auto-enable VAAPI hw transcoding if /dev/dri/renderD128 exists" + echo " - Expose port 8096 (+ DLNA 1900/udp, discovery 7359/udp)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local MEDIA_PATH="" + prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH + MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}" + + mkdir -p "$JELLYFIN_DIR" + ensure_docker_dir_ownership "$JELLYFIN_DIR" + cd "$JELLYFIN_DIR" || return 1 + + local TZ_VAL; TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + + # Hardware acceleration: only wire /dev/dri through if a render node exists, + # otherwise the container would fail to start on a GPU-less host. + local HWACCEL_BLOCK="" RENDER_GID + if [ -e /dev/dri/renderD128 ]; then + RENDER_GID=$(getent group render | cut -d: -f3 2>/dev/null || echo "989") + HWACCEL_BLOCK=" devices: + - /dev/dri/renderD128:/dev/dri/renderD128 + group_add: + - \"$RENDER_GID\"" + log_success "Render node found — enabling VAAPI hardware transcoding (render gid $RENDER_GID)" + else + log_warning "No /dev/dri/renderD128 — Jellyfin will use CPU transcoding." + fi + + cat > docker-compose.yml << JELLYFIN_COMPOSE +name: jellyfin + +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + hostname: jellyfin + restart: unless-stopped + environment: + - TZ=$TZ_VAL +$HWACCEL_BLOCK + 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" + log_success "Jellyfin configured at $JELLYFIN_DIR" + + configure_caddy_for_service "Jellyfin" "8096" "jellyfin" + + write_readme "$JELLYFIN_DIR" << MD +# Jellyfin + +Free media server (movies, TV, music) — a no-paywall alternative to Emby. + +- Web UI: http://localhost:8096 +- Media folder (read-only): \`$MEDIA_PATH\` → mounted at /media +- App data: \`config/\` and \`cache/\` in this folder +- Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`. + +## Manage +\`\`\`bash +cd $JELLYFIN_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Notes +- Hardware transcoding (Intel/AMD VAAPI) is enabled automatically when + \`/dev/dri/renderD128\` exists on the host; otherwise transcoding is CPU-only. +- First launch: open the web UI and complete the setup wizard, then add your + media libraries pointing at /media. +MD + + local START_JF="" + prompt_yn "Start Jellyfin now? (y/n):" "y" START_JF + if [ "$START_JF" = "y" ] || [ "$START_JF" = "Y" ]; then + docker compose up -d && log_success "Jellyfin started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:8096" + echo "" +} diff --git a/services/linux-to-sync.sh b/services/linux-to-sync.sh new file mode 100644 index 0000000..7b8cf9e --- /dev/null +++ b/services/linux-to-sync.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# services/linux-to-sync.sh — Clone the private linux-to-sync repository. +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- LINUX-TO-SYNC ----). +# Clones outis1one/linux-to-sync to ~/linux-to-sync via SSH or HTTPS+PAT. +# No server/container — this is a personal sync/config repo. + +register_service linux-to-sync extras "Personal sync & config scripts (linux-to-sync private repo)" + +install_linux-to-sync() { + local SYNC_DIR="$ACTUAL_HOME/linux-to-sync" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] linux-to-sync would:" + echo " - Clone outis1one/linux-to-sync to $SYNC_DIR" + echo " - Authenticate via SSH key or GitHub Personal Access Token" + return 0 + fi + + echo "" + echo " Requires access to github.com/outis1one/linux-to-sync" + echo " Authenticate with ONE of:" + echo " [1] SSH key already added to your GitHub account" + echo " [2] GitHub Personal Access Token (PAT)" + echo "" + + local AUTH_METHOD="" + prompt_text "Authentication method [1=SSH, 2=PAT, default: 1]:" "1" AUTH_METHOD + AUTH_METHOD="${AUTH_METHOD:-1}" + + if [ "$AUTH_METHOD" = "2" ]; then + echo "" + echo " Create a PAT at: https://github.com/settings/tokens/new" + echo " Select the 'repo' scope for full repository access." + echo "" + local GH_TOKEN="" + prompt_text "GitHub Personal Access Token:" "" GH_TOKEN + if [ -z "$GH_TOKEN" ]; then + log_warning "No token provided — skipping." + return 0 + fi + + if git clone "https://$GH_TOKEN@github.com/outis1one/linux-to-sync.git" "$SYNC_DIR" 2>/dev/null; then + cd "$SYNC_DIR" || return 1 + # Remove token from remote URL so it isn't stored in plain text + git remote set-url origin "https://github.com/outis1one/linux-to-sync.git" + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" + log_success "linux-to-sync cloned to $SYNC_DIR" + echo " Note: re-enter your token for future push/pull, or:" + echo " git config credential.helper store" + else + log_error "Clone failed — check your token and try again." + return 1 + fi + else + echo "" + echo " Attempting SSH clone (your SSH key must be added to GitHub)..." + if git clone git@github.com:outis1one/linux-to-sync.git "$SYNC_DIR" 2>/dev/null; then + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SYNC_DIR" + log_success "linux-to-sync cloned to $SYNC_DIR" + else + log_error "SSH clone failed." + echo "" + echo " To add your SSH key to GitHub:" + echo " 1. cat ~/.ssh/id_rsa.pub (or id_ed25519.pub)" + echo " 2. github.com/settings/keys → New SSH key → paste" + echo " Then retry: sudo ./setup.sh linux-to-sync" + return 1 + fi + fi + + write_readme "$SYNC_DIR" << MD +# linux-to-sync + +Private personal sync and config repository cloned from outis1one/linux-to-sync. + +## Update +\`\`\`bash +cd $SYNC_DIR +git pull +\`\`\` +MD + + echo "" + echo " Cloned to: $SYNC_DIR" + echo "" +} diff --git a/services/lyrion.sh b/services/lyrion.sh new file mode 100644 index 0000000..ecdea8c --- /dev/null +++ b/services/lyrion.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# services/lyrion.sh — Lyrion Music Server for Squeezebox devices, apps, Chromecast. +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- LYRION MUSIC SERVER ----). +# Uses network_mode: host so UDP discovery (Chromecast, Squeezebox) works without +# manual port-forwarding. Own ~/docker/lyrion/ with compose + .env. + +register_service lyrion media "Music streaming server — Squeezebox, Chromecast (Lyrion)" 9000 + +install_lyrion() { + require_docker || return 1 + + local LYRION_DIR="$DOCKER_DIR/lyrion" + local DEFAULT_MUSIC="$ACTUAL_HOME/music" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Lyrion Music Server would:" + echo " - Create $LYRION_DIR with docker-compose.yml + .env (config/ playlists/)" + echo " - Mount a music folder (default $DEFAULT_MUSIC) read-only at /music" + echo " - Run with network_mode: host (required for Chromecast/Squeezebox UDP discovery)" + echo " - Expose port 9000 (web), 9090 (CLI), 3483 (players)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local MUSIC_PATH="" + prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH + MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}" + + mkdir -p "$LYRION_DIR" + ensure_docker_dir_ownership "$LYRION_DIR" + cd "$LYRION_DIR" || return 1 + + local TZ_VAL UID_VAL GID_VAL + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + + cat > docker-compose.yml << LYRION_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=$UID_VAL + - PGID=$GID_VAL + - TZ=$TZ_VAL + volumes: + - ./config:/config:rw + - \${MUSIC_PATH}:/music:ro + - ./playlists:/playlists:rw + - /etc/localtime:/etc/localtime:ro +LYRION_COMPOSE + + cat > .env << LYRION_ENV +MUSIC_PATH=$MUSIC_PATH +LYRION_ENV + + mkdir -p config playlists + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$LYRION_DIR" + log_success "Lyrion Music Server configured at $LYRION_DIR" + + configure_caddy_for_service "Lyrion" "9000" "lyrion" + + write_readme "$LYRION_DIR" << MD +# Lyrion Music Server + +Stream music to Squeezebox devices, the Squeezer Android/iOS app, and Chromecast. +Formerly known as Logitech Media Server (LMS). + +- Web UI: http://localhost:9000 +- Player port: 3483 (Squeezeboxes / apps) +- CLI port: 9090 +- Music folder (read-only): \`$MUSIC_PATH\` → mounted at /music +- App data: \`config/\` and \`playlists/\` + +## Manage +\`\`\`bash +cd $LYRION_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Notes +- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices + works without manual port mapping. +- Change the music path in \`.env\` (\`MUSIC_PATH=\`), then \`docker compose up -d\`. +- Add music libraries in the web UI under Settings → Music Library. +MD + + local START_LMS="" + prompt_yn "Start Lyrion Music Server now? (y/n):" "y" START_LMS + if [ "$START_LMS" = "y" ] || [ "$START_LMS" = "Y" ]; then + docker compose up -d && log_success "Lyrion started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:9000" + echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery" + echo "" +} diff --git a/services/magicmirror.sh b/services/magicmirror.sh new file mode 100644 index 0000000..e9d0851 --- /dev/null +++ b/services/magicmirror.sh @@ -0,0 +1,171 @@ +#!/bin/bash +# services/magicmirror.sh — Modular smart mirror / info dashboard (MagicMirror²). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- MAGIC MIRROR ----). +# Supports 1-3 instances (ports 8081-8083) each in ~/docker/magicmirror//. +# If you provide an existing config.js, third-party MMM-* modules are detected +# and cloned from GitHub automatically. + +register_service magicmirror utilities "Modular smart mirror / info dashboard (MagicMirror²)" 8081 + +install_magicmirror() { + require_docker || return 1 + + local MM_BASE="$DOCKER_DIR/magicmirror" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] MagicMirror would:" + echo " - Ask how many instances (1-3, ports 8081-8083)" + echo " - Create $MM_BASE// for each instance" + echo " - Optionally copy your existing config.js + clone MMM-* modules" + echo " - Offer a Caddy reverse proxy (first instance) and to start" + return 0 + fi + + # Number of instances + local MM_COUNT="" + prompt_text "How many MagicMirror instances? [1-3, default: 1]:" "1" MM_COUNT + MM_COUNT="${MM_COUNT:-1}" + [ "$MM_COUNT" -gt 3 ] 2>/dev/null && MM_COUNT=3 + [ "$MM_COUNT" -lt 1 ] 2>/dev/null && MM_COUNT=1 + + mkdir -p "$MM_BASE" + chown "$ACTUAL_USER:$ACTUAL_USER" "$MM_BASE" + + local TZ_VAL; TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + local i MM_PORT MM_DIR + + for i in $(seq 1 "$MM_COUNT"); do + MM_PORT=$((8080 + i)) + MM_DIR="$MM_BASE/$i" + + echo "" + echo "── Instance $i (port $MM_PORT) ──" + mkdir -p "$MM_DIR" + ensure_docker_dir_ownership "$MM_DIR" + cd "$MM_DIR" || continue + + 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=$TZ_VAL + 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 + + # Offer to copy an existing config.js + local MM_CONFIG_CHOICE="" + echo "" + echo " Config options:" + echo " [1] Use default config (basic built-in modules)" + echo " [2] Copy existing config.js from a path" + if [ "$UNATTENDED" = true ]; then + MM_CONFIG_CHOICE="1" + else + read -r -p " Choose [1]: " MM_CONFIG_CHOICE + MM_CONFIG_CHOICE="${MM_CONFIG_CHOICE:-1}" + fi + + if [ "$MM_CONFIG_CHOICE" = "2" ]; then + local MM_CONFIG_PATH="" + read -r -p " Path to config.js: " MM_CONFIG_PATH + if [ -f "$MM_CONFIG_PATH" ]; then + cp "$MM_CONFIG_PATH" config/config.js + log_success "Copied config from $MM_CONFIG_PATH" + + # Copy custom.css if it exists next to config.js + local MM_CSS_DIR="${MM_CONFIG_PATH%/*}" + [ -f "$MM_CSS_DIR/custom.css" ] && cp "$MM_CSS_DIR/custom.css" css/custom.css && log_success "Copied custom.css" + + # Detect MMM-* third-party modules referenced in config + local THIRD_PARTY_MODS + THIRD_PARTY_MODS=$(grep -oP "module:\s*[\"']MMM-[^\"']+[\"']" config/config.js 2>/dev/null \ + | sed "s/module:\s*[\"']//g" | sed "s/[\"']//g" | sort -u) + + if [ -n "$THIRD_PARTY_MODS" ]; then + echo "" + echo " Third-party modules found in config:" + echo "$THIRD_PARTY_MODS" | while read -r mod; do echo " - $mod"; done + echo "" + local MM_DL_MODS="" + prompt_yn " Download these modules from GitHub? (y/n):" "y" MM_DL_MODS + if [ "$MM_DL_MODS" = "y" ] || [ "$MM_DL_MODS" = "Y" ]; then + cd modules || true + echo "$THIRD_PARTY_MODS" | while read -r mod; do + [ -z "$mod" ] || [ -d "$mod" ] && continue + echo " Downloading $mod..." + 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 || \ + log_warning "Could not find $mod — search at https://github.com/topics/magicmirror" + done + cd "$MM_DIR" || true + fi + fi + else + log_warning "File not found: $MM_CONFIG_PATH — using default config" + fi + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MM_DIR" + log_success "MagicMirror instance $i configured at $MM_DIR (port $MM_PORT)" + + # Offer Caddy only for first instance + [ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "$MM_PORT" "mirror" + + local START_MM="" + prompt_yn "Start instance $i now? (y/n):" "y" START_MM + if [ "$START_MM" = "y" ] || [ "$START_MM" = "Y" ]; then + docker compose up -d && log_success "MagicMirror instance $i started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:$MM_PORT" + done + + write_readme "$MM_BASE" << MD +# MagicMirror² + +Modular smart mirror / info dashboard. Each instance has its own port and +independent config, modules, and CSS. + +| Instance | Port | Directory | +|----------|------|-----------| +$(for j in $(seq 1 "$MM_COUNT"); do echo "| $j | $((8080 + j)) | \`$MM_BASE/$j/\` |"; done) + +## Manage +\`\`\`bash +cd $MM_BASE/1 +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Config +- Edit \`/config/config.js\` for layout and module settings. +- Add CSS overrides in \`/css/custom.css\`. +- Third-party modules go in \`/modules//\`. + Then run \`docker exec magicmirror-PORT sh -c 'cd /opt/magic_mirror/modules/ && npm install --production'\` + +## Finding modules +Browse: https://github.com/topics/magicmirror +MD + + echo "" + echo " MagicMirror config: $MM_BASE//config/config.js" + echo "" +} diff --git a/services/mealie.sh b/services/mealie.sh new file mode 100644 index 0000000..1db9eb3 --- /dev/null +++ b/services/mealie.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# services/mealie.sh — Recipe manager & meal planner (Mealie). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- MEALIE ----). +# Own ~/docker/mealie/ with a standalone docker-compose.yml. + +register_service mealie utilities "Recipe manager & meal planner (Mealie)" 9925 + +install_mealie() { + require_docker || return 1 + + local MEALIE_DIR="$DOCKER_DIR/mealie" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Mealie would:" + echo " - Create $MEALIE_DIR with docker-compose.yml (data/)" + echo " - Expose port 9925" + echo " - Default login: changeme@email.com / MyPassword (change immediately)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + mkdir -p "$MEALIE_DIR" + ensure_docker_dir_ownership "$MEALIE_DIR" + cd "$MEALIE_DIR" || return 1 + + local TZ_VAL UID_VAL GID_VAL + TZ_VAL=$(cat /etc/timezone 2>/dev/null || echo "UTC") + UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + + 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=$UID_VAL + - PGID=$GID_VAL + - TZ=$TZ_VAL + - 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" + log_success "Mealie configured at $MEALIE_DIR" + + configure_caddy_for_service "Mealie" "9925" "recipes" + + write_readme "$MEALIE_DIR" << MD +# Mealie + +Recipe manager and meal planner — import recipes from any URL, plan meals, +and generate shopping lists. Optional AI-powered recipe parsing. + +- Web UI: http://localhost:9925 +- Default login: changeme@email.com / MyPassword (change immediately!) +- App data: \`data/\` + +## Manage +\`\`\`bash +cd $MEALIE_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Notes +- If using Caddy, update \`BASE_URL\` in \`docker-compose.yml\` to your domain. +MD + + local START_MEALIE="" + prompt_yn "Start Mealie now? (y/n):" "y" START_MEALIE + if [ "$START_MEALIE" = "y" ] || [ "$START_MEALIE" = "Y" ]; then + docker compose up -d && log_success "Mealie started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:9925" + echo " Default: changeme@email.com / MyPassword (change immediately!)" + echo "" +} diff --git a/services/meshcentral.sh b/services/meshcentral.sh new file mode 100644 index 0000000..7dfb25e --- /dev/null +++ b/services/meshcentral.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# services/meshcentral.sh — Self-hosted remote device management server (MeshCentral). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- MESHCENTRAL SERVER ----). +# Own ~/docker/meshcentral/ with a standalone docker-compose.yml + .env. +# HTTPS on port 4430, agent listener on 4433. First visit: create admin account. + +register_service meshcentral utilities "Self-hosted remote device management server (MeshCentral)" 4430 + +install_meshcentral() { + require_docker || return 1 + + local MC_DIR="$DOCKER_DIR/meshcentral" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] MeshCentral would:" + echo " - Create $MC_DIR with docker-compose.yml + .env (data/ files/ backups/)" + echo " - Prompt for hostname (domain or IP for agent connections)" + echo " - Expose port 4430 (HTTPS web) and 4433 (agent)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + local MC_HOSTNAME="" + prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME + MC_HOSTNAME="${MC_HOSTNAME:-localhost}" + + mkdir -p "$MC_DIR" + ensure_docker_dir_ownership "$MC_DIR" + cd "$MC_DIR" || return 1 + + 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 + + cat > .env << MC_ENV +MC_HOSTNAME=$MC_HOSTNAME +MC_REVERSE_PROXY=false +MC_TLS_PORT=443 +MC_ENV + + mkdir -p data files backups + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" + log_success "MeshCentral configured at $MC_DIR" + + configure_caddy_for_service "MeshCentral" "4430" "mesh" + + write_readme "$MC_DIR" << MD +# MeshCentral + +Self-hosted remote device management — remotely access, manage, and monitor +all your computers from a single web interface. Install agents on each device. + +- Web UI: https://localhost:4430 (self-signed cert on first launch) +- Agent listener: port 4433 (devices connect here — forward this port if remote) +- Hostname: \`$MC_HOSTNAME\` (update \`MC_HOSTNAME\` in .env if it changes) +- App data: \`data/\`, \`files/\`, \`backups/\` + +## Manage +\`\`\`bash +cd $MC_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## First launch +1. Open https://localhost:4430 (accept the self-signed cert warning) +2. Create your admin account +3. Go to "My Devices" → "+ Add Device" → download the agent for each OS +4. Install the agent on every computer you want to manage + +## Remote access +For devices outside your LAN to connect: +- Forward **TCP port 4433** on your router to this server +- Set \`MC_HOSTNAME\` in \`.env\` to your public domain/IP, then restart + +## Docs +https://meshcentral.com/docs/ +MD + + local START_MC="" + prompt_yn "Start MeshCentral now? (y/n):" "y" START_MC + if [ "$START_MC" = "y" ] || [ "$START_MC" = "Y" ]; then + docker compose up -d && log_success "MeshCentral started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: https://localhost:4430 (accept self-signed cert)" + echo " First visit: create your admin account" + echo "" +} diff --git a/services/silent-send.sh b/services/silent-send.sh new file mode 100644 index 0000000..c6e6d11 --- /dev/null +++ b/services/silent-send.sh @@ -0,0 +1,212 @@ +#!/bin/bash +# services/silent-send.sh — Silent Send browser extension (PII redaction for AI chat). +# Part of the modular post-install system (sourced by setup.sh). +# +# NON-DOCKER module. Silent Send is a browser extension (Chrome/Brave/Firefox/ +# Safari) that intercepts personal info before it's sent to AI chatbots and +# swaps in user-defined substitutes — entirely client-side, no server/container. +# +# Because it ships as source you load into a browser, this module: +# 1. installs the build toolchain (git, Node.js >= 18, npm), +# 2. clones the repo to ~/silent-send (or a path you choose), +# 3. runs `npm install` so the Firefox build/sign tooling (web-ext) is ready, +# 4. optionally builds a signed Firefox .xpi (needs free Mozilla API creds), +# 5. prints exactly how to load/build it in each browser. +# +# Source: https://github.com/outis1one/silent-send + +register_service silent-send extras "Browser extension: redact PII before it reaches AI chatbots" + +install_silent-send() { + # Non-docker — no require_docker. + local SS_REPO="https://github.com/outis1one/silent-send.git" + local SS_DIR="$ACTUAL_HOME/silent-send" + + cat << "EOF" +╔═══════════════════════════════════════════════════════╗ +║ ║ +║ SILENT SEND ║ +║ Redact PII before it reaches AI chatbots ║ +║ (browser extension — Chrome / Brave / Firefox) ║ +║ ║ +╚═══════════════════════════════════════════════════════╝ +EOF + echo "" + echo " A browser extension that intercepts names, emails, secrets and other" + echo " personal data before it's sent to AI services, swapping in your own" + echo " substitutes. All client-side — there is no server or container." + echo "" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] silent-send install would:" + echo " - Install build deps: git, Node.js >= 18 (NodeSource 22.x), npm" + echo " - Clone $SS_REPO into $SS_DIR (or update if already present)" + echo " - Run 'npm install' in $SS_DIR (Firefox build/sign tooling: web-ext)" + echo " - Optionally build a signed Firefox .xpi (needs Mozilla API creds)" + echo " - Print load-unpacked + build/sign instructions for each browser" + return 0 + fi + + # ── Clone location ──────────────────────────────────────────────────────── + prompt_text " Clone location [$SS_DIR]:" "$SS_DIR" SS_DIR + SS_DIR="${SS_DIR/#\~/$ACTUAL_HOME}" + SS_DIR="${SS_DIR%/}" + + # ── 1. git ───────────────────────────────────────────────────────────────── + if ! command -v git >/dev/null 2>&1; then + log_info "Installing git..." + apt-get install -y git >/dev/null 2>&1 || { log_error "Failed to install git."; return 1; } + fi + log_success "git: $(git --version | awk '{print $3}')" + + # ── 2. Node.js >= 18 + npm (needed to build/sign for Firefox & Safari) ────── + local NODE_MAJOR=0 + command -v node >/dev/null 2>&1 && NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + if ! [ "$NODE_MAJOR" -ge 18 ] 2>/dev/null; then + log_warning "Node.js >= 18 required for building/signing (found: $(node -v 2>/dev/null || echo none))." + local INSTALL_NODE="" + prompt_yn " Install Node.js 22 LTS from NodeSource now? (y/n):" "y" INSTALL_NODE + if [ "$INSTALL_NODE" = "y" ] || [ "$INSTALL_NODE" = "Y" ]; then + log_info "Installing Node.js 22 LTS..." + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - >/dev/null 2>&1 + apt-get install -y nodejs >/dev/null 2>&1 + NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1) + fi + fi + if [ "$NODE_MAJOR" -ge 18 ] 2>/dev/null; then + log_success "Node.js: $(node -v) npm: $(npm -v 2>/dev/null || echo '?')" + else + log_warning "Node.js < 18 — Chrome 'load unpacked' still works, but the Firefox" + log_warning "signed build won't. Install Node 18+ later, then run 'npm install' in $SS_DIR." + fi + + # ── 3. Clone / update the repo ────────────────────────────────────────────── + if [ -d "$SS_DIR/.git" ]; then + log_info "Updating existing checkout in $SS_DIR..." + git -C "$SS_DIR" pull --ff-only || log_warning "Could not fast-forward — keeping current checkout." + else + log_info "Cloning $SS_REPO → $SS_DIR..." + git clone --depth 1 "$SS_REPO" "$SS_DIR" || { log_error "Clone failed."; return 1; } + fi + log_success "Source ready at $SS_DIR" + + # ── 4. npm install (build/sign tooling: web-ext) ──────────────────────────── + if command -v npm >/dev/null 2>&1; then + log_info "Installing npm dependencies (this readies the Firefox build/sign tooling)..." + ( cd "$SS_DIR" && npm install ) \ + || log_warning "npm install reported errors — Chrome 'load unpacked' still works without it." + fi + + # ── 5. Optional: build a signed Firefox .xpi ──────────────────────────────── + # Needs free Mozilla API credentials. Skipped in unattended mode. + if [ "$UNATTENDED" != true ] && [ "$NODE_MAJOR" -ge 18 ] 2>/dev/null; then + echo "" + local DO_FF="" + prompt_yn " Build a signed Firefox .xpi now? (needs free Mozilla API creds) (y/n):" "n" DO_FF + if [ "$DO_FF" = "y" ] || [ "$DO_FF" = "Y" ]; then + echo "" + echo " Get credentials (free) at:" + echo " https://addons.mozilla.org/developers/addon/api/key/" + echo "" + local FF_KEY="" FF_SECRET="" + prompt_text " WEB_EXT_API_KEY (e.g. user:12345678:901), blank to skip:" "" FF_KEY + prompt_text " WEB_EXT_API_SECRET, blank to skip:" "" FF_SECRET + if [ -n "$FF_KEY" ] && [ -n "$FF_SECRET" ]; then + cat > "$SS_DIR/.env" << ENVEOF +WEB_EXT_API_KEY="$FF_KEY" +WEB_EXT_API_SECRET="$FF_SECRET" +ENVEOF + chmod 600 "$SS_DIR/.env" + log_info "Signing (first run takes 1-5 min)..." + if ( cd "$SS_DIR" && npm run sign:firefox ); then + log_success "Signed .xpi written to $SS_DIR/dist/firefox-signed/" + else + log_warning "Signing failed — check the output above. You can retry: (cd $SS_DIR && npm run sign:firefox)" + fi + else + echo " Skipping Firefox signing (no credentials entered)." + fi + fi + fi + + # ── 6. Hand the checkout back to the user ─────────────────────────────────── + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$SS_DIR" 2>/dev/null || true + + # ── 7. README ─────────────────────────────────────────────────────────────── + write_readme "$SS_DIR" << MD +# Silent Send + +Browser extension that intercepts personal information (names, emails, +usernames, hostnames, phone numbers, API keys, tokens, SSNs, credit cards…) +before it's sent to AI chatbots, swapping in your own substitutes. Everything +runs client-side in the browser — there is no server or container. + +Source checkout: \`$SS_DIR\` · Upstream: https://github.com/outis1one/silent-send + +## Load it in your browser + +### Chrome / Brave (no build needed) +1. Open \`chrome://extensions/\` (or \`brave://extensions/\`) +2. Turn on **Developer mode** (top-right) +3. **Load unpacked** → select \`$SS_DIR\` +4. Refresh any open AI chat tabs after code updates + +### Firefox (signed, persistent) +A signed \`.xpi\` is required for a permanent install: +\`\`\`bash +cd $SS_DIR +cp .env.example .env # then add your Mozilla API key/secret +npm run sign:firefox # → dist/firefox-signed/*.xpi +\`\`\` +Open the \`.xpi\` in Firefox (File → Open File) to install. +Temporary test session (no signing): \`npm run run:firefox\`. + +### Safari (macOS only) +\`\`\`bash +npm install && ./build-safari.sh +open "safari-build/Silent Send.xcodeproj" # then Product → Run in Xcode +\`\`\` + +## Updating +\`\`\`bash +cd $SS_DIR && git pull && npm install +\`\`\` +Then reload the extension (Chrome/Brave: the reload icon on the extensions +page; Firefox: re-sign and re-open the new .xpi). + +## Configure +Use the extension's popup/options UI to set your identity, substitution +mappings, custom domains, and to import/export your config. +MD + + # ── 8. Summary ────────────────────────────────────────────────────────────── + echo "" + echo "═══════════════════════════════════════════════════════" + echo " SILENT SEND — READY" + echo "═══════════════════════════════════════════════════════" + echo "" + echo " Source checkout: $SS_DIR" + echo "" + + # Note which browsers are present to point the user at the right steps. + local _found=() + command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1 && _found+=("Chrome") + command -v brave-browser >/dev/null 2>&1 && _found+=("Brave") + command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1 && _found+=("Chromium") + command -v firefox >/dev/null 2>&1 && _found+=("Firefox") + if [ "${#_found[@]}" -gt 0 ]; then + echo " Browsers detected on this machine: ${_found[*]}" + else + echo " No browser detected here — load the extension on whichever machine" + echo " has your browser (the checkout above is what you point it at)." + fi + echo "" + echo " Chrome / Brave → chrome://extensions → Developer mode → Load unpacked" + echo " → select $SS_DIR" + echo " Firefox → cd $SS_DIR && cp .env.example .env (add Mozilla creds)" + echo " → npm run sign:firefox → open dist/firefox-signed/*.xpi" + echo "" + echo " Full instructions: $SS_DIR/README.md" + echo "" + log_success "Silent Send installed. Load it in your browser to start redacting." +} diff --git a/services/traccar.sh b/services/traccar.sh new file mode 100644 index 0000000..0a03bdd --- /dev/null +++ b/services/traccar.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# services/traccar.sh — GPS tracking server (Traccar). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- TRACCAR ----). +# Own ~/docker/traccar/ with a standalone docker-compose.yml + config XML. + +register_service traccar utilities "GPS tracking server — phones, vehicles, assets (Traccar)" 8082 + +install_traccar() { + require_docker || return 1 + + local TRACCAR_DIR="$DOCKER_DIR/traccar" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Traccar would:" + echo " - Create $TRACCAR_DIR with docker-compose.yml + config/traccar.xml" + echo " - Expose port 8082 (web) and 5000-5150 (device protocols)" + echo " - Default login: admin@admin.com / admin (change immediately!)" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + mkdir -p "$TRACCAR_DIR" + ensure_docker_dir_ownership "$TRACCAR_DIR" + cd "$TRACCAR_DIR" || return 1 + + 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 + + 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" + log_success "Traccar configured at $TRACCAR_DIR" + + configure_caddy_for_service "Traccar" "8082" "traccar" + + write_readme "$TRACCAR_DIR" << MD +# Traccar + +GPS tracking server. Track phones, vehicles, and assets via the Traccar +Android/iOS app, OwnTracks, or any of 200+ supported device protocols. + +- Web UI: http://localhost:8082 +- Default login: admin@admin.com / admin (change immediately!) +- Device protocols: ports 5000-5150 (TCP + UDP) +- Config: \`config/traccar.xml\` +- App data: \`data/\` and \`logs/\` + +## Manage +\`\`\`bash +cd $TRACCAR_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Mobile apps +- Traccar Client (Android/iOS): set server to \`http://YOUR-IP:8082\` +- OwnTracks (Android/iOS): configure HTTP endpoint to Traccar +MD + + local START_TRACCAR="" + prompt_yn "Start Traccar now? (y/n):" "y" START_TRACCAR + if [ "$START_TRACCAR" = "y" ] || [ "$START_TRACCAR" = "Y" ]; then + docker compose up -d && log_success "Traccar started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Access at: http://localhost:8082" + echo " Default: admin@admin.com / admin (change immediately!)" + echo "" +} diff --git a/services/wg-easy.sh b/services/wg-easy.sh new file mode 100644 index 0000000..422ef03 --- /dev/null +++ b/services/wg-easy.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# services/wg-easy.sh — WireGuard VPN with a web management UI (wg-easy). +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- WG-EASY ----). +# Own ~/docker/wg-easy/ with a standalone docker-compose.yml + .env. +# Requires cap_add: NET_ADMIN + SYS_MODULE and ip_forward sysctl. +# Forward UDP 51820 on your router to this server for external VPN access. + +register_service wg-easy utilities "WireGuard VPN with web management UI (wg-easy)" 51821 + +install_wg-easy() { + require_docker || return 1 + + local WGEASY_DIR="$DOCKER_DIR/wg-easy" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] wg-easy would:" + echo " - Create $WGEASY_DIR with docker-compose.yml + .env (config/)" + echo " - Auto-detect public IP for WG_HOST" + echo " - Generate a random web UI password" + echo " - Expose port 51821 (web UI) + 51820/udp (VPN)" + echo " - Require router port-forward: UDP 51820 → this server" + echo " - Offer a Caddy reverse proxy and to start the container" + return 0 + fi + + mkdir -p "$WGEASY_DIR" + ensure_docker_dir_ownership "$WGEASY_DIR" + cd "$WGEASY_DIR" || return 1 + + # Auto-detect public IP as default for WG_HOST + local PUBLIC_IP WG_HOST WG_PASSWORD + PUBLIC_IP=$(curl -s --connect-timeout 5 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 [$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" + log_success "wg-easy configured at $WGEASY_DIR" + + configure_caddy_for_service "wg-easy" "51821" "vpn" + + write_readme "$WGEASY_DIR" << MD +# wg-easy + +WireGuard VPN with a web UI for managing clients, generating QR codes, +and monitoring connections. + +- Web UI: http://localhost:51821 +- VPN: UDP port 51820 (forward this on your router) +- Password: stored in \`.env\` (\`WG_PASSWORD\`) +- VPN host: \`$WG_HOST\` (update \`WG_HOST\` in .env if your IP changes) +- Config: \`config/\` + +## Manage +\`\`\`bash +cd $WGEASY_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` + +## Router setup +Forward **UDP port 51820** to this server's LAN IP for external VPN access. + +## Adding clients +Open http://localhost:51821, log in with your password, click "+ New Client", +download or scan the QR code with the WireGuard app. +MD + + local START_WGEASY="" + prompt_yn "Start wg-easy now? (y/n):" "y" START_WGEASY + if [ "$START_WGEASY" = "y" ] || [ "$START_WGEASY" = "Y" ]; then + docker compose up -d && log_success "wg-easy started" || log_warning "Failed to start — check: docker compose logs" + fi + + echo "" + echo " Web UI: http://localhost:51821" + echo " Password: $WG_PASSWORD (saved in .env)" + echo " Router: forward UDP 51820 → this server for external VPN access" + echo "" +} diff --git a/services/wolf-pair.sh b/services/wolf-pair.sh new file mode 100644 index 0000000..c6fc969 --- /dev/null +++ b/services/wolf-pair.sh @@ -0,0 +1,315 @@ +#!/bin/bash +# services/wolf-pair.sh — Moonlight pairing web UI for Wolf. +# Part of the modular post-install system (sourced by setup.sh). +# +# Builds a tiny Python HTTP container (server.py + Dockerfile baked below) +# that watches Wolf's docker logs for pairing secrets and serves a PIN entry +# form on port 8090. No command line needed: visit the URL, type the PIN. +# +# The container runs with network_mode: host so that server.py can reach +# Wolf's pairing API at http://localhost:47989 and tail `docker logs wolf` +# via the mounted docker socket. + +register_service wolf-pair gaming "Moonlight pairing web UI for Wolf" 8090 + +install_wolf-pair() { + require_docker || return 1 + + local WOLFPAIR_DIR="$DOCKER_DIR/wolf-pair" + local WOLFPAIR_PORT=8090 + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] wolf-pair install would:" + echo " - Create $WOLFPAIR_DIR with server.py, Dockerfile, docker-compose.yml" + echo " - Build the wolf-pair image (python:3.12-alpine + docker-cli)" + echo " - Run the container with network_mode: host (for localhost:47989 access)" + echo " - Mount /var/run/docker.sock:ro (for docker logs wolf)" + echo " - Open port $WOLFPAIR_PORT in UFW" + echo " - Optionally configure a Caddy reverse proxy" + return 0 + fi + + mkdir -p "$WOLFPAIR_DIR" + ensure_docker_dir_ownership "$WOLFPAIR_DIR" + cd "$WOLFPAIR_DIR" || return 1 + + # ── 1. server.py ────────────────────────────────────────────────────────── + log_info "Writing server.py..." + cat > "$WOLFPAIR_DIR/server.py" << 'PYEOF' +#!/usr/bin/env python3 +""" +wolf-pair — single-backend pairing helper for Wolf/Moonlight. + +GET / → if a fresh pairing secret is pending: serve a PIN form. + if none: serve a waiting page that auto-refreshes. +POST / → take the PIN from the form, attach the freshest secret read from + Wolf's logs, and proxy {pin, secret} to Wolf's /pin/ endpoint. + +Wolf's pairing secrets are SINGLE-USE: Wolf erases a secret from its map the +instant any PIN is submitted for it (correct or not). Because the secret stays +in `docker logs` forever, we must never re-offer a secret we've already +submitted — otherwise the user resubmits a dead secret and Wolf returns +"key not found". We track submitted secrets and fall back to the waiting page +until Moonlight initiates a brand-new pairing (which mints a new secret). +""" +import json, subprocess, re, urllib.request, urllib.error +from http.server import HTTPServer, BaseHTTPRequestHandler + +WOLF_HTTP = "http://localhost:47989" + +# Secrets already submitted to Wolf. Wolf erases a secret on first submit, so a +# secret in here is dead — show the waiting page instead of re-offering it. +_submitted_hashes: set = set() + +PIN_LOG_RE = re.compile(r'Insert pin at http://\S+/pin/#([0-9A-Fa-f]+)') + +HTML_WAITING = b""" + + + +Wolf Pairing + + +

No pairing request yet

+

In Moonlight, add this server, then return here.
+This page refreshes automatically every 3 seconds.

+""" + + +def parse_latest_hash(log_text): + """Return the most recent pairing secret found in log text, or None.""" + matches = PIN_LOG_RE.findall(log_text) + return matches[-1] if matches else None + + +def latest_hash(): + """Freshest pairing secret that hasn't been submitted yet, or None.""" + try: + r = subprocess.run( + ['docker', 'logs', '--tail', '200', 'wolf'], + capture_output=True, text=True, timeout=5) + h = parse_latest_hash(r.stdout + r.stderr) + return h if (h and h not in _submitted_hashes) else None + except Exception: + return None + + +def build_pin_form(secret): + """Self-contained PIN form. Submits only the PIN; the server attaches the + secret at POST time so a stale page can't send an already-used secret.""" + body = f""" + + + +Wolf Pairing + + +

Moonlight Pairing

+

Enter the 4-digit PIN shown in Moonlight

+ + +
+ +""" + return body.encode('utf-8') + + +def send_response_body(handler, status, content_type, body): + handler.send_response(status) + handler.send_header('Content-Type', content_type) + handler.send_header('Content-Length', str(len(body))) + handler.send_header('Connection', 'close') + handler.end_headers() + handler.wfile.write(body) + + +class Handler(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def do_GET(self): + secret = latest_hash() + if secret: + send_response_body(self, 200, 'text/html; charset=utf-8', build_pin_form(secret)) + return + send_response_body(self, 200, 'text/html; charset=utf-8', HTML_WAITING) + + def do_POST(self): + # Read the freshest secret NOW (not whatever a stale page baked in). + secret = latest_hash() + length = int(self.headers.get('Content-Length', 0)) + raw = self.rfile.read(length) if length else b'' + + if not secret: + send_response_body(self, 409, 'text/plain; charset=utf-8', + ('No active pairing request. In Moonlight, add this host again ' + 'to start a fresh pairing, then enter the new PIN here.').encode()) + return + + try: + pin = str(json.loads(raw).get('pin', '')).strip() + except Exception: + pin = '' + + payload = json.dumps({'pin': pin, 'secret': secret}).encode() + req = urllib.request.Request(WOLF_HTTP + '/pin/', data=payload, + headers={'Content-Type': 'application/json'}) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = resp.read() + _submitted_hashes.add(secret) # consumed by Wolf — never reuse + send_response_body(self, resp.status, + resp.headers.get('Content-Type', 'text/plain'), data) + except urllib.error.HTTPError as e: + if e.code == 400: + # Secret wasn't in Wolf's map (expired/already used) — retire it. + _submitted_hashes.add(secret) + send_response_body(self, 400, 'text/plain; charset=utf-8', + ('This pairing request expired or was already used. ' + 'Re-add the host in Moonlight and enter the new PIN.').encode()) + else: + send_response_body(self, e.code, 'text/plain; charset=utf-8', + ('Wolf returned an error (%s). Try again.' % e.code).encode()) + except Exception: + send_response_body(self, 502, 'text/plain; charset=utf-8', + b'Could not reach Wolf. Is the wolf container running?') + + def log_message(self, *a): pass + + +if __name__ == '__main__': + HTTPServer(('0.0.0.0', 8090), Handler).serve_forever() +PYEOF + log_success "server.py written" + + # ── 2. Dockerfile ───────────────────────────────────────────────────────── + log_info "Writing Dockerfile..." + cat > "$WOLFPAIR_DIR/Dockerfile" << 'DOCKERFILE' +FROM python:3.12-alpine +RUN apk add --no-cache docker-cli +WORKDIR /app +COPY server.py . +CMD ["python3", "server.py"] +DOCKERFILE + log_success "Dockerfile written" + + # ── 3. docker-compose.yml ───────────────────────────────────────────────── + # network_mode: host — server.py reaches Wolf at localhost:47989 directly. + # Docker socket (ro) — server.py calls `docker logs wolf` to read secrets. + log_info "Writing docker-compose.yml..." + cat > "$WOLFPAIR_DIR/docker-compose.yml" << 'COMPOSE' +name: wolf-pair + +services: + wolf-pair: + build: + context: . + dockerfile: Dockerfile + container_name: wolf-pair + network_mode: host + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped +COMPOSE + log_success "docker-compose.yml written" + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR" + + # ── 4. Firewall ─────────────────────────────────────────────────────────── + if command -v ufw &>/dev/null; then + ufw allow "${WOLFPAIR_PORT}/tcp" comment "wolf-pair pairing UI" >/dev/null 2>&1 || true + log_success "UFW: opened port $WOLFPAIR_PORT/tcp" + fi + + # ── 5. Caddy (optional) ─────────────────────────────────────────────────── + configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" + + # ── 6. README ───────────────────────────────────────────────────────────── + write_readme "$WOLFPAIR_DIR" << 'MD' +# wolf-pair + +Browser-based Moonlight pairing helper for Wolf. + +Visit `http://:8090` when Moonlight shows a pairing PIN — the page +detects the pending request automatically (auto-refreshes every 3 s while +waiting) and lets you type the PIN without running any CLI commands. + +## How it works + +1. In Moonlight, add this server → a 4-digit PIN appears. +2. Open `http://:8090` in any browser. +3. The page shows a PIN form — type the PIN and press **Pair**. + +The server reads Wolf's docker logs for the current pairing secret, submits +`{pin, secret}` to Wolf's `/pin/` API, and marks the secret as used so a +stale browser tab can never re-submit a dead secret. + +## Manage + +```bash +cd ~/docker/wolf-pair +docker compose up -d # start +docker compose down # stop +docker compose up -d --build # rebuild after source changes +docker compose logs -f # follow logs +``` + +## Notes + +- Requires the Wolf container (`wolf`) to be running. +- Moonlight's actual video/audio stream is direct UDP/TCP to the server IP + and cannot be proxied — only the pairing page goes through wolf-pair. +- If you set up a Caddy subdomain (e.g. `wolf-pair.yourdomain.com`), that + subdomain is for the PIN form only. +MD + + # ── 7. Build & start ────────────────────────────────────────────────────── + echo "" + log_success "wolf-pair configured at $WOLFPAIR_DIR" + echo "" + local START_WOLFPAIR="" + prompt_yn "Build and start wolf-pair now? (y/n):" "y" START_WOLFPAIR + if [ "$START_WOLFPAIR" = "y" ] || [ "$START_WOLFPAIR" = "Y" ]; then + log_info "Building wolf-pair (python:3.12-alpine + docker-cli)..." + if docker compose up -d --build; then + log_success "wolf-pair started" + else + log_warning "Build failed — check: docker compose logs" + return 1 + fi + fi + + echo "" + echo " Pairing UI: http://localhost:${WOLFPAIR_PORT}" + echo " When Moonlight shows a PIN, open that URL and enter it." + echo "" +} diff --git a/setup.sh b/setup.sh index 1ce817d..ed6fa9b 100755 --- a/setup.sh +++ b/setup.sh @@ -20,7 +20,7 @@ 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 backup) +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 ) @@ -79,6 +79,8 @@ is_installed() { 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 }