Files
platform/scripts/setup/officer-setup/lib/proxy.sh
T
pastilhas 681df1c8bc make machine-setup.sh and officer-setup.sh actually run on macOS
Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.

The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.

Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.

Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:

  - grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
    table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
    jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
    dependency elsewhere in this script, so that's a correctness fix, not just a port.
  - ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
    enough for NPM's container to reach) — both silently mis-reported on macOS, since the
    "command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
    New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
  - getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
    its full 30-minute timeout regardless of whether the record actually resolved. macOS
    branch uses dscacheutil.
  - stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
    option -- c"), and the failure was VISIBLE and made every layout directory report as
    wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
  - apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
    installers (one literally named install_update_linux.sh, the other hardcoding
    "_Linux_" into the release asset URL) — each gets a brew branch, formula names
    verified against the live Homebrew catalog rather than assumed.
  - sudo -u "$USERNAME" to become the SAME account the process already is — real on
    Linux, where this whole script runs as root and dropping to the owner needs no
    password; on macOS the process never escalates in the first place, so re-invoking
    sudo for yourself asks for a real password on every call, or fails outright without
    sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
    opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
    setup's own as_owner() (already the shared entry point there) got the same branch,
    and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
    were rerouted through it too.

Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).

Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.

Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
2026-08-26 21:10:08 +01:00

525 lines
21 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
# `ss` is Linux-only; `listening_addrs` (lib/base.sh) branches to `lsof` on macOS. Same
# silent-empty-result failure mode as postgres.sh's pg_port_in_use if this were left as-is.
listen="$(listening_addrs "$port" | head -1)"
[[ -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
# `getent` is glibc/Linux-only — absent on macOS (confirmed: "command not found" on a
# real machine), silently swallowed by 2>/dev/null, which would have spun this through
# its full 30-minute timeout regardless of whether the record actually resolved.
# `dscacheutil` is macOS's own equivalent, consulting the same system resolver.
if [[ "$(uname -s)" == "Darwin" ]]; then
got="$(dscacheutil -q host -a name "$domain" 2>/dev/null | awk '/^ip_address:/ {print $2; exit}')"
else
got="$(getent hosts "$domain" 2>/dev/null | awk '{print $1}' | head -1)"
fi
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.
#
# Docker Desktop on macOS has no systemd unit to order — it is a GUI app the OS launches, not
# a service tailscaled could be declared a dependency of — and this account does not run as
# root there either, so `mkdir -p /etc/systemd/...` would fail on permissions rather than do
# something merely unnecessary. Same shape as machine-setup's MACOS_SKIP: skip with a reason
# rather than let a Linux-only action fail confusingly.
proxy_order_docker_after_tailscaled() {
[[ "$CHALLENGE" == "dns" ]] || return 0
if [[ "$(uname -s)" == "Darwin" ]]; then
ok "Docker Desktop has no systemd unit to order — skipping"
return 0
fi
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.
# `ufw` is Debian/Ubuntu — no macOS equivalent through this interface (pf/pfctl is a
# different rule syntax entirely, and the Application Firewall machine-setup's own
# MACOS_SKIP already documents for "Firewall" is per-APP, not per-subnet/per-port). Skipped
# rather than guessed at: Docker Desktop for Mac also runs containers inside its own VM, so
# bridge-to-host traffic does not cross the real host firewall the same way it does when
# Docker runs directly on a Linux kernel — an unverified pf rule here could be solving a
# problem macOS's Docker networking model does not actually have.
proxy_allow_bridge_to_host() {
local port="$1" subnet
if [[ "$(uname -s)" == "Darwin" ]]; then
ok "no ufw on macOS — Docker Desktop's own networking model handles bridge-to-host, skipping"
BRIDGE_GATEWAY="$(docker network inspect "$PROXY_NET" -f '{{(index .IPAM.Config 0).Gateway}}')"
return 0
fi
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
}