wire the reverse proxy in as officer-setup 12
~/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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" <<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
|
||||
# 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 '<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
|
||||
}
|
||||
Reference in New Issue
Block a user