diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1d59bfb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +All notable changes to this project. Versions follow `MAJOR.MINOR.PATCH`. +The project is pre-1.0 while the modular system reaches parity with the +monolithic `ubuntu-post-install-*.sh` scripts. + +## [0.9.5] - 2026-06-03 + +### Added +- `services/minecraft.sh` *(gaming)* — full port of the standalone + `setupminecraft.sh`, converted to the per-service-folder model. Each server + is its own `~/docker//` with a standalone compose, so multiple + servers run side by side (port auto-bumps 25565→25566…). Preserves all the + niceties: Fabric/Quilt/Paper/Vanilla/Forge flavours, the live Modrinth + version/mod-availability picker, curated mods, Vanilla Tweaks datapacks, + whitelist UUID pre-population, LuckPerms bootstrap, Chunky pre-gen, playit.gg + tunnel, generated MINECRAFT_NETWORKING.md / CLIENT_MODS.md, and the + client-mods download web page (its own folder + compose). + +### Fixed +- Minecraft compose env-block emission (trailing-newline bug from the original + that glued `ports:` onto the last env line — now valid YAML). + +### Notes +- `gaming` group now: `js99er`, `minecraft`, `wolf`. +- Still pending: `whitelist` Minecraft helper; migrating the ~65 monolith + services into `services/`. + +## [0.9.4] - 2026-06-03 + +The first versioned release. Introduces the **modular post-install system** so +you can install the whole box at once *or* run a single service, with one +source of truth (no per-service script duplication, nothing generated). + +### Added +- `setup.sh` dispatcher: interactive menu, run-one (`sudo ./setup.sh `), + `--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/MODULAR.md b/MODULAR.md new file mode 100644 index 0000000..e01b166 --- /dev/null +++ b/MODULAR.md @@ -0,0 +1,95 @@ +# Modular Post-Install (`setup.sh` + `lib/` + `services/`) + +This is the new structure that gives you **one source of truth** *and* the +ability to **run just the service you want** — without maintaining a pile of +near-duplicate standalone scripts. + +## Why + +The full `ubuntu-post-install-*.sh` scripts are great as a "run once, set up the +whole box" experience, but to add or update one service you edit a 300 KB file +(in two or three places). The separate `setup-*.sh` scripts are easy to run for +one service, but duplicate logic and drift apart. + +The fix is **not** to generate per-service scripts from the monolith (that just +triples the maintenance surface). It's to have **one implementation per service** +in a module, shared helpers in a library, and a thin dispatcher with two entry +points. + +## Layout + +``` +setup.sh # dispatcher: menu, run-one, --list, --dry-run, --unattended +lib/common.sh # shared helpers: logging, prompts, ownership, Caddy wiring, + # the service registry. THE single source of truth. +services/ + base.sh # essential CLI packages (incl. glow) + homeassistant.sh # Home Assistant + ... # one file per service +``` + +## Usage + +```bash +sudo ./setup.sh # interactive menu (whiptail or text) +sudo ./setup.sh homeassistant # install one service +sudo ./setup.sh base glow # install several +./setup.sh --list # list services, grouped +sudo ./setup.sh --dry-run --unattended minecraft # preview, no prompts +``` + +## Anatomy of a service module + +Each `services/.sh` does exactly two things: **register** itself and +define **install_**. + +```bash +#!/bin/bash +register_service myapp homelab "What it does" 1234 # name group description [port] + +install_myapp() { + require_docker || return 1 + local DIR="$DOCKER_DIR/myapp" + [ "$DRY_RUN" = true ] && { echo "[DRY-RUN] Would create $DIR"; return 0; } + mkdir -p "$DIR"; ensure_docker_dir_ownership "$DIR"; cd "$DIR" || return 1 + cat > docker-compose.yml << 'YAML' + ... +YAML + configure_caddy_for_service "MyApp" "1234" "myapp" # optional reverse proxy + prompt_yn "Start now? (y/n):" "y" START && docker compose up -d +} +``` + +Helpers available from `lib/common.sh`: `log_info/success/warning/error`, +`prompt_yn`, `prompt_text`, `run_cmd`, `ensure_docker_dir_ownership`, +`generate_password`, `validate_password`, `configure_caddy_for_service`, +`require_root`, `require_docker`. Globals: `DOCKER_DIR`, `ACTUAL_USER`, +`ACTUAL_HOME`, `DRY_RUN`, `UNATTENDED`. + +Every service installs to its **own folder** `~/docker//` with its **own +`docker-compose.yml`** (the DoTheEvo `selfhosted-apps-docker` layout) — never a +single shared compose file. + +## Groups + +`base` · `homelab` · `gaming` · `backup`. The menu and `--list` are grouped by +these. The **gaming** group (Wolf, js99er, Minecraft) makes this script a +sensible base for either a homelab box or a gaming box — install only what that +machine needs. + +## Migration status + +This is an incremental migration. The big `ubuntu-post-install-*-crowdsec.sh` +script remains the current "install everything" entry point until the modules +reach parity, at which point it is retired (like the `original` and +`-no-keycloak` tiers, which stay frozen as the evolution record). + +| Module | Status | +|--------|--------| +| `base` (incl. glow) | ✅ done | +| `homeassistant` | ✅ done | +| `minecraft` (multi-instance, rich) | ⏳ porting from `setupminecraft.sh` | +| `wolf` (gaming) | ⏳ porting from `setupwolf.sh` | +| `js99er` (gaming) | ⏳ porting from `setupjs99er.sh` | +| `backup` (Kopia, cross-cutting) | ⏳ porting from `setupbackup.sh` | +| remaining ~65 services | ⏳ migrate from the monolith incrementally | diff --git a/SCRIPT-VARIANTS.md b/SCRIPT-VARIANTS.md index 0032911..0f7ec6e 100644 --- a/SCRIPT-VARIANTS.md +++ b/SCRIPT-VARIANTS.md @@ -12,6 +12,16 @@ complete, standalone script). `` is `24.04` or `26.04`. +> New services are added to the **`-crowdsec`** tier only (the current tip of +> the evolution); the original and `-no-keycloak` scripts are frozen as +> historical snapshots. For example, **Home Assistant** (home-automation hub, +> port 8123) is available in the `-crowdsec` variants. It ships with a +> `trusted_proxies` config pre-seeded so it works behind the Caddy reverse +> proxy out of the box, and the installer asks whether to use **bridge** +> networking (port 8123 published — proxy-friendly, isolated) or **host** +> networking (needed for LAN device auto-discovery: Chromecast, HomeKit, +> mDNS, some Zigbee/Z-Wave/Bluetooth). + ## Which one? - **Original (`.sh`)** — unchanged baseline, kept for fallback. Still offers diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..b0bb878 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.9.5 diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..e6bd534 --- /dev/null +++ b/lib/common.sh @@ -0,0 +1,213 @@ +#!/bin/bash +# lib/common.sh — shared helpers for the modular post-install system. +# +# This is the single source of truth for the helper functions every service +# module relies on (logging, prompts, ownership, Caddy wiring, the service +# registry). Both the full menu (setup.sh) and single-service runs source it, +# so there is exactly ONE implementation of each helper. +# +# Modules under services/*.sh source this file (guarded), register themselves +# with register_service, and define an install_ function. + +# Guard against double-sourcing +[ -n "${_COMMON_SH_LOADED:-}" ] && return 0 +_COMMON_SH_LOADED=1 + +# ── Global modes (overridable by the dispatcher / environment) ─────────────── +DRY_RUN="${DRY_RUN:-false}" +UNATTENDED="${UNATTENDED:-false}" + +# ── Identity / paths ───────────────────────────────────────────────────────── +# The actual (non-root) user, even when run under sudo. +ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}" +ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6)" +[ -z "$ACTUAL_HOME" ] && ACTUAL_HOME="$HOME" +# Per-service docker folders live here: ~/docker//docker-compose.yml +DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + +# ── Colored logging ────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# ── Service registry ───────────────────────────────────────────────────────── +# Modules call: register_service [port] +declare -gA SERVICE_GROUP=() +declare -gA SERVICE_DESC=() +declare -gA SERVICE_PORT=() +declare -ga SERVICE_ORDER=() + +register_service() { + local name="$1" group="$2" desc="$3" port="${4:-}" + SERVICE_GROUP["$name"]="$group" + SERVICE_DESC["$name"]="$desc" + SERVICE_PORT["$name"]="$port" + SERVICE_ORDER+=("$name") +} + +# ── Pre-flight ─────────────────────────────────────────────────────────────── +require_root() { + if [ "${EUID:-$(id -u)}" -ne 0 ]; then + log_error "Please run as root (use sudo)." + exit 1 + fi +} + +require_docker() { + if ! command -v docker &>/dev/null; then + log_error "Docker is not installed. Install Docker first (run: $0 docker)." + return 1 + fi +} + +# ── Command execution honoring dry-run ─────────────────────────────────────── +run_cmd() { + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would execute: $*" + return 0 + else + "$@" + fi +} + +# Ensure Docker directories are owned by the actual user (not root) +ensure_docker_dir_ownership() { + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would set ownership of $* to $ACTUAL_USER:$ACTUAL_USER" + return 0 + fi + for dir in "$@"; do + [ -d "$dir" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$dir" 2>/dev/null || true + done +} + +# Generate a secure alphanumeric password (no special characters) +generate_password() { + local length="${1:-32}" + openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" +} + +# Validate password (alphanumeric only, minimum length). Returns 0/1. +validate_password() { + local password="$1" min_length="${2:-12}" + if [ ${#password} -lt "$min_length" ]; then + echo " ⚠ Password must be at least $min_length characters long"; return 1 + fi + if echo "$password" | grep -q '[^a-zA-Z0-9]'; then + echo " ⚠ Password must contain only letters and numbers (no special characters)"; return 1 + fi + return 0 +} + +# Prompt yes/no, honoring unattended. prompt_yn "Question?" "default" VARNAME +prompt_yn() { + local question="$1" default="$2" varname="$3" response + if [ "$UNATTENDED" = true ]; then + eval "$varname='$default'"; echo "$question [auto: $default]"; return + fi + read -p "$question " response + eval "$varname='$response'" +} + +# Prompt text, honoring unattended. prompt_text "Question?" "default" VARNAME +prompt_text() { + local question="$1" default="$2" varname="$3" response + if [ "$UNATTENDED" = true ]; then + eval "$varname='$default'"; echo "$question [auto: $default]"; return + fi + read -p "$question " response + eval "$varname='${response:-$default}'" +} + +# ── Caddy reverse-proxy wiring (shared by every web service) ───────────────── +# Usage: configure_caddy_for_service "Name" "PORT" "default-subdomain" ["extra"] +configure_caddy_for_service() { + local SERVICE_NAME="$1" SERVICE_PORT="$2" DEFAULT_SUBDOMAIN="$3" EXTRA_CONFIG="${4:-}" + + # Caddy not installed → nothing to do + [ -d "$DOCKER_DIR/caddy" ] || return 0 + + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " CADDY REVERSE PROXY CONFIGURATION" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Caddy is installed. You can configure a reverse proxy for $SERVICE_NAME." + echo "" + + local CONFIGURE_CADDY="" + prompt_yn "Configure Caddy reverse proxy for $SERVICE_NAME? (y/n):" "n" CONFIGURE_CADDY + if [ "$CONFIGURE_CADDY" != "y" ] && [ "$CONFIGURE_CADDY" != "Y" ]; then + echo " Skipping Caddy configuration." + echo " Access $SERVICE_NAME at: http://localhost:$SERVICE_PORT" + return 0 + fi + + echo "" + echo "Enter the full domain for $SERVICE_NAME:" + echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" + echo "" + local SERVICE_DOMAIN="" + prompt_text "Domain:" "" SERVICE_DOMAIN + if [ -z "$SERVICE_DOMAIN" ]; then + echo " ⚠ No domain provided, skipping Caddy configuration."; return 0 + fi + + local CADDY_DIR="$DOCKER_DIR/caddy" + local CADDYFILE="$CADDY_DIR/Caddyfile" + local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + + if [ -f "$CADDYFILE" ]; then + echo " Backing up Caddyfile to: $(basename "$BACKUP_FILE")" + cp "$CADDYFILE" "$BACKUP_FILE" + else + echo " Creating new Caddyfile"; touch "$CADDYFILE" + fi + + if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then + echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile" + local OVERWRITE="" + prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE + if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then + echo " Keeping existing configuration."; return 0 + fi + sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE" + fi + + echo " Adding $SERVICE_NAME configuration to Caddyfile..." + cat >> "$CADDYFILE" << CADDY_BLOCK + +# $SERVICE_NAME +$SERVICE_DOMAIN { + reverse_proxy localhost:$SERVICE_PORT + + # Security headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + # Logging for CrowdSec (Caddy JSON access logs) + log { + output file /var/log/caddy/${SERVICE_DOMAIN}.log + format json + } +$EXTRA_CONFIG +} +CADDY_BLOCK + + echo " ✓ Configuration added to Caddyfile" + echo " Reloading Caddy configuration..." + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + echo " ✓ $SERVICE_NAME is now accessible at: https://$SERVICE_DOMAIN" + else + echo " ⚠ Failed to reload Caddy. Check: docker logs caddy" + echo " You can restore from backup: $BACKUP_FILE" + fi + echo "" +} 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/base.sh b/services/base.sh new file mode 100644 index 0000000..a2875e6 --- /dev/null +++ b/services/base.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# services/base.sh — essential CLI packages installed on every box. +# Part of the modular post-install system (sourced by setup.sh). + +register_service base base "Essential CLI packages (net-tools, git, htop, glow, …)" + +install_base() { + log_info "Installing essential packages..." + run_cmd apt-get update -y + + # Core utilities present on every install. + run_cmd apt-get install -y \ + net-tools ncdu git curl wget htop tree zip unzip \ + ca-certificates gnupg jq rsync || log_warning "Some essential packages failed to install" + + # glow — terminal markdown reader (charmbracelet). Not in Ubuntu repos, + # so add Charm's apt repository first. + install_glow +} + +# glow is also exposed as its own module so it can be (re)installed on its own. +install_glow() { + if command -v glow >/dev/null 2>&1; then + log_success "glow already installed ($(glow --version 2>/dev/null | head -1))" + return 0 + fi + log_info "Installing glow (terminal markdown reader) from the Charm apt repo..." + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow" + return 0 + fi + sudo mkdir -p /etc/apt/keyrings + if curl -fsSL https://repo.charm.sh/apt/gpg.key \ + | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then + echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" \ + | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null + if sudo apt-get update -y && sudo apt-get install -y glow; then + log_success "glow installed ($(glow --version 2>/dev/null | head -1))" + else + log_warning "glow install failed — see https://github.com/charmbracelet/glow" + fi + else + log_warning "Could not fetch Charm signing key — skipping glow" + fi +} + +# Register glow as a standalone service too (./setup.sh glow). +register_service glow base "Terminal markdown reader (charmbracelet/glow)" diff --git a/services/homeassistant.sh b/services/homeassistant.sh new file mode 100644 index 0000000..624ae25 --- /dev/null +++ b/services/homeassistant.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# services/homeassistant.sh — Home Assistant home-automation hub. +# Part of the modular post-install system (sourced by setup.sh). + +register_service homeassistant homelab "Home automation hub (Home Assistant)" 8123 + +install_homeassistant() { + require_docker || return 1 + log_info "Installing Home Assistant..." + local HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR" + return 0 + fi + + mkdir -p "$HOMEASSISTANT_DIR" + ensure_docker_dir_ownership "$HOMEASSISTANT_DIR" + cd "$HOMEASSISTANT_DIR" || return 1 + + # Networking mode: bridge (published port) vs host networking. + echo "" + echo " Home Assistant networking mode:" + echo " 1) Bridge - container gets its own network; port 8123 is published" + echo " to the host. Works behind the Caddy reverse proxy and" + echo " keeps HA isolated. Recommended for most setups." + echo " 2) Host - HA shares the host's network directly. Needed for" + echo " auto-discovery of devices on your LAN (Chromecast/Cast," + echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)." + local HA_NETMODE="" + prompt_text " Choose networking mode [1]:" "1" HA_NETMODE + local HA_NET_LINES + if [ "$HA_NETMODE" = "2" ]; then + HA_NET_LINES=" network_mode: host" + echo " → Host networking selected (best device discovery)." + else + HA_NET_LINES=" ports: + - \"8123:8123\"" + echo " → Bridge networking selected (port 8123 published)." + fi + + cat > docker-compose.yml << HOMEASSISTANT_COMPOSE +name: homeassistant + +services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant:stable + container_name: homeassistant + hostname: homeassistant + restart: unless-stopped + privileged: true + environment: + - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") + volumes: + - ./config:/config + - /run/dbus:/run/dbus:ro +${HA_NET_LINES} +HOMEASSISTANT_COMPOSE + + mkdir -p config + + # Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy. + # Only written on a fresh install (never clobber an existing config). + if [ ! -f config/configuration.yaml ]; then + cat > config/configuration.yaml << 'HA_CONFIG' +# Loads default set of integrations. Do not remove. +default_config: + +# Allow access through a reverse proxy (e.g. Caddy) +http: + use_x_forwarded_for: true + trusted_proxies: + - 172.16.0.0/12 + - 192.168.0.0/16 + - 10.0.0.0/8 + - 127.0.0.1 + - ::1 +HA_CONFIG + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR" + echo "" + log_success "Home Assistant configured at $HOMEASSISTANT_DIR" + + configure_caddy_for_service "Home Assistant" "8123" "home" + + local START_HA="" + prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA + if [ "$START_HA" = "y" ] || [ "$START_HA" = "Y" ]; then + docker compose up -d 2>/dev/null && log_success "Home Assistant started" || log_warning "Failed to start" + fi + + echo " Access at: http://localhost:8123" + echo " First run: open the URL and create your admin account (onboarding)." + echo " Note: first startup can take a minute while HA initializes." + echo "" +} 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/minecraft.sh b/services/minecraft.sh new file mode 100644 index 0000000..815f9b4 --- /dev/null +++ b/services/minecraft.sh @@ -0,0 +1,2167 @@ +#!/bin/bash +# services/minecraft.sh — Minecraft server (Fabric/Quilt/Paper/Vanilla/Forge), +# multi-instance, mod & datapack pickers, playit.gg tunnel, client-mods page. +# Part of the modular post-install system (sourced by setup.sh). +# +# Ported from the standalone setupminecraft.sh. Converted to the per-service +# folder model: each instance lives in its OWN folder under $DOCKER_DIR with its +# OWN standalone docker-compose.yml (no shared compose, no python insert logic). +# +# default / first instance id "minecraft" -> $DOCKER_DIR/minecraft/ +# any other instance id "" -> $DOCKER_DIR// +# +# Helpers (log_*, prompt_yn, prompt_text, ensure_docker_dir_ownership, …) and +# globals (DOCKER_DIR, ACTUAL_USER, ACTUAL_HOME, DRY_RUN, UNATTENDED) come from +# lib/common.sh — do NOT redefine them here. No `set -e`: this file is sourced +# into a long-running dispatcher, so we use explicit checks + `|| return 1`. + +register_service minecraft gaming "Minecraft server (Fabric/Quilt/Paper, multi-instance)" 25565 + +install_minecraft() { + require_docker || return 1 + + # ── DRY-RUN: summarise and bail BEFORE any prompting / curl / docker ─────── + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would set up a Minecraft server instance:" + echo " • Create an instance folder under $DOCKER_DIR (default: $DOCKER_DIR/minecraft)" + echo " • Write a standalone docker-compose.yml building the itzg/minecraft-server image" + echo " • Optionally download selected mods (Modrinth) and a client-mods web page" + echo " • Optionally add a playit.gg tunnel service to the instance compose" + echo " • Generate MINECRAFT_NETWORKING.md and CLIENT_MODS.md in the instance folder" + return 0 + fi + + echo "" + echo "╔═══════════════════════════════════════════════════════╗" + echo "║ Minecraft Server Setup ║" + echo "║ Fabric · Mods · Vanilla Tweaks · Networking ║" + echo "╚═══════════════════════════════════════════════════════╝" + echo "" + + # ── Minecraft version ────────────────────────────────────────────────────── + log_info "Fetching recent Minecraft versions..." + local _MC_JSON + _MC_JSON=$(curl -sf --max-time 10 "https://launchermeta.mojang.com/mc/game/version_manifest.json" 2>/dev/null) + local RECENT_VERSIONS=() + mapfile -t RECENT_VERSIONS < <(echo "$_MC_JSON" | python3 -c " +import sys, json +d = json.load(sys.stdin) +releases = [v['id'] for v in d['versions'] if v['type'] == 'release'] +for v in releases[:3]: + print(v) +" 2>/dev/null) + local LATEST_SNAPSHOT + LATEST_SNAPSHOT=$(echo "$_MC_JSON" | python3 -c " +import sys, json +d = json.load(sys.stdin) +snaps = [v['id'] for v in d['versions'] if v['type'] == 'snapshot'] +print(snaps[0] if snaps else '') +" 2>/dev/null) + + if [ ${#RECENT_VERSIONS[@]} -eq 0 ]; then + RECENT_VERSIONS=("1.21.4" "1.21.3" "1.21.1") + log_warning "Could not fetch version list — using defaults" + fi + + # ── Server flavour ───────────────────────────────────────────────────────── + echo "" + log_info "Server Flavour" + echo "" + echo " 1) Fabric (default) — Lightweight, best mod ecosystem, required for" + echo " all mods in this setup. Recommended." + echo "" + echo " 2) Quilt — Fabric fork, compatible with most Fabric mods." + echo " More experimental; use if you need Quilt-only mods." + echo "" + echo " 3) Paper — High-performance Spigot fork. Best for plugin-based" + echo " servers. Does NOT support Fabric mods." + echo "" + echo " 4) Vanilla — Pure Mojang server. No mods or plugins. Simplest" + echo " setup but no mod support — mods below won't apply." + echo "" + echo " 5) Forge — Heavy modpack loader (FTB, Technic etc). Needs a" + echo " matching Forge client. Not compatible with Fabric mods." + echo "" + local FLAVOUR_CHOICE="" + prompt_text "Flavour [1]:" "1" FLAVOUR_CHOICE + + local FLAVOUR FLAVOUR_NAME + case $FLAVOUR_CHOICE in + 1) FLAVOUR="FABRIC"; FLAVOUR_NAME="Fabric" ;; + 2) FLAVOUR="QUILT"; FLAVOUR_NAME="Quilt" ;; + 3) FLAVOUR="PAPER"; FLAVOUR_NAME="Paper" ;; + 4) FLAVOUR="VANILLA"; FLAVOUR_NAME="Vanilla" ;; + 5) FLAVOUR="FORGE"; FLAVOUR_NAME="Forge" ;; + *) FLAVOUR="FABRIC"; FLAVOUR_NAME="Fabric" ;; + esac + log_success "$FLAVOUR_NAME selected" + + local SUPPORTS_FABRIC_MODS=false + [[ "$FLAVOUR" == "FABRIC" || "$FLAVOUR" == "QUILT" ]] && SUPPORTS_FABRIC_MODS=true + + if [ "$SUPPORTS_FABRIC_MODS" = false ]; then + log_warning "$FLAVOUR_NAME does not support Fabric mods — mod and datapack selection will be skipped." + fi + + # ── Basic server config ──────────────────────────────────────────────────── + echo "" + log_info "Server Configuration" + local SERVER_NAME="" + prompt_text "Server name [My Minecraft Server]:" "My Minecraft Server" SERVER_NAME + + # ── Instance id ──────────────────────────────────────────────────────────── + # Each instance gets its own folder, container name, compose service and port, + # so you can run several servers side by side. The first server defaults to + # 'minecraft'; pick a unique id for more. + slugify() { echo "$1" | tr '[:upper:] ' '[:lower:]-' | tr -cd 'a-z0-9-' | sed 's/--*/-/g; s/^-//; s/-$//'; } + local _def_slug + _def_slug="$(slugify "$SERVER_NAME")"; [ -z "$_def_slug" ] && _def_slug="minecraft" + # If no minecraft instance folder exists yet, default to 'minecraft'. + if [ ! -d "$DOCKER_DIR/minecraft" ]; then + _def_slug="minecraft" + fi + local MC_NAME="" + prompt_text "Instance id (folder + container name) [${_def_slug}]:" "$_def_slug" MC_NAME + MC_NAME="$(slugify "${MC_NAME:-$_def_slug}")"; [ -z "$MC_NAME" ] && MC_NAME="minecraft" + local MC_DIR="$DOCKER_DIR/$MC_NAME" + if [ -d "$MC_DIR" ]; then + log_warning "An instance folder '${MC_NAME}' already exists at ${MC_DIR}." + log_warning "Re-running will update its files; the existing world data is left as-is." + fi + log_info "Instance: ${MC_NAME} → ${MC_DIR}" + + local MAX_PLAYERS="" + prompt_text "Max players [20]:" "20" MAX_PLAYERS + + local DIFFICULTY="" + prompt_text "Difficulty (peaceful/easy/normal/hard) [normal]:" "normal" DIFFICULTY + + local GAMEMODE="" + prompt_text "Game mode (survival/creative/adventure) [survival]:" "survival" GAMEMODE + + local WHITELIST="" + prompt_yn "Enable whitelist? (y/n) [n]:" "n" WHITELIST + local WHITELIST_ENABLED=false + [[ $WHITELIST =~ ^[Yy]$ ]] && WHITELIST_ENABLED=true || WHITELIST_ENABLED=false + + local WHITELIST_PLAYERS=() + if [ "$WHITELIST_ENABLED" = true ] && [ "$UNATTENDED" != true ]; then + echo "" + log_info "Whitelist Players" + echo " Enter player gamertags to pre-populate the whitelist." + echo " UUIDs are looked up automatically. Press Enter alone when done." + echo "" + while true; do + local _GT="" + read -p " Gamertag (Enter to finish): " _GT + [ -z "$_GT" ] && break + WHITELIST_PLAYERS+=("$_GT") + log_info " Added: $_GT" + done + if [ ${#WHITELIST_PLAYERS[@]} -gt 0 ]; then + log_success " ${#WHITELIST_PLAYERS[@]} player(s) queued for whitelist" + else + log_info " No players entered — whitelist will be empty until you add players manually" + fi + fi + + # Port auto-bump: default 25565; if an existing instance compose already + # maps :25565 and this is not the default instance, default to 25566. + local _def_port=25565 + if [ "$MC_NAME" != "minecraft" ] \ + && grep -qs ':25565"' "$DOCKER_DIR"/*/docker-compose.yml 2>/dev/null; then + _def_port=25566 + fi + local MC_PORT="" + prompt_text "Minecraft server port [${_def_port}]:" "$_def_port" MC_PORT + if grep -qs "\"${MC_PORT}:25565\"" "$DOCKER_DIR"/*/docker-compose.yml 2>/dev/null; then + log_warning "Port ${MC_PORT} is already mapped by another instance — pick a unique host port." + fi + + local MC_RAM="" + prompt_text "Memory allocation in GB [4]:" "4" MC_RAM + + # ── Mod selection (Fabric/Quilt only) ────────────────────────────────────── + local SELECTED_MODS=() + local SELECTED_DATAPACKS=() + local MC_VERSION="" + + declare -A MODS + declare -A MOD_DESC + declare -A MOD_DEFAULT + declare -A MOD_MODRINTH_ID + local MOD_ORDER=() + + if [ "$SUPPORTS_FABRIC_MODS" = true ]; then + echo "" + log_info "Mod Selection" + echo "Toggle mods on/off. Performance mods are pre-selected (recommended)." + echo "" + + MODS["nochatreports"]="NoChatReports" + MOD_DESC["nochatreports"]="Removes Mojang's chat reporting system server-side." + MOD_DEFAULT["nochatreports"]="y" + MOD_MODRINTH_ID["nochatreports"]="qQyHxfxd" + + MODS["essentials"]="Essentials for Fabric" + MOD_DESC["essentials"]="Homes, warps, TPA, /back, /spawn, /heal, /fly, admin tools." + MOD_DEFAULT["essentials"]="y" + MOD_MODRINTH_ID["essentials"]="fessentials" + + MODS["luckperms"]="LuckPerms" + MOD_DESC["luckperms"]="Permissions system. Pre-configured with default/mod/admin groups." + MOD_DEFAULT["luckperms"]="y" + MOD_MODRINTH_ID["luckperms"]="luckperms" + + MODS["lithium"]="Lithium" + MOD_DESC["lithium"]="Server performance — optimises mob AI, physics, block ticking. 30-50% faster TPS." + MOD_DEFAULT["lithium"]="y" + MOD_MODRINTH_ID["lithium"]="lithium" + + MODS["ferritecore"]="FerriteCore" + MOD_DESC["ferritecore"]="Reduces server memory usage significantly." + MOD_DEFAULT["ferritecore"]="y" + MOD_MODRINTH_ID["ferritecore"]="ferritecore" + + MODS["starlight"]="Starlight" + MOD_DESC["starlight"]="Rewrites the lighting engine — big reduction in lag spikes." + MOD_DEFAULT["starlight"]="y" + MOD_MODRINTH_ID["starlight"]="starlight" + + MODS["chunky"]="Chunky" + MOD_DESC["chunky"]="Pre-generates chunks so players don't cause lag exploring new areas. Essential for elytra flyers." + MOD_DEFAULT["chunky"]="y" + MOD_MODRINTH_ID["chunky"]="chunky" + + MODS["c2me"]="C2ME (Concurrent Chunk Management)" + MOD_DESC["c2me"]="Multithreads chunk generation. Alpha-quality — can crash on startup. Only enable if you need it." + MOD_DEFAULT["c2me"]="n" + MOD_MODRINTH_ID["c2me"]="c2me-fabric" + + MODS["spark"]="Spark" + MOD_DESC["spark"]="Server profiler — diagnose lag, TPS drops, memory issues." + MOD_DEFAULT["spark"]="y" + MOD_MODRINTH_ID["spark"]="spark" + + MODS["carpet"]="Carpet" + MOD_DESC["carpet"]="Technical Minecraft features, mob spawning tweaks, debug tools." + MOD_DEFAULT["carpet"]="y" + MOD_MODRINTH_ID["carpet"]="carpet" + + MODS["ledger"]="Ledger" + MOD_DESC["ledger"]="Block change logging and grief tracking. Query who broke/placed what." + MOD_DEFAULT["ledger"]="y" + MOD_MODRINTH_ID["ledger"]="ledger" + + MODS["serverreplay"]="ServerReplay" + MOD_DESC["serverreplay"]="Records server-side replays viewable with ReplayMod on client." + MOD_DEFAULT["serverreplay"]="n" + MOD_MODRINTH_ID["serverreplay"]="server-replay" + + MOD_ORDER=("nochatreports" "essentials" "luckperms" "lithium" "ferritecore" + "starlight" "chunky" "c2me" "spark" "carpet" "ledger" "serverreplay") + + # ── Version picker with mod availability table ───────────────────────── + echo "" + log_info "Checking mod availability across recent versions (this takes a few seconds)..." + echo "" + + local COL_MOD=22 + local COL_VER=12 + + printf " %-${COL_MOD}s" "Mod" + local ver + for ver in "${RECENT_VERSIONS[@]}"; do + printf " %-${COL_VER}s" "$ver" + done + echo "" + printf " %-${COL_MOD}s" "$(printf '%0.s─' $(seq 1 $COL_MOD))" + for ver in "${RECENT_VERSIONS[@]}"; do + printf " %-${COL_VER}s" "$(printf '%0.s─' $(seq 1 $COL_VER))" + done + echo "" + + declare -A MOD_AVAIL # key: "mod:ver" → "yes"/"no" + local mod slug label result + for mod in "${MOD_ORDER[@]}"; do + slug="${MOD_MODRINTH_ID[$mod]}" + label="${MODS[$mod]}" + printf " %-${COL_MOD}s" "${label:0:$COL_MOD}" + for ver in "${RECENT_VERSIONS[@]}"; do + result=$(curl -sf --max-time 8 \ + "https://api.modrinth.com/v2/project/${slug}/version?game_versions=%5B%22${ver}%22%5D&loaders=%5B%22fabric%22%5D" \ + | python3 -c "import sys,json; v=json.load(sys.stdin); print('yes' if v else 'no')" 2>/dev/null || echo "?") + MOD_AVAIL["${mod}:${ver}"]="$result" + if [ "$result" = "yes" ]; then + printf " \033[0;32m%-${COL_VER}s\033[0m" "✓" + elif [ "$result" = "no" ]; then + printf " \033[0;31m%-${COL_VER}s\033[0m" "✗ not yet" + else + printf " %-${COL_VER}s" "?" + fi + done + echo "" + done + echo "" + + # Find the version with the best mod availability to use as the default. + local _BEST_VER_IDX=0 + local _BEST_VER_COUNT=-1 + local i _count + for i in "${!RECENT_VERSIONS[@]}"; do + _count=0 + for mod in "${MOD_ORDER[@]}"; do + [ "${MOD_AVAIL[${mod}:${RECENT_VERSIONS[$i]}]}" = "yes" ] && _count=$((_count+1)) + done + if [ $_count -gt $_BEST_VER_COUNT ]; then + _BEST_VER_COUNT=$_count + _BEST_VER_IDX=$i + fi + done + local _DEFAULT_VER_NUM=$((_BEST_VER_IDX+1)) + + echo " Which version do you want to use?" + local _ver _suffix + for i in "${!RECENT_VERSIONS[@]}"; do + _ver="${RECENT_VERSIONS[$i]}" + _count=0 + for mod in "${MOD_ORDER[@]}"; do + [ "${MOD_AVAIL[${mod}:${_ver}]}" = "yes" ] && _count=$((_count+1)) + done + _suffix="(${_count}/${#MOD_ORDER[@]} mods available)" + [ "$i" -eq "$_BEST_VER_IDX" ] && _suffix="$_suffix ← Recommended" + if [[ "$_ver" =~ ^[2-9][0-9]\. ]] && [ "$_count" -eq 0 ]; then + _suffix="$_suffix ⚠ new versioning — mods not yet compatible" + fi + echo " $((i+1))) $_ver $_suffix" + done + local _SNAP_OPT="" _SNAP_NUM _MAN_NUM + if [ -n "$LATEST_SNAPSHOT" ]; then + _SNAP_NUM=$(( ${#RECENT_VERSIONS[@]} + 1 )) + _MAN_NUM=$(( ${#RECENT_VERSIONS[@]} + 2 )) + echo " ${_SNAP_NUM}) ${LATEST_SNAPSHOT} ⚠ snapshot — mods may not be available yet" + _SNAP_OPT="$LATEST_SNAPSHOT" + else + _MAN_NUM=$(( ${#RECENT_VERSIONS[@]} + 1 )) + fi + echo " ${_MAN_NUM}) Enter manually" + echo "" + local _VER_CHOICE="" + read -p "Choice [${_DEFAULT_VER_NUM}]: " _VER_CHOICE + _VER_CHOICE="${_VER_CHOICE:-${_DEFAULT_VER_NUM}}" + + if [ "$_VER_CHOICE" -le "${#RECENT_VERSIONS[@]}" ] 2>/dev/null; then + MC_VERSION="${RECENT_VERSIONS[$((_VER_CHOICE-1))]}" + local _picked_count=0 + for mod in "${MOD_ORDER[@]}"; do + [ "${MOD_AVAIL[${mod}:${MC_VERSION}]}" = "yes" ] && _picked_count=$((_picked_count+1)) + done + if [ "$_picked_count" -eq 0 ] && [ "$_BEST_VER_COUNT" -gt 0 ]; then + echo "" + log_warning "No mods are available for $MC_VERSION yet!" + log_warning "Server will crash on startup — Fabric rejects mods built for a different version string." + log_warning "Recommended: use ${RECENT_VERSIONS[$_BEST_VER_IDX]} where all mods are available." + local _switch="" + read -p "Switch to ${RECENT_VERSIONS[$_BEST_VER_IDX]} instead? (y/n) [y]: " -n 1 -r _switch; echo + if [[ ${_switch:-y} =~ ^[Yy]$ ]]; then + MC_VERSION="${RECENT_VERSIONS[$_BEST_VER_IDX]}" + log_success "Switched to $MC_VERSION" + else + log_warning "Continuing with $MC_VERSION — mods will be skipped (server runs vanilla)" + SELECTED_MODS=() + fi + fi + elif [ -n "$_SNAP_OPT" ] && [ "$_VER_CHOICE" = "$_SNAP_NUM" ] 2>/dev/null; then + MC_VERSION="$_SNAP_OPT" + log_warning "Snapshot selected — Fabric and mods may not support this version yet" + else + read -p "Enter Minecraft version: " MC_VERSION + if ! [[ "$MC_VERSION" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then + MC_VERSION="${RECENT_VERSIONS[$_BEST_VER_IDX]}" + log_warning "Invalid version — defaulting to $MC_VERSION" + fi + fi + log_success "Using Minecraft $MC_VERSION" + + # Select defaults, auto-skipping only mods KNOWN to be incompatible. + for mod in "${MOD_ORDER[@]}"; do + if [[ "${MOD_DEFAULT[$mod]}" == "y" ]]; then + if [[ "${MOD_AVAIL[${mod}:${MC_VERSION}]}" == "no" ]]; then + log_info " Auto-skipping ${MODS[$mod]} (not available for $MC_VERSION)" + else + SELECTED_MODS+=("$mod") + fi + fi + done + + local choice key marker all_num done_num + while true; do + echo "" + for i in "${!MOD_ORDER[@]}"; do + key="${MOD_ORDER[$i]}" + marker=" " + [[ " ${SELECTED_MODS[*]} " =~ " ${key} " ]] && marker="✓" + if [[ "${MOD_AVAIL[${key}:${MC_VERSION}]}" == "no" ]]; then + printf " %2d) %s %-22s %s" \ + "$((i+1))" "$marker" "${MODS[$key]}" "${MOD_DESC[$key]}" + echo -e " \033[0;31m[not available for $MC_VERSION]\033[0m" + else + printf " %2d) %s %-22s %s\n" \ + "$((i+1))" "$marker" "${MODS[$key]}" "${MOD_DESC[$key]}" + fi + done + echo "" + echo " $(( ${#MOD_ORDER[@]} + 1 ))) Select All" + echo " $(( ${#MOD_ORDER[@]} + 2 ))) Done" + echo "" + read -p "Selection: " choice + + all_num=$(( ${#MOD_ORDER[@]} + 1 )) + done_num=$(( ${#MOD_ORDER[@]} + 2 )) + + if [ "$choice" = "$all_num" ]; then + SELECTED_MODS=("${MOD_ORDER[@]}"); break + elif [ "$choice" = "$done_num" ] || [ "$choice" = "done" ]; then + break + elif [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && \ + [ "$choice" -le "${#MOD_ORDER[@]}" ]; then + key="${MOD_ORDER[$((choice-1))]}" + if [[ " ${SELECTED_MODS[*]} " =~ " ${key} " ]]; then + SELECTED_MODS=("${SELECTED_MODS[@]/$key}") + SELECTED_MODS=(${SELECTED_MODS[@]}) + else + SELECTED_MODS+=("$key") + fi + else + log_warning "Invalid selection" + fi + done + + # ── Vanilla Tweaks datapacks ─────────────────────────────────────────── + echo "" + log_info "Vanilla Tweaks Datapacks" + echo "Toggle datapacks. Recommended defaults are pre-selected." + echo "" + + declare -A DPACKS + declare -A DPACK_DESC + declare -A DPACK_DEFAULT + declare -A DPACK_VT_ID + declare -A DPACK_CAT + + # ── Decorative / Cosmetic ────────────────────────────────────────────── + DPACKS["armor_statues"]="Armor Statues" + DPACK_DESC["armor_statues"]="Book to pose and customise armor stands." + DPACK_DEFAULT["armor_statues"]="y"; DPACK_CAT["armor_statues"]="Decorative/Cosmetic" + DPACK_VT_ID["armor_statues"]="armorStatues" + + DPACKS["custom_nether_portals"]="Custom Nether Portals" + DPACK_DESC["custom_nether_portals"]="Build nether portals in any shape using crying obsidian." + DPACK_DEFAULT["custom_nether_portals"]="n"; DPACK_CAT["custom_nether_portals"]="Decorative/Cosmetic" + DPACK_VT_ID["custom_nether_portals"]="customNetherPortals" + + DPACKS["mini_blocks"]="Mini Blocks" + DPACK_DESC["mini_blocks"]="Craft 1/8-scale decorative versions of most blocks." + DPACK_DEFAULT["mini_blocks"]="n"; DPACK_CAT["mini_blocks"]="Decorative/Cosmetic" + DPACK_VT_ID["mini_blocks"]="miniBlocks" + + DPACKS["more_mob_heads"]="More Mob Heads" + DPACK_DESC["more_mob_heads"]="Mobs have a chance to drop their head on death." + DPACK_DEFAULT["more_mob_heads"]="y"; DPACK_CAT["more_mob_heads"]="Decorative/Cosmetic" + DPACK_VT_ID["more_mob_heads"]="moreMobHeads" + + DPACKS["name_colors"]="Name Colors" + DPACK_DESC["name_colors"]="Players set their own name color using a trigger." + DPACK_DEFAULT["name_colors"]="n"; DPACK_CAT["name_colors"]="Decorative/Cosmetic" + DPACK_VT_ID["name_colors"]="nameColors" + + DPACKS["player_head_drops"]="Player Head Drops" + DPACK_DESC["player_head_drops"]="Players drop their head when killed by another player." + DPACK_DEFAULT["player_head_drops"]="y"; DPACK_CAT["player_head_drops"]="Decorative/Cosmetic" + DPACK_VT_ID["player_head_drops"]="playerHeadDrops" + + DPACKS["silence_mobs"]="Silence Mobs" + DPACK_DESC["silence_mobs"]="Name a mob 'silence_me' to mute it permanently." + DPACK_DEFAULT["silence_mobs"]="y"; DPACK_CAT["silence_mobs"]="Decorative/Cosmetic" + DPACK_VT_ID["silence_mobs"]="silenceMobs" + + DPACKS["wandering_trades"]="Wandering Trades" + DPACK_DESC["wandering_trades"]="Wandering trader sells mini blocks." + DPACK_DEFAULT["wandering_trades"]="n"; DPACK_CAT["wandering_trades"]="Decorative/Cosmetic" + DPACK_VT_ID["wandering_trades"]="wanderingTrades" + + DPACKS["wandering_trades_hermit"]="Wandering Trades (Hermit Edition)" + DPACK_DESC["wandering_trades_hermit"]="Wandering trader sells Hermitcraft player heads." + DPACK_DEFAULT["wandering_trades_hermit"]="n"; DPACK_CAT["wandering_trades_hermit"]="Decorative/Cosmetic" + DPACK_VT_ID["wandering_trades_hermit"]="wanderingTradesHermitEdition" + + # ── Convenience ───────────────────────────────────────────────────────── + DPACKS["cauldron_concrete"]="Cauldron Concrete" + DPACK_DESC["cauldron_concrete"]="Dip concrete powder in a water cauldron to make concrete." + DPACK_DEFAULT["cauldron_concrete"]="n"; DPACK_CAT["cauldron_concrete"]="Convenience" + DPACK_VT_ID["cauldron_concrete"]="cauldronConcrete" + + DPACKS["cauldron_mud"]="Cauldron Mud" + DPACK_DESC["cauldron_mud"]="Add water to a dirt-filled cauldron to make mud." + DPACK_DEFAULT["cauldron_mud"]="n"; DPACK_CAT["cauldron_mud"]="Convenience" + DPACK_VT_ID["cauldron_mud"]="cauldronMud" + + DPACKS["chunk_loaders"]="Chunk Loaders" + DPACK_DESC["chunk_loaders"]="Craftable item that keeps chunks loaded when you're offline." + DPACK_DEFAULT["chunk_loaders"]="n"; DPACK_CAT["chunk_loaders"]="Convenience" + DPACK_VT_ID["chunk_loaders"]="chunkLoaders" + + DPACKS["double_shulker_shells"]="Double Shulker Shells" + DPACK_DESC["double_shulker_shells"]="Shulkers always drop 2 shells." + DPACK_DEFAULT["double_shulker_shells"]="y"; DPACK_CAT["double_shulker_shells"]="Convenience" + DPACK_VT_ID["double_shulker_shells"]="doubleShulkerShells" + + DPACKS["dragon_drops"]="Dragon Drops" + DPACK_DESC["dragon_drops"]="Ender Dragon drops an elytra and dragon egg on first kill." + DPACK_DEFAULT["dragon_drops"]="y"; DPACK_CAT["dragon_drops"]="Convenience" + DPACK_VT_ID["dragon_drops"]="dragonDrops" + + DPACKS["elevators"]="Elevators" + DPACK_DESC["elevators"]="Craft elevator blocks that teleport players vertically." + DPACK_DEFAULT["elevators"]="n"; DPACK_CAT["elevators"]="Convenience" + DPACK_VT_ID["elevators"]="elevators" + + DPACKS["ender_chest_drops"]="Ender Chest Drops" + DPACK_DESC["ender_chest_drops"]="Ender chest drops 8 obsidian + eye of ender when broken." + DPACK_DEFAULT["ender_chest_drops"]="n"; DPACK_CAT["ender_chest_drops"]="Convenience" + DPACK_VT_ID["ender_chest_drops"]="enderChestDrops" + + DPACKS["fast_leaf_decay"]="Fast Leaf Decay" + DPACK_DESC["fast_leaf_decay"]="Leaves decay much faster after a tree is felled." + DPACK_DEFAULT["fast_leaf_decay"]="y"; DPACK_CAT["fast_leaf_decay"]="Convenience" + DPACK_VT_ID["fast_leaf_decay"]="fastLeafDecay" + + DPACKS["glass_always_drops"]="Glass Always Drops" + DPACK_DESC["glass_always_drops"]="Breaking glass without Silk Touch still returns the block." + DPACK_DEFAULT["glass_always_drops"]="n"; DPACK_CAT["glass_always_drops"]="Convenience" + DPACK_VT_ID["glass_always_drops"]="glassAlwaysDrops" + + DPACKS["more_effective_tools"]="More Effective Tools" + DPACK_DESC["more_effective_tools"]="Axes/pickaxes/shovels also break nearby matching blocks." + DPACK_DEFAULT["more_effective_tools"]="n"; DPACK_CAT["more_effective_tools"]="Convenience" + DPACK_VT_ID["more_effective_tools"]="moreEffectiveTools" + + DPACKS["multiplayer_sleep"]="Multiplayer Sleep" + DPACK_DESC["multiplayer_sleep"]="Only one player needs to sleep to skip the night." + DPACK_DEFAULT["multiplayer_sleep"]="y"; DPACK_CAT["multiplayer_sleep"]="Convenience" + DPACK_VT_ID["multiplayer_sleep"]="multiplayerSleep" + + DPACKS["painting_picker"]="Painting Picker" + DPACK_DESC["painting_picker"]="Cycle through painting variants when placing via a trigger." + DPACK_DEFAULT["painting_picker"]="n"; DPACK_CAT["painting_picker"]="Convenience" + DPACK_VT_ID["painting_picker"]="paintingPicker" + + DPACKS["redstone_rotation_wrench"]="Redstone Rotation Wrench" + DPACK_DESC["redstone_rotation_wrench"]="Craft a wrench to rotate redstone components in place." + DPACK_DEFAULT["redstone_rotation_wrench"]="n"; DPACK_CAT["redstone_rotation_wrench"]="Convenience" + DPACK_VT_ID["redstone_rotation_wrench"]="redstoneRotationWrench" + + DPACKS["spectator_conduit_power"]="Spectator Conduit Power" + DPACK_DESC["spectator_conduit_power"]="Spectators get conduit power effects (useful for builders)." + DPACK_DEFAULT["spectator_conduit_power"]="n"; DPACK_CAT["spectator_conduit_power"]="Convenience" + DPACK_VT_ID["spectator_conduit_power"]="spectatorConduitPower" + + DPACKS["spectator_night_vision"]="Spectator Night Vision" + DPACK_DESC["spectator_night_vision"]="Night vision is automatically applied in spectator mode." + DPACK_DEFAULT["spectator_night_vision"]="n"; DPACK_CAT["spectator_night_vision"]="Convenience" + DPACK_VT_ID["spectator_night_vision"]="spectatorNightVision" + + DPACKS["storm_channeling"]="Storm Channeling" + DPACK_DESC["storm_channeling"]="Trident Channeling works in rain, not just thunderstorms." + DPACK_DEFAULT["storm_channeling"]="n"; DPACK_CAT["storm_channeling"]="Convenience" + DPACK_VT_ID["storm_channeling"]="stormChanneling" + + DPACKS["terracotta_rotation_wrench"]="Terracotta Rotation Wrench" + DPACK_DESC["terracotta_rotation_wrench"]="Craft a wrench to rotate glazed terracotta in place." + DPACK_DEFAULT["terracotta_rotation_wrench"]="n"; DPACK_CAT["terracotta_rotation_wrench"]="Convenience" + DPACK_VT_ID["terracotta_rotation_wrench"]="terracottaRotationWrench" + + DPACKS["timber"]="Timber" + DPACK_DESC["timber"]="Chop the bottom log to fell an entire tree instantly." + DPACK_DEFAULT["timber"]="n"; DPACK_CAT["timber"]="Convenience" + DPACK_VT_ID["timber"]="timber" + + DPACKS["unlock_all_recipes"]="Unlock All Recipes" + DPACK_DESC["unlock_all_recipes"]="All crafting recipes unlocked for all players from the start." + DPACK_DEFAULT["unlock_all_recipes"]="n"; DPACK_CAT["unlock_all_recipes"]="Convenience" + DPACK_VT_ID["unlock_all_recipes"]="unlockAllRecipes" + + DPACKS["weed_stripper"]="Weed Stripper" + DPACK_DESC["weed_stripper"]="Hoe clears grass, flowers and shrubs in a wider area." + DPACK_DEFAULT["weed_stripper"]="n"; DPACK_CAT["weed_stripper"]="Convenience" + DPACK_VT_ID["weed_stripper"]="weedStripper" + + # ── Gameplay Changes ──────────────────────────────────────────────────── + DPACKS["anti_creeper_grief"]="Anti Creeper Grief" + DPACK_DESC["anti_creeper_grief"]="Creeper explosions don't destroy blocks." + DPACK_DEFAULT["anti_creeper_grief"]="n"; DPACK_CAT["anti_creeper_grief"]="Gameplay Changes" + DPACK_VT_ID["anti_creeper_grief"]="antiCreeperGrief" + + DPACKS["anti_enderman_grief"]="Anti Enderman Grief" + DPACK_DESC["anti_enderman_grief"]="Endermen can't pick up blocks — stops world griefing." + DPACK_DEFAULT["anti_enderman_grief"]="y"; DPACK_CAT["anti_enderman_grief"]="Gameplay Changes" + DPACK_VT_ID["anti_enderman_grief"]="antiEndermanGrief" + + DPACKS["anti_ghast_grief"]="Anti Ghast Grief" + DPACK_DESC["anti_ghast_grief"]="Ghast fireballs don't destroy Nether blocks." + DPACK_DEFAULT["anti_ghast_grief"]="n"; DPACK_CAT["anti_ghast_grief"]="Gameplay Changes" + DPACK_VT_ID["anti_ghast_grief"]="antiGhastGrief" + + DPACKS["armored_elytra"]="Armored Elytra" + DPACK_DESC["armored_elytra"]="Combine elytra and chestplate to wear both at once." + DPACK_DEFAULT["armored_elytra"]="n"; DPACK_CAT["armored_elytra"]="Gameplay Changes" + DPACK_VT_ID["armored_elytra"]="armoredElytra" + + DPACKS["bat_membranes"]="Bat Membranes" + DPACK_DESC["bat_membranes"]="Bats drop membranes used to craft a gliding cape." + DPACK_DEFAULT["bat_membranes"]="n"; DPACK_CAT["bat_membranes"]="Gameplay Changes" + DPACK_VT_ID["bat_membranes"]="batMembranes" + + DPACKS["classic_fishing"]="Classic Fishing Lure" + DPACK_DESC["classic_fishing"]="Restore pre-1.16 fishing — treasure loot in any open water." + DPACK_DEFAULT["classic_fishing"]="n"; DPACK_CAT["classic_fishing"]="Gameplay Changes" + DPACK_VT_ID["classic_fishing"]="classicFishingLure" + + DPACKS["confetti_creepers"]="Confetti Creepers" + DPACK_DESC["confetti_creepers"]="Creepers explode into colourful fireworks — cosmetic only." + DPACK_DEFAULT["confetti_creepers"]="n"; DPACK_CAT["confetti_creepers"]="Gameplay Changes" + DPACK_VT_ID["confetti_creepers"]="confettiCreepers" + + DPACKS["graves"]="Graves" + DPACK_DESC["graves"]="Creates a grave on death that stores your items." + DPACK_DEFAULT["graves"]="y"; DPACK_CAT["graves"]="Gameplay Changes" + DPACK_VT_ID["graves"]="graves" + + DPACKS["husks_drop_sand"]="Husks Drop Sand" + DPACK_DESC["husks_drop_sand"]="Husks drop sand when killed — makes sand renewable." + DPACK_DEFAULT["husks_drop_sand"]="n"; DPACK_CAT["husks_drop_sand"]="Gameplay Changes" + DPACK_VT_ID["husks_drop_sand"]="husksDropSand" + + DPACKS["silk_touch_amethyst"]="Silk Touch Building Amethyst" + DPACK_DESC["silk_touch_amethyst"]="Silk Touch lets you mine amethyst clusters as placeable blocks." + DPACK_DEFAULT["silk_touch_amethyst"]="n"; DPACK_CAT["silk_touch_amethyst"]="Gameplay Changes" + DPACK_VT_ID["silk_touch_amethyst"]="silkTouchBuildingAmethyst" + + DPACKS["xp_bottling"]="XP Bottling" + DPACK_DESC["xp_bottling"]="Store your XP in bottles of enchanting at a grindstone." + DPACK_DEFAULT["xp_bottling"]="n"; DPACK_CAT["xp_bottling"]="Gameplay Changes" + DPACK_VT_ID["xp_bottling"]="xpBottling" + + # ── Informative ─────────────────────────────────────────────────────── + DPACKS["afk_display"]="AFK Display" + DPACK_DESC["afk_display"]="Shows [AFK] next to player names when idle." + DPACK_DEFAULT["afk_display"]="y"; DPACK_CAT["afk_display"]="Informative" + DPACK_VT_ID["afk_display"]="afkDisplay" + + DPACKS["coords_hud"]="Coordinates HUD" + DPACK_DESC["coords_hud"]="Players toggle coordinate display in the actionbar." + DPACK_DEFAULT["coords_hud"]="y"; DPACK_CAT["coords_hud"]="Informative" + DPACK_VT_ID["coords_hud"]="coordinatesHud" + + DPACKS["durability_ping"]="Durability Ping" + DPACK_DESC["durability_ping"]="Sound + actionbar alert when tool/armor durability gets low." + DPACK_DEFAULT["durability_ping"]="y"; DPACK_CAT["durability_ping"]="Informative" + DPACK_VT_ID["durability_ping"]="durabilityPing" + + DPACKS["nether_portal_coords"]="Nether Portal Coords" + DPACK_DESC["nether_portal_coords"]="Chat shows overworld↔nether coordinate conversion on entry." + DPACK_DEFAULT["nether_portal_coords"]="y"; DPACK_CAT["nether_portal_coords"]="Informative" + DPACK_VT_ID["nether_portal_coords"]="netherPortalCoords" + + DPACKS["real_time_clock"]="Real Time Clock" + DPACK_DESC["real_time_clock"]="Shows real-world time in actionbar via a trigger." + DPACK_DEFAULT["real_time_clock"]="n"; DPACK_CAT["real_time_clock"]="Informative" + DPACK_VT_ID["real_time_clock"]="realTimeClock" + + DPACKS["spawning_spheres"]="Spawning Spheres" + DPACK_DESC["spawning_spheres"]="Visualise the mob spawn radius around a block." + DPACK_DEFAULT["spawning_spheres"]="n"; DPACK_CAT["spawning_spheres"]="Informative" + DPACK_VT_ID["spawning_spheres"]="spawningSpheres" + + DPACKS["track_raw_statistics"]="Track Raw Statistics" + DPACK_DESC["track_raw_statistics"]="Scoreboards tracking raw stat values (distance, damage, etc.)." + DPACK_DEFAULT["track_raw_statistics"]="n"; DPACK_CAT["track_raw_statistics"]="Informative" + DPACK_VT_ID["track_raw_statistics"]="trackRawStatistics" + + DPACKS["track_statistics"]="Track Statistics" + DPACK_DESC["track_statistics"]="Scoreboards for deaths, mob kills, playtime." + DPACK_DEFAULT["track_statistics"]="y"; DPACK_CAT["track_statistics"]="Informative" + DPACK_VT_ID["track_statistics"]="trackStatistics" + + DPACKS["village_death_messages"]="Village Death Messages" + DPACK_DESC["village_death_messages"]="Chat alert when a villager is killed nearby." + DPACK_DEFAULT["village_death_messages"]="y"; DPACK_CAT["village_death_messages"]="Informative" + DPACK_VT_ID["village_death_messages"]="villageDeathMessages" + + DPACKS["workstation_highlights"]="Workstation Highlights" + DPACK_DESC["workstation_highlights"]="Particles show which workstation a villager is linked to." + DPACK_DEFAULT["workstation_highlights"]="y"; DPACK_CAT["workstation_highlights"]="Informative" + DPACK_VT_ID["workstation_highlights"]="workstationHighlights" + + DPACKS["wandering_trader_ann"]="Wandering Trader Announcements" + DPACK_DESC["wandering_trader_ann"]="Chat message when a Wandering Trader appears near spawn." + DPACK_DEFAULT["wandering_trader_ann"]="n"; DPACK_CAT["wandering_trader_ann"]="Informative" + DPACK_VT_ID["wandering_trader_ann"]="wanderingTraderAnnouncements" + + # ── Teleport Commands ─────────────────────────────────────────────────── + DPACKS["tp_back"]="Back" + DPACK_DESC["tp_back"]="Return to last death or teleport location via trigger." + DPACK_DEFAULT["tp_back"]="n"; DPACK_CAT["tp_back"]="Teleport Commands" + DPACK_VT_ID["tp_back"]="back" + + DPACKS["homes"]="Homes" + DPACK_DESC["homes"]="Set and teleport to named home locations via trigger." + DPACK_DEFAULT["homes"]="n"; DPACK_CAT["homes"]="Teleport Commands" + DPACK_VT_ID["homes"]="homes" + + DPACKS["spawn"]="Spawn" + DPACK_DESC["spawn"]="Set and return to a global spawn point via trigger." + DPACK_DEFAULT["spawn"]="n"; DPACK_CAT["spawn"]="Teleport Commands" + DPACK_VT_ID["spawn"]="spawn" + + DPACKS["tpa"]="TPA" + DPACK_DESC["tpa"]="Teleport-request system via trigger." + DPACK_DEFAULT["tpa"]="n"; DPACK_CAT["tpa"]="Teleport Commands" + DPACK_VT_ID["tpa"]="tpa" + + # ── Admin Tools ───────────────────────────────────────────────────────── + DPACKS["custom_villager_shops"]="Custom Villager Shops" + DPACK_DESC["custom_villager_shops"]="Build custom villager trade shops using a book." + DPACK_DEFAULT["custom_villager_shops"]="n"; DPACK_CAT["custom_villager_shops"]="Admin Tools" + DPACK_VT_ID["custom_villager_shops"]="customVillagerShops" + + DPACKS["kill_empty_boats"]="Kill Empty Boats" + DPACK_DESC["kill_empty_boats"]="Periodically removes riderless boats to cut entity lag." + DPACK_DEFAULT["kill_empty_boats"]="n"; DPACK_CAT["kill_empty_boats"]="Admin Tools" + DPACK_VT_ID["kill_empty_boats"]="killEmptyBoats" + + local DPACK_ORDER=( + # Decorative / Cosmetic + "armor_statues" "custom_nether_portals" "mini_blocks" "more_mob_heads" + "name_colors" "player_head_drops" "silence_mobs" + "wandering_trades" "wandering_trades_hermit" + # Convenience + "cauldron_concrete" "cauldron_mud" "chunk_loaders" "double_shulker_shells" + "dragon_drops" "elevators" "ender_chest_drops" "fast_leaf_decay" + "glass_always_drops" "more_effective_tools" "multiplayer_sleep" + "painting_picker" "redstone_rotation_wrench" "spectator_conduit_power" + "spectator_night_vision" "storm_channeling" "terracotta_rotation_wrench" + "timber" "unlock_all_recipes" "weed_stripper" + # Gameplay Changes + "anti_creeper_grief" "anti_enderman_grief" "anti_ghast_grief" + "armored_elytra" "bat_membranes" "classic_fishing" "confetti_creepers" + "graves" "husks_drop_sand" "silk_touch_amethyst" "xp_bottling" + # Informative + "afk_display" "coords_hud" "durability_ping" "nether_portal_coords" + "real_time_clock" "spawning_spheres" "track_raw_statistics" + "track_statistics" "village_death_messages" "workstation_highlights" + "wandering_trader_ann" + # Teleport Commands + "tp_back" "homes" "spawn" "tpa" + # Admin Tools + "custom_villager_shops" "kill_empty_boats" + ) + + local dp + for dp in "${DPACK_ORDER[@]}"; do + [[ "${DPACK_DEFAULT[$dp]}" == "y" ]] && SELECTED_DATAPACKS+=("$dp") + done + + echo "" + log_info "Vanilla Tweaks datapacks" + echo " ✓ = pre-selected (recommended defaults)" + echo " Only the recommended datapacks are shown — toggle numbers then press Done." + echo " Pick 'Show all datapacks' to browse the full Vanilla Tweaks catalogue." + echo "" + + local SHOW_ALL=false + local VISIBLE=() _LAST_CAT _cat _marker _n _dp_choice _dp_toggle _dp_all _dp_done _dp_key + while true; do + VISIBLE=() + for dp in "${DPACK_ORDER[@]}"; do + if [ "$SHOW_ALL" = true ] || [[ "${DPACK_DEFAULT[$dp]}" == "y" ]]; then + VISIBLE+=("$dp") + fi + done + + _LAST_CAT="" + for i in "${!VISIBLE[@]}"; do + dp="${VISIBLE[$i]}" + _cat="${DPACK_CAT[$dp]}" + if [ "$_cat" != "$_LAST_CAT" ]; then + echo "" + echo -e " \033[1;33m── ${_cat}\033[0m" + _LAST_CAT="$_cat" + fi + _marker=" " + [[ " ${SELECTED_DATAPACKS[*]} " =~ " ${dp} " ]] && _marker="✓" + printf " %2d) %s %-34s %s\n" "$((i+1))" "$_marker" "${DPACKS[$dp]}" "${DPACK_DESC[$dp]}" + done + echo "" + _n=${#VISIBLE[@]} + if [ "$SHOW_ALL" = true ]; then + echo " $(( _n + 1 ))) Show recommended only" + else + echo " $(( _n + 1 ))) Show all datapacks (${#DPACK_ORDER[@]} total)" + fi + echo " $(( _n + 2 ))) Select All" + echo " $(( _n + 3 ))) Done" + echo "" + read -p "Selection: " _dp_choice + + _dp_toggle=$(( _n + 1 )) + _dp_all=$(( _n + 2 )) + _dp_done=$(( _n + 3 )) + + if [ "$_dp_choice" = "$_dp_toggle" ]; then + [ "$SHOW_ALL" = true ] && SHOW_ALL=false || SHOW_ALL=true + elif [ "$_dp_choice" = "$_dp_all" ]; then + for dp in "${VISIBLE[@]}"; do + [[ " ${SELECTED_DATAPACKS[*]} " =~ " ${dp} " ]] || SELECTED_DATAPACKS+=("$dp") + done + break + elif [ "$_dp_choice" = "$_dp_done" ] || [ "$_dp_choice" = "done" ]; then + break + elif [[ "$_dp_choice" =~ ^[0-9]+$ ]] && [ "$_dp_choice" -ge 1 ] && \ + [ "$_dp_choice" -le "$_n" ]; then + _dp_key="${VISIBLE[$((_dp_choice-1))]}" + if [[ " ${SELECTED_DATAPACKS[*]} " =~ " ${_dp_key} " ]]; then + SELECTED_DATAPACKS=("${SELECTED_DATAPACKS[@]/$_dp_key}") + SELECTED_DATAPACKS=(${SELECTED_DATAPACKS[@]}) + else + SELECTED_DATAPACKS+=("$_dp_key") + fi + else + log_warning "Invalid selection" + fi + done + fi + + # Vanilla/Paper/Forge: still need a concrete version (no picker ran above). + if [ -z "$MC_VERSION" ]; then + prompt_text "Minecraft version [${RECENT_VERSIONS[0]}]:" "${RECENT_VERSIONS[0]}" MC_VERSION + fi + + # ── Chunky pre-generation config ──────────────────────────────────────────── + local PREGEN_RADIUS=5000 + local USE_BORDER=true + local BORDER_SIZE=10000 + if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then + echo "" + log_info "Chunky Pre-Generation" + echo "Chunky pre-generates chunks around spawn so players exploring new areas" + echo "won't cause lag spikes. Run once before opening the server to players." + echo "" + prompt_text "Pre-generation radius in blocks [5000]:" "5000" PREGEN_RADIUS + BORDER_SIZE=$(( PREGEN_RADIUS * 2 )) + echo "" + local _border="" + prompt_yn "Set world border at ${BORDER_SIZE} blocks (2× radius)? (y/n) [y]:" "y" _border + [[ ${_border:-y} =~ ^[Yy]$ ]] && USE_BORDER=true || USE_BORDER=false + fi + + # ── Networking ────────────────────────────────────────────────────────────── + echo "" + log_info "Networking / Remote Access" + echo "" + echo "How will players connect from outside your network?" + echo "" + echo " 1) Port forward + DNS (recommended)" + echo " Forward your Minecraft port on your router, add DNS records." + echo " Players connect to mc.yourdomain.com — no port number needed." + echo " Full step-by-step instructions generated in MINECRAFT_NETWORKING.md" + echo "" + echo " 2) playit.gg tunnel (fallback — use if you cannot port forward)" + echo " Free tunnel, no router access needed, works behind CGNAT." + echo " All player traffic routes through playit.gg's servers." + echo " See their privacy policy: https://playit.gg/privacy-policy/" + echo "" + echo " 3) Local only (no external access)" + echo "" + local NET_CHOICE="" + prompt_text "Networking choice [1]:" "1" NET_CHOICE + + local USE_PLAYIT=false + local USE_PORTFORWARD=false + case $NET_CHOICE in + 1) USE_PORTFORWARD=true ;; + 2) USE_PLAYIT=true ;; + 3) ;; + esac + + local MC_DOMAIN="" + local BASE_DOMAIN="" + [ -f "$DOCKER_DIR/.config" ] && BASE_DOMAIN=$(grep '^BASE_DOMAIN=' "$DOCKER_DIR/.config" 2>/dev/null | cut -d= -f2-) + if [ "$USE_PLAYIT" = true ] || [ "$USE_PORTFORWARD" = true ]; then + if [ -n "$BASE_DOMAIN" ]; then + local _PREFIX="" + prompt_text "Subdomain prefix for Minecraft [mc].${BASE_DOMAIN}:" "mc" _PREFIX + MC_DOMAIN="${_PREFIX}.${BASE_DOMAIN}" + echo " → ${MC_DOMAIN}" + else + prompt_text "Domain for Minecraft (e.g. mc.yourdomain.com) [leave blank to skip]:" "" MC_DOMAIN + fi + fi + + # ── Create directory structure ────────────────────────────────────────────── + mkdir -p "$MC_DIR"/{data,mods-download,datapacks-download,config} + ensure_docker_dir_ownership "$MC_DIR" + # The minecraft server runs as uid=1000; pre-create writable dirs so mods + # (C2ME, Lithium) can write their config files on first start. + chown -R 1000:1000 "$MC_DIR/data" "$MC_DIR/config" 2>/dev/null \ + || log_warning "Could not chown minecraft dirs to uid 1000 — if C2ME/Lithium crash on start, run: sudo chown -R 1000:1000 $MC_DIR/data $MC_DIR/config" + cd "$MC_DIR" || return 1 + + # ── Whitelist pre-population ──────────────────────────────────────────────── + if [ "$WHITELIST_ENABLED" = true ] && [ ${#WHITELIST_PLAYERS[@]} -gt 0 ]; then + log_info "Looking up UUIDs for whitelist players..." + local _WL_JSON="[" + local _WL_FIRST=true + local _WL_COUNT=0 + local _player _resp _uuid _name + for _player in "${WHITELIST_PLAYERS[@]}"; do + _resp=$(curl -sf --max-time 10 \ + "https://api.mojang.com/users/profiles/minecraft/${_player}" 2>/dev/null || echo "") + if [ -z "$_resp" ]; then + log_warning " '$_player' not found — skipping (account may not exist)" + continue + fi + _uuid=$(echo "$_resp" | python3 -c " +import sys, json +d = json.load(sys.stdin) +uid = d['id'] +print(f'{uid[:8]}-{uid[8:12]}-{uid[12:16]}-{uid[16:20]}-{uid[20:]}') +" 2>/dev/null || echo "") + _name=$(echo "$_resp" | python3 -c " +import sys, json; d=json.load(sys.stdin); print(d.get('name',''))" 2>/dev/null || echo "$_player") + if [ -z "$_uuid" ]; then + log_warning " Could not parse UUID for '$_player' — skipping" + continue + fi + log_success " $_name → $_uuid" + [ "$_WL_FIRST" = true ] || _WL_JSON+="," + _WL_FIRST=false + _WL_COUNT=$((_WL_COUNT + 1)) + _WL_JSON+=" + {\"uuid\": \"$_uuid\", \"name\": \"$_name\"}" + done + _WL_JSON+=" +]" + echo "$_WL_JSON" > "$MC_DIR/data/whitelist.json" + chown 1000:1000 "$MC_DIR/data/whitelist.json" 2>/dev/null || true + log_success "whitelist.json written with $_WL_COUNT player(s)" + fi + + # ── Fetch mod JARs from Modrinth ──────────────────────────────────────────── + download_modrinth_mod() { + local slug="$1" + local label="$2" + local mc_ver="$3" + local loader="${4:-fabric}" + + local api_url="https://api.modrinth.com/v2/project/${slug}/version" + local query="?game_versions=%5B%22${mc_ver}%22%5D&loaders=%5B%22${loader}%22%5D" + + local jar_url + jar_url=$(curl -sf "${api_url}${query}" \ + | python3 -c " +import sys, json +versions = json.load(sys.stdin) +for v in versions: + for f in v.get('files', []): + if f.get('primary'): + print(f['url']) + sys.exit(0) +" 2>/dev/null || echo "") + + if [ -z "$jar_url" ]; then + log_warning " $label not available for MC $mc_ver yet — skipping (check https://modrinth.com/mod/${slug} for updates)" + return 1 + fi + + local fname + fname=$(basename "$jar_url" | cut -d'?' -f1) + curl -sfL "$jar_url" -o "mods-download/${fname}" \ + && log_success " Downloaded: $fname" \ + || log_warning " Failed to download $label" + } + + if [ "$SUPPORTS_FABRIC_MODS" = true ] && [ ${#SELECTED_MODS[@]} -gt 0 ]; then + log_info "Downloading mods from Modrinth..." + local ALWAYS_DEPS=("fabric-api" "fabric-language-kotlin") + local dep mid + for dep in "${ALWAYS_DEPS[@]}"; do + download_modrinth_mod "$dep" "$dep" "$MC_VERSION" || true + done + for mod in "${SELECTED_MODS[@]}"; do + mid="${MOD_MODRINTH_ID[$mod]}" + download_modrinth_mod "$mid" "${MODS[$mod]}" "$MC_VERSION" || true + done + fi + + # ── Vanilla Tweaks datapacks — manual download required ────────────────────── + if [ "$SUPPORTS_FABRIC_MODS" = true ] && [ ${#SELECTED_DATAPACKS[@]} -gt 0 ]; then + local VT_VERSION + VT_VERSION=$(echo "$MC_VERSION" | awk -F. '{if ($1=="1") print $0; else print $1"."$2}') + + echo "" + log_info "Vanilla Tweaks — download your selected packs manually:" + echo "" + echo " 1. Go to: https://vanillatweaks.net/picker/datapacks/" + echo " 2. Select Minecraft version ${VT_VERSION} in the version dropdown" + echo " 3. Enable these packs (your selections from the toggle menu):" + echo "" + local _LAST_CAT="" _cat dp + for dp in "${DPACK_ORDER[@]}"; do + [[ " ${SELECTED_DATAPACKS[*]} " =~ " ${dp} " ]] || continue + _cat="${DPACK_CAT[$dp]}" + if [ "$_cat" != "$_LAST_CAT" ]; then + echo " ── ${_cat}" + _LAST_CAT="$_cat" + fi + echo " • ${DPACKS[$dp]}" + done + echo "" + echo " 4. Click Download and save the .zip file" + echo " 5. Place the .zip in: ${MC_DIR}/datapacks-download/" + echo " 6. Rebuild: cd ${MC_DIR} && docker compose build" + echo " 7. Restart: cd ${MC_DIR} && docker compose up -d" + echo "" + echo " The itzg image extracts .zip files from /datapacks/ on startup." + echo " Datapacks land in ${MC_NAME}/data/datapacks/ and persist across restarts." + fi + + # ── LuckPerms bootstrap script ────────────────────────────────────────────── + if [[ " ${SELECTED_MODS[*]} " =~ " luckperms " ]]; then + log_info "Generating LuckPerms bootstrap commands..." + mkdir -p "$MC_DIR/luckperms-bootstrap" + cat > "$MC_DIR/luckperms-bootstrap/bootstrap.txt" << 'LPEOF' +# LuckPerms bootstrap — runs once on first server start via startup script +# Groups: default (all players) → mod → admin + +lp creategroup mod +lp creategroup admin + +# Default player permissions +lp group default permission set essentials.home true +lp group default permission set essentials.sethome true +lp group default permission set essentials.delhome true +lp group default permission set essentials.back true +lp group default permission set essentials.spawn true +lp group default permission set essentials.tpa true +lp group default permission set essentials.tpaccept true +lp group default permission set essentials.tpdeny true +lp group default permission set essentials.warp true + +# Mod inherits default +lp group mod parent set default +lp group mod permission set essentials.kick true +lp group mod permission set essentials.mute true +lp group mod permission set essentials.tp true +lp group mod permission set essentials.tphere true +lp group mod permission set ledger.query true + +# Admin inherits mod +lp group admin parent set mod +lp group admin permission set luckperms.* true +lp group admin permission set essentials.* true +lp group admin permission set "*" true +LPEOF + log_success "LuckPerms bootstrap written to ${MC_NAME}/luckperms-bootstrap/bootstrap.txt" + fi + + # ── Dockerfile ──────────────────────────────────────────────────────────── + log_info "Generating Dockerfile..." + cat > "$MC_DIR/Dockerfile" << 'MCEOF' +FROM itzg/minecraft-server:latest + +# Mods and datapacks are copied in at build time +COPY mods-download/ /mods/ +COPY datapacks-download/ /datapacks/ +MCEOF + + if [[ " ${SELECTED_MODS[*]} " =~ " luckperms " ]]; then + echo "COPY luckperms-bootstrap/bootstrap.txt /luckperms-bootstrap.txt" >> "$MC_DIR/Dockerfile" + fi + log_success "Dockerfile created" + + # ── pregen-startup.sh ─────────────────────────────────────────────────────── + # Lives inside data/ (bind-mounted to /data). itzg executes /data/*.sh on + # startup; our guard exits 0 unless PREGEN=1 is set, so normal startup is + # unaffected. Do NOT COPY into /data (it's a bind-mount) or add extra mounts. + if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then + [ -e "$MC_DIR/pregen-startup.sh" ] && rm -rf "$MC_DIR/pregen-startup.sh" + mkdir -p "$MC_DIR/data" + [ -e "$MC_DIR/data/pregen-startup.sh" ] && [ ! -f "$MC_DIR/data/pregen-startup.sh" ] \ + && rm -rf "$MC_DIR/data/pregen-startup.sh" + local BORDER_LINE="" + [ "$USE_BORDER" = true ] && BORDER_LINE="mc-send-to-console \"worldborder set ${BORDER_SIZE}\"" + cat > "$MC_DIR/data/pregen-startup.sh" << PREGENEOF +#!/bin/bash +# Chunky pre-generation — run manually after the server has started: +# docker exec -e PREGEN=1 -u 1000 ${MC_NAME} bash /data/pregen-startup.sh +[ "\${PREGEN:-0}" = "1" ] || exit 0 +_PIPE=/tmp/minecraft-console-in +echo "Waiting for server to be ready (may take 1-2 minutes)..." +_WAIT=0 +until [ -p "\$_PIPE" ]; do + sleep 3 + _WAIT=\$((\$_WAIT + 3)) + if [ \$_WAIT -ge 180 ]; then + echo "ERROR: Timed out waiting for server (3 min). Is CREATE_CONSOLE_IN_PIPE=true set?" + exit 1 + fi +done +echo "Server ready. Sending pre-gen commands..." +${BORDER_LINE} +mc-send-to-console "chunky center 0 0" +mc-send-to-console "chunky radius ${PREGEN_RADIUS}" +mc-send-to-console "chunky start" +echo "Pre-gen started. Monitor progress:" +echo " docker exec ${MC_NAME} mc-send-to-console 'chunky progress'" +PREGENEOF + chmod +x "$MC_DIR/data/pregen-startup.sh" + chown 1000:1000 "$MC_DIR/data/pregen-startup.sh" 2>/dev/null || true + log_success "pregen-startup.sh written to ${MC_NAME}/data/" + fi + + # ── Standalone docker-compose.yml (per-service folder) ────────────────────── + log_info "Writing ${MC_NAME}/docker-compose.yml..." + + local MC_ENV="" + MC_ENV+=" - TYPE=${FLAVOUR}"$'\n' + MC_ENV+=" - VERSION=${MC_VERSION}"$'\n' + MC_ENV+=" - EULA=TRUE"$'\n' + MC_ENV+=" - SERVER_NAME=${SERVER_NAME}"$'\n' + MC_ENV+=" - MAX_PLAYERS=${MAX_PLAYERS}"$'\n' + MC_ENV+=" - DIFFICULTY=${DIFFICULTY}"$'\n' + MC_ENV+=" - MODE=${GAMEMODE}"$'\n' + MC_ENV+=" - WHITELIST=${WHITELIST_ENABLED}"$'\n' + MC_ENV+=" - MEMORY=${MC_RAM}G"$'\n' + MC_ENV+=" - ENABLE_RCON=false"$'\n' + MC_ENV+=" - MOTD=${SERVER_NAME}"$'\n' + MC_ENV+=" - CREATE_CONSOLE_IN_PIPE=true"$'\n' + if [ "$SUPPORTS_FABRIC_MODS" = true ]; then + MC_ENV+=" - MODS_DIR=/mods"$'\n' + fi + + # Volumes: data always; config too when chunky is selected. + local MC_VOLUMES=" - ./data:/data" + if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then + MC_VOLUMES+=" + - ./config:/data/config" + fi + + cat > "$MC_DIR/docker-compose.yml" << COMPOSEEOF +name: ${MC_NAME} + +services: + ${MC_NAME}: + build: + context: . + dockerfile: Dockerfile + container_name: ${MC_NAME} + environment: +${MC_ENV} ports: + - "${MC_PORT}:25565" + volumes: +${MC_VOLUMES} + restart: unless-stopped +COMPOSEEOF + + # Optional playit.gg service appended as a SECOND service into THIS + # instance's compose (shares the minecraft container's network namespace). + if [ "$USE_PLAYIT" = true ]; then + cat >> "$MC_DIR/docker-compose.yml" << PLAYITEOF + + playit-${MC_NAME}: + image: ghcr.io/playit-cloud/playit-agent:latest + container_name: playit-${MC_NAME} + network_mode: "service:${MC_NAME}" + env_file: + - ./.env.playit + restart: unless-stopped + depends_on: + - ${MC_NAME} +PLAYITEOF + fi + log_success "Created ${MC_NAME}/docker-compose.yml" + + if [ "$USE_PLAYIT" = true ]; then + cat > "$MC_DIR/.env.playit" << 'ENVEOF' +# Get your secret from https://playit.gg after creating a tunnel +# Then add it here: +PLAYIT_SECRET=your_secret_key_here +ENVEOF + log_success "Created ${MC_NAME}/.env.playit — add your playit.gg secret after signing up" + fi + + # ── Client mod download page (own folder + standalone compose) ────────────── + echo "" + log_info "Client Mod Download Page" + echo "" + echo "A local webpage where your players can download all client mods at once." + echo "" + local CM_ENABLE="" + prompt_yn "Enable client mod download page? (y/n) [y]:" "y" CM_ENABLE + if [[ ${CM_ENABLE:-y} =~ ^[Yy]$ ]]; then + + local MODS_PORT="" + prompt_text "Port for download page [8091]:" "8091" MODS_PORT + local MODS_DOMAIN="" + if [ -n "$BASE_DOMAIN" ]; then + local _PREFIX="" + prompt_text "Subdomain prefix for mod download page [mods].${BASE_DOMAIN}:" "mods" _PREFIX + MODS_DOMAIN="${_PREFIX}.${BASE_DOMAIN}" + echo " → ${MODS_DOMAIN}" + else + prompt_text "Subdomain for mod download page (e.g. mods.yourdomain.com) [leave blank to skip]:" "" MODS_DOMAIN + fi + + # Per-instance folder so multiple servers don't share one mods page. + local CM_NAME="client-mods"; [ "$MC_NAME" != "minecraft" ] && CM_NAME="client-mods-${MC_NAME}" + local CLIENT_MODS_DIR="$DOCKER_DIR/$CM_NAME" + mkdir -p "$CLIENT_MODS_DIR/files" + ensure_docker_dir_ownership "$CLIENT_MODS_DIR" + + log_info "Downloading client mods from Modrinth..." + + declare -A CMODS + declare -A CMOD_DESC + declare -A CMOD_SLUG + declare -A CMOD_URL + + CMODS["xaeros_minimap"]="Xaero's Minimap" + CMOD_DESC["xaeros_minimap"]="Corner minimap with waypoints, entity radar, and cave mode." + CMOD_SLUG["xaeros_minimap"]="xaeros-minimap" + CMOD_URL["xaeros_minimap"]="https://modrinth.com/mod/xaeros-minimap" + + CMODS["xaeros_worldmap"]="Xaero's World Map" + CMOD_DESC["xaeros_worldmap"]="Fullscreen map of everywhere you've explored." + CMOD_SLUG["xaeros_worldmap"]="xaeros-world-map" + CMOD_URL["xaeros_worldmap"]="https://modrinth.com/mod/xaeros-world-map" + + CMODS["replaymod"]="ReplayMod" + CMOD_DESC["replaymod"]="Record and replay your game sessions. View replays in cinematic mode." + CMOD_SLUG["replaymod"]="replaymod" + CMOD_URL["replaymod"]="https://modrinth.com/mod/replaymod" + + CMODS["nochatreports"]="NoChatReports" + CMOD_DESC["nochatreports"]="Disables Mojang's chat reporting on the client side." + CMOD_SLUG["nochatreports"]="no-chat-reports" + CMOD_URL["nochatreports"]="https://modrinth.com/mod/no-chat-reports" + + CMODS["sodium"]="Sodium" + CMOD_DESC["sodium"]="Major FPS improvement — the most impactful performance mod available." + CMOD_SLUG["sodium"]="sodium" + CMOD_URL["sodium"]="https://modrinth.com/mod/sodium" + + CMODS["iris"]="Iris Shaders" + CMOD_DESC["iris"]="Shader support that works alongside Sodium." + CMOD_SLUG["iris"]="iris" + CMOD_URL["iris"]="https://modrinth.com/mod/iris" + + CMODS["indium"]="Indium" + CMOD_DESC["indium"]="Sodium compatibility layer — needed by some other mods." + CMOD_SLUG["indium"]="indium" + CMOD_URL["indium"]="https://modrinth.com/mod/indium" + + CMODS["fabric_api"]="Fabric API" + CMOD_DESC["fabric_api"]="Required by almost all Fabric mods. Install this first." + CMOD_SLUG["fabric_api"]="fabric-api" + CMOD_URL["fabric_api"]="https://modrinth.com/mod/fabric-api" + + local CMOD_ORDER=("fabric_api" "nochatreports" "sodium" "iris" "indium" + "xaeros_minimap" "xaeros_worldmap" "replaymod") + + declare -A CMOD_FILENAME + declare -A CMOD_FILESIZE + + local key slug name jar_url fname fpath + for key in "${CMOD_ORDER[@]}"; do + slug="${CMOD_SLUG[$key]}" + name="${CMODS[$key]}" + + jar_url=$(curl -sf \ + "https://api.modrinth.com/v2/project/${slug}/version?game_versions=%5B%22${MC_VERSION}%22%5D&loaders=%5B%22fabric%22%5D" \ + | python3 -c " +import sys, json +versions = json.load(sys.stdin) +for v in versions: + for f in v.get('files', []): + if f.get('primary'): + print(f['url']) + sys.exit(0) +" 2>/dev/null || echo "") + + if [ -z "$jar_url" ]; then + log_warning " Could not find $name for MC $MC_VERSION — will link to Modrinth instead" + CMOD_FILENAME[$key]="" + CMOD_FILESIZE[$key]="" + continue + fi + + fname=$(basename "$jar_url" | cut -d'?' -f1) + fpath="$CLIENT_MODS_DIR/files/$fname" + + if curl -sfL "$jar_url" -o "$fpath"; then + log_success " Downloaded: $fname" + CMOD_FILENAME[$key]="$fname" + CMOD_FILESIZE[$key]=$(du -h "$fpath" | cut -f1) + else + log_warning " Failed: $name" + CMOD_FILENAME[$key]="" + CMOD_FILESIZE[$key]="" + fi + done + + # Essential Mod — not on Modrinth, link to official site + CMODS["essential"]="Essential Mod" + CMOD_DESC["essential"]="Invite friends to worlds, cosmetics, social features. Not on Modrinth." + CMOD_SLUG["essential"]="" + CMOD_URL["essential"]="https://essential.gg/download" + CMOD_FILENAME["essential"]="" + CMOD_FILESIZE["essential"]="" + CMOD_ORDER+=("essential") + + log_info "Generating client mod download page..." + + local MOD_CARDS="" desc fsize modrinth_url btn + for key in "${CMOD_ORDER[@]}"; do + name="${CMODS[$key]}" + desc="${CMOD_DESC[$key]}" + fname="${CMOD_FILENAME[$key]}" + fsize="${CMOD_FILESIZE[$key]}" + modrinth_url="${CMOD_URL[$key]}" + + if [ -n "$fname" ]; then + btn="⬇ Download ${fsize}" + else + btn="↗ Get from ${modrinth_url##*/}" + fi + + MOD_CARDS="${MOD_CARDS} +
+
${name}
+
${desc}
+ ${btn} +
" + done + + log_info "Creating all-mods ZIP..." + ( cd "$CLIENT_MODS_DIR/files" && \ + zip -q "../all-client-mods-mc${MC_VERSION}.zip" *.jar 2>/dev/null ) \ + && log_success "Created all-client-mods-mc${MC_VERSION}.zip" \ + || log_warning "zip not found or no jars — skipping bundle (install zip: sudo apt install zip)" + + local ZIP_SIZE ZIP_BTN="" + ZIP_SIZE=$(du -h "$CLIENT_MODS_DIR/all-client-mods-mc${MC_VERSION}.zip" 2>/dev/null | cut -f1 || echo "") + if [ -n "$ZIP_SIZE" ]; then + ZIP_BTN="⬇ Download All Mods (${ZIP_SIZE} ZIP)" + fi + + cat > "$CLIENT_MODS_DIR/index.html" << HTMLEOF + + + + + + ${SERVER_NAME} — Client Mods + + + +
+

⛏ ${SERVER_NAME}

+

Client Mods — Minecraft ${MC_VERSION} · Fabric

+
+ +
+

Download all recommended mods in one click, or pick individually below.

+ ${ZIP_BTN} +
+ +
+

📋 Install Instructions

+
    +
  1. Install Fabric Loader for Minecraft ${MC_VERSION}
  2. +
  3. Download Fabric API below (required by all mods)
  4. +
  5. Download whichever other mods you want
  6. +
  7. Place all .jar files into your .minecraft/mods/ folder
  8. +
  9. Launch Minecraft with the Fabric profile
  10. +
+
+ +
+ ${MOD_CARDS} +
+ +
Generated by ubuntu-post-install setup · ${SERVER_NAME}
+ + +HTMLEOF + log_success "Download page created at ${CM_NAME}/index.html" + + mkdir -p "$CLIENT_MODS_DIR/nginx" + cat > "$CLIENT_MODS_DIR/nginx/nginx.conf" << 'NGINXEOF' +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Mod JARs and ZIPs — long cache, force download + location /files/ { + add_header Content-Disposition "attachment"; + add_header Cache-Control "public, max-age=86400"; + } + location ~* \.zip$ { + add_header Content-Disposition "attachment"; + add_header Cache-Control "public, max-age=86400"; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } + + gzip on; + gzip_types text/html text/css application/javascript; +} +NGINXEOF + + cat > "$CLIENT_MODS_DIR/Dockerfile" << 'CLIENTDOCKEREOF' +FROM nginx:alpine +COPY index.html /usr/share/nginx/html/index.html +COPY files/ /usr/share/nginx/html/files/ +COPY all-client-mods-*.zip /usr/share/nginx/html/ +COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CLIENTDOCKEREOF + + # Standalone compose for the client-mods page (its own folder). + cat > "$CLIENT_MODS_DIR/docker-compose.yml" << CMCOMPOSEEOF +name: ${CM_NAME} + +services: + ${CM_NAME}: + build: + context: . + dockerfile: Dockerfile + container_name: ${CM_NAME} + ports: + - "${MODS_PORT}:80" + restart: unless-stopped +CMCOMPOSEEOF + log_success "Created ${CM_NAME}/docker-compose.yml" + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CLIENT_MODS_DIR" 2>/dev/null || true + + # Caddy snippet (the page is a normal HTTP service — Caddy can proxy it) + if [ -n "$MODS_DOMAIN" ]; then + local GAMING_SERVER_IP CADDY_IP="" + GAMING_SERVER_IP=$(hostname -I | awk '{print $1}') + prompt_text "Gaming server IP as seen from Caddy machine [$GAMING_SERVER_IP]:" "$GAMING_SERVER_IP" CADDY_IP + echo "" + log_info "Add to your Caddyfile on the Caddy machine:" + echo "" + echo "──────────────────────────────────────────────────" + cat << CADDYEOF +${MODS_DOMAIN} { + reverse_proxy ${CADDY_IP}:${MODS_PORT} +} +CADDYEOF + echo "──────────────────────────────────────────────────" + echo "" + echo "Reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + echo "" + fi + fi # end client mod download page + + # ── Generate CLIENT_MODS.md (into the instance folder) ────────────────────── + log_info "Generating CLIENT_MODS.md..." + cat > "$MC_DIR/CLIENT_MODS.md" << 'CLIENTEOF' +# Client Mods for Players + +These mods are installed on **your Minecraft client**, not the server. +All are optional but highly recommended for the best experience. + +## Required loader +Install [Fabric Loader](https://fabricmc.net/use/) for your Minecraft version, +then install [Fabric API](https://modrinth.com/mod/fabric-api). + +## Recommended client mods + +| Mod | Download | Notes | +|-----|----------|-------| +| **Xaero's Minimap** | [Modrinth](https://modrinth.com/mod/xaeros-minimap) | Corner minimap with waypoints, entity radar | +| **Xaero's World Map** | [Modrinth](https://modrinth.com/mod/xaeros-world-map) | Fullscreen explored world map | +| **ReplayMod** | [Modrinth](https://modrinth.com/mod/replaymod) | Record and replay game sessions | +| **NoChatReports** | [Modrinth](https://modrinth.com/mod/no-chat-reports) | Disable chat reporting (also on server) | +| **Essential Mod** | [Modrinth](https://modrinth.com/mod/essential) | Invite friends, cosmetics, social features | +| **Sodium** | [Modrinth](https://modrinth.com/mod/sodium) | Major FPS improvement | +| **Iris Shaders** | [Modrinth](https://modrinth.com/mod/iris) | Shader support (works with Sodium) | +| **Indium** | [Modrinth](https://modrinth.com/mod/indium) | Sodium compatibility layer for other mods | + +## Notes +- Xaero's Minimap and World Map are **client-only** — they work on any server + automatically, nothing needed server-side. +- ReplayMod is **client-only** — the server runs ServerReplay to record + server-side replays, but you view them with ReplayMod on your client. +- Essential Mod's world invite feature works peer-to-peer — both players need it. +- NoChatReports is optional on client when the server already has it, but + installing it on both gives the strongest protection. +CLIENTEOF + chown "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR/CLIENT_MODS.md" 2>/dev/null || true + log_success "CLIENT_MODS.md written" + + # ── Generate MINECRAFT_NETWORKING.md (into the instance folder) ───────────── + log_info "Generating MINECRAFT_NETWORKING.md..." + + local PORT_NOTE + if [ "$MC_PORT" = "25565" ]; then + PORT_NOTE="This server uses the **standard Minecraft port (25565)**." + else + PORT_NOTE="This server uses **port ${MC_PORT}** (non-standard)." + fi + + cat > "$MC_DIR/MINECRAFT_NETWORKING.md" << NETEOF +# Minecraft Server Networking Guide + +${PORT_NOTE} +$([ -n "$MC_DOMAIN" ] && echo "Intended domain: **${MC_DOMAIN}**") + +--- + +## How Minecraft server addressing works + +Minecraft Java Edition connects to servers using a **host:port** pair. +The default port is **25565**. If you use the default, players just type +your domain and the game connects automatically. If you use any other port, +players would normally have to type \`domain.com:PORT\` — unless you use a +**DNS SRV record**, which hides the port completely. Players always just type +your domain regardless of which port is actually in use. + +This means: +- **One server, default port 25565** → simple A record, no SRV needed +- **One server, non-default port** → A record + SRV record +- **Multiple servers, same IP** → each gets its own port and its own SRV record + Players type different subdomains, never see port numbers + +--- + +## Step 1 — Choose your ports + +Each Minecraft server needs its own unique port. Plan these before touching DNS. + +| Server | Subdomain players type | Port to use | Notes | +|--------|----------------------|-------------|-------| +| First server | \`mc.yourdomain.com\` | 25565 | Standard port, simplest | +| Second server | \`survival.yourdomain.com\` | 25566 | Non-standard, needs SRV | +| Third server | \`creative.yourdomain.com\` | 25567 | Non-standard, needs SRV | + +Your server is configured on port **${MC_PORT}**. + +Ports 25565–25570 are the conventional range for multiple Minecraft servers. +Any port from 1024–65535 works as long as nothing else on your server uses it. + +Check what's already in use on your server: +\`\`\`bash +sudo ss -tlnp | grep 255 +\`\`\` + +--- + +## Step 2 — Port forward on your router + +You need one port forward rule per Minecraft server. + +1. Find your server's **local IP**: + \`\`\`bash + hostname -I | awk '{print \$1}' + \`\`\` +2. Log into your router — usually **http://192.168.1.1** or **http://192.168.0.1** + (check the label on your router if unsure) +3. Find **Port Forwarding** — sometimes listed under NAT, Firewall, Virtual Servers, + or Advanced depending on your router brand +4. Add a rule for each Minecraft server: + + | Field | Value | + |-------|-------| + | External port | ${MC_PORT} | + | Internal IP | your server's local IP | + | Internal port | ${MC_PORT} | + | Protocol | TCP | + + For a second server on port 25566, add another rule with 25566 in both port fields. + +5. Save and apply. No reboot needed on most routers. + +### Common router brands — where to find port forwarding + +| Router brand | Path | +|-------------|------| +| Netgear | Advanced → Advanced Setup → Port Forwarding | +| ASUS | WAN → Virtual Server / Port Forwarding | +| TP-Link | Advanced → NAT Forwarding → Port Forwarding | +| Linksys | Security → Apps and Gaming → Port Range Forwarding | +| Eero | (app only) Settings → Network Settings → Reservations & Port Forwarding | +| Google/Nest Wifi | (app only) Settings → Network & General → Advanced Networking → Port Management | +| ISP-provided router | Usually under Firewall or Advanced — check your ISP's support pages | + +--- + +## Step 3 — Point your domain at your server + +First find your **public IP**: +\`\`\`bash +curl -s https://api.ipify.org +\`\`\` + +⚠️ **Dynamic IP warning:** Most home internet connections change IP occasionally. +If yours does, set up free DDNS (DuckDNS at duckdns.org or No-IP at noip.com) +and use their subdomain as your A record target instead of a raw IP. + +### DNS records to add + +#### Scenario A — One server on the standard port (25565) + +Just an A record. No SRV needed. Players type \`mc.yourdomain.com\` and it works. + +\`\`\` +Type: A +Name: mc +Value: your.public.ip +TTL: 1 hour (3600) +\`\`\` + +Players connect to: \`mc.yourdomain.com\` + +--- + +#### Scenario B — One server on a non-standard port (e.g. ${MC_PORT}) + +You need both an A record and a SRV record. +Without the SRV record, players would have to type \`mc.yourdomain.com:${MC_PORT}\`. +With the SRV record, they just type \`mc.yourdomain.com\` — the game resolves the port. + +\`\`\` +# A record — points the hostname at your IP +Type: A +Name: mc +Value: your.public.ip +TTL: 1 hour + +# SRV record — tells Minecraft clients which port to use +Type: SRV +Name: _minecraft._tcp.mc (some providers want the full name: + _minecraft._tcp.mc.yourdomain.com) +Priority: 0 +Weight: 5 +Port: ${MC_PORT} +Target: mc.yourdomain.com +TTL: 1 hour +\`\`\` + +Players connect to: \`mc.yourdomain.com\` + +--- + +#### Scenario C — Multiple servers on the same IP (most common setup) + +One A record pointing to your server, then one SRV record per server. +Each SRV record maps a player-friendly subdomain to a specific port. + +\`\`\` +# One A record for the host — all SRV records point here +Type: A +Name: mc +Value: your.public.ip +TTL: 1 hour + +# Server 1 — survival on port 25565 +Type: SRV +Name: _minecraft._tcp.survival +Priority: 0 +Weight: 5 +Port: 25565 +Target: mc.yourdomain.com +TTL: 1 hour + +# Server 2 — creative on port 25566 +Type: SRV +Name: _minecraft._tcp.creative +Priority: 0 +Weight: 5 +Port: 25566 +Target: mc.yourdomain.com +TTL: 1 hour + +# Server 3 — minigames on port 25567 +Type: SRV +Name: _minecraft._tcp.minigames +Priority: 0 +Weight: 5 +Port: 25567 +Target: mc.yourdomain.com +TTL: 1 hour +\`\`\` + +Players connect to: +- \`survival.yourdomain.com\` → hits port 25565 +- \`creative.yourdomain.com\` → hits port 25566 +- \`minigames.yourdomain.com\` → hits port 25567 + +Nobody types a port number. Ever. + +--- + +## Step 4 — Add records at your DNS provider + +### GoDaddy + +1. Log in → **My Products** → find your domain → click **DNS** +2. Click **Add New Record** + +**A record:** +- Type: A +- Name: mc +- Value: your.public.ip +- TTL: 1 hour +- Click Save + +**SRV record** (if needed): +- Type: SRV +- Name: \`_minecraft._tcp.mc\` (or whichever subdomain) +- Priority: 0 +- Weight: 5 +- Port: ${MC_PORT} +- Target: \`mc.yourdomain.com\` +- TTL: 1 hour +- Click Save + +Changes propagate in minutes to a few hours. +Official SRV docs: https://uk.godaddy.com/help/add-an-srv-record-19234 + +--- + +### Namecheap + +Namecheap splits the SRV record across fields in a non-obvious way. +For a subdomain like \`mc11111.yourdomain.com\`, the fields must be: + +| Field | Value | | +|-------|-------|-| +| Service | \`_minecraft\` | Always this exact value | +| Protocol | \`_tcp.mc11111\` | ← **the subdomain goes here** | +| Priority | \`0\` | | +| Weight | \`5\` | | +| Port | \`${MC_PORT}\` | | +| Target | \`mc11111.yourdomain.com.\` | Trailing dot optional | +| TTL | Automatic | | + +> ⚠️ **This is Namecheap-specific.** Every other registrar puts the subdomain in the +> Name/Host field. Namecheap is the exception — append the subdomain to the **Protocol** +> field instead. Using any other field makes the record look valid but return NXDOMAIN. + +**Steps:** + +1. Log in → **Domain List** → **Manage** → **Advanced DNS** → **Add New Record** + +2. **A record** (always needed): + + | Field | Value | + |-------|-------| + | Type | A Record | + | Host | \`mc11111\` (your chosen subdomain) | + | Value | your.public.ip | + | TTL | Automatic | + + Click the ✓ checkmark to save. + +3. **SRV record** (needed when port is not 25565): + + Fill in the fields from the main table above, substituting your actual subdomain + for \`mc11111\` and your port number for \`${MC_PORT}\`. + + Click **Save All Changes**, then wait ~30 minutes for propagation. + +**Verify:** +\`\`\`bash +nslookup -type=SRV _minecraft._tcp.mc11111.yourdomain.com +\`\`\` + +If it returns NXDOMAIN, recheck the Protocol field — it must read \`_tcp.mc11111\`, not just \`_tcp\`. + +**For additional servers** (e.g. \`mc22222\` on a different port): +- A record Host: \`mc22222\` +- SRV Protocol: \`_tcp.mc22222\` +- Each server gets its own unique subdomain and port + +Official docs: https://www.namecheap.com/support/knowledgebase/article.aspx/9776/2237/how-to-create-a-srv-record/ + +--- + +### Cloudflare + +1. Log in → **dash.cloudflare.com** → select your domain → **DNS** → **Records** +2. Click **Add record** + +**A record:** +- Type: A +- Name: mc +- IPv4 address: your.public.ip +- Proxy status: **DNS only (grey cloud)** ← critical +- TTL: Auto +- Click Save + +⚠️ **Cloudflare proxy (orange cloud) does NOT work for Minecraft.** +Minecraft uses raw TCP on port 25565, not HTTP. The orange cloud only proxies +HTTP/HTTPS traffic. Always use the grey cloud (DNS only) for Minecraft records. + +**SRV record** (if needed): +- Type: SRV +- Name: \`_minecraft._tcp.mc\` +- Priority: 0 +- Weight: 5 +- Port: ${MC_PORT} +- Target: \`mc.yourdomain.com\` +- TTL: Auto +- Click Save + +Official docs: https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/ + +--- + +## Step 5 — Open the port on your server firewall + +The router forwards the traffic, but your server's own firewall also needs to allow it. + +\`\`\`bash +# Allow your Minecraft port +sudo ufw allow ${MC_PORT}/tcp comment "Minecraft" + +# If running multiple servers, add each port +sudo ufw allow 25566/tcp comment "Minecraft server 2" +sudo ufw allow 25567/tcp comment "Minecraft server 3" + +sudo ufw reload +sudo ufw status +\`\`\` + +--- + +## Step 6 — Verify everything is working + +\`\`\`bash +# Check your A record resolved +dig mc.yourdomain.com A +short + +# Check your SRV record (if you added one) +dig _minecraft._tcp.mc.yourdomain.com SRV + +# Test raw TCP connectivity to your port +nc -zv mc.yourdomain.com ${MC_PORT} + +# Expected output from nc: +# Connection to mc.yourdomain.com ${MC_PORT} port [tcp/*] succeeded! +\`\`\` + +If \`dig\` returns your IP but \`nc\` fails, the problem is port forwarding or firewall. +If \`dig\` returns nothing, the problem is the DNS record. +If both work but Minecraft can't connect, check the server is actually running: +\`\`\`bash +cd ${MC_DIR} && docker compose ps +docker logs ${MC_NAME} +\`\`\` + +--- + +## Fallback — playit.gg (if you cannot port forward) + +Some ISPs use CGNAT (you share a public IP with many customers) which makes +port forwarding impossible. If \`curl -s https://api.ipify.org\` returns a +different IP than your router's WAN IP, you are behind CGNAT. + +In that case, use playit.gg as a free tunnel: + +1. Sign up at **https://playit.gg** +2. Create a tunnel → Minecraft Java → set local port to ${MC_PORT} +3. Copy the secret key from your dashboard +4. Add to \`${MC_NAME}/.env.playit\`: + \`\`\` + PLAYIT_SECRET=your_secret_key_here + \`\`\` +5. Restart: \`cd ${MC_DIR} && docker compose up -d\` +6. Players connect to the address shown in your playit.gg dashboard + +To use your own domain with playit.gg, add a CNAME record pointing +\`mc.yourdomain.com\` to your playit.gg tunnel address. + +Note: playit.gg routes all player traffic through their servers. +See their privacy policy at https://playit.gg/privacy-policy/ before using. + +--- + +## Player Management + +### Whitelist + +If you enabled the whitelist during setup, only players you explicitly add can join. + +**Add a player** (server must be running): +\`\`\`bash +docker exec ${MC_NAME} mc-send-to-console "whitelist add PlayerName" +\`\`\` + +**View the whitelist:** +\`\`\`bash +docker exec ${MC_NAME} mc-send-to-console "whitelist list" +\`\`\` + +**Remove a player:** +\`\`\`bash +docker exec ${MC_NAME} mc-send-to-console "whitelist remove PlayerName" +\`\`\` + +Minecraft resolves the UUID from the username automatically. The whitelist is saved +to \`${MC_NAME}/data/whitelist.json\` and persists across container restarts. + +### Get a player's UUID from their username + +Only needed if you want to pre-populate \`whitelist.json\` before the server first starts: + +\`\`\`bash +curl -s "https://api.mojang.com/users/profiles/minecraft/PLAYERNAME" | \\ + python3 -c " +import sys, json +d = json.load(sys.stdin) +uid = d['id'] +print(f'{uid[:8]}-{uid[8:12]}-{uid[12:16]}-{uid[16:20]}-{uid[20:]}') +" +\`\`\` + +Example output: \`69a79aa5-a4ef-4a5c-a61f-7ab0e52cdb6a\` + +Then edit \`${MC_NAME}/data/whitelist.json\` (create it if missing): +\`\`\`json +[ + { + "uuid": "69a79aa5-a4ef-4a5c-a61f-7ab0e52cdb6a", + "name": "PlayerName" + } +] +\`\`\` + +Add one object per player. Start the server after saving — it reads the file on startup. + +### Make a player an operator (admin) + +\`\`\`bash +docker exec ${MC_NAME} mc-send-to-console "op PlayerName" +\`\`\` + +Operators can use all game commands, kick/ban players, and edit server settings in-game. +Remove operator status with \`/deop PlayerName\` in-game or: +\`\`\`bash +docker exec ${MC_NAME} mc-send-to-console "deop PlayerName" +\`\`\` + +NETEOF + chown "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR/MINECRAFT_NETWORKING.md" 2>/dev/null || true + log_success "MINECRAFT_NETWORKING.md written" + + # Make sure everything under the instance folder is owned correctly, but + # keep data/config owned by uid 1000 (itzg runs as that user). + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MC_DIR" 2>/dev/null || true + chown -R 1000:1000 "$MC_DIR/data" "$MC_DIR/config" 2>/dev/null || true + + # ── Summary ───────────────────────────────────────────────────────────────── + echo "" + echo "═══════════════════════════════════════════════════════" + echo " Minecraft Setup Complete" + echo "═══════════════════════════════════════════════════════" + echo "" + echo " Instance: $MC_NAME ($MC_DIR)" + echo " Flavour: $FLAVOUR_NAME $MC_VERSION" + echo " Port: $MC_PORT" + echo " Memory: ${MC_RAM}GB" + [ -n "$MC_DOMAIN" ] && echo " Domain: $MC_DOMAIN" + if [ "$USE_PLAYIT" = true ]; then + echo " Networking: playit.gg tunnel" + echo " → Add your secret to ${MC_NAME}/.env.playit" + elif [ "$USE_PORTFORWARD" = true ]; then + echo " Networking: Direct port forward" + echo " → See ${MC_NAME}/MINECRAFT_NETWORKING.md" + else + echo " Networking: Local only" + fi + echo "" + echo " Mods: ${#SELECTED_MODS[@]} selected" + if [ ${#SELECTED_DATAPACKS[@]} -gt 0 ]; then + echo " Datapacks: Download from vanillatweaks.net/picker/datapacks/ → MC $MC_VERSION" + echo " Place .zip in ${MC_NAME}/datapacks-download/ then rebuild" + else + echo " Datapacks: none" + fi + if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then + echo " Pre-gen: ${PREGEN_RADIUS} block radius (~$(( PREGEN_RADIUS / 1000 * 8 ))GB per server)" + echo " World border: ${BORDER_SIZE} blocks $([ "$USE_BORDER" = true ] && echo "(enabled)" || echo "(disabled)")" + fi + echo "" + echo "Files written under ${MC_DIR}:" + echo " data/ mods-download/ datapacks-download/ Server files and downloaded mods" + echo " docker-compose.yml Standalone compose (build itzg image)" + echo " CLIENT_MODS.md What players install on their client" + echo " MINECRAFT_NETWORKING.md DNS and port forward instructions" + [ "$USE_PLAYIT" = true ] && echo " .env.playit Add playit.gg secret here" + echo "" + echo "── BACKUPS ───────────────────────────────────────────" + echo "" + echo " Protect your world: set up automatic backups of ${MC_NAME}/data with Kopia." + echo " Run: sudo ./setup.sh backup" + echo "" + + # ── Optional: start server and run pre-gen now ────────────────────────────── + local START_MC="" + prompt_yn "Start the Minecraft server now? (first build takes a few minutes) (y/n) [y]:" "y" START_MC + if [[ ${START_MC:-y} =~ ^[Yy]$ ]]; then + log_info "Building and starting ${MC_NAME}..." + if ( cd "$MC_DIR" && docker compose up -d --build ); then + log_success "${MC_NAME} started" + else + log_warning "Failed to build/start ${MC_NAME} — check: cd ${MC_DIR} && docker compose logs" + fi + + if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then + log_info "Waiting for ${MC_NAME} container to be running..." + local _WAIT=0 + until docker ps --filter "name=^${MC_NAME}$" --filter "status=running" \ + --format "{{.Names}}" 2>/dev/null | grep -q "^${MC_NAME}$"; do + sleep 3 + _WAIT=$((_WAIT + 3)) + if [ $_WAIT -ge 60 ]; then + log_warning "Container not yet showing as running — attempting pregen anyway" + break + fi + done + + echo "" + log_info "Running chunk pre-generation..." + log_info "(Waiting for server to finish loading — usually 1-2 minutes...)" + docker exec -e PREGEN=1 -u 1000 "${MC_NAME}" bash /data/pregen-startup.sh + echo "" + log_success "Pre-generation started!" + echo " Monitor: docker exec ${MC_NAME} mc-send-to-console 'chunky progress'" + echo " Logs: docker logs -f ${MC_NAME}" + fi + else + echo "" + log_info "When ready:" + echo " cd ${MC_DIR} && docker compose up -d --build" + if [[ " ${SELECTED_MODS[*]} " =~ " chunky " ]]; then + echo " docker exec -e PREGEN=1 -u 1000 ${MC_NAME} bash /data/pregen-startup.sh" + fi + fi + 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 new file mode 100755 index 0000000..aebb0bc --- /dev/null +++ b/setup.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# setup.sh — modular post-install dispatcher. +# +# One source of truth, two ways to run it: +# sudo ./setup.sh interactive menu (pick any services) +# sudo ./setup.sh ... install one or more services directly +# ./setup.sh --list list available services (grouped) +# +# Flags: +# --dry-run preview actions without making changes +# --unattended use defaults, no prompts +# +# Every service lives in services/.sh, registers itself, and defines +# install_. Adding a service = adding one file. Updating a service = +# editing one file. Nothing is duplicated or generated. +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Parse global flags, collect service names ──────────────────────────────── +DRY_RUN=false +UNATTENDED=false +DO_LIST=false +REQUESTED=() +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --unattended) UNATTENDED=true ;; + --list|-l) DO_LIST=true ;; + --version|-V) cat "$HERE/VERSION" 2>/dev/null || echo "unknown"; exit 0 ;; + -h|--help) + sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 ;; + -*) echo "Unknown flag: $arg" >&2; exit 1 ;; + *) REQUESTED+=("$arg") ;; + esac +done +export DRY_RUN UNATTENDED + +# ── Load helpers + all service modules (they self-register) ────────────────── +# shellcheck source=lib/common.sh +source "$HERE/lib/common.sh" + +shopt -s nullglob +for _mod in "$HERE"/services/*.sh; do + # shellcheck source=/dev/null + source "$_mod" +done +shopt -u nullglob + +# Ordered list of unique groups, in first-seen order. +groups_in_order() { + local seen=" " g + for name in "${SERVICE_ORDER[@]}"; do + g="${SERVICE_GROUP[$name]}" + case "$seen" in *" $g "*) : ;; *) echo "$g"; seen="$seen$g " ;; esac + done +} + +list_services() { + local g name + while IFS= read -r g; do + echo "" + echo "── ${g^^} ──" + for name in "${SERVICE_ORDER[@]}"; do + [ "${SERVICE_GROUP[$name]}" = "$g" ] || continue + printf " %-16s %s\n" "$name" "${SERVICE_DESC[$name]}" + done + done < <(groups_in_order) + echo "" +} + +run_service() { + local name="$1" + if [ -z "${SERVICE_GROUP[$name]:-}" ]; then + log_error "Unknown service: $name (try: $0 --list)" + return 1 + fi + if ! declare -F "install_${name}" >/dev/null; then + log_error "Service '$name' has no install_${name} function." + return 1 + fi + log_info "=== ${name} (${SERVICE_DESC[$name]}) ===" + "install_${name}" +} + +# ── --list ─────────────────────────────────────────────────────────────────── +if [ "$DO_LIST" = true ]; then + list_services + exit 0 +fi + +# ── Direct service install: ./setup.sh minecraft homeassistant ───────────── +if [ "${#REQUESTED[@]}" -gt 0 ]; then + require_root + rc=0 + for name in "${REQUESTED[@]}"; do + run_service "$name" || rc=1 + done + exit "$rc" +fi + +# ── Interactive menu ───────────────────────────────────────────────────────── +require_root + +SELECTED=() +if command -v whiptail >/dev/null 2>&1; then + _items=() + for name in "${SERVICE_ORDER[@]}"; do + _items+=("$name" "${SERVICE_DESC[$name]}" "OFF") + done + _choice=$(whiptail --title "Ubuntu Post-Install — Services" \ + --checklist "Select services to install (space to toggle):" 25 78 16 \ + "${_items[@]}" 3>&1 1>&2 2>&3) || { echo "Cancelled."; exit 0; } + # whiptail returns space-separated, quoted names + eval "SELECTED=($_choice)" +else + echo "Available services:" + list_services + read -rp "Enter service names to install (space-separated): " -a SELECTED +fi + +[ "${#SELECTED[@]}" -eq 0 ] && { echo "Nothing selected."; exit 0; } + +rc=0 +for name in "${SELECTED[@]}"; do + run_service "$name" || rc=1 +done +exit "$rc" diff --git a/ubuntu-post-install-24.04-crowdsec.sh b/ubuntu-post-install-24.04-crowdsec.sh index d235992..1741b9e 100644 --- a/ubuntu-post-install-24.04-crowdsec.sh +++ b/ubuntu-post-install-24.04-crowdsec.sh @@ -1741,6 +1741,23 @@ run_cmd apt install -y \ unzip \ rclone || echo "Warning: Some utilities failed to install, continuing..." +# Install glow (terminal markdown reader) from the Charm apt repo +echo "" +echo "Installing glow (terminal markdown reader)..." +if command -v glow >/dev/null 2>&1; then + echo " ✓ glow already installed" +elif [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow" +else + sudo mkdir -p /etc/apt/keyrings + if curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then + echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null + sudo apt update -y && sudo apt install -y glow && echo " ✓ glow installed" || echo " ⚠ glow install failed" + else + echo " ⚠ Could not fetch Charm signing key - skipping glow" + fi +fi + # Install OpenSSH Server echo "" echo "Installing OpenSSH Server..." @@ -2595,6 +2612,7 @@ else [ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON" [ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON" [ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON" + [ -d "$DOCKER_DIR/homeassistant" ] && EXISTING_SERVICES[HOMEASSISTANT]="ON" [ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON" [ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON" [ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON" @@ -2636,6 +2654,7 @@ else "CROWDSEC" "Intrusion prevention (CrowdSec: bans + geo + reputation)" ${EXISTING_SERVICES[CROWDSEC]:-OFF} \ "LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \ "MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \ + "HOMEASSISTANT" "Home automation hub (Home Assistant)" ${EXISTING_SERVICES[HOMEASSISTANT]:-OFF} \ "MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \ "JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \ "FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \ @@ -2667,6 +2686,7 @@ else [ -n "${EXISTING_SERVICES[CROWDSEC]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CROWDSEC \"Intrusion prevention\" ON" [ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON" [ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON" + [ -n "${EXISTING_SERVICES[HOMEASSISTANT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS HOMEASSISTANT \"Home automation\" ON" [ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON" [ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON" [ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON" @@ -2717,6 +2737,7 @@ else : ${INSTALL_CROWDSEC:="n"} : ${INSTALL_LMS:="n"} : ${INSTALL_MEALIE:="n"} + : ${INSTALL_HOMEASSISTANT:="n"} : ${INSTALL_MINECRAFT:="n"} : ${INSTALL_JELLYFIN:="n"} : ${INSTALL_FRIGATE:="n"} @@ -2743,6 +2764,7 @@ else if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then INSTALL_CROWDSEC="y"; fi if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi + if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then INSTALL_HOMEASSISTANT="y"; fi if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi @@ -2815,6 +2837,7 @@ else if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi + if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then uninstall_service "Home Assistant" "$DOCKER_DIR/homeassistant" "homeassistant"; fi if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi @@ -4946,6 +4969,106 @@ MEALIE_COMPOSE fi fi + # ---- HOME ASSISTANT ---- + if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_HOMEASSISTANT" ]; then + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ HOME ASSISTANT - Open-source home automation hub │" + echo "│ Smart-home control, automations, dashboards. │" + echo "│ Port: 8123 │" + echo "└─────────────────────────────────────────────────────────────────┘" + prompt_yn "Install Home Assistant? (y/n):" "n" INSTALL_HOMEASSISTANT + fi + + if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then + echo "Installing Home Assistant..." + HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR" + else + mkdir -p "$HOMEASSISTANT_DIR" + ensure_docker_dir_ownership "$HOMEASSISTANT_DIR" + cd "$HOMEASSISTANT_DIR" + + # Networking mode: bridge (published port) vs host networking. + echo "" + echo " Home Assistant networking mode:" + echo " 1) Bridge - container gets its own network; port 8123 is published" + echo " to the host. Works behind the Caddy reverse proxy and" + echo " keeps HA isolated. Recommended for most setups." + echo " 2) Host - HA shares the host's network directly. Needed for" + echo " auto-discovery of devices on your LAN (Chromecast/Cast," + echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)." + prompt_text " Choose networking mode [1]:" "1" HA_NETMODE + if [ "$HA_NETMODE" = "2" ]; then + HA_NET_LINES=" network_mode: host" + echo " → Host networking selected (best device discovery)." + else + HA_NET_LINES=" ports: + - \"8123:8123\"" + echo " → Bridge networking selected (port 8123 published)." + fi + + cat > docker-compose.yml << HOMEASSISTANT_COMPOSE +name: homeassistant + +services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant:stable + container_name: homeassistant + hostname: homeassistant + restart: unless-stopped + privileged: true + environment: + - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") + volumes: + - ./config:/config + - /run/dbus:/run/dbus:ro +${HA_NET_LINES} +HOMEASSISTANT_COMPOSE + + mkdir -p config + + # Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy. + # Only written on a fresh install (never clobber an existing config). + if [ ! -f config/configuration.yaml ]; then + cat > config/configuration.yaml << 'HA_CONFIG' +# Loads default set of integrations. Do not remove. +default_config: + +# Allow access through a reverse proxy (e.g. Caddy) +http: + use_x_forwarded_for: true + trusted_proxies: + - 172.16.0.0/12 + - 192.168.0.0/16 + - 10.0.0.0/8 + - 127.0.0.1 + - ::1 +HA_CONFIG + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR" + + echo "" + echo "✓ Home Assistant configured at $HOMEASSISTANT_DIR" + + # Configure Caddy reverse proxy before starting + configure_caddy_for_service "Home Assistant" "8123" "home" + + prompt_yn "Start Home Assistant now? (y/n):" "y" START_HOMEASSISTANT + if [ "$START_HOMEASSISTANT" = "y" ] || [ "$START_HOMEASSISTANT" = "Y" ]; then + docker compose up -d 2>/dev/null && echo " ✓ Home Assistant started" || echo " ⚠ Failed to start" + fi + + echo " Access at: http://localhost:8123" + echo " First run: open the URL and create your admin account (onboarding)." + echo " Note: first startup can take a minute while HA initializes." + echo "" + fi + fi + # ---- MINECRAFT SERVER ---- if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then echo "" @@ -5463,6 +5586,11 @@ CADDY_ENV # reverse_proxy mealie:9000 # } +# Home Assistant (home) +# home.{$MY_DOMAIN} { +# reverse_proxy homeassistant:8123 +# } + # ntfy (notifications) # ntfy.{$MY_DOMAIN} { # reverse_proxy ntfy:80 @@ -7144,6 +7272,10 @@ if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then ufw allow 9925/tcp comment 'Mealie' 2>/dev/null echo " ✓ Allowed Mealie (9925)" fi + if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then + ufw allow 8123/tcp comment 'Home Assistant' 2>/dev/null + echo " ✓ Allowed Home Assistant (8123)" + fi if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null echo " ✓ Allowed MagicMirror (8081-8083)" diff --git a/ubuntu-post-install-26.04-crowdsec.sh b/ubuntu-post-install-26.04-crowdsec.sh index 44c971a..6100d9b 100644 --- a/ubuntu-post-install-26.04-crowdsec.sh +++ b/ubuntu-post-install-26.04-crowdsec.sh @@ -1741,6 +1741,23 @@ run_cmd apt install -y \ unzip \ rclone || echo "Warning: Some utilities failed to install, continuing..." +# Install glow (terminal markdown reader) from the Charm apt repo +echo "" +echo "Installing glow (terminal markdown reader)..." +if command -v glow >/dev/null 2>&1; then + echo " ✓ glow already installed" +elif [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would add repo.charm.sh apt repo and install glow" +else + sudo mkdir -p /etc/apt/keyrings + if curl -fsSL https://repo.charm.sh/apt/gpg.key | sudo gpg --dearmor --yes -o /etc/apt/keyrings/charm.gpg; then + echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" | sudo tee /etc/apt/sources.list.d/charm.list >/dev/null + sudo apt update -y && sudo apt install -y glow && echo " ✓ glow installed" || echo " ⚠ glow install failed" + else + echo " ⚠ Could not fetch Charm signing key - skipping glow" + fi +fi + # Install OpenSSH Server echo "" echo "Installing OpenSSH Server..." @@ -2595,6 +2612,7 @@ else [ -d "$DOCKER_DIR/caddy" ] && EXISTING_SERVICES[CADDY]="ON" [ -d "$DOCKER_DIR/lms" ] && EXISTING_SERVICES[LYRION]="ON" [ -d "$DOCKER_DIR/mealie" ] && EXISTING_SERVICES[MEALIE]="ON" + [ -d "$DOCKER_DIR/homeassistant" ] && EXISTING_SERVICES[HOMEASSISTANT]="ON" [ -d "$DOCKER_DIR/minecraft" ] && EXISTING_SERVICES[MINECRAFT]="ON" [ -d "$DOCKER_DIR/jellyfin" ] && EXISTING_SERVICES[JELLYFIN]="ON" [ -d "$DOCKER_DIR/frigate" ] && EXISTING_SERVICES[FRIGATE]="ON" @@ -2636,6 +2654,7 @@ else "CROWDSEC" "Intrusion prevention (CrowdSec: bans + geo + reputation)" ${EXISTING_SERVICES[CROWDSEC]:-OFF} \ "LYRION" "Music streaming server (LMS)" ${EXISTING_SERVICES[LYRION]:-OFF} \ "MEALIE" "Recipe manager & meal planner" ${EXISTING_SERVICES[MEALIE]:-OFF} \ + "HOMEASSISTANT" "Home automation hub (Home Assistant)" ${EXISTING_SERVICES[HOMEASSISTANT]:-OFF} \ "MINECRAFT" "Minecraft game server" ${EXISTING_SERVICES[MINECRAFT]:-OFF} \ "JELLYFIN" "Free media server (Emby alternative)" ${EXISTING_SERVICES[JELLYFIN]:-OFF} \ "FRIGATE" "AI-powered NVR for security cameras" ${EXISTING_SERVICES[FRIGATE]:-OFF} \ @@ -2667,6 +2686,7 @@ else [ -n "${EXISTING_SERVICES[CROWDSEC]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS CROWDSEC \"Intrusion prevention\" ON" [ -n "${EXISTING_SERVICES[LYRION]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS LYRION \"Music server\" ON" [ -n "${EXISTING_SERVICES[MEALIE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MEALIE \"Recipe manager\" ON" + [ -n "${EXISTING_SERVICES[HOMEASSISTANT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS HOMEASSISTANT \"Home automation\" ON" [ -n "${EXISTING_SERVICES[MINECRAFT]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS MINECRAFT \"Game server\" ON" [ -n "${EXISTING_SERVICES[JELLYFIN]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS JELLYFIN \"Media server\" ON" [ -n "${EXISTING_SERVICES[FRIGATE]}" ] && UNINSTALL_OPTIONS="$UNINSTALL_OPTIONS FRIGATE \"NVR cameras\" ON" @@ -2717,6 +2737,7 @@ else : ${INSTALL_CROWDSEC:="n"} : ${INSTALL_LMS:="n"} : ${INSTALL_MEALIE:="n"} + : ${INSTALL_HOMEASSISTANT:="n"} : ${INSTALL_MINECRAFT:="n"} : ${INSTALL_JELLYFIN:="n"} : ${INSTALL_FRIGATE:="n"} @@ -2743,6 +2764,7 @@ else if echo "$SELECTED_SERVICES" | grep -q "CROWDSEC"; then INSTALL_CROWDSEC="y"; fi if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then INSTALL_LMS="y"; fi if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then INSTALL_MEALIE="y"; fi + if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then INSTALL_HOMEASSISTANT="y"; fi if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then INSTALL_MINECRAFT="y"; fi if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then INSTALL_JELLYFIN="y"; fi if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then INSTALL_FRIGATE="y"; fi @@ -2815,6 +2837,7 @@ else if echo "$SELECTED_SERVICES" | grep -q "CADDY"; then uninstall_service "Caddy" "$DOCKER_DIR/caddy" "caddy"; fi if echo "$SELECTED_SERVICES" | grep -q "LYRION"; then uninstall_service "Lyrion" "$DOCKER_DIR/lms" "lms"; fi if echo "$SELECTED_SERVICES" | grep -q "MEALIE"; then uninstall_service "Mealie" "$DOCKER_DIR/mealie" "mealie"; fi + if echo "$SELECTED_SERVICES" | grep -q "HOMEASSISTANT"; then uninstall_service "Home Assistant" "$DOCKER_DIR/homeassistant" "homeassistant"; fi if echo "$SELECTED_SERVICES" | grep -q "MINECRAFT"; then uninstall_service "Minecraft" "$DOCKER_DIR/minecraft" "minecraft"; fi if echo "$SELECTED_SERVICES" | grep -q "JELLYFIN"; then uninstall_service "Jellyfin" "$DOCKER_DIR/jellyfin" "jellyfin"; fi if echo "$SELECTED_SERVICES" | grep -q "FRIGATE\""; then uninstall_service "Frigate" "$DOCKER_DIR/frigate" "frigate"; fi @@ -4926,6 +4949,106 @@ MEALIE_COMPOSE fi fi + # ---- HOME ASSISTANT ---- + if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_HOMEASSISTANT" ]; then + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ HOME ASSISTANT - Open-source home automation hub │" + echo "│ Smart-home control, automations, dashboards. │" + echo "│ Port: 8123 │" + echo "└─────────────────────────────────────────────────────────────────┘" + prompt_yn "Install Home Assistant? (y/n):" "n" INSTALL_HOMEASSISTANT + fi + + if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then + echo "Installing Home Assistant..." + HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR" + else + mkdir -p "$HOMEASSISTANT_DIR" + ensure_docker_dir_ownership "$HOMEASSISTANT_DIR" + cd "$HOMEASSISTANT_DIR" + + # Networking mode: bridge (published port) vs host networking. + echo "" + echo " Home Assistant networking mode:" + echo " 1) Bridge - container gets its own network; port 8123 is published" + echo " to the host. Works behind the Caddy reverse proxy and" + echo " keeps HA isolated. Recommended for most setups." + echo " 2) Host - HA shares the host's network directly. Needed for" + echo " auto-discovery of devices on your LAN (Chromecast/Cast," + echo " HomeKit, mDNS/Zeroconf, some Zigbee/Z-Wave & Bluetooth)." + prompt_text " Choose networking mode [1]:" "1" HA_NETMODE + if [ "$HA_NETMODE" = "2" ]; then + HA_NET_LINES=" network_mode: host" + echo " → Host networking selected (best device discovery)." + else + HA_NET_LINES=" ports: + - \"8123:8123\"" + echo " → Bridge networking selected (port 8123 published)." + fi + + cat > docker-compose.yml << HOMEASSISTANT_COMPOSE +name: homeassistant + +services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant:stable + container_name: homeassistant + hostname: homeassistant + restart: unless-stopped + privileged: true + environment: + - TZ=$(cat /etc/timezone 2>/dev/null || echo "UTC") + volumes: + - ./config:/config + - /run/dbus:/run/dbus:ro +${HA_NET_LINES} +HOMEASSISTANT_COMPOSE + + mkdir -p config + + # Pre-seed trusted_proxies so HA works behind the Caddy reverse proxy. + # Only written on a fresh install (never clobber an existing config). + if [ ! -f config/configuration.yaml ]; then + cat > config/configuration.yaml << 'HA_CONFIG' +# Loads default set of integrations. Do not remove. +default_config: + +# Allow access through a reverse proxy (e.g. Caddy) +http: + use_x_forwarded_for: true + trusted_proxies: + - 172.16.0.0/12 + - 192.168.0.0/16 + - 10.0.0.0/8 + - 127.0.0.1 + - ::1 +HA_CONFIG + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HOMEASSISTANT_DIR" + + echo "" + echo "✓ Home Assistant configured at $HOMEASSISTANT_DIR" + + # Configure Caddy reverse proxy before starting + configure_caddy_for_service "Home Assistant" "8123" "home" + + prompt_yn "Start Home Assistant now? (y/n):" "y" START_HOMEASSISTANT + if [ "$START_HOMEASSISTANT" = "y" ] || [ "$START_HOMEASSISTANT" = "Y" ]; then + docker compose up -d 2>/dev/null && echo " ✓ Home Assistant started" || echo " ⚠ Failed to start" + fi + + echo " Access at: http://localhost:8123" + echo " First run: open the URL and create your admin account (onboarding)." + echo " Note: first startup can take a minute while HA initializes." + echo "" + fi + fi + # ---- MINECRAFT SERVER ---- if [ "$WHIPTAIL_USED" != true ] && [ -z "$INSTALL_MINECRAFT" ]; then echo "" @@ -5443,6 +5566,11 @@ CADDY_ENV # reverse_proxy mealie:9000 # } +# Home Assistant (home) +# home.{$MY_DOMAIN} { +# reverse_proxy homeassistant:8123 +# } + # ntfy (notifications) # ntfy.{$MY_DOMAIN} { # reverse_proxy ntfy:80 @@ -7124,6 +7252,10 @@ if [ "$CONFIGURE_UFW" = "y" ] || [ "$CONFIGURE_UFW" = "Y" ]; then ufw allow 9925/tcp comment 'Mealie' 2>/dev/null echo " ✓ Allowed Mealie (9925)" fi + if [ "$INSTALL_HOMEASSISTANT" = "y" ] || [ "$INSTALL_HOMEASSISTANT" = "Y" ]; then + ufw allow 8123/tcp comment 'Home Assistant' 2>/dev/null + echo " ✓ Allowed Home Assistant (8123)" + fi if [ "$INSTALL_MAGICMIRROR" = "y" ] || [ "$INSTALL_MAGICMIRROR" = "Y" ]; then ufw allow 8081:8083/tcp comment 'MagicMirror' 2>/dev/null echo " ✓ Allowed MagicMirror (8081-8083)"