From 840566e3f83b4f0d70a7ea8f7102ed1920b7648a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 16:25:25 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 41 +++ VERSION | 1 + services/backup.sh | 486 +++++++++++++++++++++++++ services/js99er.sh | 299 ++++++++++++++++ services/wolf.sh | 864 +++++++++++++++++++++++++++++++++++++++++++++ setup.sh | 1 + 6 files changed, 1692 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 VERSION create mode 100644 services/backup.sh create mode 100644 services/js99er.sh create mode 100644 services/wolf.sh diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5b5a9c2 --- /dev/null +++ b/CHANGELOG.md @@ -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 `), + `--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//` 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. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..a602fc9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.9.4 diff --git a/services/backup.sh b/services/backup.sh new file mode 100644 index 0000000..0a32e69 --- /dev/null +++ b/services/backup.sh @@ -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 /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 /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 /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 /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:-}"; 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." +} diff --git a/services/js99er.sh b/services/js99er.sh new file mode 100644 index 0000000..bc47bde --- /dev/null +++ b/services/js99er.sh @@ -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 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|]*fonts\.googleapis\.com[^>]*/>||g' \ + -e 's|]*fonts\.googleapis\.com[^>]*>||g' \ + -e 's|]*fonts\.gstatic\.com[^>]*/>||g' \ + -e 's|]*fonts\.gstatic\.com[^>]*>||g' \ + "$INDEX" \ + && sed -i 's|||' "$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 "" +} diff --git a/services/wolf.sh b/services/wolf.sh new file mode 100644 index 0000000..cc98b07 --- /dev/null +++ b/services/wolf.sh @@ -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 - 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." +} diff --git a/setup.sh b/setup.sh index 2f0a925..aebb0bc 100755 --- a/setup.sh +++ b/setup.sh @@ -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 ;;