From 11710f283a684ce7767be0be26d5392129509a41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 14 Aug 2026 12:56:40 +0000 Subject: [PATCH] wire the reverse proxy in as officer-setup 12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~/npm-setup-draft/setup-npm.sh, adapted to the script's own helpers and placed last — it is the only step that needs Officer already running. It ignores --unattended, as asked. Every other question in this script has a defensible default; a domain name, a DNS provider and that provider's API credentials do not, and the step is opt-in besides. Its prompts read stdin directly instead of going through confirm()/ask_required(), and they are NAMED APART (proxy_confirm, proxy_ask) so nobody later consolidates them into the shared helpers and quietly makes --unattended agree to publishing a public hostname. The valve is a TTY check rather than the flag: with no terminal there is nobody to ask, so it skips and prints the manual instructions. A cron-driven install still works. Five fixes to the draft: - `${OFFICER_REPO}/scripts/store-npm-credential.ts` — OFFICER_REPO is a git URL, not a directory, so that path was https://…/platform.git/scripts/… and the -f test could never pass. The whole persist-to-platform branch was dead code falling through to the print. Dropped it: the comment beside it already argued that not storing this password is a legitimate outcome, since only a human logging into the admin UI needs it. - NOT re-runnable, despite saying so. claim_admin returned early on an already claimed instance without setting NPM_EMAIL/NPM_PASSWORD, and get_token dereferenced both under set -u. Second run died on an unbound variable. It now asks for the existing credentials. - $HOME/dockers → $OFFICER_ROOT/dockers, matching data-path.ts. And the network is SETUP_DOCKER_NETWORK (`services`), not a second bridge called `officerdev`. - dig → getent hosts. dnsutils is not installed by this platform, so the check was command-not-found on a fresh VPS — and an empty answer is indistinguishable from "not resolving yet", so it waited the full 30 minutes before failing. - python3 → jq for host-side JSON. jq is already in the core package list; the one remaining python3 runs INSIDE the NPM container to read its own credential template, which is the point of reading it from there. Failure is contained: every function warns and returns non-zero rather than exiting, so a proxy that does not come up leaves a finished Officer install behind. Retry with `--only Proxy`. Verified: bash -n, shellcheck -S warning clean, --list shows Proxy, all seven external commands present, and the jq filters checked against sample payloads including the multi-line DNS credential. Co-Authored-By: Claude Opus 5 --- scripts/setup/officer-setup.sh | 81 ++++ scripts/setup/officer-setup/lib/proxy.sh | 492 +++++++++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 scripts/setup/officer-setup/lib/proxy.sh diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index 3402d782..baba6c80 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -88,6 +88,8 @@ source "$SCRIPT_DIR/officer-setup/lib/secrets.sh" source "$SCRIPT_DIR/officer-setup/lib/build.sh" # shellcheck source=officer-setup/lib/services.sh source "$SCRIPT_DIR/officer-setup/lib/services.sh" +# shellcheck source=officer-setup/lib/proxy.sh +source "$SCRIPT_DIR/officer-setup/lib/proxy.sh" trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR @@ -845,6 +847,85 @@ if ! skip; then fi +# ============================================================================= +# 12. Proxy +# ============================================================================= +# +# Optional, and last, because it is the only step that needs Officer to be already +# running: NPM proxies to it, and the gate below checks the bind address rather than +# taking a curl to loopback as proof. +# +# ── 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 step is opt-in besides. So its prompts read stdin directly instead of going +# through confirm()/ask_required(), which honour ASSUME_YES. +# +# The valve is a TTY check, not the flag: with no terminal there is nobody to ask, so +# it skips and prints the manual instructions. That keeps a cron-driven install working +# without letting --unattended silently agree to publishing a public hostname. + +report_section "Proxy" +step "Proxy" +if ! skip; then + echo "" + info "Reverse proxy — a real hostname and an HTTPS certificate" + echo " Optional. Skip it if you already run a proxy elsewhere, or if you" + echo " reach this instance over the tailnet and are happy with that." + echo "" + + PROXY_PORT="${ENV_PORT:-9000}" + + if [[ ! -t 0 ]]; then + warn "no terminal — skipping the proxy, which cannot be answered unattended" + proxy_skip_instructions "$PROXY_PORT" + SUMMARY+=("Proxy: skipped (no terminal)") + elif ! proxy_require_listening "$PROXY_PORT"; then + warn "skipping the proxy — Officer is not reachable the way NPM would reach it" + echo " Fix the bind address, then: officer-setup.sh --only Proxy" + SUMMARY+=("Proxy: skipped (Officer not listening on 0.0.0.0)") + elif ! proxy_confirm "Set up Nginx Proxy Manager now?"; then + proxy_skip_instructions "$PROXY_PORT" + SUMMARY+=("Proxy: skipped by request") + else + # One failure path for all of it: every function warns and returns non-zero rather + # than exiting, so a proxy that does not come up leaves a finished Officer install + # behind rather than a failed one. It is the last section for that reason. + PROXY_DOMAIN="$(proxy_ask 'Domain for this instance (e.g. officer.example.com)')" + if [[ -z "$PROXY_DOMAIN" ]]; then + warn "no domain given — skipping" + SUMMARY+=("Proxy: skipped (no domain)") + elif + proxy_detect_target && + proxy_ensure_network && + proxy_order_docker_after_tailscaled && + proxy_write_compose && + proxy_start && + proxy_claim_admin && + proxy_get_token && + { [[ "$CHALLENGE" != "dns" ]] || proxy_prompt_dns_credentials; } && + proxy_wait_for_dns "$PROXY_DOMAIN" "$TARGET_IP" && + proxy_allow_bridge_to_host "$PROXY_PORT" && + proxy_create_host "$PROXY_DOMAIN" "$PROXY_PORT" && + proxy_issue_certificate "$PROXY_DOMAIN" && + proxy_attach_certificate + then + proxy_verify "$PROXY_DOMAIN" + echo "" + ok "Officer is published at https://${PROXY_DOMAIN}" + echo " NPM admin: http://127.0.0.1:81$([[ "$CHALLENGE" == "dns" ]] && echo " or http://${TARGET_IP}:81")" + SUMMARY+=("Proxy: https://${PROXY_DOMAIN}") + else + warn "the proxy did not finish — Officer itself is unaffected and still running" + echo " Retry just this part with: officer-setup.sh --only Proxy" + ERRORS+=("Proxy: did not finish") + SUMMARY+=("Proxy: FAILED — retry with --only Proxy") + fi + fi + step_ok +fi + # ── Who you are when this exits ── # # Root, and that surprises people — reasonably, because everything this script just diff --git a/scripts/setup/officer-setup/lib/proxy.sh b/scripts/setup/officer-setup/lib/proxy.sh new file mode 100644 index 00000000..69966392 --- /dev/null +++ b/scripts/setup/officer-setup/lib/proxy.sh @@ -0,0 +1,492 @@ +#!/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 +}