Merge pull request #38 from outis1one/claude/dazzling-mendel-J4fi4

feat(gaming): add wolf-pair module — browser PIN form for Moonlight p…
This commit is contained in:
Outis
2026-06-03 14:58:46 -04:00
committed by GitHub
23 changed files with 3137 additions and 16 deletions
+104
View File
@@ -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 The project is pre-1.0 while the modular system reaches parity with the
monolithic `ubuntu-post-install-*.sh` scripts. 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/<N>/`.
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://<server>: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 ## [0.9.7] - 2026-06-03
### Added ### Added
+17 -14
View File
@@ -72,7 +72,10 @@ single shared compose file.
## Groups ## 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 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 cancel option, then offers **Caddy** (most services proxy through it), then a
**category menu you loop through** — pick a category, tick services (already **category menu you loop through** — pick a category, tick services (already
@@ -82,17 +85,17 @@ ordered first.
## Migration status ## Migration status
This is an incremental migration. The big `ubuntu-post-install-*-crowdsec.sh` Migration is complete. `setup.sh` + `services/` now cover every service
script remains the current "install everything" entry point until the modules from `ubuntu-post-install-*-crowdsec.sh` plus several extras. The monolith
reach parity, at which point it is retired (like the `original` and is retained as a frozen evolution record.
`-no-keycloak` tiers, which stay frozen as the evolution record).
| Module | Status | | Group | Modules |
|--------|--------| |-------|---------|
| `base` (incl. glow) | ✅ done | | `base` | `base`, `glow` |
| `homeassistant` | ✅ done | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` |
| `minecraft` (multi-instance, rich) | ⏳ porting from `setupminecraft.sh` | | `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `magicmirror`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `traccar`, `uptimekuma`, `watchtower`, `wg-easy` |
| `wolf` (gaming) | ⏳ porting from `setupwolf.sh` | | `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` |
| `js99er` (gaming) | ⏳ porting from `setupjs99er.sh` | | `cameras` | `frigate`, `frigate-notify` |
| `backup` (Kopia, cross-cutting) | ⏳ porting from `setupbackup.sh` | | `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
| remaining ~65 services | ⏳ migrate from the monolith incrementally | | `extras` | `linux-to-sync`, `silent-send` |
| `backup` | `backup` |
+1 -1
View File
@@ -1 +1 @@
0.9.7 1.0.0
+87
View File
@@ -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 ""
}
+134
View File
@@ -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 ""
}
+98
View File
@@ -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 ""
}
+131
View File
@@ -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 ""
}
+107
View File
@@ -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 ""
}
+96
View File
@@ -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 ""
}
+135
View File
@@ -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 ""
}
+170
View File
@@ -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 ""
}
+610
View File
@@ -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 <api-key> # 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 <api-key>"
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 <api-key>"
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 <api-key>"
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 <api-key>"
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://<server-ip>: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 <key> # 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 ""
}
+119
View File
@@ -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 ""
}
+88
View File
@@ -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 ""
}
+109
View File
@@ -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 ""
}
+171
View File
@@ -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/<N>/.
# 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/<N>/ 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 \`<instance>/config/config.js\` for layout and module settings.
- Add CSS overrides in \`<instance>/css/custom.css\`.
- Third-party modules go in \`<instance>/modules/<module-name>/\`.
Then run \`docker exec magicmirror-PORT sh -c 'cd /opt/magic_mirror/modules/<name> && npm install --production'\`
## Finding modules
Browse: https://github.com/topics/magicmirror
MD
echo ""
echo " MagicMirror config: $MM_BASE/<instance>/config/config.js"
echo ""
}
+94
View File
@@ -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 ""
}
+116
View File
@@ -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 ""
}
+212
View File
@@ -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."
}
+104
View File
@@ -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'
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE properties SYSTEM 'http://java.sun.com/dtd/properties.dtd'>
<properties>
<entry key='config.default'>./conf/default.xml</entry>
<entry key='database.driver'>org.h2.Driver</entry>
<entry key='database.url'>jdbc:h2:/opt/traccar/data/database</entry>
<entry key='database.user'>sa</entry>
<entry key='database.password'></entry>
</properties>
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 ""
}
+116
View File
@@ -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 ""
}
+315
View File
@@ -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"""<!DOCTYPE html>
<html><head>
<meta http-equiv="refresh" content="3">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Wolf Pairing</title>
<style>
body{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:100vh;margin:0;
background:linear-gradient(132deg,#720082,#3f00c3,#0047ff);color:#fff}
h2{font-size:1.6rem;margin-bottom:.5rem}p{opacity:.85;margin:.3rem 0;text-align:center}
</style>
</head><body>
<h2>No pairing request yet</h2>
<p>In Moonlight, add this server, then return here.<br>
This page refreshes automatically every 3 seconds.</p>
</body></html>"""
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"""<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Wolf Pairing</title>
<style>
body{{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:100vh;margin:0;
background:linear-gradient(132deg,#720082,#3f00c3,#0047ff);color:#fff}}
h2{{font-size:1.6rem;margin-bottom:.5rem}}
p{{opacity:.85;margin:.3rem 0;text-align:center}}
input{{font-size:2rem;width:5.5rem;text-align:center;padding:.4rem;
border:none;border-radius:.4rem;letter-spacing:.3rem}}
button{{margin-top:1rem;font-size:1.1rem;padding:.5rem 1.8rem;border:none;
border-radius:.4rem;cursor:pointer;background:#fff;color:#3f00c3;font-weight:bold}}
#msg{{margin-top:1rem;min-height:1.4em;max-width:22rem;text-align:center}}
</style>
</head><body>
<h2>Moonlight Pairing</h2>
<p>Enter the 4-digit PIN shown in Moonlight</p>
<input id="pin" type="text" inputmode="numeric" maxlength="4" autofocus autocomplete="off">
<button onclick="submitPin()">Pair</button>
<div id="msg"></div>
<script>
function submitPin() {{
var pin = document.getElementById('pin').value.trim();
var msg = document.getElementById('msg');
if (!/^\\d{{4}}$/.test(pin)) {{ msg.textContent = 'Enter the 4-digit PIN from Moonlight'; return; }}
msg.textContent = 'Pairing…';
fetch('/', {{method:'POST',
headers:{{'Content-Type':'application/json'}},
body:JSON.stringify({{pin:pin}})
}}).then(function(r){{return r.text().then(function(t){{return {{ok:r.ok,body:t}}}});}})
.then(function(r){{msg.textContent = r.ok ? 'Paired! You can close this page.' : r.body;}})
.catch(function(){{msg.textContent='Network error — try again.';}});
}}
document.getElementById('pin').addEventListener('keydown',function(e){{if(e.key==='Enter')submitPin();}});
</script>
</body></html>"""
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://<server-ip>: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://<server-ip>: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 ""
}
+3 -1
View File
@@ -20,7 +20,7 @@ set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Category display order (groups not listed here are appended alphabetically). # 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. # Service ordering hint within a category (lower = earlier). Default 50.
declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 ) declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 )
@@ -79,6 +79,8 @@ is_installed() {
base) command -v ncdu >/dev/null 2>&1 ;; base) command -v ncdu >/dev/null 2>&1 ;;
glow) command -v glow >/dev/null 2>&1 ;; glow) command -v glow >/dev/null 2>&1 ;;
crowdsec) command -v cscli >/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" ] ;; *) [ -e "$DOCKER_DIR/$1" ] ;;
esac esac
} }