v0.9.4: gaming modules (wolf, js99er), backup module, versioning

- services/wolf.sh (gaming): Games-on-Whales Wolf / Moonlight, per-service
  folder ~/docker/wolf, wolf-pair dropped, manage.sh pin workflow kept.
- services/js99er.sh (gaming): TI-99/4A emulator, own folder, port 8099,
  Selkies launcher tie-in removed.
- services/backup.sh: Kopia encrypted backups, paths adapted to ~/docker.
- Start versioning: VERSION (0.9.4), CHANGELOG.md, setup.sh --version flag.

All modules pass bash -n; ./setup.sh --list groups base/homelab/gaming/backup;
dry-run run-one exits 0 for every module with real commands guarded.

Note: minecraft module deferred to 0.9.5 (port hit a session limit).

https://claude.ai/code/session_017eA2qqq9jfF2tNtpUYL8vK
This commit is contained in:
Claude
2026-06-03 16:25:25 +00:00
parent d7b9f935c2
commit 840566e3f8
6 changed files with 1692 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# 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.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.
+1
View File
@@ -0,0 +1 @@
0.9.4
+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."
}
+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 ""
}
+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."
}
+1
View File
@@ -27,6 +27,7 @@ for arg in "$@"; do
--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 ;;