Fix wolf-pair: scan for a free port instead of hardcoding 8090

wolf-pair runs network_mode: host, so a taken 8090 fails at container
start with "address already in use" and no ports: line in
docker-compose.yml to explain why — wordpress, ntfy, and beszel all
default to 8090 too and correctly scan for a free port; wolf-pair
hardcoded it in three places (installer var, UFW rule, server.py's
bind) with no scan at all.

Now finds a free port via find_free_port, persists it in a new .env
(read back on rerun so a live install never silently moves), and
threads it into the container via WOLFPAIR_PORT so server.py binds
the scanned port instead of a literal 8090.
This commit is contained in:
Claude
2026-08-31 17:44:22 +00:00
parent 63eab19e9c
commit 8a298d161a
+52 -3
View File
@@ -174,6 +174,19 @@ CBLOCK
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -206,11 +219,29 @@ install_wolf-pair() {
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 " - Open port $WOLFPAIR_PORT in UFW (auto-scanned for a free host port —"
echo " other services, e.g. wordpress/ntfy/beszel, default to 8090 too)"
echo " - Optionally configure a Caddy reverse proxy"
return 0
fi
# network_mode: host means there's no HOST:CONTAINER ports: mapping to scan
# around a collision on — server.py binds 0.0.0.0 directly on the host, so a
# taken 8090 (wordpress/ntfy/beszel all default here too) fails at container
# start with "address already in use" and nothing in docker-compose.yml to
# point at. Scan once and persist in .env; on a rerun, keep the port already
# in use rather than silently moving it out from under an existing Caddy
# site block / bookmarked URL.
if [ -f "$WOLFPAIR_DIR/.env" ]; then
local _existing_port
_existing_port="$(grep '^WOLFPAIR_PORT=' "$WOLFPAIR_DIR/.env" 2>/dev/null | cut -d= -f2-)"
[ -n "$_existing_port" ] && WOLFPAIR_PORT="$_existing_port"
else
find_free_port WOLFPAIR_PORT "$WOLFPAIR_PORT"
fi
[ "$WOLFPAIR_PORT" != "8090" ] && \
log_info "Port 8090 already in use — wolf-pair will use $WOLFPAIR_PORT instead."
mkdir -p "$WOLFPAIR_DIR"
ensure_docker_dir_ownership "$WOLFPAIR_DIR"
cd "$WOLFPAIR_DIR" || return 1
@@ -234,10 +265,11 @@ 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
import json, os, subprocess, re, urllib.request, urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
WOLF_HTTP = "http://localhost:47989"
LISTEN_PORT = int(os.environ.get("WOLFPAIR_PORT", "8090"))
# 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.
@@ -390,7 +422,7 @@ class Handler(BaseHTTPRequestHandler):
if __name__ == '__main__':
HTTPServer.allow_reuse_address = True
HTTPServer(('0.0.0.0', 8090), Handler).serve_forever()
HTTPServer(('0.0.0.0', LISTEN_PORT), Handler).serve_forever()
PYEOF
log_success "server.py written"
@@ -420,12 +452,27 @@ services:
dockerfile: Dockerfile
container_name: wolf-pair
network_mode: host
environment:
- WOLFPAIR_PORT=${WOLFPAIR_PORT:-8090}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
COMPOSE
log_success "docker-compose.yml written"
# host networking means server.py binds this port directly — .env feeds it
# to the container's WOLFPAIR_PORT (above) via docker compose's own .env
# auto-load, same pattern as WOLF_STATE_DIR in services/wolf.sh.
backup_if_exists "$WOLFPAIR_DIR/.env"
cat > "$WOLFPAIR_DIR/.env" << EOF
# Port wolf-pair's pairing UI listens on (host networking — no port mapping
# to edit). Auto-scanned at install time to avoid clashing with other
# services that also default to 8090 (wordpress, ntfy, beszel).
WOLFPAIR_PORT=${WOLFPAIR_PORT}
EOF
chmod 600 "$WOLFPAIR_DIR/.env"
chown "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR/.env"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WOLFPAIR_DIR"
# ── 4. Caddy (optional) ───────────────────────────────────────────────────
@@ -491,6 +538,8 @@ docker compose logs -f # follow logs
- If you set up a Caddy subdomain (e.g. `wolf-pair.yourdomain.com`), that
subdomain is for the PIN form only.
MD
[ "$WOLFPAIR_PORT" != "8090" ] && \
sed -i "s/localhost:8090/localhost:${WOLFPAIR_PORT}/g" "$WOLFPAIR_DIR/README.md"
# ── 7. Build & start ──────────────────────────────────────────────────────
echo ""