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 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:14:57 -04:00
co-authored by Claude Fable 5.1
parent a06cf35cbb
commit d785ebf979
4 changed files with 427 additions and 15 deletions
+93
View File
@@ -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/<site>-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.
@@ -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"
+211
View File
@@ -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/<site>-network-api-key
where <site> 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())