Merge pull request #36 from outis1one/claude/happy-volta-RPhbD

Claude/happy volta r phb d
This commit is contained in:
Outis
2026-06-03 12:53:11 -04:00
committed by GitHub
14 changed files with 4736 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# Changelog
All notable changes to this project. Versions follow `MAJOR.MINOR.PATCH`.
The project is pre-1.0 while the modular system reaches parity with the
monolithic `ubuntu-post-install-*.sh` scripts.
## [0.9.5] - 2026-06-03
### Added
- `services/minecraft.sh` *(gaming)* — full port of the standalone
`setupminecraft.sh`, converted to the per-service-folder model. Each server
is its own `~/docker/<instance>/` with a standalone compose, so multiple
servers run side by side (port auto-bumps 25565→25566…). Preserves all the
niceties: Fabric/Quilt/Paper/Vanilla/Forge flavours, the live Modrinth
version/mod-availability picker, curated mods, Vanilla Tweaks datapacks,
whitelist UUID pre-population, LuckPerms bootstrap, Chunky pre-gen, playit.gg
tunnel, generated MINECRAFT_NETWORKING.md / CLIENT_MODS.md, and the
client-mods download web page (its own folder + compose).
### Fixed
- Minecraft compose env-block emission (trailing-newline bug from the original
that glued `ports:` onto the last env line — now valid YAML).
### Notes
- `gaming` group now: `js99er`, `minecraft`, `wolf`.
- Still pending: `whitelist` Minecraft helper; migrating the ~65 monolith
services into `services/`.
## [0.9.4] - 2026-06-03
The first versioned release. Introduces the **modular post-install system** so
you can install the whole box at once *or* run a single service, with one
source of truth (no per-service script duplication, nothing generated).
### Added
- `setup.sh` dispatcher: interactive menu, run-one (`sudo ./setup.sh <name>`),
`--list`, `--dry-run`, `--unattended`, `--version`.
- `lib/common.sh`: shared helpers (logging, prompts, ownership, Caddy wiring)
and a self-registration service registry — one implementation of each.
- Service modules (each its own `~/docker/<name>/` folder + standalone compose):
- `base` — essential CLI packages, now including **glow**.
- `glow` — terminal markdown reader (charmbracelet), standalone too.
- `homeassistant` — bridge/host networking choice, `trusted_proxies` pre-seed.
- `js99er` *(gaming)* — self-hosted TI-99/4A emulator (Selkies launcher tie-in removed).
- `wolf` *(gaming)* — Games-on-Whales Moonlight streaming (wolf-pair dropped; `pin` workflow kept).
- `backup` — Kopia encrypted backups (paths adapted to `~/docker`).
- `MODULAR.md` documenting the architecture, how to add a module, migration status.
- Service groups: `base` / `homelab` / `gaming` / `backup`.
- `glow` also added to the live `-crowdsec` monolith scripts' essential packages.
### Known gaps / next (0.9.5)
- `minecraft` module (rich, multi-instance port of `setupminecraft.sh`) — not yet
written; the background port hit a session limit.
- `whitelist` Minecraft helper not yet shipped.
- ~65 services still live only in the monolith, to migrate into `services/`.
### Earlier history (pre-versioning)
- Removed Keycloak; standardized on Authelia for SSO.
- Added `-no-keycloak` and `-crowdsec` script tiers (originals kept as the
evolution record).
- CrowdSec replaces fail2ban in the `-crowdsec` tier (SSH + Caddy, geo + IP
reputation, optional ntfy ban alerts).
- Home Assistant added to the `-crowdsec` tier.
+95
View File
@@ -0,0 +1,95 @@
# Modular Post-Install (`setup.sh` + `lib/` + `services/`)
This is the new structure that gives you **one source of truth** *and* the
ability to **run just the service you want** — without maintaining a pile of
near-duplicate standalone scripts.
## Why
The full `ubuntu-post-install-*.sh` scripts are great as a "run once, set up the
whole box" experience, but to add or update one service you edit a 300 KB file
(in two or three places). The separate `setup-*.sh` scripts are easy to run for
one service, but duplicate logic and drift apart.
The fix is **not** to generate per-service scripts from the monolith (that just
triples the maintenance surface). It's to have **one implementation per service**
in a module, shared helpers in a library, and a thin dispatcher with two entry
points.
## Layout
```
setup.sh # dispatcher: menu, run-one, --list, --dry-run, --unattended
lib/common.sh # shared helpers: logging, prompts, ownership, Caddy wiring,
# the service registry. THE single source of truth.
services/
base.sh # essential CLI packages (incl. glow)
homeassistant.sh # Home Assistant
... # one file per service
```
## Usage
```bash
sudo ./setup.sh # interactive menu (whiptail or text)
sudo ./setup.sh homeassistant # install one service
sudo ./setup.sh base glow # install several
./setup.sh --list # list services, grouped
sudo ./setup.sh --dry-run --unattended minecraft # preview, no prompts
```
## Anatomy of a service module
Each `services/<name>.sh` does exactly two things: **register** itself and
define **install_<name>**.
```bash
#!/bin/bash
register_service myapp homelab "What it does" 1234 # name group description [port]
install_myapp() {
require_docker || return 1
local DIR="$DOCKER_DIR/myapp"
[ "$DRY_RUN" = true ] && { echo "[DRY-RUN] Would create $DIR"; return 0; }
mkdir -p "$DIR"; ensure_docker_dir_ownership "$DIR"; cd "$DIR" || return 1
cat > docker-compose.yml << 'YAML'
...
YAML
configure_caddy_for_service "MyApp" "1234" "myapp" # optional reverse proxy
prompt_yn "Start now? (y/n):" "y" START && docker compose up -d
}
```
Helpers available from `lib/common.sh`: `log_info/success/warning/error`,
`prompt_yn`, `prompt_text`, `run_cmd`, `ensure_docker_dir_ownership`,
`generate_password`, `validate_password`, `configure_caddy_for_service`,
`require_root`, `require_docker`. Globals: `DOCKER_DIR`, `ACTUAL_USER`,
`ACTUAL_HOME`, `DRY_RUN`, `UNATTENDED`.
Every service installs to its **own folder** `~/docker/<name>/` with its **own
`docker-compose.yml`** (the DoTheEvo `selfhosted-apps-docker` layout) — never a
single shared compose file.
## Groups
`base` · `homelab` · `gaming` · `backup`. The menu and `--list` are grouped by
these. The **gaming** group (Wolf, js99er, Minecraft) makes this script a
sensible base for either a homelab box or a gaming box — install only what that
machine needs.
## Migration status
This is an incremental migration. The big `ubuntu-post-install-*-crowdsec.sh`
script remains the current "install everything" entry point until the modules
reach parity, at which point it is retired (like the `original` and
`-no-keycloak` tiers, which stay frozen as the evolution record).
| Module | Status |
|--------|--------|
| `base` (incl. glow) | ✅ done |
| `homeassistant` | ✅ done |
| `minecraft` (multi-instance, rich) | ⏳ porting from `setupminecraft.sh` |
| `wolf` (gaming) | ⏳ porting from `setupwolf.sh` |
| `js99er` (gaming) | ⏳ porting from `setupjs99er.sh` |
| `backup` (Kopia, cross-cutting) | ⏳ porting from `setupbackup.sh` |
| remaining ~65 services | ⏳ migrate from the monolith incrementally |
+10
View File
@@ -12,6 +12,16 @@ complete, standalone script).
`<ver>` is `24.04` or `26.04`.
> New services are added to the **`-crowdsec`** tier only (the current tip of
> the evolution); the original and `-no-keycloak` scripts are frozen as
> historical snapshots. For example, **Home Assistant** (home-automation hub,
> port 8123) is available in the `-crowdsec` variants. It ships with a
> `trusted_proxies` config pre-seeded so it works behind the Caddy reverse
> proxy out of the box, and the installer asks whether to use **bridge**
> networking (port 8123 published — proxy-friendly, isolated) or **host**
> networking (needed for LAN device auto-discovery: Chromecast, HomeKit,
> mDNS, some Zigbee/Z-Wave/Bluetooth).
## Which one?
- **Original (`.sh`)** — unchanged baseline, kept for fallback. Still offers
+1
View File
@@ -0,0 +1 @@
0.9.5
+213
View File
@@ -0,0 +1,213 @@
#!/bin/bash
# lib/common.sh — shared helpers for the modular post-install system.
#
# This is the single source of truth for the helper functions every service
# module relies on (logging, prompts, ownership, Caddy wiring, the service
# registry). Both the full menu (setup.sh) and single-service runs source it,
# so there is exactly ONE implementation of each helper.
#
# Modules under services/*.sh source this file (guarded), register themselves
# with register_service, and define an install_<name> function.
# Guard against double-sourcing
[ -n "${_COMMON_SH_LOADED:-}" ] && return 0
_COMMON_SH_LOADED=1
# ── Global modes (overridable by the dispatcher / environment) ───────────────
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
# ── Identity / paths ─────────────────────────────────────────────────────────
# The actual (non-root) user, even when run under sudo.
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6)"
[ -z "$ACTUAL_HOME" ] && ACTUAL_HOME="$HOME"
# Per-service docker folders live here: ~/docker/<service>/docker-compose.yml
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
# ── Colored logging ──────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# ── Service registry ─────────────────────────────────────────────────────────
# Modules call: register_service <name> <group> <description> [port]
declare -gA SERVICE_GROUP=()
declare -gA SERVICE_DESC=()
declare -gA SERVICE_PORT=()
declare -ga SERVICE_ORDER=()
register_service() {
local name="$1" group="$2" desc="$3" port="${4:-}"
SERVICE_GROUP["$name"]="$group"
SERVICE_DESC["$name"]="$desc"
SERVICE_PORT["$name"]="$port"
SERVICE_ORDER+=("$name")
}
# ── Pre-flight ───────────────────────────────────────────────────────────────
require_root() {
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
log_error "Please run as root (use sudo)."
exit 1
fi
}
require_docker() {
if ! command -v docker &>/dev/null; then
log_error "Docker is not installed. Install Docker first (run: $0 docker)."
return 1
fi
}
# ── Command execution honoring dry-run ───────────────────────────────────────
run_cmd() {
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would execute: $*"
return 0
else
"$@"
fi
}
# Ensure Docker directories are owned by the actual user (not root)
ensure_docker_dir_ownership() {
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER"
return 0
fi
for dir in "$@"; do
[ -d "$dir" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true
done
}
# Generate a secure alphanumeric password (no special characters)
generate_password() {
local length="${1:-32}"
openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length"
}
# Validate password (alphanumeric only, minimum length). Returns 0/1.
validate_password() {
local password="$1" min_length="${2:-12}"
if [ ${#password} -lt "$min_length" ]; then
echo " ⚠ Password must be at least $min_length characters long"; return 1
fi
if echo "$password" | grep -q '[^a-zA-Z0-9]'; then
echo " ⚠ Password must contain only letters and numbers (no special characters)"; return 1
fi
return 0
}
# Prompt yes/no, honoring unattended. prompt_yn "Question?" "default" VARNAME
prompt_yn() {
local question="$1" default="$2" varname="$3" response
if [ "$UNATTENDED" = true ]; then
eval "$varname='$default'"; echo "$question [auto: $default]"; return
fi
read -p "$question " response
eval "$varname='$response'"
}
# Prompt text, honoring unattended. prompt_text "Question?" "default" VARNAME
prompt_text() {
local question="$1" default="$2" varname="$3" response
if [ "$UNATTENDED" = true ]; then
eval "$varname='$default'"; echo "$question [auto: $default]"; return
fi
read -p "$question " response
eval "$varname='${response:-$default}'"
}
# ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
# Usage: configure_caddy_for_service "Name" "PORT" "default-subdomain" ["extra"]
configure_caddy_for_service() {
local SERVICE_NAME="$1" SERVICE_PORT="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}"
# Caddy not installed → nothing to do
[ -d "$DOCKER_DIR/caddy" ] || return 0
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " CADDY REVERSE PROXY CONFIGURATION"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Caddy is installed. You can configure a reverse proxy for $SERVICE_NAME."
echo ""
local CONFIGURE_CADDY=""
prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY
if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then
echo " Skipping Caddy configuration."
echo " Access $SERVICE_NAME at: http://localhost:$SERVICE_PORT"
return 0
fi
echo ""
echo "Enter the full domain for $SERVICE_NAME:"
echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com"
echo ""
local SERVICE_DOMAIN=""
prompt_text "Domain:" "" SERVICE_DOMAIN
if [ -z "$SERVICE_DOMAIN" ]; then
echo " ⚠ No domain provided, skipping Caddy configuration."; return 0
fi
local CADDY_DIR="$DOCKER_DIR/caddy"
local CADDYFILE="$CADDY_DIR/Caddyfile"
local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
if [ -f "$CADDYFILE" ]; then
echo " Backing up Caddyfile to: $(basename "$BACKUP_FILE")"
cp "$CADDYFILE" "$BACKUP_FILE"
else
echo " Creating new Caddyfile"; touch "$CADDYFILE"
fi
if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then
echo "$SERVICE_DOMAIN already exists in Caddyfile"
local OVERWRITE=""
prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE
if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
echo " Keeping existing configuration."; return 0
fi
sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE"
fi
echo " Adding $SERVICE_NAME configuration to Caddyfile..."
cat >> "$CADDYFILE" << CADDY_BLOCK
# $SERVICE_NAME
$SERVICE_DOMAIN {
reverse_proxy localhost:$SERVICE_PORT
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
# Logging for CrowdSec (Caddy JSON access logs)
log {
output file /var/log/caddy/${SERVICE_DOMAIN}.log
format json
}
$EXTRA_CONFIG
}
CADDY_BLOCK
echo " ✓ Configuration added to Caddyfile"
echo " Reloading Caddy configuration..."
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
echo "$SERVICE_NAME is now accessible at: https://$SERVICE_DOMAIN"
else
echo " ⚠ Failed to reload Caddy. Check: docker logs caddy"
echo " You can restore from backup: $BACKUP_FILE"
fi
echo ""
}
+486
View File
@@ -0,0 +1,486 @@
#!/bin/bash
# services/backup.sh — automatic encrypted backups with Kopia.
# Part of the modular post-install system (sourced by setup.sh).
#
# Backs up the things you can't re-download — progress, saved games, user data:
# • Minecraft worlds / player data (every <id>/data instance under $DOCKER_DIR)
# • Emulator saves & save states ($GAME_STORAGE_DIR/saves) [gaming box]
# • ES-DE scraped artwork ($GAME_STORAGE_DIR/media) [gaming box]
# • Steam user data & game saves ($GAME_STORAGE_DIR/steam) [gaming box]
# • Wolf state (/etc/wolf — config + profile_data) [gaming box]
#
# It does NOT back up ROMs or Steam game installs — those are re-downloadable.
#
# Engine: Kopia — block-level dedup + zstd compression + encryption, so the
# constantly-rewritten Minecraft region files and Steam Proton prefixes only
# store their changed blocks. Backups run automatically on a systemd timer
# (cron fallback). An optional "sync-to" step mirrors the whole repository to
# another computer or a cloud bucket (REMOTE_* lines in backup.conf).
#
# Safe to re-run: it reconnects to an existing repository and refreshes the
# config, policies, worker script and timer.
register_service backup backup "Automatic encrypted backups (Kopia)"
install_backup() {
log_info "Setting up automatic encrypted backups (Kopia)..."
# ── Repo-conventional paths ──────────────────────────────────────────────
local BACKUP_DIR="$DOCKER_DIR/backup"
local CONF_FILE="$BACKUP_DIR/backup.conf" # editable settings
local WORKER="$BACKUP_DIR/backup.sh" # generated worker
local KOPIA_CONFIG="/etc/post-install-backup/repository.config"
local CACHE_DIR="/var/cache/post-install-backup"
local SVC_NAME="post-install-backup"
local DEFAULT_REPO="$ACTUAL_HOME/backups/post-install-kopia"
echo ""
echo "╔═══════════════════════════════════════════════════════╗"
echo "║ Automatic Backup Setup · Kopia ║"
echo "║ Minecraft world · game saves · user data ║"
echo "╚═══════════════════════════════════════════════════════╝"
echo ""
echo " Backs up progress / saves / user data — NOT ROMs or game installs."
echo " Dedup + compression + encryption, scheduled automatically."
echo " Optional mirror to another computer or cloud (configurable later)."
echo ""
# ── DRY-RUN: describe the plan and bail before touching anything real ────
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would install Kopia from its official apt repository"
echo "[DRY-RUN] Would create $BACKUP_DIR (owned by $ACTUAL_USER)"
echo "[DRY-RUN] Would create/connect a Kopia repository at $DEFAULT_REPO"
echo "[DRY-RUN] Would write config $CONF_FILE and worker $WORKER"
echo "[DRY-RUN] Would install systemd service+timer $SVC_NAME (cron fallback)"
echo "[DRY-RUN] Would optionally run the first backup"
return 0
fi
# ── 1. Ensure Kopia is installed ─────────────────────────────────────────
if ! command -v kopia >/dev/null 2>&1; then
log_info "Kopia not found — installing from the official apt repository..."
if command -v apt-get >/dev/null 2>&1; then
install -d -m 0755 /etc/apt/keyrings
if curl -fsSL https://kopia.io/signing-key \
| gpg --dearmor --yes -o /etc/apt/keyrings/kopia-keyring.gpg; then
echo "deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" \
> /etc/apt/sources.list.d/kopia.list
apt-get update -y && apt-get install -y kopia
fi
fi
fi
if ! command -v kopia >/dev/null 2>&1; then
log_error "Kopia is still not installed."
echo " Install it manually, then re-run this service:"
echo " https://kopia.io/docs/installation/"
echo " Or grab the linux-amd64 binary from:"
echo " https://github.com/kopia/kopia/releases/latest"
return 1
fi
local KOPIA_BIN; KOPIA_BIN="$(command -v kopia)"
log_success "Kopia: $("$KOPIA_BIN" --version 2>/dev/null | head -1)"
# Generated config/worker live under the repo-conventional backup folder.
mkdir -p "$BACKUP_DIR" || return 1
ensure_docker_dir_ownership "$BACKUP_DIR"
# ── 2. What to back up ───────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " WHAT TO BACK UP"
echo "═══════════════════════════════════════════════════════"
echo ""
# Minecraft instances auto-detect under $DOCKER_DIR (~/docker/minecraft,
# ~/docker/minecraft-* etc.) — any folder with an itzg Dockerfile + data/.
local DEFAULT_MCBASE="$DOCKER_DIR"
echo " Each Minecraft instance's world is backed up from its <id>/data folder;"
echo " all instances under this folder are detected automatically."
echo " (type 'none' to exclude Minecraft)"
local MC_BASE_DIR=""
prompt_text " Folder containing Minecraft instance(s) [${DEFAULT_MCBASE}]:" "$DEFAULT_MCBASE" MC_BASE_DIR
if [ "$MC_BASE_DIR" = none ]; then
MC_BASE_DIR=""
else
MC_BASE_DIR="${MC_BASE_DIR/#\~/$ACTUAL_HOME}"; MC_BASE_DIR="${MC_BASE_DIR%/}"
local _found=() d
for d in "$MC_BASE_DIR"/*/; do
[ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" && [ -d "${d}data" ] \
&& _found+=("$(basename "$d")")
done
if [ "${#_found[@]}" -gt 0 ]; then
log_success " Detected Minecraft instance(s): ${_found[*]}"
else
log_warning " No instances detected yet under $MC_BASE_DIR — picked up once created."
fi
fi
# ── Optional gaming-box sources (only matter on a Wolf gaming machine) ────
echo ""
echo " The following are only relevant on a gaming box (Wolf + emulators +"
echo " Steam). On a plain homelab server you can leave them disabled."
local DEFAULT_STORAGE="$ACTUAL_HOME/drives/games"
local GAME_STORAGE_DIR=""
prompt_text " Game storage dir (ROMs/Steam/saves live here) [${DEFAULT_STORAGE}]:" "$DEFAULT_STORAGE" GAME_STORAGE_DIR
GAME_STORAGE_DIR="${GAME_STORAGE_DIR/#\~/$ACTUAL_HOME}"
GAME_STORAGE_DIR="${GAME_STORAGE_DIR%/}"
echo ""
local _a=""
prompt_yn " Back up emulator saves ($GAME_STORAGE_DIR/saves)? (y/N):" "n" _a
local BACKUP_SAVES; BACKUP_SAVES=$([[ "$_a" =~ ^[Yy]$ ]] && echo yes || echo no)
prompt_yn " Back up Steam user data/saves (game installs excluded)? (y/N):" "n" _a
local BACKUP_STEAM; BACKUP_STEAM=$([[ "$_a" =~ ^[Yy]$ ]] && echo yes || echo no)
prompt_yn " Back up ES-DE scraped artwork ($GAME_STORAGE_DIR/media)? (y/N):" "n" _a
local BACKUP_MEDIA; BACKUP_MEDIA=$([[ "$_a" =~ ^[Yy]$ ]] && echo yes || echo no)
# /etc/wolf holds Wolf's config AND profile_data/ — which is where Wolf persists
# every app's whole /home/retro (ES-DE settings, gamelists, controller configs,
# RetroArch configs + saves + save states, standalone-emulator saves, etc.).
echo ""
echo " /etc/wolf includes pairing/config AND profile_data — where Wolf stores"
echo " every app's home dir (ES-DE settings, controller mappings, RetroArch"
echo " saves & save states, standalone-emulator saves)."
prompt_yn " Back up Wolf state (/etc/wolf)? (y/N):" "n" _a
local BACKUP_WOLF; BACKUP_WOLF=$([[ "$_a" =~ ^[Yy]$ ]] && echo yes || echo no)
local WOLF_STATE_DIR="/etc/wolf"
# ── 3. Repository location (local now; remote mirror later) ──────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " BACKUP REPOSITORY (local)"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Backups are stored in a local Kopia repository. You can mirror it to"
echo " another computer or the cloud later (see backup.conf, REMOTE_* lines)."
echo " Put it on a DIFFERENT drive from your data if you can."
echo ""
local REPO_DIR=""
prompt_text " Repository path [${DEFAULT_REPO}]:" "$DEFAULT_REPO" REPO_DIR
REPO_DIR="${REPO_DIR/#\~/$ACTUAL_HOME}"
REPO_DIR="${REPO_DIR%/}"
# Repository password — generate a strong one unless the user supplies their own.
echo ""
echo " The repository is encrypted. A strong password is generated and stored"
echo " in backup.conf (root-only). KEEP A COPY — without it backups cannot be"
echo " restored, even by you."
echo ""
local KOPIA_PASSWORD=""
if [ "$UNATTENDED" = true ]; then
echo " [auto] Generating a random repository password."
else
read -rsp " Repository password [Enter = auto-generate]: " KOPIA_PASSWORD; echo
fi
if [ -z "$KOPIA_PASSWORD" ]; then
KOPIA_PASSWORD="$(generate_password 32)"
log_info " Generated a random repository password (saved in backup.conf)."
fi
# ── 4. Retention + schedule ──────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " SCHEDULE & RETENTION"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " 1) Daily at 03:00 (recommended)"
echo " 2) Every 6 hours"
echo " 3) Hourly"
echo " 4) Custom (systemd OnCalendar)"
echo ""
local _sch=""
prompt_text " How often? [1]:" "1" _sch
local ONCALENDAR SCHED_LABEL
case "${_sch:-1}" in
2) ONCALENDAR="*-*-* 00,06,12,18:00:00"; SCHED_LABEL="every 6 hours" ;;
3) ONCALENDAR="hourly"; SCHED_LABEL="hourly" ;;
4) prompt_text " OnCalendar expression:" "*-*-* 03:00:00" ONCALENDAR; SCHED_LABEL="$ONCALENDAR" ;;
*) ONCALENDAR="*-*-* 03:00:00"; SCHED_LABEL="daily at 03:00" ;;
esac
echo ""
local KEEP_LATEST=""
prompt_text " How many recent snapshots to keep (latest)? [10]:" "10" KEEP_LATEST
local KEEP_DAILY=7 KEEP_WEEKLY=4 KEEP_MONTHLY=6
# ── 5. Write backup.conf ─────────────────────────────────────────────────
log_info "Writing $CONF_FILE ..."
tee "$CONF_FILE" >/dev/null << CONFEOF
# ── post-install backup config (read by backup.sh) ───────────────────────────
# Generated on $(date '+%F %T'). Safe to hand-edit.
KOPIA="$KOPIA_BIN"
KOPIA_CONFIG="$KOPIA_CONFIG"
KOPIA_CACHE_DIR="$CACHE_DIR"
# Repository encryption password — KEEP A COPY somewhere safe.
KOPIA_PASSWORD='$KOPIA_PASSWORD'
# ── Sources (progress / saves / user data only) ──────────────────────────────
# MC_BASE_DIR holds Minecraft instances; every <id>/data with an itzg Dockerfile
# is snapshotted automatically (covers multi-server setups).
MC_BASE_DIR="$MC_BASE_DIR"
GAME_STORAGE_DIR="$GAME_STORAGE_DIR"
WOLF_STATE_DIR="$WOLF_STATE_DIR"
BACKUP_SAVES="$BACKUP_SAVES" # \$GAME_STORAGE_DIR/saves
BACKUP_STEAM="$BACKUP_STEAM" # \$GAME_STORAGE_DIR/steam (game installs excluded by policy)
BACKUP_MEDIA="$BACKUP_MEDIA" # \$GAME_STORAGE_DIR/media (ES-DE scraped artwork)
BACKUP_WOLF="$BACKUP_WOLF" # /etc/wolf — config + profile_data (ES-DE/RetroArch/controllers/saves)
# ── Optional offsite mirror ──────────────────────────────────────────────────
# Mirror the WHOLE repository to another computer or the cloud after each run.
# Leave REMOTE_TYPE=none to stay local-only. When ready, set the type and args:
#
# Another computer (SFTP):
# REMOTE_TYPE="sftp"
# REMOTE_ARGS="--host BACKUP_HOST --username USER --path /srv/backups/pi-kopia --keyfile /root/.ssh/id_ed25519 --known-hosts /root/.ssh/known_hosts"
#
# Backblaze B2:
# REMOTE_TYPE="b2"
# REMOTE_ARGS="--bucket MY_BUCKET --key-id KEY_ID --key APP_KEY"
#
# S3-compatible:
# REMOTE_TYPE="s3"
# REMOTE_ARGS="--bucket MY_BUCKET --endpoint s3.us-west-002.example.com --access-key AK --secret-access-key SK"
#
# Any rclone remote (run 'rclone config' first):
# REMOTE_TYPE="rclone"
# REMOTE_ARGS="--remote-path myremote:pi-kopia"
#
# Full list: https://kopia.io/docs/reference/command-line/common/repository-sync-to/
REMOTE_TYPE="none"
REMOTE_ARGS=""
CONFEOF
chown root:root "$CONF_FILE" 2>/dev/null || true
chmod 600 "$CONF_FILE"
log_success "backup.conf written (chmod 600 — contains the repo password)"
# ── 6. Create / connect the repository, set policies ─────────────────────
log_info "Preparing repository at $REPO_DIR ..."
mkdir -p "$REPO_DIR" "$CACHE_DIR" "$(dirname "$KOPIA_CONFIG")"
kp() { env KOPIA_PASSWORD="$KOPIA_PASSWORD" "$KOPIA_BIN" --config-file="$KOPIA_CONFIG" "$@"; }
if kp repository status >/dev/null 2>&1; then
log_success "Already connected to a repository."
elif test -e "$REPO_DIR/kopia.repository.f"; then
log_info "Existing repository found — connecting..."
kp repository connect filesystem --path="$REPO_DIR" --cache-directory="$CACHE_DIR" \
|| { log_error "Failed to connect to existing repository."; return 1; }
log_success "Connected to existing repository."
else
log_info "Creating new repository..."
kp repository create filesystem --path="$REPO_DIR" --cache-directory="$CACHE_DIR" \
|| { log_error "Failed to create repository."; return 1; }
log_success "Repository created."
fi
log_info "Applying global policy (zstd compression + retention)..."
kp policy set --global --compression=zstd >/dev/null
kp policy set --global \
--keep-latest="$KEEP_LATEST" \
--keep-daily="$KEEP_DAILY" \
--keep-weekly="$KEEP_WEEKLY" \
--keep-monthly="$KEEP_MONTHLY" \
--keep-annual=0 --keep-hourly=0 >/dev/null
log_success "Retention: keep latest $KEEP_LATEST, $KEEP_DAILY daily, $KEEP_WEEKLY weekly, $KEEP_MONTHLY monthly"
# Exclude Steam game installs (re-downloadable) while keeping saves/userdata.
if [ "$BACKUP_STEAM" = yes ]; then
log_info "Setting Steam ignore rules (excluding game installs, keeping saves)..."
kp policy set "$GAME_STORAGE_DIR/steam" \
--add-ignore='**/steamapps/common' \
--add-ignore='**/steamapps/downloading' \
--add-ignore='**/steamapps/shadercache' \
--add-ignore='**/steamapps/temp' \
--add-ignore='**/steamapps/workshop' \
--add-ignore='**/depotcache' >/dev/null 2>&1 \
|| log_warning "Could not pre-set Steam ignore policy (will still apply on first snapshot if the path exists)."
fi
# ── 7. Generate the worker script ────────────────────────────────────────
log_info "Writing worker $WORKER ..."
cat > "$WORKER" << 'WORKEREOF'
#!/bin/bash
# Generated by the post-install backup service — runs one backup cycle with Kopia.
#
# sudo ./backup.sh run a backup now
# sudo ./backup.sh snapshots list snapshots
# sudo ./backup.sh policy show retention/ignore policy
# sudo ./backup.sh restore how to restore / browse snapshots
#
# Reads settings from backup.conf next to this script.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
[ -f "$CONF" ] || { echo "Config not found: $CONF (re-run the backup service)"; exit 1; }
# shellcheck source=/dev/null
source "$CONF"
export KOPIA_PASSWORD
log() { echo "[$(date '+%F %T')] $*"; }
k() { "$KOPIA" --config-file="$KOPIA_CONFIG" "$@"; }
if ! k repository status >/dev/null 2>&1; then
log "ERROR: not connected to a repository — re-run the backup service"
exit 1
fi
case "${1:-run}" in
snapshots) k snapshot list; exit 0 ;;
policy) k policy show --global; exit 0 ;;
restore)
echo "List snapshots, then restore one to a target directory:"
echo " sudo ./backup.sh snapshots"
echo " sudo $KOPIA --config-file=$KOPIA_CONFIG restore <SNAPSHOT_ID> /path/to/restore-here"
echo ""
echo "Or browse every snapshot as a read-only filesystem:"
echo " sudo mkdir -p /mnt/kopia"
echo " sudo $KOPIA --config-file=$KOPIA_CONFIG mount all /mnt/kopia"
exit 0 ;;
esac
log "===== Backup starting ====="
# Flush each running Minecraft world to disk first so snapshots are consistent.
if [ -n "${MC_BASE_DIR:-}" ] && command -v docker >/dev/null 2>&1; then
_flushed=0
for d in "$MC_BASE_DIR"/*/; do
[ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" || continue
name="$(basename "$d")"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$name"; then
log "Flushing Minecraft world '$name' (save-all)..."
docker exec "$name" mc-send-to-console save-all flush 2>/dev/null \
|| docker exec "$name" rcon-cli save-all 2>/dev/null || true
_flushed=1
fi
done
[ "$_flushed" = 1 ] && sleep 5
fi
rc=0
snap() {
local label="$1" path="$2"
if [ -z "$path" ] || [ ! -e "$path" ]; then
log "skip $label — not found: ${path:-<unset>}"; return
fi
log "Snapshotting $label: $path"
if ! k snapshot create --description="post-install: $label" "$path"; then
log "WARNING: snapshot failed for $label"; rc=1
fi
}
if [ -n "${MC_BASE_DIR:-}" ]; then
for d in "$MC_BASE_DIR"/*/; do
[ -f "${d}Dockerfile" ] && grep -qs itzg "${d}Dockerfile" && [ -d "${d}data" ] || continue
nm="$(basename "$d")"
case "$nm" in minecraft*) lbl="$nm" ;; *) lbl="minecraft-$nm" ;; esac
snap "$lbl" "${d}data"
done
fi
[ "${BACKUP_SAVES:-no}" = yes ] && snap "emulator-saves" "$GAME_STORAGE_DIR/saves"
[ "${BACKUP_STEAM:-no}" = yes ] && snap "steam-userdata" "$GAME_STORAGE_DIR/steam"
[ "${BACKUP_MEDIA:-no}" = yes ] && snap "es-de-media" "$GAME_STORAGE_DIR/media"
[ "${BACKUP_WOLF:-no}" = yes ] && snap "wolf-state" "$WOLF_STATE_DIR"
# Optional: mirror the whole repository offsite (another computer / cloud).
if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then
log "Mirroring repository to remote ($REMOTE_TYPE)..."
# shellcheck disable=SC2086
if ! k repository sync-to "$REMOTE_TYPE" $REMOTE_ARGS; then
log "WARNING: remote mirror failed"; rc=1
fi
fi
if [ "$rc" -eq 0 ]; then log "===== Backup complete ====="; else log "===== Backup finished WITH WARNINGS ====="; fi
exit "$rc"
WORKEREOF
chmod +x "$WORKER"
chown root:root "$WORKER" 2>/dev/null || true
log_success "backup.sh written"
# ── 8. Install systemd timer (fallback: cron) ────────────────────────────
local AUTORUN=""
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then
log_info "Installing systemd service + timer ($SCHED_LABEL)..."
tee "/etc/systemd/system/${SVC_NAME}.service" >/dev/null << UNITEOF
[Unit]
Description=Post-install backup (Minecraft world + game saves via Kopia)
After=docker.service network-online.target
Wants=docker.service
[Service]
Type=oneshot
ExecStart=/bin/bash $WORKER run
UNITEOF
tee "/etc/systemd/system/${SVC_NAME}.timer" >/dev/null << UNITEOF
[Unit]
Description=Schedule post-install backup ($SCHED_LABEL)
[Timer]
OnCalendar=$ONCALENDAR
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
UNITEOF
systemctl daemon-reload
systemctl enable --now "${SVC_NAME}.timer"
log_success "Timer enabled: $SCHED_LABEL"
AUTORUN="systemctl list-timers ${SVC_NAME}.timer"
else
log_warning "systemd not detected — installing a cron job instead."
local CRON
case "${_sch:-1}" in
2) CRON="0 0,6,12,18 * * *" ;;
3) CRON="0 * * * *" ;;
*) CRON="0 3 * * *" ;;
esac
echo "$CRON root /bin/bash $WORKER run >> /var/log/${SVC_NAME}.log 2>&1" \
> "/etc/cron.d/${SVC_NAME}"
log_success "Cron job installed: $CRON"
AUTORUN="cat /etc/cron.d/${SVC_NAME}"
fi
# ── 9. First backup now? ─────────────────────────────────────────────────
echo ""
local _now=""
prompt_yn " Run the first backup now? (Y/n):" "y" _now
if [[ ! "$_now" =~ ^[Nn]$ ]]; then
/bin/bash "$WORKER" run || log_warning "First backup reported warnings — check the output above."
fi
# ── Summary ──────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════════════════"
echo " BACKUPS CONFIGURED"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Repository : $REPO_DIR (encrypted, dedup + zstd)"
echo " Schedule : $SCHED_LABEL"
echo " Config : $CONF_FILE"
echo " Worker : $WORKER"
echo " Backing up :"
[ -n "$MC_BASE_DIR" ] && echo " • Minecraft worlds $MC_BASE_DIR/*/data (all instances)"
[ "$BACKUP_SAVES" = yes ] && echo " • Emulator saves $GAME_STORAGE_DIR/saves"
[ "$BACKUP_STEAM" = yes ] && echo " • Steam user data $GAME_STORAGE_DIR/steam (game installs excluded)"
[ "$BACKUP_MEDIA" = yes ] && echo " • ES-DE scraped art $GAME_STORAGE_DIR/media"
[ "$BACKUP_WOLF" = yes ] && echo " • Wolf state $WOLF_STATE_DIR (config + profile_data)"
echo " NOT backed up: ROMs, Steam game installs (re-downloadable)."
echo ""
echo " Commands:"
echo " sudo $WORKER back up now"
echo " sudo $WORKER snapshots list snapshots"
echo " sudo $WORKER restore restore / browse"
echo " $AUTORUN"
echo ""
echo " Offsite mirror (another computer / cloud): set REMOTE_TYPE + REMOTE_ARGS"
echo " in backup.conf — examples are in the file."
echo ""
log_warning "Save your repository password (in backup.conf) somewhere safe —"
log_warning "without it the encrypted backups cannot be restored."
echo ""
log_success "Backups configured."
}
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# services/base.sh — essential CLI packages installed on every box.
# Part of the modular post-install system (sourced by setup.sh).
register_service base base "Essential CLI packages (net-tools, git, htop, glow, …)"
install_base() {
log_info "Installing essential packages..."
run_cmd apt-get update -y
# Core utilities present on every install.
run_cmd apt-get install -y \
net-tools ncdu git curl wget htop tree zip unzip \
ca-certificates gnupg jq rsync || log_warning "Some essential packages failed to install"
# glow — terminal markdown reader (charmbracelet). Not in Ubuntu repos,
# so add Charm's apt repository first.
install_glow
}
# glow is also exposed as its own module so it can be (re)installed on its own.
install_glow() {
if command -v glow >/dev/null 2>&1; then
log_success "glow already installed ($(glow --version 2>/dev/null | head -1))"
return 0
fi
log_info "Installing glow (terminal markdown reader) from the Charm apt repo..."
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow"
return 0
fi
sudo mkdir -p /etc/apt/keyrings
if curl -fsSL https://repo.charm.sh/apt/gpg.key \
| sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" \
| sudo tee /etc/apt/sources.list.d/charm.list >/dev/null
if sudo apt-get update -y && sudo apt-get install -y glow; then
log_success "glow installed ($(glow --version 2>/dev/null | head -1))"
else
log_warning "glow install failed — see https://github.com/charmbracelet/glow"
fi
else
log_warning "Could not fetch Charm signing key — skipping glow"
fi
}
# Register glow as a standalone service too (./setup.sh glow).
register_service glow base "Terminal markdown reader (charmbracelet/glow)"
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# services/homeassistant.sh — Home Assistant home-automation hub.
# Part of the modular post-install system (sourced by setup.sh).
register_service homeassistant homelab "Home automation hub (Home Assistant)" 8123
install_homeassistant() {
require_docker || return 1
log_info "Installing Home Assistant..."
local HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR"
return 0
fi
mkdir -p "$HOMEASSISTANT_DIR"
ensure_docker_dir_ownership "$HOMEASSISTANT_DIR"
cd "$HOMEASSISTANT_DIR" || return 1
# Networking mode: bridge (published port) vs host networking.
echo ""
echo " Home Assistant networking mode:"
echo " 1) Bridge - container gets its own network; port 8123 is published"
echo " to the host. Works behind the Caddy reverse proxy and"
echo " keeps HA isolated. Recommended for most setups."
echo " 2) Host - HA shares the host's network directly. Needed for"
echo " auto-discovery of devices on your LAN (Chromecast/Cast,"
echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)."
local HA_NETMODE=""
prompt_text " Choose networking mode [1]:" "1" HA_NETMODE
local HA_NET_LINES
if [ "$HA_NETMODE" = "2" ]; then
HA_NET_LINES=" network_mode: host"
echo " → Host networking selected (best device discovery)."
else
HA_NET_LINES=" ports:
- \"8123:8123\""
echo " → Bridge networking selected (port 8123 published)."
fi
cat > docker-compose.yml << HOMEASSISTANT_COMPOSE
name: homeassistant
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
hostname: homeassistant
restart: unless-stopped
privileged: true
environment:
- TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
volumes:
- ./config:/config
- /run/dbus:/run/dbus:ro
${HA_NET_LINES}
HOMEASSISTANT_COMPOSE
mkdir -p config
# Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy.
# Only written on a fresh install (never clobber an existing config).
if [ ! -f config/configuration.yaml ]; then
cat > config/configuration.yaml << 'HA_CONFIG'
# Loads default set of integrations. Do not remove.
default_config:
# Allow access through a reverse proxy (e.g. Caddy)
http:
use_x_forwarded_for: true
trusted_proxies:
- 172.16.0.0/12
- 192.168.0.0/16
- 10.0.0.0/8
- 127.0.0.1
- ::1
HA_CONFIG
fi
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR"
echo ""
log_success "Home Assistant configured at $HOMEASSISTANT_DIR"
configure_caddy_for_service "Home Assistant" "8123" "home"
local START_HA=""
prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA
if [ "$START_HA" = "y" ] || [ "$START_HA" = "Y" ]; then
docker compose up -d 2>/dev/null && log_success "Home Assistant started" || log_warning "Failed to start"
fi
echo " Access at: http://localhost:8123"
echo " First run: open the URL and create your admin account (onboarding)."
echo " Note: first startup can take a minute while HA initializes."
echo ""
}
+299
View File
@@ -0,0 +1,299 @@
#!/bin/bash
# services/js99er.sh — Self-hosted TI-99/4A emulator (js99er.net).
# Part of the modular post-install system (sourced by setup.sh).
#
# Builds the js99er-angular source into a static site (multi-stage Docker
# build) with an offline Google Fonts fix, served by nginx. Each service lives
# in its own folder with its own standalone docker-compose.yml.
register_service js99er gaming "Self-hosted TI-99/4A emulator (js99er.net)" 8099
install_js99er() {
require_docker || return 1
log_info "Installing js99er (TI-99/4A emulator)..."
local JS99ER_DIR="$DOCKER_DIR/js99er"
# Port: default 8099 (8090 clashes with wolf-pair on a shared gaming box).
local JS99ER_PORT=""
prompt_text "Local port to expose js99er on [8099]:" "8099" JS99ER_PORT
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $JS99ER_DIR (with nginx/ subdir)"
echo "[DRY-RUN] Would clone/update https://github.com/Rasmus-M/js99er-angular.git into $JS99ER_DIR/src"
echo "[DRY-RUN] Would write Dockerfile, nginx/nginx.conf and a standalone docker-compose.yml"
echo "[DRY-RUN] Would build the js99er image and start the container on port $JS99ER_PORT"
return 0
fi
mkdir -p "$JS99ER_DIR/nginx"
ensure_docker_dir_ownership "$JS99ER_DIR"
cd "$JS99ER_DIR" || return 1
# ── 1. Clone / update js99er source ──────────────────────────────────────
log_info "Fetching js99er source..."
if [ -d "$JS99ER_DIR/src/.git" ]; then
git -C "$JS99ER_DIR/src" pull --ff-only || { log_error "Failed to update js99er source"; return 1; }
else
git clone --depth 1 https://github.com/Rasmus-M/js99er-angular.git "$JS99ER_DIR/src" \
|| { log_error "Failed to clone js99er source"; return 1; }
fi
log_success "Source ready"
# ── 2. Dockerfile ────────────────────────────────────────────────────────
# Strategy:
# • Disable Angular CLI's font-inlining optimisation (it fetches from
# fonts.googleapis.com at BUILD time and fails offline).
# • After the build, patch the output index.html to remove any remaining
# Google Fonts <link> tags that were in the source index.html.
# • Download the actual font files from Google's CDN during the Docker
# build (we still have internet at build time) and serve them locally.
# • Inject a local fonts.css that references those local files.
log_info "Creating Dockerfile..."
cat > "$JS99ER_DIR/Dockerfile" << 'DOCKERFILE'
# ── Stage 1: build ────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
# git needed if package.json has git deps; python3/make/g++ for native modules
RUN apk add --no-cache git python3 make g++
WORKDIR /app
COPY src/package*.json ./
# Use --legacy-peer-deps because js99er-angular has some older peer dep chains
RUN npm ci --legacy-peer-deps
COPY src/ ./
# Disable Angular's build-time font inlining so it doesn't call out to
# fonts.googleapis.com (which would break in an air-gapped build).
# The jq approach is cleanest; fall back to sed if jq isn't present.
RUN if command -v jq >/dev/null 2>&1; then \
jq '.projects["js99er"].architect.build.options.optimization = {"scripts":true,"styles":{"minify":true,"inlineCritical":true},"fonts":false}' \
angular.json > angular.json.tmp && mv angular.json.tmp angular.json; \
else \
sed -i 's/"optimization": true/"optimization": {"scripts":true,"styles":{"minify":true,"inlineCritical":true},"fonts":false}/' angular.json || true; \
fi
RUN npx ng build --configuration production --output-path /dist 2>&1
# Find where index.html actually landed (Angular may nest under /dist/browser
# or /dist/js99er depending on the project name in angular.json)
RUN find /dist -name "index.html" | head -5
# Resolve the actual index.html path and patch it
RUN INDEX=$(find /dist -name "index.html" | head -1) \
&& echo "Patching: $INDEX" \
&& sed -i \
-e 's|<link[^>]*fonts\.googleapis\.com[^>]*/>||g' \
-e 's|<link[^>]*fonts\.googleapis\.com[^>]*>||g' \
-e 's|<link[^>]*fonts\.gstatic\.com[^>]*/>||g' \
-e 's|<link[^>]*fonts\.gstatic\.com[^>]*>||g' \
"$INDEX" \
&& sed -i 's|</head>|<link rel="stylesheet" href="/fonts/fonts.css"></head>|' "$INDEX" \
&& echo "Patched index.html OK" \
&& grep -i "fonts" "$INDEX" || true
# ── Stage 2: download fonts ───────────────────────────────────────────────────
# Do this in a separate stage so the font files are fetched fresh at build time
# using a known-good mechanism, and are not baked into the source tree.
FROM alpine AS fontfetcher
RUN apk add --no-cache curl xxd bash
WORKDIR /fonts
# We use the google-webfonts-helper ZIP download API — one request, all variants.
# This is more reliable than trying to parse the JSON API and extract individual URLs.
# Double-quotes around the URL are required because of the & in query params.
RUN curl -fsSL \
"https://gwfh.mranftl.com/api/fonts/roboto?download=zip&subsets=latin&variants=300,regular,500,700&formats=woff2" \
-o roboto.zip \
&& unzip roboto.zip \
&& rm roboto.zip \
&& ls -la
# Material Icons — download the woff2 directly from Google's CDN.
# This URL is stable; Material Icons has not changed its CDN path in years.
# We verify the file is actually a woff2 (starts with wOF2 magic bytes).
RUN curl -fsSL \
"https://fonts.gstatic.com/s/materialicons/v140/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2" \
-o material-icons.woff2 \
&& MAGIC=$(xxd -p -l 4 material-icons.woff2) \
&& echo "Magic bytes: $MAGIC" \
&& [ "$MAGIC" = "774f4632" ] \
&& echo "Material Icons OK (valid wOF2)" \
|| (echo "ERROR: Not a valid woff2 file. Got magic: $MAGIC"; exit 1)
# Generate the CSS that maps font-family names to the local files.
# File names come from what gwfh actually produces (roboto-v{N}-latin-{variant}.woff2).
RUN ls *.woff2 | sort && echo "--- files above ---"
# Generate fonts.css in pure shell — no python3 needed
RUN <<'GENCSS'
#!/bin/bash
set -e
CSS="/* ================================================================
Local fonts — replaces fonts.googleapis.com CDN references
Generated at Docker build time
================================================================ */
"
for f in $(ls roboto-*.woff2 2>/dev/null | sort); do
# filename pattern: roboto-v{N}-latin-{variant}.woff2
variant=$(echo "$f" | sed 's/roboto-v[0-9]*-latin-\(.*\)\.woff2/\1/')
case "$variant" in
300) weight="300" ;;
regular) weight="400" ;;
500) weight="500" ;;
700) weight="700" ;;
*) weight="400" ;;
esac
CSS="${CSS}@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: ${weight};
font-display: swap;
src: url('/fonts/${f}') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
"
done
MATERIAL=$(ls material-icons.woff2 2>/dev/null || true)
if [ -n "$MATERIAL" ]; then
CSS="${CSS}@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
font-display: block;
src: url('/fonts/material-icons.woff2') format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
"
fi
printf '%s' "$CSS" > fonts.css
echo "fonts.css written — first 400 chars:"
head -c 400 fonts.css
GENCSS
# ── Stage 3: serve ────────────────────────────────────────────────────────────
FROM nginx:alpine
# Copy whichever subdirectory contains index.html
RUN mkdir -p /usr/share/nginx/html
COPY --from=builder /dist /dist-tmp
RUN INDEX=$(find /dist-tmp -name "index.html" | head -1) \
&& DIST_DIR=$(dirname "$INDEX") \
&& cp -r "$DIST_DIR"/. /usr/share/nginx/html/ \
&& rm -rf /dist-tmp
COPY --from=fontfetcher /fonts /usr/share/nginx/html/fonts
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
DOCKERFILE
log_success "Dockerfile created"
# ── 3. nginx config ──────────────────────────────────────────────────────
cat > "$JS99ER_DIR/nginx/nginx.conf" << 'NGINXCONF'
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Fonts — long cache, CORS open (woff2 needs it in some browsers)
location /fonts/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
}
# Static assets — JS, CSS, images, fonts
location ~* \.(js|css|ico|png|svg|woff2|woff|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Angular router — unknown paths serve index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
NGINXCONF
log_success "nginx config created"
# ── 4. Standalone docker-compose.yml (per-service folder) ────────────────
cat > "$JS99ER_DIR/docker-compose.yml" << COMPOSE
name: js99er
services:
js99er:
build:
context: .
dockerfile: Dockerfile
container_name: js99er
ports:
- "${JS99ER_PORT}:80"
restart: unless-stopped
COMPOSE
log_success "Created js99er/docker-compose.yml"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JS99ER_DIR"
echo ""
log_success "js99er configured at $JS99ER_DIR"
# ── 5. Reverse proxy (no-ops if Caddy isn't installed locally) ───────────
configure_caddy_for_service "js99er" "$JS99ER_PORT" "js99er"
# ── 6. Build & start ─────────────────────────────────────────────────────
local START_JS99ER=""
prompt_yn "Build and start js99er now? (first build takes a few minutes) (y/n):" "y" START_JS99ER
if [ "$START_JS99ER" = "y" ] || [ "$START_JS99ER" = "Y" ]; then
log_info "Building and starting js99er..."
if docker compose up -d --build; then
log_success "js99er started"
log_info "Verifying fonts are served correctly..."
local HTTP_STATUS
HTTP_STATUS=$(curl -so /dev/null -w "%{http_code}" "http://localhost:${JS99ER_PORT}/fonts/fonts.css" 2>/dev/null || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
log_success "fonts.css is being served (HTTP 200)"
else
log_warning "fonts.css returned HTTP $HTTP_STATUS — check: docker logs js99er"
fi
else
log_warning "Failed to build/start js99er — check: docker compose logs"
fi
fi
# ── 7. Access summary ────────────────────────────────────────────────────
echo ""
echo " Access at: http://localhost:${JS99ER_PORT}"
echo " If you set a domain above, it is also reachable via that domain (HTTPS)."
echo " Online alternative (no install needed): https://js99er.net"
echo ""
}
File diff suppressed because it is too large Load Diff
+864
View File
@@ -0,0 +1,864 @@
#!/bin/bash
# services/wolf.sh — Cloud gaming via Moonlight (Games-on-Whales Wolf).
# Part of the modular post-install system (sourced by setup.sh).
#
# Self-hosted Moonlight streaming server. One Wolf container spins up app
# containers (ES-DE/RetroArch, Steam, Lutris, Firefox, full desktop) on demand,
# with virtual displays and virtual gamepads — no monitor, no dummy plug.
# Stream to any Moonlight client (TV, phone, PC, Fire TV stick, etc.).
#
# Ported from setup-wolf.sh. The dispatcher runs as root (require_root), so the
# original's refuse-root check and sudo prefixes are dropped. The wolf-pair
# helper service is dropped (it depended on repo files we don't ship); the
# `./manage.sh pin` command replaces it.
register_service wolf gaming "Cloud gaming via Moonlight (Games-on-Whales Wolf)" 47989
install_wolf() {
require_docker || return 1
local WOLF_DIR="$DOCKER_DIR/wolf"
# Moonlight / Wolf default ports
local WOLF_PORTS_TCP=(47984 47989 48010)
local WOLF_PORTS_UDP=(47999 48100 48200)
cat << "EOF"
╔═══════════════════════════════════════════════════════╗
║ ║
║ WOLF CLOUD GAMING SETUP ║
║ Self-hosted Moonlight streaming (Games-on-Whales)
║ ║
╚═══════════════════════════════════════════════════════╝
EOF
echo ""
# ── Dry-run summary (do nothing that touches hardware/docker/apt) ─────────
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Wolf install would:"
echo " - Check for an NVIDIA GPU with driver >= 530 and detect its render node"
echo " - Ensure nvidia-drm modeset=1 (modprobe.d + GRUB/systemd-boot), may need reboot"
echo " - Install the NVIDIA Container Toolkit if missing"
echo " - Set up uinput/uhid modules + virtual-input udev rules"
echo " - Build the NVIDIA driver volume (nvidia-driver-vol) + copy CUDA/NVENC libs"
echo " - Detect LAN / Tailscale IP for Wolf to advertise"
echo " - Write $WOLF_DIR/docker-compose.yml and $WOLF_DIR/manage.sh"
echo " - Open Moonlight UFW ports: TCP ${WOLF_PORTS_TCP[*]} / UDP ${WOLF_PORTS_UDP[*]}"
echo " - Start Wolf and inject Steam + EmulationStation app profiles"
return 0
fi
# ── OS / Docker Compose sanity ────────────────────────────────────────────
if [ -f /etc/os-release ]; then
. /etc/os-release
log_success "OS: $PRETTY_NAME"
fi
if ! docker compose version &>/dev/null; then
log_error "Docker Compose v2 not found. Install: apt-get install docker-compose-plugin"
return 1
fi
log_success "Docker found (Compose: $(docker compose version --short))"
# ── NVIDIA checks ─────────────────────────────────────────────────────────
local HAS_NVIDIA=false DRIVER_VER GPU_NAME DRIVER_MAJOR
if command -v nvidia-smi &>/dev/null && nvidia-smi &>/dev/null 2>&1; then
HAS_NVIDIA=true
DRIVER_VER=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -n1)
GPU_NAME=$(nvidia-smi --query-gpu=name --format=csv,noheader | head -n1)
log_success "NVIDIA GPU: $GPU_NAME (driver $DRIVER_VER)"
# Wolf requires driver >= 530.30.02
DRIVER_MAJOR=$(echo "$DRIVER_VER" | cut -d. -f1)
if [ "$DRIVER_MAJOR" -lt 530 ] 2>/dev/null; then
log_error "Wolf needs NVIDIA driver >= 530.30.02 (you have $DRIVER_VER)."
log_error "Update: apt-get install nvidia-driver-535 && reboot"
return 1
fi
else
log_error "No working NVIDIA GPU detected (nvidia-smi failed)."
log_error "Wolf needs a working NVIDIA driver for hardware encoding."
log_error "Install one, reboot, and re-run this module."
return 1
fi
# ── Detect the NVIDIA DRM render node ─────────────────────────────────────
# Wolf reads WOLF_RENDER_NODE (default /dev/dri/renderD128) to detect the GPU
# vendor, then only selects an encoder whose vendor matches. On systems with
# both an Intel iGPU and an NVIDIA card, renderD128 is usually the Intel GPU,
# so Wolf detects "Intel", picks VA-API, and never tries NVENC.
#
# 0x10de is NVIDIA's PCI vendor ID. To pick the exact card the driver manages
# (unambiguous on multi-GPU hosts) we cross-check each candidate's PCI bus
# address against the bus IDs nvidia-smi reports.
local WOLF_RENDER_NODE="" NV_BUS_SHORT _rnode _dev _pci_short
NV_BUS_SHORT=$(nvidia-smi --query-gpu=pci.bus_id --format=csv,noheader 2>/dev/null \
| awk -F: '{print $(NF-1)":"$NF}' | tr 'A-F' 'a-f')
for _rnode in /dev/dri/renderD*; do
[ -e "$_rnode" ] || continue
_dev="/sys/class/drm/$(basename "$_rnode")/device"
[ "$(cat "$_dev/vendor" 2>/dev/null)" = "0x10de" ] || continue # NVIDIA vendor
_pci_short=$(basename "$(readlink -f "$_dev" 2>/dev/null)" | awk -F: '{print $(NF-1)":"$NF}')
if [ -n "$NV_BUS_SHORT" ]; then
if printf '%s\n' "$NV_BUS_SHORT" | grep -qix "$_pci_short"; then
WOLF_RENDER_NODE="$_rnode"; break
fi
else
WOLF_RENDER_NODE="$_rnode"; break
fi
done
if [ -n "$WOLF_RENDER_NODE" ]; then
log_success "NVIDIA render node detected: $WOLF_RENDER_NODE (Wolf will use it for NVENC)"
else
WOLF_RENDER_NODE="/dev/dri/renderD128"
log_warning "Could not match an NVIDIA render node to nvidia-smi — defaulting to $WOLF_RENDER_NODE"
log_warning "If Wolf logs 'Using h265 encoder: va', set WOLF_RENDER_NODE manually to your NVIDIA card."
log_warning "List candidates with: for n in /dev/dri/renderD*; do echo \$n \$(cat /sys/class/drm/\$(basename \$n)/device/vendor); done"
fi
# ── nvidia-drm modeset=1 (required for Wolf's virtual displays) ───────────
# Primary method: modprobe.d (bootloader-agnostic). Also set it in GRUB and
# systemd-boot cmdline as a backup. Check sysfs (older: Y/N, newer 5xx: 1/0)
# and /proc/cmdline — if either confirms modeset=1 we are good.
local MODESET
MODESET=$(cat /sys/module/nvidia_drm/parameters/modeset 2>/dev/null | tr -d '[:space:]')
if grep -q "nvidia-drm.modeset=1" /proc/cmdline 2>/dev/null; then
MODESET="1" # cmdline is authoritative — module may just not be loaded yet
fi
if [[ "$MODESET" != "Y" && "$MODESET" != "1" && "$MODESET" != "2" ]]; then
echo ""
log_warning "Kernel module nvidia-drm is NOT loaded with modeset=1."
log_warning "Wolf needs this to create virtual displays."
echo ""
local ENABLE_MODESET="y"
if [ "$UNATTENDED" = true ]; then
log_warning "Unattended mode — enabling nvidia-drm modeset=1 (no auto-reboot)."
ENABLE_MODESET="y"
else
read -p "Enable nvidia-drm modeset=1 now (requires reboot)? (y/n) [y]: " -n 1 -r; echo
ENABLE_MODESET="${REPLY:-y}"
fi
if [[ "$ENABLE_MODESET" =~ ^[Yy]$ ]]; then
# ── Method 1: modprobe.d (bootloader-agnostic, most reliable) ──
local MODPROBE_CONF="/etc/modprobe.d/nvidia-drm-modeset.conf"
if ! grep -qs "modeset=1" "$MODPROBE_CONF" 2>/dev/null; then
echo "options nvidia-drm modeset=1" | tee "$MODPROBE_CONF" >/dev/null
log_success "Written: $MODPROBE_CONF"
fi
# Rebuild initramfs so the option is baked in
if command -v update-initramfs &>/dev/null; then
log_info "Rebuilding initramfs (this takes ~30 s)..."
update-initramfs -u -k all
fi
# ── Method 2: GRUB (if present) ──
local GRUB_FILE="/etc/default/grub"
if [ -f "$GRUB_FILE" ] && ! grep -q "nvidia-drm.modeset=1" "$GRUB_FILE"; then
sed -i \
's/\(GRUB_CMDLINE_LINUX_DEFAULT="[^"]*\)"/\1 nvidia-drm.modeset=1"/' \
"$GRUB_FILE"
if command -v update-grub &>/dev/null; then
update-grub 2>/dev/null
log_success "Added nvidia-drm.modeset=1 to GRUB"
fi
fi
# ── Method 3: systemd-boot (Ubuntu 24.04+ EFI installs) ──
local SBOOT_CONF
SBOOT_CONF=$(find /boot/loader/entries/ -name "*.conf" 2>/dev/null | head -1)
if [ -n "$SBOOT_CONF" ] && ! grep -q "nvidia-drm.modeset=1" "$SBOOT_CONF"; then
sed -i 's/\(^options .*\)/\1 nvidia-drm.modeset=1/' "$SBOOT_CONF"
log_success "Added nvidia-drm.modeset=1 to systemd-boot entry: $(basename "$SBOOT_CONF")"
fi
echo ""
log_warning "A REBOOT is required. Re-run this module after rebooting."
if [ "$UNATTENDED" = true ]; then
log_warning "Unattended mode — skipping reboot. Reboot manually, then re-run: sudo ./setup.sh wolf"
return 0
fi
read -p "Reboot now? (y/n) [y]: " -n 1 -r; echo
if [[ ${REPLY:-y} =~ ^[Yy]$ ]]; then
reboot
fi
return 0
else
log_warning "Continuing without modeset=1 — Wolf may fail to start virtual displays."
fi
else
log_success "nvidia-drm modeset is active (sysfs reports: $MODESET)"
fi
# ── NVIDIA Container Toolkit (bootstraps the driver volume build) ─────────
if ! command -v nvidia-container-cli &>/dev/null; then
log_warning "nvidia-container-cli not found — installing NVIDIA Container Toolkit..."
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -sL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null
apt-get update
apt-get install -y nvidia-container-toolkit
nvidia-ctk runtime configure --runtime=docker
systemctl restart docker
log_success "NVIDIA Container Toolkit installed"
fi
# ── Virtual input devices (gamepads) ──────────────────────────────────────
log_info "Setting up virtual gamepad support..."
# uinput / uhid kernel modules
if [ ! -e /dev/uinput ]; then
log_info "Loading uinput kernel module..."
modprobe uinput || log_warning "Could not load uinput module"
fi
[ -e /dev/uhid ] || modprobe uhid 2>/dev/null || true
# Make uinput load at boot
if [ ! -f /etc/modules-load.d/uinput.conf ]; then
echo "uinput" | tee /etc/modules-load.d/uinput.conf >/dev/null
fi
# udev rules so Wolf can access virtual input devices
local UDEV_RULES="/etc/udev/rules.d/85-wolf-virtual-inputs.rules"
if [ ! -f "$UDEV_RULES" ]; then
log_info "Installing Wolf virtual-input udev rules..."
tee "$UDEV_RULES" >/dev/null << 'UDEV'
# Wolf virtual input devices
KERNEL=="uinput", SUBSYSTEM=="misc", MODE="0660", GROUP="input", OPTIONS+="static_node=uinput", TAG+="uaccess"
KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{name}=="Wolf PS5 (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf X-Box One (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf PS5 (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf gamepad (virtual) motion sensors", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
SUBSYSTEMS=="input", ATTRS{name}=="Wolf Nintendo (virtual) pad", GROUP="root", MODE="0660", ENV{ID_SEAT}="seat9"
UDEV
udevadm control --reload-rules && udevadm trigger
log_success "udev rules installed"
else
log_info "Wolf udev rules already present"
fi
# ── Build the NVIDIA driver volume (GoW recommended 'manual' method) ──────
# More stable than the container-toolkit method for Wolf. The volume holds
# userspace driver files matching the host kernel driver, mounted into app
# containers.
log_info "Building NVIDIA driver volume for Wolf (matches host driver $DRIVER_VER)..."
local NV_KVER VOL_HAS
NV_KVER=$(cat /sys/module/nvidia/version 2>/dev/null || echo "$DRIVER_VER")
if docker volume ls --format '{{.Name}}' | grep -q '^nvidia-driver-vol$'; then
# Check if the volume matches the current driver; if not, rebuild
VOL_HAS=$(docker run --rm -v nvidia-driver-vol:/usr/nvidia alpine \
sh -c 'ls /usr/nvidia/lib 2>/dev/null | grep -o "libnvidia-glcore.so.[0-9.]*" | head -1' 2>/dev/null || echo "")
if echo "$VOL_HAS" | grep -q "$NV_KVER"; then
log_success "nvidia-driver-vol already matches driver $NV_KVER"
else
log_warning "Driver volume is stale — rebuilding for $NV_KVER"
docker volume rm nvidia-driver-vol >/dev/null 2>&1 || true
fi
fi
if ! docker volume ls --format '{{.Name}}' | grep -q '^nvidia-driver-vol$'; then
log_info "Building gow/nvidia-driver:latest (driver $NV_KVER)..."
curl -fsSL https://raw.githubusercontent.com/games-on-whales/gow/master/images/nvidia-driver/Dockerfile \
| docker build -t gow/nvidia-driver:latest -f - --build-arg NV_VERSION="$NV_KVER" . \
|| { log_error "Failed to build the NVIDIA driver image."; return 1; }
log_info "Populating nvidia-driver-vol..."
docker create --rm --mount source=nvidia-driver-vol,destination=/usr/nvidia gow/nvidia-driver:latest sh >/dev/null
log_success "nvidia-driver-vol created"
fi
# The GOW nvidia-driver image only ships OpenGL/Vulkan libs — not libcuda.so,
# libnvcuvid.so, or libnvidia-encode.so, which GStreamer's nvcodec elements
# need for NVENC. Copy them straight from the host driver into the volume:
# host userspace libs always match the running kernel module, so there's no
# version-skew risk; Wolf finds them via LD_LIBRARY_PATH=/usr/nvidia/lib.
local _VOL_HAS_CUDA HOST_CUDA HOST_LIB_DIR
_VOL_HAS_CUDA=$(docker run --rm -v nvidia-driver-vol:/usr/nvidia alpine \
sh -c 'ls /usr/nvidia/lib/libcuda.so* 2>/dev/null | head -1' 2>/dev/null || echo "")
if [ -z "$_VOL_HAS_CUDA" ]; then
log_info "Copying CUDA/NVENC libs from host driver into nvidia-driver-vol..."
HOST_CUDA=$(ldconfig -p 2>/dev/null | awk '/libcuda\.so\.1/ {print $NF; exit}')
[ -z "$HOST_CUDA" ] && HOST_CUDA=$(find /usr/lib /usr/lib64 /usr/lib/x86_64-linux-gnu \
-name 'libcuda.so.*' 2>/dev/null | head -1)
if [ -z "$HOST_CUDA" ] || [ ! -e "$HOST_CUDA" ]; then
log_warning "Could not find libcuda.so on the host — Wolf may fall back to VA-API."
log_warning "Confirm the NVIDIA driver is fully installed (nvidia-smi works)."
else
HOST_LIB_DIR=$(dirname "$HOST_CUDA")
log_info "Host NVIDIA libs: $HOST_LIB_DIR"
if docker run --rm \
-v nvidia-driver-vol:/usr/nvidia \
-v "$HOST_LIB_DIR":/hostlib:ro \
alpine sh -c '
mkdir -p /usr/nvidia/lib
ok=0
for base in libcuda libnvcuvid libnvidia-encode libnvidia-ptxjitcompiler; do
for f in /hostlib/${base}.so*; do
[ -e "$f" ] && cp -a "$f" /usr/nvidia/lib/ && ok=1
done
done
[ -e /usr/nvidia/lib/libcuda.so.1 ] && echo "have-cuda-symlink"
[ $ok -eq 1 ]
' 2>&1; then
log_success "CUDA/NVENC libs copied — Wolf should now select the NVIDIA encoder"
else
log_warning "Failed to copy CUDA libs into the volume — Wolf may use VA-API."
fi
fi
else
log_success "nvidia-driver-vol already has CUDA libs"
fi
# ── Game storage location ─────────────────────────────────────────────────
# ROMs, Steam library, and saves all live under GAME_STORAGE_DIR.
# $GAME_STORAGE_DIR/roms/ → ES-DE at /ROMs
# $GAME_STORAGE_DIR/steam/ → Steam at /home/retro/.steam
# $GAME_STORAGE_DIR/saves/ → RetroArch saves
echo ""
echo "═══════════════════════════════════════════════════════"
echo " GAME STORAGE LOCATION"
echo "═══════════════════════════════════════════════════════"
echo ""
log_info "Drives on this machine:"
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT | grep -v "^loop" | sed 's/^/ /'
echo ""
echo " ROMs, Steam library, and saves will be stored under one directory."
echo " Recommended: a larger/secondary drive (HDD) to keep the OS SSD free."
echo " The directory will be created if it doesn't exist."
echo " If it's on an unmounted drive the script will mount it and add it to fstab."
echo ""
local DEFAULT_STORAGE="$ACTUAL_HOME/drives/games" GAME_STORAGE_DIR=""
prompt_text " Game storage path [${DEFAULT_STORAGE}]:" "$DEFAULT_STORAGE" GAME_STORAGE_DIR
GAME_STORAGE_DIR="${GAME_STORAGE_DIR:-$DEFAULT_STORAGE}"
GAME_STORAGE_DIR="${GAME_STORAGE_DIR/#\~/$ACTUAL_HOME}"
# Check if the path crosses an unmounted drive
local _PARENT
_PARENT=$(dirname "$GAME_STORAGE_DIR")
if [ ! -d "$_PARENT" ]; then
log_warning "Parent directory $_PARENT does not exist."
echo ""
echo " If this path is on a separate drive, pick the device to mount:"
echo ""
lsblk -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT | grep -v "^loop" | sed 's/^/ /'
echo ""
local RAW_DEV=""
prompt_text " Device to mount at ${GAME_STORAGE_DIR%/*} (e.g. sda, sdb1) or Enter to skip:" "" RAW_DEV
if [ -n "$RAW_DEV" ]; then
RAW_DEV="${RAW_DEV##/dev/}"
local DEV="/dev/$RAW_DEV"
local MOUNT_POINT="${GAME_STORAGE_DIR%/*}"
if [ -b "$DEV" ]; then
local PARTITION="${DEV}"
[[ "$DEV" =~ [0-9]$ ]] || PARTITION="${DEV}1"
if ! blkid "$PARTITION" &>/dev/null && \
! fdisk -l "$DEV" 2>/dev/null | grep -q "^${PARTITION}"; then
log_info "Creating partition on $DEV..."
printf 'g\nn\n1\n\n\nw\n' | fdisk "$DEV"
partprobe "$DEV"; sleep 2
fi
if ! blkid -s TYPE "$PARTITION" 2>/dev/null | grep -q TYPE; then
log_info "Formatting ${PARTITION} as ext4..."
mkfs.ext4 -F -L "games" "$PARTITION"
else
log_info "${PARTITION} already has a filesystem — keeping data"
fi
mkdir -p "$MOUNT_POINT"
mount "$PARTITION" "$MOUNT_POINT"
local PART_UUID
PART_UUID=$(blkid -s UUID -o value "$PARTITION")
if [ -n "$PART_UUID" ]; then
if grep -qs "$PART_UUID" /etc/fstab; then
log_info "fstab: UUID=${PART_UUID} already present"
else
echo "UUID=${PART_UUID} ${MOUNT_POINT} ext4 defaults,nofail 0 2" \
| tee -a /etc/fstab >/dev/null
log_success "fstab: UUID=${PART_UUID}${MOUNT_POINT} (nofail, auto-mount on boot)"
fi
else
log_warning "Could not read UUID for ${PARTITION} — add /etc/fstab entry manually"
fi
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MOUNT_POINT" 2>/dev/null || true
log_success "${PARTITION} mounted at ${MOUNT_POINT}"
else
log_warning "$DEV not found — continuing, ensure drive is mounted before starting Wolf"
fi
fi
fi
# Create the storage sub-directories
mkdir -p "$GAME_STORAGE_DIR/roms" "$GAME_STORAGE_DIR/steam" \
"$GAME_STORAGE_DIR/saves" "$GAME_STORAGE_DIR/media"
log_success "Storage layout: $GAME_STORAGE_DIR/{roms,steam,saves,media}"
# ── docker-compose.yml ────────────────────────────────────────────────────
log_info "Generating docker-compose.yml..."
mkdir -p /etc/wolf/cfg
mkdir -p "$WOLF_DIR"
ensure_docker_dir_ownership "$WOLF_DIR"
cd "$WOLF_DIR" || return 1
# Detect the LAN interface (the one used to reach the internet, not VPN/loopback)
local LAN_IFACE LAN_IP LAN_MAC
LAN_IFACE=$(ip route get 8.8.8.8 2>/dev/null | grep -oP 'dev \K\S+' | head -1)
LAN_IP=$(ip route get 8.8.8.8 2>/dev/null | grep -oP 'src \K\S+' | head -1)
LAN_MAC=$(ip link show "$LAN_IFACE" 2>/dev/null | grep -oP 'ether \K\S+' | head -1)
log_success "LAN interface: $LAN_IFACE IP: $LAN_IP MAC: $LAN_MAC"
# If Tailscale is running, Wolf must advertise the Tailscale IP so Moonlight
# clients on the tailnet can connect.
local TS_IP TS_MAC WOLF_IP WOLF_MAC
TS_IP=$(tailscale ip -4 2>/dev/null | head -1)
if [ -n "$TS_IP" ]; then
TS_MAC=$(ip link show tailscale0 2>/dev/null | grep -oP 'ether \K\S+' | head -1)
WOLF_IP="$TS_IP"
WOLF_MAC="${TS_MAC:-$LAN_MAC}"
log_success "Tailscale detected — Wolf will advertise Tailscale IP: $TS_IP"
log_info "In Moonlight use 'Add PC' and enter: $TS_IP"
else
WOLF_IP="$LAN_IP"
WOLF_MAC="$LAN_MAC"
fi
cat > docker-compose.yml << EOF
name: wolf
services:
wolf:
image: ghcr.io/games-on-whales/wolf:stable
container_name: wolf
network_mode: host
restart: unless-stopped
environment:
- NVIDIA_DRIVER_VOLUME_NAME=nvidia-driver-vol
- HOST_APPS_STATE_FOLDER=/etc/wolf
- WOLF_INTERNAL_IP=${WOLF_IP}
- WOLF_INTERNAL_MAC=${WOLF_MAC}
- WOLF_RENDER_NODE=${WOLF_RENDER_NODE}
- LD_LIBRARY_PATH=/usr/nvidia/lib:/usr/nvidia/lib32
volumes:
- /etc/wolf/:/etc/wolf:rw
- /var/run/docker.sock:/var/run/docker.sock:rw
- /dev/:/dev/:rw
- /run/udev:/run/udev:rw
- nvidia-driver-vol:/usr/nvidia:rw
devices:
- /dev/dri
- /dev/uinput
- /dev/uhid
- /dev/nvidia-uvm
- /dev/nvidia-uvm-tools
- /dev/nvidia-caps/nvidia-cap1
- /dev/nvidia-caps/nvidia-cap2
- /dev/nvidiactl
- /dev/nvidia0
- /dev/nvidia-modeset
device_cgroup_rules:
- 'c 13:* rmw'
volumes:
nvidia-driver-vol:
external: true
EOF
log_success "docker-compose.yml created"
# ── Firewall ──────────────────────────────────────────────────────────────
if command -v ufw &>/dev/null; then
log_info "Opening Moonlight ports in UFW..."
for p in "${WOLF_PORTS_TCP[@]}"; do ufw allow "${p}/tcp" comment "Wolf/Moonlight" >/dev/null 2>&1 || true; done
for p in "${WOLF_PORTS_UDP[@]}"; do ufw allow "${p}/udp" comment "Wolf/Moonlight" >/dev/null 2>&1 || true; done
log_success "Ports opened: TCP ${WOLF_PORTS_TCP[*]} / UDP ${WOLF_PORTS_UDP[*]}"
else
log_warning "ufw not installed — if you use a firewall, open these ports:"
echo " TCP: ${WOLF_PORTS_TCP[*]} UDP: ${WOLF_PORTS_UDP[*]}"
fi
# ── Management script ─────────────────────────────────────────────────────
cat > manage.sh << 'MEOF'
#!/bin/bash
case "$1" in
start) docker compose up -d; echo "Wolf started. Pair Moonlight to this server's IP." ;;
stop) docker compose down ;;
restart) docker compose restart ;;
logs) docker compose logs -f wolf ;;
status) docker compose ps; echo; docker ps --filter "name=Wolf" --format "table {{.Names}}\t{{.Status}}" ;;
update)
docker compose pull
docker compose up -d
;;
add-apps)
WOLF_CFG=/etc/wolf/cfg/config.toml
GAME_DIR="${2}"
if [ -z "$GAME_DIR" ]; then
echo "Usage: ./manage.sh add-apps /path/to/game/storage"
echo " e.g. ./manage.sh add-apps /home/user/drives/games"
exit 1
fi
if [ ! -f "$WOLF_CFG" ]; then
echo "Wolf config not found at $WOLF_CFG — is Wolf running?"
exit 1
fi
python3 - "$GAME_DIR" "$WOLF_CFG" << 'PYEOF'
import sys
games = sys.argv[1].rstrip('/')
cfg = sys.argv[2]
with open(cfg, 'r') as f:
lines = f.readlines()
profiles_seen, first_start, insert_at = 0, None, len(lines)
for i, line in enumerate(lines):
if line.strip() == '[[profiles]]':
profiles_seen += 1
if profiles_seen == 1: first_start = i
elif profiles_seen == 2: insert_at = i; break
if first_start is None:
print('ERROR: no [[profiles]] section found'); sys.exit(1)
first_block = lines[first_start:insert_at]
has_steam = any("name = 'WolfSteam'" in l for l in first_block)
has_esde = any("name = 'WolfES-DE'" in l for l in first_block)
to_insert = []
if not has_steam:
to_insert += [
'\n',
" [[profiles.apps]]\n",
" icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png'\n",
" start_virtual_compositor = true\n",
" title = 'Steam'\n",
'\n',
" [profiles.apps.runner]\n",
" base_create_json = '''{\n",
' "HostConfig": {\n',
' "IpcMode": "host",\n',
' "CapAdd": ["SYS_ADMIN", "SYS_NICE", "SYS_PTRACE", "NET_RAW", "MKNOD", "NET_ADMIN"],\n',
' "SecurityOpt": ["seccomp=unconfined", "apparmor=unconfined"],\n',
' "Ulimits": [{"Name":"nofile", "Hard":10240, "Soft":10240}],\n',
' "Privileged": false,\n',
' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n',
' }\n',
"}\n",
"'''\n",
" devices = []\n",
" env = [ 'PROTON_LOG=1', 'RUN_SWAY=true', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n",
" image = 'ghcr.io/games-on-whales/steam:edge'\n",
" mounts = [ '" + games + "/steam:/home/retro/.steam:rw' ]\n",
" name = 'WolfSteam'\n",
" ports = []\n",
" type = 'docker'\n",
]
if not has_esde:
to_insert += [
'\n',
" [[profiles.apps]]\n",
" icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png'\n",
" start_virtual_compositor = true\n",
" title = 'EmulationStation'\n",
'\n',
" [profiles.apps.runner]\n",
" base_create_json = '''{\n",
' "HostConfig": {\n',
' "IpcMode": "host",\n',
' "Privileged": false,\n',
' "CapAdd": ["NET_RAW", "MKNOD", "NET_ADMIN"],\n',
' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n',
' }\n',
"}\n",
"'''\n",
" devices = []\n",
" env = [ 'RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n",
" image = 'ghcr.io/games-on-whales/es-de:edge'\n",
" mounts = [ '" + games + "/roms:/ROMs:rw', '" + games + "/saves:/home/retro/.config/retroarch/saves:rw', '" + games + "/media:/media:rw' ]\n",
" name = 'WolfES-DE'\n",
" ports = []\n",
" type = 'docker'\n",
]
if to_insert:
new_lines = lines[:insert_at] + to_insert + lines[insert_at:]
with open(cfg, 'w') as f:
f.writelines(new_lines)
added = [n for n, exists in [('Steam', has_steam), ('EmulationStation', has_esde)] if not exists]
print(f"Added to default profile: {', '.join(added)}")
else:
print('Both apps already in default profile')
PYEOF
docker compose restart wolf
echo "Wolf restarted. Steam and EmulationStation should now appear in Moonlight."
;;
backup)
echo "Set up backups with the modular system: sudo ./setup.sh backup"
;;
pin)
# Wolf logs: "Insert pin at http://SOMEIP:47989/pin/#HEXHASH"
# Extract just the hash fragment and build URLs for every interface
# so it works whether you're on LAN, VPN, or any other network.
HASH=$(docker compose logs wolf 2>&1 | grep "Insert pin at" | tail -1 \
| grep -oP '#[A-Fa-f0-9]+')
if [ -z "$HASH" ]; then
echo ""
echo " No pairing request found."
echo " Open Moonlight, add this server by IP, and a PIN URL will appear here."
echo ""
else
# Collect all non-loopback IPv4 addresses
ALL_IPS=$(ip -4 addr show | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+(?=/)' \
| grep -v '^127\.')
echo ""
echo " Moonlight is showing a 4-digit PIN."
echo " Open ONE of these URLs in a browser and enter that PIN:"
echo ""
while IFS= read -r ip; do
IFACE=$(ip -4 addr show | grep -B2 "inet $ip/" | grep -oP '^\d+: \K\S+(?=:)' | head -1)
echo " http://$ip:47989/pin/$HASH ($IFACE)"
done <<< "$ALL_IPS"
echo ""
echo " Use the URL matching whichever network Moonlight is on."
echo " (LAN IP for local, VPN/mesh IP for remote)"
echo ""
fi
;;
*)
echo "Wolf Cloud Gaming"
echo " ./manage.sh start - Start Wolf"
echo " ./manage.sh stop - Stop Wolf"
echo " ./manage.sh restart - Restart Wolf"
echo " ./manage.sh logs - Follow Wolf logs"
echo " ./manage.sh status - Show Wolf + app containers"
echo " ./manage.sh pin - Show recent Moonlight pairing PIN link"
echo " ./manage.sh update - Pull latest Wolf image and restart"
echo " ./manage.sh add-apps <path> - Add Steam + ES-DE to Wolf config"
echo " ./manage.sh backup - How to set up backups (sudo ./setup.sh backup)"
;;
esac
MEOF
chmod +x manage.sh
# ── Start Wolf ────────────────────────────────────────────────────────────
echo ""
log_info "Starting Wolf (first start pulls the image — give it a minute)..."
docker compose up -d
# Wolf writes /etc/wolf/cfg/config.toml on first start. Wait for it, then
# wire in game storage.
log_info "Waiting for Wolf to generate /etc/wolf/cfg/config.toml..."
local WOLF_CFG=/etc/wolf/cfg/config.toml _i
for _i in $(seq 1 30); do
[ -f "$WOLF_CFG" ] && break
sleep 2
done
if [ -f "$WOLF_CFG" ]; then
log_info "Adding Steam and EmulationStation to Wolf config..."
python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" << 'PYEOF'
import sys
games = sys.argv[1].rstrip('/')
cfg = sys.argv[2]
with open(cfg, 'r') as f:
lines = f.readlines()
# Wolf uses [[profiles]] / [[profiles.apps]] format.
# Apps must be inserted into the FIRST profile (the default paired-client
# profile) before the second [[profiles]] section starts.
profiles_seen, first_start, insert_at = 0, None, len(lines)
for i, line in enumerate(lines):
if line.strip() == '[[profiles]]':
profiles_seen += 1
if profiles_seen == 1: first_start = i
elif profiles_seen == 2: insert_at = i; break
if first_start is None:
print('[ERROR] No [[profiles]] section found in config'); sys.exit(1)
first_block = lines[first_start:insert_at]
has_steam = any("name = 'WolfSteam'" in l for l in first_block)
has_esde = any("name = 'WolfES-DE'" in l for l in first_block)
to_insert = []
if not has_steam:
to_insert += [
'\n',
" [[profiles.apps]]\n",
" icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/steam/assets/icon.png'\n",
" start_virtual_compositor = true\n",
" title = 'Steam'\n",
'\n',
" [profiles.apps.runner]\n",
" base_create_json = '''{\n",
' "HostConfig": {\n',
' "IpcMode": "host",\n',
' "CapAdd": ["SYS_ADMIN", "SYS_NICE", "SYS_PTRACE", "NET_RAW", "MKNOD", "NET_ADMIN"],\n',
' "SecurityOpt": ["seccomp=unconfined", "apparmor=unconfined"],\n',
' "Ulimits": [{"Name":"nofile", "Hard":10240, "Soft":10240}],\n',
' "Privileged": false,\n',
' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n',
' }\n',
"}\n",
"'''\n",
" devices = []\n",
" env = [ 'PROTON_LOG=1', 'RUN_SWAY=true', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n",
" image = 'ghcr.io/games-on-whales/steam:edge'\n",
f" mounts = [ '{games}/steam:/home/retro/.steam:rw' ]\n",
" name = 'WolfSteam'\n",
" ports = []\n",
" type = 'docker'\n",
]
if not has_esde:
to_insert += [
'\n',
" [[profiles.apps]]\n",
" icon_png_path = 'https://games-on-whales.github.io/wildlife/apps/es-de/assets/icon.png'\n",
" start_virtual_compositor = true\n",
" title = 'EmulationStation'\n",
'\n',
" [profiles.apps.runner]\n",
" base_create_json = '''{\n",
' "HostConfig": {\n',
' "IpcMode": "host",\n',
' "Privileged": false,\n',
' "CapAdd": ["NET_RAW", "MKNOD", "NET_ADMIN"],\n',
' "DeviceCgroupRules": ["c 13:* rmw", "c 244:* rmw"]\n',
' }\n',
"}\n",
"'''\n",
" devices = []\n",
" env = [ 'RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*' ]\n",
" image = 'ghcr.io/games-on-whales/es-de:edge'\n",
f" mounts = [ '{games}/roms:/ROMs:rw', '{games}/saves:/home/retro/.config/retroarch/saves:rw', '{games}/media:/media:rw' ]\n",
" name = 'WolfES-DE'\n",
" ports = []\n",
" type = 'docker'\n",
]
if to_insert:
new_lines = lines[:insert_at] + to_insert + lines[insert_at:]
with open(cfg, 'w') as f:
f.writelines(new_lines)
added = [n for n, exists in [('Steam', has_steam), ('EmulationStation', has_esde)] if not exists]
print(f"[INFO] Added to default profile: {', '.join(added)}")
else:
print('[INFO] Steam and EmulationStation already in default profile')
PYEOF
docker compose restart wolf
log_success "Wolf restarted with updated config"
else
log_warning "Wolf config not generated in time. Add apps manually to /etc/wolf/cfg/config.toml"
log_warning "Then run: ./manage.sh restart"
fi
# Hand the folder back to the real user
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLF_DIR"
local ALL_IPS
ALL_IPS=$(ip -4 addr show | grep -oP '(?<=inet )\d+\.\d+\.\d+\.\d+(?=/)' | grep -v '^127\.')
echo ""
echo "═══════════════════════════════════════════════════════"
echo " WOLF IS RUNNING"
echo "═══════════════════════════════════════════════════════"
echo ""
echo " Server addresses:"
while IFS= read -r ip; do
IFACE=$(ip -4 addr show | grep -B2 "inet $ip/" | grep -oP '^\d+: \K\S+(?=:)' | head -1)
printf " %-18s (%s)\n" "$ip" "$IFACE"
done <<< "$ALL_IPS"
echo ""
echo "── PAIRING ──────────────────────────────────────────"
echo ""
echo " 1. Install Moonlight on your device:"
echo " • Sony Bravia / Google TV → Play Store ('Moonlight Game Streaming')"
echo " • Roku TV → plug in a Fire TV / Chromecast / NVIDIA Shield,"
echo " install Moonlight there"
echo " • Phone / PC / Mac → moonlight-stream.org"
echo ""
echo " 2. In Moonlight, add this server by IP:"
if [ -n "$TS_IP" ]; then
echo " Tailscale: $TS_IP ← use this (Wolf is configured for Tailscale)"
echo " LAN: $LAN_IP (only works on the local network)"
else
echo " LAN: $LAN_IP"
echo " For remote access install Tailscale, then re-run this module."
fi
echo ""
echo " 3. Moonlight shows a 4-digit PIN. On this server run:"
echo " cd $WOLF_DIR && ./manage.sh pin"
echo " It prints a URL for every interface — open the one matching"
echo " whichever network Moonlight is on, then type the PIN."
echo ""
echo " 4. First launch of each app downloads its container image."
echo " A black screen for ~60 s is normal."
echo ""
echo " Return to launcher: Ctrl+Alt+Shift+W or START+UP+RB (controller)"
echo ""
echo "── PAIRING (the ./manage.sh pin workflow) ────────────"
echo ""
echo " Wolf's PIN entry page is served directly by Wolf on port 47989 — no"
echo " separate pairing service or reverse proxy is needed. Workflow:"
echo ""
echo " 1. Open Moonlight → add server by IP → a 4-digit PIN appears."
echo " 2. On this server run: cd $WOLF_DIR && ./manage.sh pin"
echo " 3. It extracts the pairing URL from 'docker logs wolf' and prints"
echo " one link per interface (LAN, Tailscale, etc.)."
echo " 4. Open the link matching Moonlight's network and type the PIN."
echo ""
echo " NOTE: Moonlight streaming uses direct UDP/TCP to this server's IP"
echo " (LAN or VPN). Pairing is just the one-time PIN exchange above."
echo ""
echo "── GAME STORAGE ──────────────────────────────────────"
echo ""
echo " ${GAME_STORAGE_DIR}/"
echo " roms/ → /ROMs (EmulationStation)"
echo " steam/ → /home/retro/.steam (Steam Big Picture)"
echo " saves/ → /home/retro/.config/retroarch/saves (RetroArch saves)"
echo " media/ → /media (ES-DE scraped artwork)"
echo ""
echo " Other app data (ES-DE settings, controller mappings, save states,"
echo " standalone-emulator saves) is persisted by Wolf under /etc/wolf and"
echo " is included when you set up backups."
echo ""
echo "── APPS ──────────────────────────────────────────────"
echo ""
echo " • EmulationStation - ES-DE + RetroArch + Dolphin/PCSX2/Cemu/Ryujinx/more"
echo " • Steam - Big Picture + Proton"
echo " • Lutris - Wine / GOG / Epic / non-Steam"
echo " • RetroArch - standalone, all cores"
echo " • Prismlauncher - Minecraft"
echo " • Kodi - media center"
echo " • Firefox / Desktop - browser and full XFCE desktop"
echo " • Wolf UI / Pegasus - alternative launchers"
echo ""
echo "── MULTIPLAYER ───────────────────────────────────────"
echo ""
echo " Same-screen co-op → create a LOBBY in Wolf UI; each joiner gets"
echo " their own virtual gamepad (1 stream)"
echo " Online together → each player launches their own session,"
echo " all connect to the same game server"
echo ""
echo "Manage: cd $WOLF_DIR && ./manage.sh {start|stop|restart|logs|status|pin|update|add-apps}"
echo ""
echo "── BACKUPS ───────────────────────────────────────────"
echo ""
echo " Back up your saves, progress and user data (Steam user data and all of"
echo " /etc/wolf: ES-DE settings, controller mappings, RetroArch saves/states,"
echo " emulator saves). ROMs and game installs are skipped."
echo ""
echo " Set up automatic backups with the backup module:"
echo " sudo ./setup.sh backup"
echo ""
log_success "Done. Pair Moonlight and play."
}
Executable
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
# setup.sh — modular post-install dispatcher.
#
# One source of truth, two ways to run it:
# sudo ./setup.sh interactive menu (pick any services)
# sudo ./setup.sh <service> ... install one or more services directly
# ./setup.sh --list list available services (grouped)
#
# Flags:
# --dry-run preview actions without making changes
# --unattended use defaults, no prompts
#
# Every service lives in services/<name>.sh, registers itself, and defines
# install_<name>. Adding a service = adding one file. Updating a service =
# editing one file. Nothing is duplicated or generated.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Parse global flags, collect service names ────────────────────────────────
DRY_RUN=false
UNATTENDED=false
DO_LIST=false
REQUESTED=()
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--unattended) UNATTENDED=true ;;
--list|-l) DO_LIST=true ;;
--version|-V) cat "$HERE/VERSION" 2>/dev/null || echo "unknown"; exit 0 ;;
-h|--help)
sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit 0 ;;
-*) echo "Unknown flag: $arg" >&2; exit 1 ;;
*) REQUESTED+=("$arg") ;;
esac
done
export DRY_RUN UNATTENDED
# ── Load helpers + all service modules (they self-register) ──────────────────
# shellcheck source=lib/common.sh
source "$HERE/lib/common.sh"
shopt -s nullglob
for _mod in "$HERE"/services/*.sh; do
# shellcheck source=/dev/null
source "$_mod"
done
shopt -u nullglob
# Ordered list of unique groups, in first-seen order.
groups_in_order() {
local seen=" " g
for name in "${SERVICE_ORDER[@]}"; do
g="${SERVICE_GROUP[$name]}"
case "$seen" in *" $g "*) : ;; *) echo "$g"; seen="$seen$g " ;; esac
done
}
list_services() {
local g name
while IFS= read -r g; do
echo ""
echo "── ${g^^} ──"
for name in "${SERVICE_ORDER[@]}"; do
[ "${SERVICE_GROUP[$name]}" = "$g" ] || continue
printf " %-16s %s\n" "$name" "${SERVICE_DESC[$name]}"
done
done < <(groups_in_order)
echo ""
}
run_service() {
local name="$1"
if [ -z "${SERVICE_GROUP[$name]:-}" ]; then
log_error "Unknown service: $name (try: $0 --list)"
return 1
fi
if ! declare -F "install_${name}" >/dev/null; then
log_error "Service '$name' has no install_${name} function."
return 1
fi
log_info "=== ${name} (${SERVICE_DESC[$name]}) ==="
"install_${name}"
}
# ── --list ───────────────────────────────────────────────────────────────────
if [ "$DO_LIST" = true ]; then
list_services
exit 0
fi
# ── Direct service install: ./setup.sh minecraft homeassistant ─────────────
if [ "${#REQUESTED[@]}" -gt 0 ]; then
require_root
rc=0
for name in "${REQUESTED[@]}"; do
run_service "$name" || rc=1
done
exit "$rc"
fi
# ── Interactive menu ─────────────────────────────────────────────────────────
require_root
SELECTED=()
if command -v whiptail >/dev/null 2>&1; then
_items=()
for name in "${SERVICE_ORDER[@]}"; do
_items+=("$name" "${SERVICE_DESC[$name]}" "OFF")
done
_choice=$(whiptail --title "Ubuntu Post-Install — Services" \
--checklist "Select services to install (space to toggle):" 25 78 16 \
"${_items[@]}" 3>&1 1>&2 2>&3) || { echo "Cancelled."; exit 0; }
# whiptail returns space-separated, quoted names
eval "SELECTED=($_choice)"
else
echo "Available services:"
list_services
read -rp "Enter service names to install (space-separated): " -a SELECTED
fi
[ "${#SELECTED[@]}" -eq 0 ] && { echo "Nothing selected."; exit 0; }
rc=0
for name in "${SELECTED[@]}"; do
run_service "$name" || rc=1
done
exit "$rc"
+132
View File
@@ -1741,6 +1741,23 @@ run_cmd apt install -y \
unzip \
rclone || echo "Warning: Some utilities failed to install, continuing..."
# Install glow (terminal markdown reader) from the Charm apt repo
echo ""
echo "Installing glow (terminal markdown reader)..."
if command -v glow >/dev/null 2>&1; then
echo " ✓ glow already installed"
elif [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow"
else
sudo mkdir -p /etc/apt/keyrings
if curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null
sudo apt update -y && sudo apt install -y glow && echo " ✓ glow installed" || echo " ⚠ glow install failed"
else
echo " ⚠ Could not fetch Charm signing key - skipping glow"
fi
fi
# Install OpenSSH Server
echo ""
echo "Installing OpenSSH Server..."
@@ -2595,6 +2612,7 @@ else
[ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON"
[ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON"
[ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON"
[ -d "$DOCKER_DIR/homeassistant" ] && EXISTING_SERVICES[HOMEASSISTANT]="ON"
[ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON"
[ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON"
[ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON"
@@ -2636,6 +2654,7 @@ else
"CROWDSEC" "Intrusion prevention (CrowdSec: bans + geo + reputation)" ${EXISTING_SERVICES[CROWDSEC]:-OFF} \
"LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \
"MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \
"HOMEASSISTANT" "Home automation hub (Home Assistant)" ${EXISTING_SERVICES[HOMEASSISTANT]:-OFF} \
"MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \
"JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \
"FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \
@@ -2667,6 +2686,7 @@ else
[ -n "${EXISTING_SERVICES[CROWDSEC]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CROWDSEC \"Intrusion prevention\" ON"
[ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON"
[ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON"
[ -n "${EXISTING_SERVICES[HOMEASSISTANT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS HOMEASSISTANT \"Home automation\" ON"
[ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON"
[ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON"
[ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON"
@@ -2717,6 +2737,7 @@ else
: ${INSTALL_CROWDSEC:="n"}
: ${INSTALL_LMS:="n"}
: ${INSTALL_MEALIE:="n"}
: ${INSTALL_HOMEASSISTANT:="n"}
: ${INSTALL_MINECRAFT:="n"}
: ${INSTALL_JELLYFIN:="n"}
: ${INSTALL_FRIGATE:="n"}
@@ -2743,6 +2764,7 @@ else
if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then INSTALL_CROWDSEC="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then INSTALL_HOMEASSISTANT="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi
@@ -2815,6 +2837,7 @@ else
if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi
if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi
if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi
if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then uninstall_service "Home Assistant" "$DOCKER_DIR/homeassistant" "homeassistant"; fi
if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi
if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi
if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi
@@ -4946,6 +4969,106 @@ MEALIE_COMPOSE
fi
fi
# ---- HOME ASSISTANT ----
if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_HOMEASSISTANT" ]; then
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ HOME ASSISTANT - Open-source home automation hub │"
echo "│ Smart-home control, automations, dashboards. │"
echo "│ Port: 8123 │"
echo "└─────────────────────────────────────────────────────────────────┘"
prompt_yn "Install Home Assistant? (y/n):" "n" INSTALL_HOMEASSISTANT
fi
if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then
echo "Installing Home Assistant..."
HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR"
else
mkdir -p "$HOMEASSISTANT_DIR"
ensure_docker_dir_ownership "$HOMEASSISTANT_DIR"
cd "$HOMEASSISTANT_DIR"
# Networking mode: bridge (published port) vs host networking.
echo ""
echo " Home Assistant networking mode:"
echo " 1) Bridge - container gets its own network; port 8123 is published"
echo " to the host. Works behind the Caddy reverse proxy and"
echo " keeps HA isolated. Recommended for most setups."
echo " 2) Host - HA shares the host's network directly. Needed for"
echo " auto-discovery of devices on your LAN (Chromecast/Cast,"
echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)."
prompt_text " Choose networking mode [1]:" "1" HA_NETMODE
if [ "$HA_NETMODE" = "2" ]; then
HA_NET_LINES=" network_mode: host"
echo " → Host networking selected (best device discovery)."
else
HA_NET_LINES=" ports:
- \"8123:8123\""
echo " → Bridge networking selected (port 8123 published)."
fi
cat > docker-compose.yml << HOMEASSISTANT_COMPOSE
name: homeassistant
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
hostname: homeassistant
restart: unless-stopped
privileged: true
environment:
- TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
volumes:
- ./config:/config
- /run/dbus:/run/dbus:ro
${HA_NET_LINES}
HOMEASSISTANT_COMPOSE
mkdir -p config
# Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy.
# Only written on a fresh install (never clobber an existing config).
if [ ! -f config/configuration.yaml ]; then
cat > config/configuration.yaml << 'HA_CONFIG'
# Loads default set of integrations. Do not remove.
default_config:
# Allow access through a reverse proxy (e.g. Caddy)
http:
use_x_forwarded_for: true
trusted_proxies:
- 172.16.0.0/12
- 192.168.0.0/16
- 10.0.0.0/8
- 127.0.0.1
- ::1
HA_CONFIG
fi
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR"
echo ""
echo "✓ Home Assistant configured at $HOMEASSISTANT_DIR"
# Configure Caddy reverse proxy before starting
configure_caddy_for_service "Home Assistant" "8123" "home"
prompt_yn "Start Home Assistant now? (y/n):" "y" START_HOMEASSISTANT
if [ "$START_HOMEASSISTANT" = "y" ] || [ "$START_HOMEASSISTANT" = "Y" ]; then
docker compose up -d 2>/dev/null && echo " ✓ Home Assistant started" || echo " ⚠ Failed to start"
fi
echo " Access at: http://localhost:8123"
echo " First run: open the URL and create your admin account (onboarding)."
echo " Note: first startup can take a minute while HA initializes."
echo ""
fi
fi
# ---- MINECRAFT SERVER ----
if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then
echo ""
@@ -5463,6 +5586,11 @@ CADDY_ENV
# reverse_proxy mealie:9000
# }
# Home Assistant (home)
# home.{$MY_DOMAIN} {
# reverse_proxy homeassistant:8123
# }
# ntfy (notifications)
# ntfy.{$MY_DOMAIN} {
# reverse_proxy ntfy:80
@@ -7144,6 +7272,10 @@ if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then
ufw allow 9925/tcp comment 'Mealie' 2>/dev/null
echo " ✓ Allowed Mealie (9925)"
fi
if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then
ufw allow 8123/tcp comment 'Home Assistant' 2>/dev/null
echo " ✓ Allowed Home Assistant (8123)"
fi
if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then
ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null
echo " ✓ Allowed MagicMirror (8081-8083)"
+132
View File
@@ -1741,6 +1741,23 @@ run_cmd apt install -y \
unzip \
rclone || echo "Warning: Some utilities failed to install, continuing..."
# Install glow (terminal markdown reader) from the Charm apt repo
echo ""
echo "Installing glow (terminal markdown reader)..."
if command -v glow >/dev/null 2>&1; then
echo " ✓ glow already installed"
elif [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow"
else
sudo mkdir -p /etc/apt/keyrings
if curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null
sudo apt update -y && sudo apt install -y glow && echo " ✓ glow installed" || echo " ⚠ glow install failed"
else
echo " ⚠ Could not fetch Charm signing key - skipping glow"
fi
fi
# Install OpenSSH Server
echo ""
echo "Installing OpenSSH Server..."
@@ -2595,6 +2612,7 @@ else
[ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON"
[ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON"
[ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON"
[ -d "$DOCKER_DIR/homeassistant" ] && EXISTING_SERVICES[HOMEASSISTANT]="ON"
[ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON"
[ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON"
[ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON"
@@ -2636,6 +2654,7 @@ else
"CROWDSEC" "Intrusion prevention (CrowdSec: bans + geo + reputation)" ${EXISTING_SERVICES[CROWDSEC]:-OFF} \
"LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \
"MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \
"HOMEASSISTANT" "Home automation hub (Home Assistant)" ${EXISTING_SERVICES[HOMEASSISTANT]:-OFF} \
"MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \
"JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \
"FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \
@@ -2667,6 +2686,7 @@ else
[ -n "${EXISTING_SERVICES[CROWDSEC]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CROWDSEC \"Intrusion prevention\" ON"
[ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON"
[ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON"
[ -n "${EXISTING_SERVICES[HOMEASSISTANT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS HOMEASSISTANT \"Home automation\" ON"
[ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON"
[ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON"
[ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON"
@@ -2717,6 +2737,7 @@ else
: ${INSTALL_CROWDSEC:="n"}
: ${INSTALL_LMS:="n"}
: ${INSTALL_MEALIE:="n"}
: ${INSTALL_HOMEASSISTANT:="n"}
: ${INSTALL_MINECRAFT:="n"}
: ${INSTALL_JELLYFIN:="n"}
: ${INSTALL_FRIGATE:="n"}
@@ -2743,6 +2764,7 @@ else
if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then INSTALL_CROWDSEC="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then INSTALL_HOMEASSISTANT="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi
if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi
@@ -2815,6 +2837,7 @@ else
if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi
if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi
if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi
if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then uninstall_service "Home Assistant" "$DOCKER_DIR/homeassistant" "homeassistant"; fi
if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi
if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi
if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi
@@ -4926,6 +4949,106 @@ MEALIE_COMPOSE
fi
fi
# ---- HOME ASSISTANT ----
if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_HOMEASSISTANT" ]; then
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ HOME ASSISTANT - Open-source home automation hub │"
echo "│ Smart-home control, automations, dashboards. │"
echo "│ Port: 8123 │"
echo "└─────────────────────────────────────────────────────────────────┘"
prompt_yn "Install Home Assistant? (y/n):" "n" INSTALL_HOMEASSISTANT
fi
if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then
echo "Installing Home Assistant..."
HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR"
else
mkdir -p "$HOMEASSISTANT_DIR"
ensure_docker_dir_ownership "$HOMEASSISTANT_DIR"
cd "$HOMEASSISTANT_DIR"
# Networking mode: bridge (published port) vs host networking.
echo ""
echo " Home Assistant networking mode:"
echo " 1) Bridge - container gets its own network; port 8123 is published"
echo " to the host. Works behind the Caddy reverse proxy and"
echo " keeps HA isolated. Recommended for most setups."
echo " 2) Host - HA shares the host's network directly. Needed for"
echo " auto-discovery of devices on your LAN (Chromecast/Cast,"
echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)."
prompt_text " Choose networking mode [1]:" "1" HA_NETMODE
if [ "$HA_NETMODE" = "2" ]; then
HA_NET_LINES=" network_mode: host"
echo " → Host networking selected (best device discovery)."
else
HA_NET_LINES=" ports:
- \"8123:8123\""
echo " → Bridge networking selected (port 8123 published)."
fi
cat > docker-compose.yml << HOMEASSISTANT_COMPOSE
name: homeassistant
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant
hostname: homeassistant
restart: unless-stopped
privileged: true
environment:
- TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC")
volumes:
- ./config:/config
- /run/dbus:/run/dbus:ro
${HA_NET_LINES}
HOMEASSISTANT_COMPOSE
mkdir -p config
# Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy.
# Only written on a fresh install (never clobber an existing config).
if [ ! -f config/configuration.yaml ]; then
cat > config/configuration.yaml << 'HA_CONFIG'
# Loads default set of integrations. Do not remove.
default_config:
# Allow access through a reverse proxy (e.g. Caddy)
http:
use_x_forwarded_for: true
trusted_proxies:
- 172.16.0.0/12
- 192.168.0.0/16
- 10.0.0.0/8
- 127.0.0.1
- ::1
HA_CONFIG
fi
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR"
echo ""
echo "✓ Home Assistant configured at $HOMEASSISTANT_DIR"
# Configure Caddy reverse proxy before starting
configure_caddy_for_service "Home Assistant" "8123" "home"
prompt_yn "Start Home Assistant now? (y/n):" "y" START_HOMEASSISTANT
if [ "$START_HOMEASSISTANT" = "y" ] || [ "$START_HOMEASSISTANT" = "Y" ]; then
docker compose up -d 2>/dev/null && echo " ✓ Home Assistant started" || echo " ⚠ Failed to start"
fi
echo " Access at: http://localhost:8123"
echo " First run: open the URL and create your admin account (onboarding)."
echo " Note: first startup can take a minute while HA initializes."
echo ""
fi
fi
# ---- MINECRAFT SERVER ----
if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then
echo ""
@@ -5443,6 +5566,11 @@ CADDY_ENV
# reverse_proxy mealie:9000
# }
# Home Assistant (home)
# home.{$MY_DOMAIN} {
# reverse_proxy homeassistant:8123
# }
# ntfy (notifications)
# ntfy.{$MY_DOMAIN} {
# reverse_proxy ntfy:80
@@ -7124,6 +7252,10 @@ if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then
ufw allow 9925/tcp comment 'Mealie' 2>/dev/null
echo " ✓ Allowed Mealie (9925)"
fi
if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then
ufw allow 8123/tcp comment 'Home Assistant' 2>/dev/null
echo " ✓ Allowed Home Assistant (8123)"
fi
if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then
ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null
echo " ✓ Allowed MagicMirror (8081-8083)"