Files
platform/scripts/setup/officer-setup/lib/proxy.sh
T
pastilhasandClaude Opus 5 0230e62a71 the platform stops explaining what Headscale is
Correcting my own reasoning from the last commit. I left "Headscale's API", "the
Headscale admin credential" and similar alone on the grounds that they name the
thing the plugin drives, so renaming them would make the sentences false. True as
far as it goes, and it misses the point: those sentences are in the PLATFORM. It
has no business knowing what Headscale is at all. Correct language in the wrong
repository is still a remnant.

Ten of them, each replaced with the platform's own vocabulary rather than the
plugin's domain:

- `crypto.ts` and `secret-store.ts` listed "the Headscale admin credential" among
  what one key used to protect. It is "the tailnet's admin credential" — the
  purpose, which is a word this repository owns.
- `catalogue.ts` and `availability.ts` explained `alsoServes` — a LIVE mechanism —
  entirely through a Headscale example that left the repository. Restated as the
  general shape, with one parenthesis saying which case produced it.
- The photos types file explained another plugin's sidecar.
- `App.tsx` cited `/headscale/:section` as the example of a route pair; that route
  does not exist. `/photos/:section` does.
- `plugins/router.ts` pointed at `useCompanionLogStream`, a hook that lives in a
  plugin now.
- `registry.ts` said in the present tense that `headscale` is `admin` — the
  permission is the plugin's and it declares its own, so a member-facing enrolment
  surface would be the PLUGIN's to add, not this file's.
- `proxy.sh` cited `headscale/schema.ts` as a style reference. That file is not in
  this repository.
- machine-setup said "Its Headscale app" — the app is the Offscale plugin.

`machine-setup/lib/tailscale.sh` keeps all ten of its mentions: it exists to
explain tailscale, headscale and offscale to somebody choosing between them, on a
core install, before any plugin exists. That is machine setup's subject, not the
plugin's domain leaking upward.

What survives elsewhere is only dated history — "was here until 2026-08-15", "It
was: music, vault, slskd, headscale, …" — plus the notes about the real
`headscale_servers` orphan and the secret-store key removed yesterday. Those record
that something called headscale used to be here, which is a different claim from
explaining what it does.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged, setup scripts parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:33:28 +00:00

493 lines
19 KiB
Bash

#!/bin/bash
# officer-setup — Nginx Proxy Manager, the optional last step.
#
# Publishes the running instance on a real hostname with a Let's Encrypt certificate.
# Entirely optional: someone with a proxy elsewhere declines and is printed the values
# they need instead.
#
# PRECONDITION: Officer is running and bound to 0.0.0.0. Checked, not assumed — see
# proxy_require_listening.
#
# ── Why this section ignores --unattended ──
#
# Every other question in this script has a defensible default. None of these do: a
# domain name, a DNS provider and that provider's API credentials cannot be guessed,
# and the whole step is opt-in besides. So the prompts here read stdin directly rather
# than going through confirm()/ask_required(), which honour ASSUME_YES.
#
# The safety valve is a TTY check rather than the flag: with no terminal there is
# nobody to ask, so the section skips itself and prints the manual instructions. That
# covers a cron-driven install without making --unattended silently agree to a proxy.
[[ -n "${OFFICER_SETUP_PROXY_LOADED:-}" ]] && return 0
OFFICER_SETUP_PROXY_LOADED=1
# `${OFFICER_ROOT}/dockers`, matching src/servers/data-path.ts, which derives that
# directory from the install root. The draft used $HOME/dockers, which is a different
# place on every machine and not the one the app store provisions into.
proxy_dir() { echo "${OFFICER_ROOT}/dockers/nginx-proxy-manager"; }
# The network machine-setup already created. It defaults to `services` there, so a
# second name would leave two bridges on the same box with containers unable to see
# each other by name.
PROXY_NET="${SETUP_DOCKER_NETWORK:-services}"
PROXY_API="http://127.0.0.1:81/api"
# ── prompts that always ask ──
#
# Deliberately not confirm()/ask_required(): see the header. Named apart so nobody
# later "fixes" them into the shared helpers and quietly makes --unattended agree to
# provisioning a public hostname.
proxy_confirm() {
local answer
read -rp " $1 [y/N]: " answer || return 1
[[ "$answer" =~ ^[Yy] ]]
}
proxy_ask() {
local answer
read -rp " $1: " answer || return 1
printf '%s' "$answer"
}
# ── 0. is Officer reachable the way NPM will reach it? ──
#
# `curl 127.0.0.1:$PORT` succeeds even when the process binds loopback ONLY, which is
# exactly the case NPM cannot reach: it dials from inside a container, where 127.0.0.1
# is the container itself. Passing this gate on a curl check produces a 504 later that
# reads like a firewall fault. So the bind ADDRESS is what gets checked.
proxy_require_listening() {
local port="$1" listen
listen="$(ss -ltnH "sport = :$port" 2>/dev/null | awk '{print $4}')"
[[ -n "$listen" ]] || {
warn "nothing is listening on port ${port} — start Officer first"
return 1
}
if ! grep -qE '(^|\s)(0\.0\.0\.0|\*):'"$port"'$' <<<"$listen"; then
warn "Officer is listening on: ${listen}"
info "NPM runs in a container, so 127.0.0.1 there is the container itself."
info "A loopback-only listener is invisible to it and yields a 504."
return 1
fi
ok "Officer is listening on 0.0.0.0:${port}"
}
# ── 1. where will the hostname point? ──
#
# Tailnet DNS-01 is mandatory. Let's Encrypt cannot reach 100.64.0.0/10, so HTTP-01
# always fails. The A record is not needed to ISSUE (validation is a TXT
# record) but is needed to USE the name.
# Public HTTP-01 works with no API keys, but the A record must already resolve here.
proxy_detect_target() {
local ts=""
command -v tailscale >/dev/null 2>&1 && ts="$(tailscale ip -4 2>/dev/null | head -1 || true)"
if [[ -n "$ts" ]]; then
TARGET_IP="$ts"
CHALLENGE="dns"
ok "Tailscale detected — ${TARGET_IP}"
info "Tailnet addresses are unreachable from Let's Encrypt, so the certificate"
info "needs a DNS-01 challenge, which needs your DNS provider's API credentials."
else
TARGET_IP="$(curl -sf --max-time 10 https://api.ipify.org || true)"
[[ -n "$TARGET_IP" ]] || {
warn "could not determine this machine's public IP"
return 1
}
CHALLENGE="http"
ok "No Tailscale — public IP ${TARGET_IP} (HTTP-01, no API keys needed)"
fi
}
# Read by indirect expansion — `${!hint}` where hint is "DNS_HINT_${DNS_PROVIDER}" —
# which shellcheck cannot follow, hence the disable rather than a rewrite. Naming them
# this way is what lets a provider with no hint simply not have one.
# shellcheck disable=SC2034
DNS_HINT_godaddy="Create an API key at https://developer.godaddy.com/keys (Production).
You need both the Key and the Secret. Scope it to DNS only if offered."
# shellcheck disable=SC2034
DNS_HINT_cloudflare="Create a token at https://dash.cloudflare.com/profile/api-tokens
Use template 'Edit zone DNS'. Permissions: Zone:DNS:Edit for the zone."
# shellcheck disable=SC2034
DNS_HINT_digitalocean="Create a Personal Access Token with WRITE scope at
https://cloud.digitalocean.com/account/api/tokens"
# The exact credential file format per provider ships INSIDE the NPM image, so it is
# read from there rather than hardcoded — that keeps working as certbot plugins change.
proxy_prompt_dns_credentials() {
echo ""
info "Supported providers include: cloudflare, godaddy, digitalocean, route53,"
info "namecheap, ovh, linode, vultr, hetzner, gandi, google, azure …"
DNS_PROVIDER="$(proxy_ask 'DNS provider')"
[[ -n "$DNS_PROVIDER" ]] || {
warn "no provider given"
return 1
}
local hint="DNS_HINT_${DNS_PROVIDER}"
[[ -n "${!hint:-}" ]] && {
echo ""
info "${!hint}"
}
echo ""
info "Credential format this provider expects:"
docker exec npm python3 -c \
"import json;d=json.load(open('/app/certbot/dns-plugins.json'));print(d['${DNS_PROVIDER}']['credentials'])" \
2>/dev/null | sed 's/^/ /' ||
warn "could not read the template — check the provider name is spelled correctly"
echo ""
info "Paste the credential lines exactly as shown above (blank line to finish):"
DNS_CREDENTIALS=""
local line
while IFS= read -r line; do
[[ -z "$line" ]] && break
DNS_CREDENTIALS+="$line"$'\n'
done
[[ -n "$DNS_CREDENTIALS" ]] || {
warn "no credentials entered"
return 1
}
}
# ── 2. wait for DNS ──
#
# `getent hosts` rather than `dig`: dig comes from dnsutils, which this platform does
# not install, so the draft's version was command-not-found on a fresh VPS — and since
# an empty answer is indistinguishable from "not resolving yet", it waited the full
# thirty minutes before failing. getent is in libc and always there.
#
# The cost is that it reads the system resolver rather than a public one, so a stale
# local cache can satisfy it. Worth it against a check that cannot run at all.
proxy_wait_for_dns() {
local domain="$1" want="$2" got elapsed=0 interval=15 timeout=1800
echo ""
info "Point this DNS record at the machine now:"
echo ""
info " ${domain}. A ${want}"
echo ""
[[ "$CHALLENGE" == "dns" ]] &&
info "(Tailnet: the certificate can issue without this, but the name will not resolve until it exists.)"
while ((elapsed < timeout)); do
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
if [[ "$got" == "$want" ]]; then
ok "${domain} resolves to ${want}"
return 0
fi
printf '\r waiting — %s (%ss) ' "${got:-not resolving yet}" "$elapsed"
sleep "$interval"
elapsed=$((elapsed + interval))
done
echo ""
warn "${domain} still does not resolve to ${want} after $((timeout / 60)) minutes"
[[ "$CHALLENGE" == "dns" ]] && proxy_confirm "Continue anyway and issue the certificate?" && return 0
warn "cannot issue an HTTP-01 certificate until DNS resolves here"
return 1
}
proxy_ensure_network() {
docker network inspect "$PROXY_NET" >/dev/null 2>&1 && return 0
docker network create "$PROXY_NET" >/dev/null && ok "created docker network ${PROXY_NET}"
}
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled that
# address does not exist yet and the WHOLE container fails to start, not just that port.
proxy_order_docker_after_tailscaled() {
[[ "$CHALLENGE" == "dns" ]] || return 0
local f=/etc/systemd/system/docker.service.d/10-after-tailscaled.conf
[[ -f "$f" ]] && return 0
mkdir -p "$(dirname "$f")"
cat >"$f" <<'EOF'
# NPM binds its admin UI to the tailnet IP. If docker starts before tailscaled, that
# address does not exist and the container fails to start entirely.
[Unit]
After=tailscaled.service
Wants=tailscaled.service
EOF
systemctl daemon-reload
ok "docker ordered after tailscaled"
report_changed "$f" "docker ordered after tailscaled so NPM can bind the tailnet IP"
}
# Admin UI (81) is NEVER published on 0.0.0.0. Until it is claimed, anyone who reaches
# it can take the instance; afterwards it can issue certificates and re-point every
# proxied service on the box. 80/443 are public only when they need to be.
proxy_write_compose() {
local dir admin_binds public_binds
dir="$(proxy_dir)"
install -d -o "$USERNAME" -g "$(user_group)" "$dir" "$dir/npm_data" "$dir/letsencrypt"
admin_binds=" - \"127.0.0.1:81:81\""
if [[ "$CHALLENGE" == "dns" ]]; then
admin_binds+=$'\n'" - \"${TARGET_IP}:81:81\""
public_binds=" - \"${TARGET_IP}:80:80\""$'\n'" - \"${TARGET_IP}:443:443\""
else
public_binds=" - \"80:80\""$'\n'" - \"443:443\""
fi
cat >"${dir}/docker-compose.yaml" <<EOF
# Generated by officer-setup. Reverse proxy for this Officer instance.
#
# The admin UI (81) is bound to loopback$([[ "$CHALLENGE" == "dns" ]] && echo " and the tailnet") only, never
# 0.0.0.0 — it can issue certificates and re-point every proxied service on this box.
#
# NOTE: ufw does NOT filter docker-published ports. Exposure is decided by the bind
# addresses below and by the DOCKER-USER chain in /etc/ufw/after.rules.
name: npm
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: npm
restart: always
networks: [${PROXY_NET}]
ports:
${public_binds}
${admin_binds}
volumes:
- ./npm_data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
${PROXY_NET}:
external: true
EOF
chown "${USERNAME}:$(user_group)" "${dir}/docker-compose.yaml"
ok "wrote ${dir}/docker-compose.yaml"
report_changed "${dir}/docker-compose.yaml" "nginx-proxy-manager compose file"
}
proxy_start() {
as_owner "docker compose --project-directory '$(proxy_dir)' up -d" / >/dev/null
local i
for i in $(seq 1 60); do
curl -sf "$PROXY_API/" >/dev/null 2>&1 && {
ok "NPM answered after ${i}s"
report_started "npm" "nginx-proxy-manager container"
return 0
}
sleep 1
done
warn "NPM did not become ready — check: docker logs npm"
return 1
}
# ── claim the admin account immediately ──
#
# NPM 2.15 replaced the fixed default login with a first-run wizard: while the user
# count is zero, ANYONE who reaches port 81 can claim admin. Done in the same breath as
# starting the container. The bind addresses above already make that window unreachable
# from outside, but this does not rely on that alone.
#
# The re-run path is the half the draft was missing: it returned early on an already
# claimed instance WITHOUT setting NPM_EMAIL/NPM_PASSWORD, and the next function
# dereferenced both under `set -u`. So the second run of a "re-runnable" script died on
# an unbound variable. An existing instance asks for the credentials instead.
proxy_claim_admin() {
if curl -sf "$PROXY_API/" | grep -q '"setup":true'; then
ok "NPM admin is already claimed"
echo ""
info "This instance already has an admin account. Its credentials are needed to"
info "add the proxy host below."
NPM_EMAIL="$(proxy_ask 'NPM admin email')"
NPM_PASSWORD="$(proxy_ask 'NPM admin password')"
[[ -n "$NPM_EMAIL" && -n "$NPM_PASSWORD" ]] || {
warn "both are needed to continue"
return 1
}
return 0
fi
echo ""
info "Create the NPM admin account."
NPM_EMAIL="$(proxy_ask 'Admin email')"
[[ -n "$NPM_EMAIL" ]] || {
warn "no email given"
return 1
}
NPM_PASSWORD="$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-20)"
curl -sf -X POST "$PROXY_API/users" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg e "$NPM_EMAIL" --arg p "$NPM_PASSWORD" \
'{name:"Admin",nickname:"Admin",email:$e,roles:["admin"],is_disabled:false,auth:{type:"password",secret:$p}}')" \
>/dev/null || {
warn "failed to create the NPM admin user"
return 1
}
curl -sf "$PROXY_API/" | grep -q '"setup":true' || {
warn "admin creation did not take"
return 1
}
ok "NPM admin claimed: ${NPM_EMAIL}"
# ── the admin password ──
#
# Deliberately NOT written to a file. The platform's shape is that
# secrets/officer-keys.db holds ENCRYPTION KEYS, one per purpose, and the credential
# itself lives encrypted in Postgres. A third plaintext location is the pattern
# the platform elsewhere calls "debt to avoid copying, not a precedent to follow".
#
# Nothing programmatic needs this after setup — only a human logging into the admin
# UI — so not storing it is a legitimate outcome rather than a gap.
#
# The DNS API credentials are deliberately never handled either: NPM must keep a
# plaintext copy in npm_data/database.sqlite for certbot to auto-renew, so copying
# them anywhere else adds exposure without adding protection.
echo ""
warn "This password is shown ONCE and is not stored anywhere:"
echo ""
echo " ${NPM_EMAIL}"
echo " ${NPM_PASSWORD}"
echo ""
info "Put it in your password manager now."
proxy_confirm "Saved it?" || {
warn "stopping so the password is not lost — the container is running and claimed"
return 1
}
}
proxy_api() {
local method="$1" path="$2" body="${3:-}"
if [[ -n "$body" ]]; then
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d "$body"
else
curl -sf -X "$method" "${PROXY_API}${path}" -H "Authorization: Bearer $TOKEN"
fi
}
proxy_get_token() {
TOKEN="$(curl -sf -X POST "$PROXY_API/tokens" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg i "$NPM_EMAIL" --arg s "$NPM_PASSWORD" '{identity:$i,secret:$s}')" |
jq -r '.token')" || {
warn "could not authenticate to the NPM API"
return 1
}
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || {
warn "NPM rejected those admin credentials"
return 1
}
}
# ── let the bridge reach the host process ──
#
# Officer runs on the HOST under pm2, not in a container. Bridge → host traffic DOES
# traverse INPUT, so ufw's default-deny drops it — unlike docker-published ports, which
# bypass ufw entirely. The symptom is a 504 that looks like a network fault. A container
# upstream would need none of this, which is why container upstreams are preferable when
# there is a choice.
proxy_allow_bridge_to_host() {
local port="$1" subnet
subnet="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Subnet}}')"
if ufw status 2>/dev/null | grep -q "${port}.*${subnet%%/*}"; then
ok "ufw already allows the bridge to reach port ${port}"
else
ufw allow from "$subnet" to any port "$port" proto tcp >/dev/null
ok "ufw: allowed ${subnet} → :${port}"
report_changed "ufw" "allowed ${subnet} to reach port ${port} (bridge to host)"
fi
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
}
# Created WITHOUT ssl first, deliberately. Enabling force-SSL before a certificate
# exists gives a host that 301s to https and then fails the handshake — curl reports
# 000, which reads like a network fault rather than a config mistake.
proxy_create_host() {
local domain="$1" port="$2" existing
existing="$(proxy_api GET /nginx/proxy-hosts | jq -r --arg d "$domain" \
'map(select(.domain_names | index($d))) | .[0].id // empty')"
if [[ -n "$existing" ]]; then
HOST_ID="$existing"
ok "proxy host already exists (id ${HOST_ID})"
return 0
fi
HOST_ID="$(proxy_api POST /nginx/proxy-hosts "$(jq -nc \
--arg d "$domain" --arg h "$BRIDGE_GATEWAY" --argjson p "$port" \
'{domain_names:[$d],forward_scheme:"http",forward_host:$h,forward_port:$p,
access_list_id:0,certificate_id:0,block_exploits:true,caching_enabled:false,
allow_websocket_upgrade:true,ssl_forced:false,http2_support:false,
hsts_enabled:false,hsts_subdomains:false,meta:{},advanced_config:"",locations:[]}')" |
jq -r '.id')"
[[ -n "$HOST_ID" && "$HOST_ID" != "null" ]] || {
warn "could not create the proxy host"
return 1
}
ok "proxy host created (id ${HOST_ID}) → ${BRIDGE_GATEWAY}:${port}"
}
# NPM 2.15 REMOVED letsencrypt_email and letsencrypt_agree from the certificate schema.
# Sending them returns: 400 data/meta must NOT have additional properties.
proxy_issue_certificate() {
local domain="$1" meta
CERT_ID="$(proxy_api GET /nginx/certificates | jq -r --arg d "$domain" \
'map(select(.domain_names | index($d))) | .[0].id // empty')"
[[ -n "$CERT_ID" ]] && {
ok "certificate already exists (id ${CERT_ID})"
return 0
}
if [[ "$CHALLENGE" == "dns" ]]; then
meta="$(jq -nc --arg p "$DNS_PROVIDER" --arg c "$DNS_CREDENTIALS" \
'{dns_challenge:true,dns_provider:$p,dns_provider_credentials:$c,propagation_seconds:120}')"
info "Requesting the certificate via DNS-01 — about two minutes, for the plugin"
info "install and DNS propagation."
else
meta='{"dns_challenge":false}'
info "Requesting the certificate via HTTP-01"
fi
CERT_ID="$(proxy_api POST /nginx/certificates "$(jq -nc \
--arg d "$domain" --argjson m "$meta" \
'{provider:"letsencrypt",nice_name:$d,domain_names:[$d],meta:$m}')" | jq -r '.id')"
[[ -n "$CERT_ID" && "$CERT_ID" != "null" ]] || {
warn "the certificate request failed — see: docker logs npm"
return 1
}
ok "certificate issued (id ${CERT_ID})"
}
proxy_attach_certificate() {
proxy_api PUT "/nginx/proxy-hosts/${HOST_ID}" "$(jq -nc --argjson c "$CERT_ID" \
'{certificate_id:$c,ssl_forced:true,http2_support:true,hsts_enabled:false,hsts_subdomains:false}')" \
>/dev/null || {
warn "could not attach the certificate"
return 1
}
ok "certificate attached, force-SSL and HTTP/2 on"
}
proxy_verify() {
local domain="$1" code
code="$(curl -so /dev/null -w '%{http_code}' --max-time 20 \
--resolve "${domain}:443:${TARGET_IP}" "https://${domain}/" || echo 000)"
case "$code" in
200 | 30[0-9]) ok "https://${domain} → ${code}" ;;
000) warn "TLS handshake failed — certificate not attached, or force-SSL set before it existed" ;;
502) warn "502 — nothing listening on the upstream port" ;;
504) warn "504 — upstream unreachable: ufw dropping bridge→host, or the wrong forward_host" ;;
*) warn "unexpected response: ${code}" ;;
esac
}
proxy_skip_instructions() {
local port="$1" gw
gw="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || echo '<bridge-gateway>')"
cat <<EOF
To put Officer behind your own proxy, point it at:
http://<this-machine>:${port}
If that proxy runs in a container ON this machine, use ${gw}:${port} — inside a
container 127.0.0.1 is the container itself — and let it through ufw:
ufw allow from <container-subnet> to any port ${port} proto tcp
Officer must bind 0.0.0.0, not 127.0.0.1, or the proxy cannot reach it.
EOF
}