#!/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" </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 # headscale/schema.ts 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 '')" cat <:${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 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 }