From d785ebf97970d03193a1ac2325b2f5913498b8d9 Mon Sep 17 00:00:00 2001 From: Michael McGary Date: Thu, 15 Oct 2026 13:14:57 -0400 Subject: [PATCH] Document the LAN-only translate.watersmedia.org setup at North Attleboro PUBLIC-ACCESS.md now describes what actually runs: a local UniFi DNS record at Apollo Beach and North Attleboro pointing the name at the Boba-Fett NAS, a DSM reverse proxy rule from port 80 to the booth Mac at 10.1.7.39:3100 with WebSocket headers, X-Forwarded-Host and a 3600 s read timeout, and an nginx block that redirects https:// to http://. The hand-written nginx example is kept for non-DSM proxies, with the booth Mac address corrected and proxy_buffering off for live audio. docs/examples/waters-church-na/ holds the runbook, the script run on Boba-Fett, and the UniFi DNS record script used to create the records. Co-Authored-By: Claude Fable 5.1 --- docs/PUBLIC-ACCESS.md | 74 ++++-- docs/examples/waters-church-na/README.md | 93 ++++++++ .../boba-fett-translate-proxy.sh | 64 ++++++ .../waters-church-na/unifi-dns-record.py | 211 ++++++++++++++++++ 4 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 docs/examples/waters-church-na/README.md create mode 100755 docs/examples/waters-church-na/boba-fett-translate-proxy.sh create mode 100755 docs/examples/waters-church-na/unifi-dns-record.py diff --git a/docs/PUBLIC-ACCESS.md b/docs/PUBLIC-ACCESS.md index 224f34d..f2095be 100644 --- a/docs/PUBLIC-ACCESS.md +++ b/docs/PUBLIC-ACCESS.md @@ -57,16 +57,45 @@ For a second campus, repeat with its own name — `translate-north`, --- -## The other way: your existing nginx proxy +## The other way: a name on your existing reverse proxy -You already run a reverse proxy at `10.1.1.4` fronting `check.watersmedia.org`, -with split-horizon DNS. It can front this too, though it means the traffic goes -through the NAS network to reach the booth Mac. +You already run a reverse proxy on the NAS at `10.1.1.4` (Boba-Fett, Synology +DSM) fronting `check.watersmedia.org`. It can front this too, though it means +the traffic goes through the NAS network to reach the booth Mac. -Add a DNS record for `translate.watersmedia.org` — public to your WAN address, -internal to `10.1.1.4` — and a server block. Copy the one that already serves -the checklist app, change the name, and point it at the **booth Mac**, not the -NAS: +This is what North Attleboro runs as of 2026-10-15, in the **LAN-only** form: +`http://translate.watersmedia.org` works on the building's WiFi at both the +Apollo Beach and North Attleboro sites and nowhere else. Nothing was opened to +the WAN. The pieces, with the exact scripts and a step-by-step runbook under +[`examples/waters-church-na/`](examples/waters-church-na/README.md): + +| Piece | Where | What | +| --- | --- | --- | +| Local DNS | UniFi consoles at AB and NA (Settings → Policy Table → DNS) | A record `translate.watersmedia.org` → `10.1.1.4`, TTL 300, created with `unifi-dns-record.py` and a per-console Network API key | +| Proxy rule | DSM → Control Panel → Login Portal → Advanced → Reverse Proxy | HTTP `translate.watersmedia.org:80` → HTTP `10.1.7.39:3100`, WebSocket headers, `X-Forwarded-Host: $http_host`, read timeout 3600 s | +| HTTPS | `/usr/local/etc/nginx/sites-enabled/translate-redirect.conf` on the NAS | `https://` answers a 301 to `http://`; the name has no certificate, so browsers warn before the redirect | + +Both NAS pieces come from `boba-fett-translate-proxy.sh`, run as +`ssh -t boba-fett sudo bash translate-proxy.sh` because DSM's reverse-proxy API +is root-only. Going through DSM rather than a hand-edited nginx file means the +rule shows up in the DSM UI and survives DSM updates. DSM adds `Host`, +`X-Real-IP`, `X-Forwarded-For` and `X-Forwarded-Proto` on its own. + +Then: Operator page → **Public address** → `http://translate.watersmedia.org` +(no trailing slash), so the QR code and poster use the name instead of the +IP and port. + +To let people off-site use the same name later: add a public DNS record for +`translate.watersmedia.org` pointing at the NA WAN address, get a certificate +in DSM (Control Panel → Security → Certificate → Let's Encrypt), replace the +redirect with an HTTPS rule to the same destination, and switch Public address +to `https://`. The app behaves the same either way, including the `/operator` +404 through a proxy. + +### If the proxy is plain nginx instead of DSM + +DSM writes most of this for you. On a hand-managed nginx the server block +needs all of it: ```nginx server { @@ -77,7 +106,7 @@ server { ssl_certificate_key /path/to/privkey.pem; location / { - proxy_pass http://10.6.3.222:3100; # the booth Mac + proxy_pass http://10.1.7.39:3100; # the booth Mac proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -95,21 +124,36 @@ server { # the room reconnects at once. proxy_read_timeout 3600s; proxy_send_timeout 3600s; + + # Live audio: do not hold HLS segments back. + proxy_buffering off; } } ``` -This has one real drawback the tunnel does not: the booth Mac needs a fixed -address on the network, or the proxy breaks the next time DHCP moves it. +Either way the booth Mac needs a fixed address or a DHCP reservation, or the +proxy breaks the next time DHCP moves it. --- ## Checking it worked ```bash -curl -s https://translate.watersmedia.org/healthz +curl -s http://translate.watersmedia.org/healthz # https:// once published ``` -Should return `{"ok":true,…}`. Then open the page on a phone **with WiFi turned -off** — that is the real test. If the page loads but captions never appear, the -WebSocket headers are missing. +Should return `{"ok":true,…}`, the same as `curl -s http://10.1.7.39:3100/healthz`. +A proxy that passes the WebSocket answers `HTTP/1.1 101 Switching Protocols` to: + +```bash +curl -si -N -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ + -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ + http://translate.watersmedia.org/ws | head -1 +``` + +Then open the page on a phone: for the LAN-only setup on the guest WiFi, for a +published one **with WiFi turned off**. That is the real test. If the page loads +but captions never appear, the WebSocket headers are missing. If phones on +guest WiFi cannot connect at all, look at client isolation on that network +before anything else. `/operator` returning 404 through the proxy is by design; +staff use `http://10.1.7.39:3100/operator` on the LAN. diff --git a/docs/examples/waters-church-na/README.md b/docs/examples/waters-church-na/README.md new file mode 100644 index 0000000..fca19af --- /dev/null +++ b/docs/examples/waters-church-na/README.md @@ -0,0 +1,93 @@ +# translate.watersmedia.org — local DNS and reverse proxy + +Puts the Church Translate congregation page on a church hostname inside the +Apollo Beach and North Attleboro networks. LAN only, plain HTTP, no WAN access. +The app's own requirements are in [PUBLIC-ACCESS.md](../../PUBLIC-ACCESS.md). This +was done on 2026-10-15 against app 3.23.2 build 121. + +| What | Value | +| --- | --- | +| App host | Translate Mac, North Attleboro LAN `10.1.7.39` | +| App port | TCP 3100, plain HTTP + WebSocket on `/ws` (the only port to expose) | +| Reverse proxy | Boba-Fett (Synology DSM 7.0.1), `10.1.1.4`, `ssh boba-fett` (port 202, user Michael) | +| Hostname | `translate.watersmedia.org` | +| Local DNS | A record on the AB and NA UDM Pro consoles → `10.1.1.4`, TTL 300 | +| HTTPS | no certificate; `https://translate.watersmedia.org` answers 301 to `http://` | + +`curl -s http://10.1.7.39:3100/healthz` should return `{"ok":true,"live":true,...}` +before anything below is tested. + +## 1. Local DNS on UniFi (AB and NA) — done 2026-10-15 + +Each console has its own Network API key saved as +`~/.config/unifi/-network-api-key` (created in Settings → Control Plane → +Integrations → Create API Key; all five sites are saved on Michael's Mac). The +record was created with: + +```bash +python3 docs/examples/waters-church-na/unifi-dns-record.py translate.watersmedia.org 10.1.1.4 --sites apollo-beach,north-attleboro +``` + +Resulting policy ids: AB `379f868d-a956-4ec1-acef-13557153ff3f`, NA +`16bf7109-ec68-41f9-9c16-fa6a62471430`. Both gateways answer `10.1.1.4`. +`--list` shows every DNS policy per console; `--dry-run` previews. + +Why Boba-Fett and not the Mac: the proxy owns the hostname so the port never +appears in the URL, and AB clients reach `10.1.1.4` over the site-to-site link +exactly as they already do for `chronicles-na.waterschurch.org`. + +## 2. Reverse proxy on Boba-Fett — done 2026-10-15 + +[boba-fett-translate-proxy.sh](boba-fett-translate-proxy.sh) does the whole +job and is idempotent. It was run on 2026-10-15; afterwards `/healthz`, +`/api/languages`, the `/ws` upgrade (101) and the HTTPS 301 all checked out +from both the AB and NA LANs, and `/operator` returned 404 as designed. A copy lives at `~/translate-proxy.sh` on Boba-Fett +(Michael's home). DSM's reverse-proxy API and nginx reload both need root, and +sudo there asks for Michael's password, so run it interactively: + +```bash +ssh -t boba-fett sudo bash translate-proxy.sh +``` + +What it creates: + +| Piece | Where | Content | +| --- | --- | --- | +| DSM reverse proxy rule | Control Panel → Login Portal → Advanced → Reverse Proxy, "translate.watersmedia.org" | HTTP `translate.watersmedia.org:80` → HTTP `10.1.7.39:3100`; custom headers `Upgrade: $http_upgrade`, `Connection: $connection_upgrade` (WebSocket), `X-Forwarded-Host: $http_host`; read timeout 3600 s; HTTP/1.1 | +| HTTPS redirect | `/usr/local/etc/nginx/sites-enabled/translate-redirect.conf` | `server { listen 443 ssl; server_name translate.watersmedia.org; return 301 http://translate.watersmedia.org$request_uri; }` | + +DSM already sends `Host`, `X-Real-IP`, `X-Forwarded-For` and +`X-Forwarded-Proto` on every rule. The whole host lands on `/`, which the app +requires. `/operator` returning 404 through the proxy is intended; staff use +`http://10.1.7.39:3100/operator` on the LAN. + +To undo: delete the rule in the DSM Reverse Proxy list, remove the redirect +file, and `sudo synosystemctl reload nginx`. + +Known limits: + +- The HTTPS redirect only runs after a TLS handshake with Boba-Fett's default + certificate, so a browser shows a certificate warning first. A trusted + certificate for this name would need DNS-01 validation against the + watersmedia.org zone (cPanel), which is not set up. +- DSM's reverse proxy cannot turn `proxy_buffering` off. HLS segments under + `/live/` are small discrete files and the WebSocket is a tunnel after upgrade, + so the effect is minor; if audio lag is reported, that is the first thing to + revisit with a hand-written nginx include. + +## 3. After the proxy answers + +1. On the NA LAN open `http://10.1.7.39:3100/operator`, enter the operator PIN, + and set **Public address** to `http://translate.watersmedia.org` (no trailing + slash). Otherwise the QR code and poster keep advertising `10.1.7.39:3100`. +2. Verify from a machine at AB and one at NA: + +```bash +curl -s http://translate.watersmedia.org/healthz +``` + + It must return the same JSON as the direct `10.1.7.39:3100` check. +3. Open the page on a phone on guest wifi and confirm captions appear + (WebSocket) and spoken audio plays (`/live/`). If phones cannot connect at + all, look at client isolation on the guest network and the guest → `10.1.1.4` + firewall rule before anything else. diff --git a/docs/examples/waters-church-na/boba-fett-translate-proxy.sh b/docs/examples/waters-church-na/boba-fett-translate-proxy.sh new file mode 100755 index 0000000..c0b11a4 --- /dev/null +++ b/docs/examples/waters-church-na/boba-fett-translate-proxy.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# translate.watersmedia.org on Boba-Fett (Synology DSM 7.0.1). +# +# 1. Creates the DSM reverse-proxy rule http://translate.watersmedia.org:80 +# -> http://10.1.7.39:3100 (Church Translate) with WebSocket headers, +# X-Forwarded-Host, and a 3600 s read timeout, through DSM's own API so it +# shows up in Control Panel -> Login Portal -> Advanced -> Reverse Proxy. +# 2. Adds an nginx server block that answers https://translate.watersmedia.org +# with a 301 to the http:// address (the name is LAN-only and has no +# certificate, so browsers warn before the redirect; that is expected). +# 3. Tests the nginx config, reloads it, and prints a local check. +# +# Idempotent: re-running skips the rule if it exists and rewrites the redirect. +# Run on Boba-Fett as root: sudo bash ~/translate-proxy.sh +set -euo pipefail + +NAME=translate.watersmedia.org +BACKEND_IP=10.1.7.39 +BACKEND_PORT=3100 +RP_JSON=/usr/syno/etc/www/ReverseProxy.json +REDIRECT_CONF=/usr/local/etc/nginx/sites-enabled/translate-redirect.conf + +if [ "$(id -u)" != 0 ]; then + echo "Run with sudo: sudo bash $0" >&2 + exit 1 +fi + +echo "== 1. DSM reverse proxy rule for $NAME" +if grep -q "\"fqdn\" : \"$NAME\"" "$RP_JSON"; then + echo " rule already present in $RP_JSON, skipping create" +else + # $http_upgrade etc. are nginx variables and must reach DSM literally. + ENTRY='{"description":"translate.watersmedia.org","frontend":{"acl":null,"fqdn":"translate.watersmedia.org","https":{"hsts":false},"port":80,"protocol":0},"backend":{"fqdn":"10.1.7.39","port":3100,"protocol":0},"customize_headers":[{"name":"Upgrade","value":"$http_upgrade"},{"name":"Connection","value":"$connection_upgrade"},{"name":"X-Forwarded-Host","value":"$http_host"}],"proxy_connect_timeout":60,"proxy_read_timeout":3600,"proxy_send_timeout":60,"proxy_http_version":1,"proxy_intercept_errors":false}' + OUT=$(/usr/syno/bin/synowebapi --exec api=SYNO.Core.AppPortal.ReverseProxy method=create version=1 entry="$ENTRY" 2>&1 || true) + echo " $OUT" + if ! echo "$OUT" | grep -q '"success" *: *true'; then + echo " synowebapi did not report success; stopping before the redirect." >&2 + exit 1 + fi +fi + +echo "== 2. HTTPS -> HTTP redirect block" +cat > "$REDIRECT_CONF" <<'EOF' +# translate.watersmedia.org is served over plain HTTP on the LAN (no certificate). +# Anyone who types https:// is sent to the http:// address. Added 2026-10-15. +server { + listen 443 ssl; + server_name translate.watersmedia.org; + return 301 http://translate.watersmedia.org$request_uri; +} +EOF +echo " wrote $REDIRECT_CONF" + +echo "== 3. nginx test + reload" +/usr/bin/nginx -t +/usr/syno/bin/synosystemctl reload nginx 2>/dev/null || /usr/bin/nginx -s reload +echo " reloaded" + +echo "== 4. Local checks from Boba-Fett" +echo -n " http /healthz : " +curl -s -m 5 --resolve "$NAME:80:127.0.0.1" "http://$NAME/healthz"; echo +echo -n " https / : " +curl -sk -m 5 -o /dev/null -w '%{http_code} -> %{redirect_url}\n' --resolve "$NAME:443:127.0.0.1" "https://$NAME/" +echo "done" diff --git a/docs/examples/waters-church-na/unifi-dns-record.py b/docs/examples/waters-church-na/unifi-dns-record.py new file mode 100755 index 0000000..1a640bf --- /dev/null +++ b/docs/examples/waters-church-na/unifi-dns-record.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Create or update a local DNS A record on every Waters Church UniFi console. + +Usage: + docs/examples/waters-church-na/unifi-dns-record.py repo.watersmedia.org 10.3.8.90 + docs/examples/waters-church-na/unifi-dns-record.py repo.watersmedia.org 10.3.8.90 --sites apollo-beach,norwood + docs/examples/waters-church-na/unifi-dns-record.py repo.watersmedia.org 10.3.8.90 --dry-run + docs/examples/waters-church-na/unifi-dns-record.py --list # show every DNS policy per site + +Each console needs its own Network API key (Site Manager keys from api.ui.com are +rejected locally). Create one per console in the UniFi UI: + Settings -> Control Plane -> Integrations -> Create API Key +and save it, one line, mode 600, as: + ~/.config/unifi/-network-api-key +where is one of the names in SITES below. + +The script talks to the console's Network Integration API +(/proxy/network/integration/v1/sites/{siteId}/dns/policies) over the LAN / +site-to-site links, so it must run from inside the church network. Gateways use +self-signed certificates, so certificate verification is disabled for them. +Keys are read from disk and sent only in the X-API-KEY header; they are never +printed. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import ssl +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +# Console name -> gateway LAN address (UDM Pro at each site, 2026-10). +SITES = { + "apollo-beach": "10.3.0.1", + "north-attleboro": "10.1.0.1", + "woonsocket": "10.2.0.1", + "norwood": "10.4.0.1", + "southcoast": "10.5.0.1", +} + +KEY_DIR = os.path.expanduser("~/.config/unifi") +TIMEOUT = 15 + + +class ApiError(RuntimeError): + pass + + +class Console: + def __init__(self, name: str, gateway: str): + self.name = name + self.gateway = gateway + self.base = f"https://{gateway}/proxy/network/integration/v1" + self.key_path = os.path.join(KEY_DIR, f"{name}-network-api-key") + self.key = self._load_key() + self.ctx = ssl.create_default_context() + self.ctx.check_hostname = False + self.ctx.verify_mode = ssl.CERT_NONE + + def _load_key(self) -> str | None: + try: + with open(self.key_path) as fh: + key = fh.read().strip() + except FileNotFoundError: + return None + return key or None + + def request(self, method: str, path: str, body: dict | None = None, query: dict | None = None): + url = self.base + path + if query: + url += "?" + urllib.parse.urlencode(query) + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("X-API-KEY", self.key or "") + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=TIMEOUT, context=self.ctx) as resp: + raw = resp.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace")[:300] + raise ApiError(f"{method} {path} -> HTTP {e.code}: {detail}") from None + except (urllib.error.URLError, TimeoutError, OSError) as e: + raise ApiError(f"{method} {path} -> {e}") from None + + def site_id(self) -> str: + sites = self.request("GET", "/sites").get("data", []) + if not sites: + raise ApiError("console reports no sites") + for s in sites: + if s.get("internalReference") == "default" or s.get("name") == "default": + return s["id"] + return sites[0]["id"] + + def dns_policies(self, site_id: str) -> list[dict]: + out: list[dict] = [] + offset, limit = 0, 100 + while True: + page = self.request("GET", f"/sites/{site_id}/dns/policies", query={"offset": offset, "limit": limit}) + data = page.get("data", []) + out.extend(data) + total = page.get("totalCount") + offset += len(data) + if not data or (total is not None and offset >= total) or len(data) < limit: + return out + + def ensure_a_record(self, site_id: str, fqdn: str, ipv4: str, ttl: int | None, dry_run: bool) -> str: + existing = [p for p in self.dns_policies(site_id) if p.get("domain", "").lower() == fqdn.lower()] + a_records = [p for p in existing if p.get("type") == "A_RECORD"] + other = [p for p in existing if p.get("type") != "A_RECORD"] + if other: + kinds = ", ".join(sorted({p.get("type", "?") for p in other})) + return f"SKIPPED: {fqdn} already has a non-A policy here ({kinds}); fix by hand" + if len(a_records) > 1: + return f"SKIPPED: {len(a_records)} A records already exist for {fqdn}; fix by hand" + + body = {"type": "A_RECORD", "enabled": True, "domain": fqdn, "ipv4Address": ipv4} + if ttl: + body["ttlSeconds"] = ttl + + if a_records: + cur = a_records[0] + same = cur.get("ipv4Address") == ipv4 and cur.get("enabled", True) and (ttl is None or cur.get("ttlSeconds") == ttl) + if same: + return f"unchanged: {fqdn} -> {ipv4} already present (id {cur.get('id')})" + if dry_run: + return f"would UPDATE {fqdn}: {cur.get('ipv4Address')} -> {ipv4} (id {cur.get('id')})" + self.request("PUT", f"/sites/{site_id}/dns/policies/{cur['id']}", body=body) + return f"UPDATED {fqdn}: {cur.get('ipv4Address')} -> {ipv4}" + + if dry_run: + return f"would CREATE {fqdn} -> {ipv4}" + created = self.request("POST", f"/sites/{site_id}/dns/policies", body=body) + return f"CREATED {fqdn} -> {ipv4} (id {created.get('id') if isinstance(created, dict) else '?'})" + + +def resolve_via(gateway: str, fqdn: str) -> str: + """Ask the gateway's resolver directly; returns the answer or a short status.""" + dig = shutil.which("dig") + if not dig: + return "(dig not installed)" + try: + res = subprocess.run([dig, "+short", "+time=3", "+tries=1", f"@{gateway}", fqdn, "A"], + capture_output=True, text=True, timeout=10) + except subprocess.TimeoutExpired: + return "(timeout)" + answer = res.stdout.strip().replace("\n", " ") + return answer or "(no answer)" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("fqdn", nargs="?", help="host name, e.g. repo.watersmedia.org") + ap.add_argument("ipv4", nargs="?", help="IPv4 address the name should resolve to") + ap.add_argument("--sites", default=",".join(SITES), help="comma-separated subset of: " + ", ".join(SITES)) + ap.add_argument("--ttl", type=int, default=300, help="TTL in seconds (default 300; the API rejects a null TTL)") + ap.add_argument("--dry-run", action="store_true", help="show what would change without writing") + ap.add_argument("--list", action="store_true", help="print every DNS policy on each console and exit") + ap.add_argument("--no-verify", action="store_true", help="skip the dig check after writing") + args = ap.parse_args() + + if not args.list and (not args.fqdn or not args.ipv4): + ap.error("fqdn and ipv4 are required unless --list is given") + + names = [s.strip() for s in args.sites.split(",") if s.strip()] + unknown = [n for n in names if n not in SITES] + if unknown: + ap.error(f"unknown site(s): {', '.join(unknown)}") + + failures = 0 + for name in names: + console = Console(name, SITES[name]) + label = f"{name:16s} {console.gateway:10s}" + if not console.key: + print(f"{label} NO KEY: save the console's API key to {console.key_path}") + failures += 1 + continue + try: + site_id = console.site_id() + if args.list: + policies = console.dns_policies(site_id) + print(f"{label} {len(policies)} DNS policies") + for p in sorted(policies, key=lambda p: p.get("domain", "")): + target = p.get("ipv4Address") or p.get("ipv6Address") or p.get("targetDomain") or p.get("text") or "" + flag = "" if p.get("enabled", True) else " (disabled)" + print(f" {p.get('type', '?'):12s} {p.get('domain', ''):40s} -> {target}{flag}") + continue + result = console.ensure_a_record(site_id, args.fqdn, args.ipv4, args.ttl, args.dry_run) + line = f"{label} {result}" + if not args.dry_run and not args.no_verify and not result.startswith("SKIPPED"): + line += f" | dig @{console.gateway}: {resolve_via(console.gateway, args.fqdn)}" + print(line) + if result.startswith("SKIPPED"): + failures += 1 + except ApiError as e: + print(f"{label} FAILED: {e}") + failures += 1 + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) -- 2.54.0