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:
+104
@@ -4,6 +4,110 @@ All notable changes to this project. Versions follow `MAJOR.MINOR.PATCH`.
|
||||
The project is pre-1.0 while the modular system reaches parity with the
|
||||
monolithic `ubuntu-post-install-*.sh` scripts.
|
||||
|
||||
## [1.0.0] - 2026-06-03
|
||||
|
||||
### Milestone: full parity with the monolith
|
||||
|
||||
Every service from `ubuntu-post-install-24.04-crowdsec.sh` is now a module.
|
||||
The modular system (`setup.sh` + `services/`) is the primary install path.
|
||||
The monolith is retained as a frozen evolution record.
|
||||
|
||||
### Added
|
||||
- `services/linux-to-sync.sh` *(extras)* — clones the private
|
||||
`outis1one/linux-to-sync` repository to `~/linux-to-sync` via SSH key or
|
||||
GitHub PAT (PAT is stripped from the remote URL after clone for security).
|
||||
`is_installed` marker checks `~/linux-to-sync/.git`.
|
||||
- Updated `MODULAR.md` migration table to show the completed module inventory
|
||||
grouped by category.
|
||||
|
||||
## [0.9.11] - 2026-06-03
|
||||
|
||||
### Added
|
||||
- **Utilities batch** — 8 service modules migrated from the monolith:
|
||||
- `services/mealie.sh` *(utilities)* — Recipe manager & meal planner. PUID/PGID baked;
|
||||
default creds noted (change immediately). Port 9925 → internal 9000.
|
||||
- `services/actualbudget.sh` *(utilities)* — Open-source personal finance (Actual Budget).
|
||||
Minimal container; bank sync via SimpleFIN optional. Port 5006.
|
||||
- `services/traccar.sh` *(utilities)* — GPS tracking server for phones, vehicles, assets.
|
||||
Ships a starter `config/traccar.xml` with H2 embedded DB. Port 8082 + 5000-5150 device
|
||||
protocols (TCP+UDP).
|
||||
- `services/fmd.sh` *(utilities)* — FindMyDevice server for Android. Generates a random
|
||||
admin password; mobile app from F-Droid (not Play Store). Port 8084.
|
||||
- `services/ddclient.sh` *(utilities)* — Dynamic DNS updater; no web UI. Ships a
|
||||
`config/ddclient.conf` template covering Cloudflare, DuckDNS, No-IP. Default start
|
||||
prompt is "n" — edit config first.
|
||||
- `services/wg-easy.sh` *(utilities)* — WireGuard VPN with web UI. Auto-detects public
|
||||
IP for `WG_HOST`; generates random password; requires `NET_ADMIN` + `SYS_MODULE` caps
|
||||
and `ip_forward` sysctl. Ports 51820/udp (VPN) + 51821/tcp (web).
|
||||
- `services/meshcentral.sh` *(utilities)* — Self-hosted remote device management server.
|
||||
Prompts for hostname (domain/IP for agent connections). Ports 4430 (HTTPS) + 4433 (agent).
|
||||
- `services/magicmirror.sh` *(utilities)* — Modular smart mirror / info dashboard.
|
||||
Multi-instance (1-3, ports 8081-8083); each instance in `~/docker/magicmirror/<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
|
||||
|
||||
### Added
|
||||
|
||||
+17
-14
@@ -72,7 +72,10 @@ single shared compose file.
|
||||
|
||||
## Groups
|
||||
|
||||
`base` · `homelab` · `utilities` · `media` · `cameras` · `gaming` · `backup`.
|
||||
`base` · `homelab` · `utilities` · `media` · `cameras` · `gaming` · `extras` ·
|
||||
`backup`. `extras` holds non-Docker add-ons pulled from other repos (e.g. the
|
||||
`silent-send` browser extension) — things that install/build on the host rather
|
||||
than running as a container, so this script stays a single entry point for them.
|
||||
The guided menu (`sudo ./setup.sh`) shows the **required** packages first with a
|
||||
cancel option, then offers **Caddy** (most services proxy through it), then a
|
||||
**category menu you loop through** — pick a category, tick services (already
|
||||
@@ -82,17 +85,17 @@ ordered first.
|
||||
|
||||
## Migration status
|
||||
|
||||
This is an incremental migration. The big `ubuntu-post-install-*-crowdsec.sh`
|
||||
script remains the current "install everything" entry point until the modules
|
||||
reach parity, at which point it is retired (like the `original` and
|
||||
`-no-keycloak` tiers, which stay frozen as the evolution record).
|
||||
Migration is complete. `setup.sh` + `services/` now cover every service
|
||||
from `ubuntu-post-install-*-crowdsec.sh` plus several extras. The monolith
|
||||
is retained as a frozen evolution record.
|
||||
|
||||
| Module | Status |
|
||||
|--------|--------|
|
||||
| `base` (incl. glow) | ✅ done |
|
||||
| `homeassistant` | ✅ done |
|
||||
| `minecraft` (multi-instance, rich) | ⏳ porting from `setupminecraft.sh` |
|
||||
| `wolf` (gaming) | ⏳ porting from `setupwolf.sh` |
|
||||
| `js99er` (gaming) | ⏳ porting from `setupjs99er.sh` |
|
||||
| `backup` (Kopia, cross-cutting) | ⏳ porting from `setupbackup.sh` |
|
||||
| remaining ~65 services | ⏳ migrate from the monolith incrementally |
|
||||
| Group | Modules |
|
||||
|-------|---------|
|
||||
| `base` | `base`, `glow` |
|
||||
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` |
|
||||
| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `magicmirror`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `traccar`, `uptimekuma`, `watchtower`, `wg-easy` |
|
||||
| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||
| `cameras` | `frigate`, `frigate-notify` |
|
||||
| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
|
||||
| `extras` | `linux-to-sync`, `silent-send` |
|
||||
| `backup` | `backup` |
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -20,7 +20,7 @@ set -uo pipefail
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Category display order (groups not listed here are appended alphabetically).
|
||||
CATEGORY_ORDER=(base homelab utilities media cameras gaming backup)
|
||||
CATEGORY_ORDER=(base homelab utilities media cameras gaming extras backup)
|
||||
# Service ordering hint within a category (lower = earlier). Default 50.
|
||||
declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 )
|
||||
|
||||
@@ -79,6 +79,8 @@ is_installed() {
|
||||
base) command -v ncdu >/dev/null 2>&1 ;;
|
||||
glow) command -v glow >/dev/null 2>&1 ;;
|
||||
crowdsec) command -v cscli >/dev/null 2>&1 ;;
|
||||
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
|
||||
linux-to-sync) [ -d "$ACTUAL_HOME/linux-to-sync/.git" ] ;;
|
||||
*) [ -e "$DOCKER_DIR/$1" ] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user