Files
ubuntu-post-install/services/wolf-pair.sh
T
Claude bec9228c55 Back up existing files before every service overwrites them
Confirmed live: install_frigate()'s fresh-install path overwrote a
working, hand-crafted docker-compose.yml (Frigate + mosquitto +
frigate-notify) with zero backup, because that file's shape didn't match
what frigate.sh's own "existing install" detection knew how to recognize.
Every service's own detection is a judgment call about what counts as
"already installed" and can miss a real setup built outside this repo's
conventions.

lib/common.sh gains backup_if_exists(FILE) — copies FILE to
FILE.bak.<timestamp> if it exists, no-ops otherwise (including DRY_RUN).
Applied before every service's own `cat > docker-compose.yml`/`cat > .env`
write across all 60 services that do one (115 call sites), plus a matching
standalone-mode stub added to every service's own bootstrap block, same
convention already used for port_in_use/find_free_port. This doesn't
replace a service's own update/fresh-reinstall detection — it's the safety
net underneath it, so a wrong detection costs a .bak file to restore from
instead of the original silently disappearing.

Also fixes the actual gap that surfaced this: services/frigate.sh's
Authelia offer only checked for Authelia installed locally on Frigate's
own box, which is never true for a dedicated NVR box with no local Caddy
either (the common shape — Caddy lives elsewhere, snippet-generation mode
already handles that). Now offers Authelia protection unconditionally and,
when Authelia isn't local, asks whether it lives on the same machine as
Caddy (still "import authelia", since that's local to wherever Caddy ends
up) or on a genuinely separate third machine (the explicit
header-pinned forward_auth form, per CLAUDE.md's "forward_auth to a remote
Authelia" note, needed because a bare authelia:9091 shortcut only works
one hop).
2026-08-26 17:26:17 +00:00

518 lines
22 KiB
Bash

#!/bin/bash
# services/wolf-pair.sh — Moonlight pairing web UI for Wolf.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash wolf-pair.sh
# (Docker must already be installed when run standalone)
#
# Builds a tiny Python HTTP container (server.py + Dockerfile baked below)
# that watches Wolf's docker logs for pairing secrets and serves a PIN entry
# form on port 8090. No command line needed: visit the URL, type the PIN.
#
# The container runs with network_mode: host so that server.py can reach
# Wolf's pairing API at http://localhost:47989 and tail `docker logs wolf`
# via the mounted docker socket.
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
# Determine mode: local Caddy, remote Caddy, or none
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
# Domain prompt — pre-fill from SITE_DOMAIN when available
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Build upstream — remote Caddy uses host IP:port, not container name
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
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"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
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
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
log_info "Copy to Caddy machine:"
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service wolf-pair gaming "Moonlight pairing web UI for Wolf" 8090
install_wolf-pair() {
require_docker || return 1
local WOLFPAIR_DIR="$DOCKER_DIR/wolf-pair"
local WOLFPAIR_PORT=8090
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] wolf-pair install would:"
echo " - Create $WOLFPAIR_DIR with server.py, Dockerfile, docker-compose.yml"
echo " - Build the wolf-pair image (python:3.12-alpine + docker-cli)"
echo " - Run the container with network_mode: host (for localhost:47989 access)"
echo " - Mount /var/run/docker.sock:ro (for docker logs wolf)"
echo " - Open port $WOLFPAIR_PORT in UFW"
echo " - Optionally configure a Caddy reverse proxy"
return 0
fi
mkdir -p "$WOLFPAIR_DIR"
ensure_docker_dir_ownership "$WOLFPAIR_DIR"
cd "$WOLFPAIR_DIR" || return 1
# ── 1. server.py ──────────────────────────────────────────────────────────
log_info "Writing server.py..."
cat > "$WOLFPAIR_DIR/server.py" << 'PYEOF'
#!/usr/bin/env python3
"""
wolf-pair — single-backend pairing helper for Wolf/Moonlight.
GET / → if a fresh pairing secret is pending: serve a PIN form.
if none: serve a waiting page that auto-refreshes.
POST / → take the PIN from the form, attach the freshest secret read from
Wolf's logs, and proxy {pin, secret} to Wolf's /pin/ endpoint.
Wolf's pairing secrets are SINGLE-USE: Wolf erases a secret from its map the
instant any PIN is submitted for it (correct or not). Because the secret stays
in `docker logs` forever, we must never re-offer a secret we've already
submitted — otherwise the user resubmits a dead secret and Wolf returns
"key not found". We track submitted secrets and fall back to the waiting page
until Moonlight initiates a brand-new pairing (which mints a new secret).
"""
import json, subprocess, re, urllib.request, urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
WOLF_HTTP = "http://localhost:47989"
# Secrets already submitted to Wolf. Wolf erases a secret on first submit, so a
# secret in here is dead — show the waiting page instead of re-offering it.
_submitted_hashes: set = set()
PIN_LOG_RE = re.compile(r'Insert pin at http://\S+/pin/#([0-9A-Fa-f]+)')
HTML_WAITING = b"""<!DOCTYPE html>
<html><head>
<meta http-equiv="refresh" content="3">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Wolf Pairing</title>
<style>
body{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:100vh;margin:0;
background:linear-gradient(132deg,#720082,#3f00c3,#0047ff);color:#fff}
h2{font-size:1.6rem;margin-bottom:.5rem}p{opacity:.85;margin:.3rem 0;text-align:center}
</style>
</head><body>
<h2>No pairing request yet</h2>
<p>In Moonlight, add this server, then return here.<br>
This page refreshes automatically every 3 seconds.</p>
</body></html>"""
def parse_latest_hash(log_text):
"""Return the most recent pairing secret found in log text, or None."""
matches = PIN_LOG_RE.findall(log_text)
return matches[-1] if matches else None
def latest_hash():
"""Freshest pairing secret that hasn't been submitted yet, or None."""
try:
r = subprocess.run(
['docker', 'logs', '--tail', '200', 'wolf'],
capture_output=True, text=True, timeout=5)
h = parse_latest_hash(r.stdout + r.stderr)
return h if (h and h not in _submitted_hashes) else None
except Exception:
return None
def build_pin_form(secret):
"""Self-contained PIN form. Submits only the PIN; the server attaches the
secret at POST time so a stale page can't send an already-used secret."""
body = f"""<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Wolf Pairing</title>
<style>
body{{font-family:sans-serif;display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:100vh;margin:0;
background:linear-gradient(132deg,#720082,#3f00c3,#0047ff);color:#fff}}
h2{{font-size:1.6rem;margin-bottom:.5rem}}
p{{opacity:.85;margin:.3rem 0;text-align:center}}
input{{font-size:2rem;width:5.5rem;text-align:center;padding:.4rem;
border:none;border-radius:.4rem;letter-spacing:.3rem}}
button{{margin-top:1rem;font-size:1.1rem;padding:.5rem 1.8rem;border:none;
border-radius:.4rem;cursor:pointer;background:#fff;color:#3f00c3;font-weight:bold}}
#msg{{margin-top:1rem;min-height:1.4em;max-width:22rem;text-align:center}}
</style>
</head><body>
<h2>Moonlight Pairing</h2>
<p>Enter the 4-digit PIN shown in Moonlight</p>
<input id="pin" type="text" inputmode="numeric" maxlength="4" autofocus autocomplete="off">
<button onclick="submitPin()">Pair</button>
<div id="msg"></div>
<script>
function submitPin() {{
var pin = document.getElementById('pin').value.trim();
var msg = document.getElementById('msg');
if (!/^\\d{{4}}$/.test(pin)) {{ msg.textContent = 'Enter the 4-digit PIN from Moonlight'; return; }}
msg.textContent = 'Pairing…';
fetch('/', {{method:'POST',
headers:{{'Content-Type':'application/json'}},
body:JSON.stringify({{pin:pin}})
}}).then(function(r){{return r.text().then(function(t){{return {{ok:r.ok,body:t}}}});}})
.then(function(r){{msg.textContent = r.ok ? 'Paired! You can close this page.' : r.body;}})
.catch(function(){{msg.textContent='Network error — try again.';}});
}}
document.getElementById('pin').addEventListener('keydown',function(e){{if(e.key==='Enter')submitPin();}});
</script>
</body></html>"""
return body.encode('utf-8')
def send_response_body(handler, status, content_type, body):
handler.send_response(status)
handler.send_header('Content-Type', content_type)
handler.send_header('Content-Length', str(len(body)))
handler.send_header('Connection', 'close')
handler.end_headers()
handler.wfile.write(body)
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def do_GET(self):
secret = latest_hash()
if secret:
send_response_body(self, 200, 'text/html; charset=utf-8', build_pin_form(secret))
return
send_response_body(self, 200, 'text/html; charset=utf-8', HTML_WAITING)
def do_POST(self):
# Read the freshest secret NOW (not whatever a stale page baked in).
secret = latest_hash()
length = int(self.headers.get('Content-Length', 0))
raw = self.rfile.read(length) if length else b''
if not secret:
send_response_body(self, 409, 'text/plain; charset=utf-8',
('No active pairing request. In Moonlight, add this host again '
'to start a fresh pairing, then enter the new PIN here.').encode())
return
try:
pin = str(json.loads(raw).get('pin', '')).strip()
except Exception:
pin = ''
payload = json.dumps({'pin': pin, 'secret': secret}).encode()
req = urllib.request.Request(WOLF_HTTP + '/pin/', data=payload,
headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = resp.read()
_submitted_hashes.add(secret) # consumed by Wolf — never reuse
send_response_body(self, resp.status,
resp.headers.get('Content-Type', 'text/plain'), data)
except urllib.error.HTTPError as e:
if e.code == 400:
# Secret wasn't in Wolf's map (expired/already used) — retire it.
_submitted_hashes.add(secret)
send_response_body(self, 400, 'text/plain; charset=utf-8',
('This pairing request expired or was already used. '
'Re-add the host in Moonlight and enter the new PIN.').encode())
else:
send_response_body(self, e.code, 'text/plain; charset=utf-8',
('Wolf returned an error (%s). Try again.' % e.code).encode())
except Exception:
send_response_body(self, 502, 'text/plain; charset=utf-8',
b'Could not reach Wolf. Is the wolf container running?')
def log_message(self, *a): pass
if __name__ == '__main__':
HTTPServer.allow_reuse_address = True
HTTPServer(('0.0.0.0', 8090), Handler).serve_forever()
PYEOF
log_success "server.py written"
# ── 2. Dockerfile ─────────────────────────────────────────────────────────
log_info "Writing Dockerfile..."
cat > "$WOLFPAIR_DIR/Dockerfile" << 'DOCKERFILE'
FROM python:3.12-alpine
RUN apk add --no-cache docker-cli
WORKDIR /app
COPY server.py .
CMD ["python3", "server.py"]
DOCKERFILE
log_success "Dockerfile written"
# ── 3. docker-compose.yml ─────────────────────────────────────────────────
# network_mode: host — server.py reaches Wolf at localhost:47989 directly.
# Docker socket (ro) — server.py calls `docker logs wolf` to read secrets.
log_info "Writing docker-compose.yml..."
backup_if_exists "$WOLFPAIR_DIR/docker-compose.yml"
cat > "$WOLFPAIR_DIR/docker-compose.yml" << 'COMPOSE'
name: wolf-pair
services:
wolf-pair:
build:
context: .
dockerfile: Dockerfile
container_name: wolf-pair
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
COMPOSE
log_success "docker-compose.yml written"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR"
# ── 4. Caddy (optional) ───────────────────────────────────────────────────
# Decided before the firewall rule below: wolf-pair has no login of its
# own (that's the whole reason to offer Authelia here), so if Caddy ends
# up fronting it locally, the UFW rule must not also leave the bare port
# open to the internet — that would bypass Authelia entirely.
local WOLFPAIR_EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect wolf-pair with Authelia SSO? (y/n):" "y" _use_auth
[[ "$_use_auth" =~ ^[Yy]$ ]] && WOLFPAIR_EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" "$WOLFPAIR_EXTRA_BLOCK"
# ── 5. Firewall ───────────────────────────────────────────────────────────
if command -v ufw &>/dev/null; then
if [[ "$CADDY_SERVICE_CONFIGURED" == true && "$CADDY_SERVICE_MODE" == "local" ]]; then
ufw delete allow "${WOLFPAIR_PORT}/tcp" 2>/dev/null || true
ufw_allow_from_caddy_net "${WOLFPAIR_PORT}"
else
ufw allow "${WOLFPAIR_PORT}/tcp" comment "wolf-pair pairing UI" >/dev/null 2>&1 || true
log_success "UFW: opened port $WOLFPAIR_PORT/tcp"
fi
ensure_ufw_enabled
fi
# ── 6. README ─────────────────────────────────────────────────────────────
write_readme "$WOLFPAIR_DIR" << 'MD'
# wolf-pair
Browser-based Moonlight pairing helper for Wolf.
Visit `http://<server-ip>:8090` when Moonlight shows a pairing PIN — the page
detects the pending request automatically (auto-refreshes every 3 s while
waiting) and lets you type the PIN without running any CLI commands.
## How it works
1. In Moonlight, add this server → a 4-digit PIN appears.
2. Open `http://<server-ip>:8090` in any browser.
3. The page shows a PIN form — type the PIN and press **Pair**.
The server reads Wolf's docker logs for the current pairing secret, submits
`{pin, secret}` to Wolf's `/pin/` API, and marks the secret as used so a
stale browser tab can never re-submit a dead secret.
## Manage
```bash
cd ~/docker/wolf-pair
docker compose up -d # start
docker compose down # stop
docker compose up -d --build # rebuild after source changes
docker compose logs -f # follow logs
```
## Notes
- Requires the Wolf container (`wolf`) to be running.
- Moonlight's actual video/audio stream is direct UDP/TCP to the server IP
and cannot be proxied — only the pairing page goes through wolf-pair.
- If you set up a Caddy subdomain (e.g. `wolf-pair.yourdomain.com`), that
subdomain is for the PIN form only.
MD
# ── 7. Build & start ──────────────────────────────────────────────────────
echo ""
log_success "wolf-pair configured at $WOLFPAIR_DIR"
echo ""
local START_WOLFPAIR=""
prompt_yn "Build and start wolf-pair now? (y/n):" "y" START_WOLFPAIR
if [ "$START_WOLFPAIR" = "y" ] || [ "$START_WOLFPAIR" = "Y" ]; then
log_info "Building wolf-pair (python:3.12-alpine + docker-cli)..."
if docker compose up -d --build; then
log_success "wolf-pair started"
else
log_warning "Build failed — check: docker compose logs"
return 1
fi
fi
echo ""
echo " Pairing UI: http://localhost:${WOLFPAIR_PORT}"
echo " When Moonlight shows a PIN, open that URL and enter it."
echo ""
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_wolf-pair