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
|
The project is pre-1.0 while the modular system reaches parity with the
|
||||||
monolithic `ubuntu-post-install-*.sh` scripts.
|
monolithic `ubuntu-post-install-*.sh` scripts.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-06-03
|
||||||
|
|
||||||
|
### Milestone: full parity with the monolith
|
||||||
|
|
||||||
|
Every service from `ubuntu-post-install-24.04-crowdsec.sh` is now a module.
|
||||||
|
The modular system (`setup.sh` + `services/`) is the primary install path.
|
||||||
|
The monolith is retained as a frozen evolution record.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `services/linux-to-sync.sh` *(extras)* — clones the private
|
||||||
|
`outis1one/linux-to-sync` repository to `~/linux-to-sync` via SSH key or
|
||||||
|
GitHub PAT (PAT is stripped from the remote URL after clone for security).
|
||||||
|
`is_installed` marker checks `~/linux-to-sync/.git`.
|
||||||
|
- Updated `MODULAR.md` migration table to show the completed module inventory
|
||||||
|
grouped by category.
|
||||||
|
|
||||||
|
## [0.9.11] - 2026-06-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Utilities batch** — 8 service modules migrated from the monolith:
|
||||||
|
- `services/mealie.sh` *(utilities)* — Recipe manager & meal planner. PUID/PGID baked;
|
||||||
|
default creds noted (change immediately). Port 9925 → internal 9000.
|
||||||
|
- `services/actualbudget.sh` *(utilities)* — Open-source personal finance (Actual Budget).
|
||||||
|
Minimal container; bank sync via SimpleFIN optional. Port 5006.
|
||||||
|
- `services/traccar.sh` *(utilities)* — GPS tracking server for phones, vehicles, assets.
|
||||||
|
Ships a starter `config/traccar.xml` with H2 embedded DB. Port 8082 + 5000-5150 device
|
||||||
|
protocols (TCP+UDP).
|
||||||
|
- `services/fmd.sh` *(utilities)* — FindMyDevice server for Android. Generates a random
|
||||||
|
admin password; mobile app from F-Droid (not Play Store). Port 8084.
|
||||||
|
- `services/ddclient.sh` *(utilities)* — Dynamic DNS updater; no web UI. Ships a
|
||||||
|
`config/ddclient.conf` template covering Cloudflare, DuckDNS, No-IP. Default start
|
||||||
|
prompt is "n" — edit config first.
|
||||||
|
- `services/wg-easy.sh` *(utilities)* — WireGuard VPN with web UI. Auto-detects public
|
||||||
|
IP for `WG_HOST`; generates random password; requires `NET_ADMIN` + `SYS_MODULE` caps
|
||||||
|
and `ip_forward` sysctl. Ports 51820/udp (VPN) + 51821/tcp (web).
|
||||||
|
- `services/meshcentral.sh` *(utilities)* — Self-hosted remote device management server.
|
||||||
|
Prompts for hostname (domain/IP for agent connections). Ports 4430 (HTTPS) + 4433 (agent).
|
||||||
|
- `services/magicmirror.sh` *(utilities)* — Modular smart mirror / info dashboard.
|
||||||
|
Multi-instance (1-3, ports 8081-8083); each instance in `~/docker/magicmirror/<N>/`.
|
||||||
|
Optionally copies existing `config.js` and auto-clones `MMM-*` third-party modules
|
||||||
|
from GitHub (tries MichMich → bugsounet → MagicMirrorOrg org order).
|
||||||
|
- **Cameras batch** — 2 service modules:
|
||||||
|
- `services/frigate.sh` *(cameras)* — AI-powered NVR with object detection. Auto-enables
|
||||||
|
`/dev/dri/renderD128` for hardware-accelerated detection when present; ships a starter
|
||||||
|
`config/config.yml` with camera examples. `privileged: true` + 1 GB tmpfs cache.
|
||||||
|
Ports 5000 (web), 8554 (RTSP restream), 8555 (WebRTC). Default start prompt is "n" —
|
||||||
|
edit config first.
|
||||||
|
- `services/frigate-notify.sh` *(cameras)* — Push notification sidecar for Frigate events.
|
||||||
|
Auto-detects local Frigate and ntfy installs to pre-fill config defaults. Supports ntfy,
|
||||||
|
Pushover, Discord, Gotify, Telegram, and more. No web UI.
|
||||||
|
|
||||||
|
## [0.9.10] - 2026-06-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Media batch** — 6 service modules migrated from the monolith:
|
||||||
|
- `services/jellyfin.sh` *(media)* — Free media server (movies, TV, music). Auto-detects
|
||||||
|
`/dev/dri/renderD128` and enables VAAPI hardware transcoding with the render GID when
|
||||||
|
present; falls back to CPU transcoding otherwise. Ports 8096, 1900/udp (DLNA),
|
||||||
|
7359/udp (discovery).
|
||||||
|
- `services/emby.sh` *(media)* — Emby media server. UID/GID baked from the install-time
|
||||||
|
user; HW transcoding block left commented (uncomment `/dev/dri` once GPU confirmed).
|
||||||
|
Ports 8096 (web) and 8920 (HTTPS).
|
||||||
|
- `services/audiobookshelf.sh` *(media)* — Audiobook & podcast server. Separate audiobooks
|
||||||
|
and podcasts paths; podcasts folder defaults to `./podcasts` inside the service dir.
|
||||||
|
Port 13378.
|
||||||
|
- `services/arm.sh` *(media)* — Automatic Ripping Machine for DVDs, Blu-rays, CDs.
|
||||||
|
Detects optical drives at install time (`/dev/sr*`); runs with `privileged: true`.
|
||||||
|
Ripped output split into movies/ and music/. Port 8080.
|
||||||
|
- `services/lyrion.sh` *(media)* — Lyrion Music Server (formerly LMS) for Squeezebox
|
||||||
|
devices, the Squeezer app, and Chromecast. Uses `network_mode: host` so UDP discovery
|
||||||
|
works without manual port mapping. Port 9000.
|
||||||
|
- `services/immich.sh` *(media)* — Self-hosted photo & video backup (like Google Photos).
|
||||||
|
Full multi-container stack (immich-server, immich-machine-learning, valkey/redis,
|
||||||
|
postgres). Two library strategies: (1) unified — all photos in one place with an
|
||||||
|
auto-generated `import-photos.sh` helper that handles admin account creation, API key
|
||||||
|
generation, storage template config, and CLI upload; (2) external — existing photos
|
||||||
|
indexed read-only, new uploads separate. Port 2283.
|
||||||
|
|
||||||
|
## [0.9.9] - 2026-06-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **New `extras` category** for non-Docker add-ons sourced from other repos —
|
||||||
|
things that build/install on the host instead of running as a container.
|
||||||
|
Inserted into `CATEGORY_ORDER` between `gaming` and `backup`.
|
||||||
|
- `services/silent-send.sh` *(extras)* — installs the **Silent Send** browser
|
||||||
|
extension (redacts PII before it's sent to AI chatbots). Installs the build
|
||||||
|
toolchain (git, Node.js ≥18 via NodeSource, npm), clones
|
||||||
|
`outis1one/silent-send` to `~/silent-send`, runs `npm install` so the Firefox
|
||||||
|
build/sign tooling (`web-ext`) is ready, optionally builds a signed Firefox
|
||||||
|
`.xpi` (with Mozilla API creds), and prints load-unpacked / build instructions
|
||||||
|
per browser. README written to the checkout. No server/container.
|
||||||
|
- `is_installed` marker for `silent-send` (checks `~/silent-send/.git`).
|
||||||
|
|
||||||
|
## [0.9.8] - 2026-06-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `services/wolf-pair.sh` *(gaming)* — browser-based Moonlight pairing UI for
|
||||||
|
Wolf. Builds a tiny Python HTTP container (`python:3.12-alpine` + `docker-cli`)
|
||||||
|
that watches `docker logs wolf` for the current pairing secret and serves a
|
||||||
|
PIN entry form on port 8090. Eliminates the `./manage.sh pin` CLI workflow —
|
||||||
|
open `http://<server>:8090`, type the 4-digit PIN, done. Runs with
|
||||||
|
`network_mode: host` so it can reach Wolf's `/pin/` API at `localhost:47989`;
|
||||||
|
mounts the Docker socket read-only for log access. Optional Caddy subdomain.
|
||||||
|
|
||||||
## [0.9.7] - 2026-06-03
|
## [0.9.7] - 2026-06-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+17
-14
@@ -72,7 +72,10 @@ single shared compose file.
|
|||||||
|
|
||||||
## Groups
|
## Groups
|
||||||
|
|
||||||
`base` · `homelab` · `utilities` · `media` · `cameras` · `gaming` · `backup`.
|
`base` · `homelab` · `utilities` · `media` · `cameras` · `gaming` · `extras` ·
|
||||||
|
`backup`. `extras` holds non-Docker add-ons pulled from other repos (e.g. the
|
||||||
|
`silent-send` browser extension) — things that install/build on the host rather
|
||||||
|
than running as a container, so this script stays a single entry point for them.
|
||||||
The guided menu (`sudo ./setup.sh`) shows the **required** packages first with a
|
The guided menu (`sudo ./setup.sh`) shows the **required** packages first with a
|
||||||
cancel option, then offers **Caddy** (most services proxy through it), then a
|
cancel option, then offers **Caddy** (most services proxy through it), then a
|
||||||
**category menu you loop through** — pick a category, tick services (already
|
**category menu you loop through** — pick a category, tick services (already
|
||||||
@@ -82,17 +85,17 @@ ordered first.
|
|||||||
|
|
||||||
## Migration status
|
## Migration status
|
||||||
|
|
||||||
This is an incremental migration. The big `ubuntu-post-install-*-crowdsec.sh`
|
Migration is complete. `setup.sh` + `services/` now cover every service
|
||||||
script remains the current "install everything" entry point until the modules
|
from `ubuntu-post-install-*-crowdsec.sh` plus several extras. The monolith
|
||||||
reach parity, at which point it is retired (like the `original` and
|
is retained as a frozen evolution record.
|
||||||
`-no-keycloak` tiers, which stay frozen as the evolution record).
|
|
||||||
|
|
||||||
| Module | Status |
|
| Group | Modules |
|
||||||
|--------|--------|
|
|-------|---------|
|
||||||
| `base` (incl. glow) | ✅ done |
|
| `base` | `base`, `glow` |
|
||||||
| `homeassistant` | ✅ done |
|
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` |
|
||||||
| `minecraft` (multi-instance, rich) | ⏳ porting from `setupminecraft.sh` |
|
| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `magicmirror`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `traccar`, `uptimekuma`, `watchtower`, `wg-easy` |
|
||||||
| `wolf` (gaming) | ⏳ porting from `setupwolf.sh` |
|
| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||||
| `js99er` (gaming) | ⏳ porting from `setupjs99er.sh` |
|
| `cameras` | `frigate`, `frigate-notify` |
|
||||||
| `backup` (Kopia, cross-cutting) | ⏳ porting from `setupbackup.sh` |
|
| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
|
||||||
| remaining ~65 services | ⏳ migrate from the monolith incrementally |
|
| `extras` | `linux-to-sync`, `silent-send` |
|
||||||
|
| `backup` | `backup` |
|
||||||
|
|||||||
@@ -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)"
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
# Category display order (groups not listed here are appended alphabetically).
|
# Category display order (groups not listed here are appended alphabetically).
|
||||||
CATEGORY_ORDER=(base homelab utilities media cameras gaming backup)
|
CATEGORY_ORDER=(base homelab utilities media cameras gaming extras backup)
|
||||||
# Service ordering hint within a category (lower = earlier). Default 50.
|
# Service ordering hint within a category (lower = earlier). Default 50.
|
||||||
declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 )
|
declare -A SERVICE_PRIORITY=( [caddy]=1 [crowdsec]=2 [authelia]=3 )
|
||||||
|
|
||||||
@@ -79,6 +79,8 @@ is_installed() {
|
|||||||
base) command -v ncdu >/dev/null 2>&1 ;;
|
base) command -v ncdu >/dev/null 2>&1 ;;
|
||||||
glow) command -v glow >/dev/null 2>&1 ;;
|
glow) command -v glow >/dev/null 2>&1 ;;
|
||||||
crowdsec) command -v cscli >/dev/null 2>&1 ;;
|
crowdsec) command -v cscli >/dev/null 2>&1 ;;
|
||||||
|
silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;;
|
||||||
|
linux-to-sync) [ -d "$ACTUAL_HOME/linux-to-sync/.git" ] ;;
|
||||||
*) [ -e "$DOCKER_DIR/$1" ] ;;
|
*) [ -e "$DOCKER_DIR/$1" ] ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user