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:
@@ -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.
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ for arg in "$@"; do
|
|||||||
--dry-run) DRY_RUN=true ;;
|
--dry-run) DRY_RUN=true ;;
|
||||||
--unattended) UNATTENDED=true ;;
|
--unattended) UNATTENDED=true ;;
|
||||||
--list|-l) DO_LIST=true ;;
|
--list|-l) DO_LIST=true ;;
|
||||||
|
--version|-V) cat "$HERE/VERSION" 2>/dev/null || echo "unknown"; exit 0 ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||||
exit 0 ;;
|
exit 0 ;;
|
||||||
|
|||||||
Reference in New Issue
Block a user