diff --git a/CLAUDE.md b/CLAUDE.md index 9004604..b735024 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -241,10 +241,31 @@ eshpfi-management/ │ └── README.md # what this stack does, how to deploy ├── stacks-mirror/ # gitignored snapshot of live host state (drift detection) │ └── // # populated by sync-stacks.sh, NOT a deploy source +├── dns/ # fleet internal DNS — *.internal names +│ ├── internal.yaml # source of truth (hosts, sites, aliases) +│ └── README.md # workflow, naming, IPv6 caveat └── docs/ └── pfi/ # general PFI infrastructure reference ``` +## Internal DNS (`*.internal`) + +Fleet hosts have names: `..internal`, sites `ana` / `esh` / `nh3`. +`dns/internal.yaml` is the source of truth; the AdGuard resolvers are derived +state. + +```bash +$EDITOR dns/internal.yaml +scripts/dns-sync.py --dry-run # diff +scripts/dns-sync.py # apply +``` + +The sync is authoritative **within `.internal` only** — names added by hand in +the AdGuard UI get deleted, but rewrites in other zones (ESH's `esteban.net` +entries) are left alone. See `dns/README.md`, especially the IPv6 note: v6 +addresses only go in the file once they are pinned statically on the host, +because SLAAC addresses rotate and a stale record is worse than none. + ## Working rules - **Copies, not symlinks.** Files here reflect what's on the server at the time of the last sync. When you edit here, the server doesn't change until you deploy. diff --git a/dns/README.md b/dns/README.md new file mode 100644 index 0000000..dd2fc84 --- /dev/null +++ b/dns/README.md @@ -0,0 +1,125 @@ +# Fleet internal DNS — `*.internal` + +Names for fleet hosts so nobody has to remember addresses. Built 2026-08-19 +because IPv6 makes memorising them hopeless — and, more to the point, because +v6 addresses are *derived* rather than assigned, so they cannot be reliably +memorised **or** written down once and trusted. + +``` +dns/internal.yaml the source of truth — hosts, sites, aliases +scripts/dns-sync.py reconciles the resolvers against it +``` + +## Adding a name + +Edit `dns/internal.yaml`, then: + +```bash +scripts/dns-sync.py --dry-run # see the diff +scripts/dns-sync.py # apply, with a prompt +``` + +That is the whole workflow. It is deliberately the same shape as +`deploy-stack.sh`: a file in git is the intent, the running system is derived +state, and you see a diff before anything changes. + +## Naming + +`..internal`, sites **`ana`** (Anaheim colo), **`esh`** (home lab), +**`nh3`** (office). + +`.internal` is ICANN-reserved for private use, which is why it is used here +rather than `.local` (reserved for mDNS — the old `searxng.pfi.local` was a +standards collision that happened to work) or an invented TLD that could later +collide with a real one. + +**Every name is published to every resolver.** The site label says where a host +*is*, not which resolver knows about it — `ana-docker.ana.internal` resolves +from ESH and NH3 too. + +Irvine is not a fourth zone: `irv-ml1` is reachable only through NH3's +WireGuard tunnel and numbered out of NH3's `10.100.79.0/24`, so it lives under +`nh3`. Worth revisiting if Irvine ever becomes a site in its own right. + +## The resolvers + +| site | resolver | API port | +|---|---|---| +| ana | ana-docker `10.250.50.70` | **8053** | +| esh | esh-docker-vm `10.0.50.45` | 8080 | +| nh3 | nh3-docker `10.100.50.40` | 8080 | + +ana is the odd one out — `:8080` and `:3000` were already taken on that host — +so the port is carried per-site in `internal.yaml` rather than assumed by the +script. + +The colo resolver (`stacks/adguard-ana/`) was stood up as part of this work; +before it, colo hosts resolved straight against `1.1.1.1` and the site had no +way to answer for internal names. ESH and NH3 run older, unmanaged compose +files, left alone on purpose — adopting three live resolvers into this repo +while also introducing a new naming system is two risky changes at once. + +## Two properties worth not breaking + +**Authority is scoped to the zone, not the resolver.** Only rewrites ending in +`.internal` are managed. The ESH resolver carries hand-made `esteban.net` +entries that predate this system; the sync reads them, ignores them, and leaves +them alone. If this ever grows to manage another zone, that scoping is the +thing to be careful with — resolver-wide authority would silently delete +somebody else's work. + +**Within the zone it is authoritative.** Names added by hand in the AdGuard UI +*will* be deleted by the next sync. That is the point: one place to look. + +## Credential + +`scripts/dns-sync.py` authenticates as a dedicated **`infra-ops`** AdGuard user, +not as the operator's account, and pulls the password from the vault: + +```bash +secret get nh3-dev/adguard-infra-ops-password +``` + +⚠️ The vault appends a trailing newline on read. The script strips it, because +a password carrying a stray `\n` fails auth in a way that looks exactly like a +wrong password. + +The existing `lkraven` AdGuard user was left untouched. Config backups from +before the user was added are on each resolver as +`AdGuardHome.yaml.bak-preinfraops-*`. + +## ⚠️ IPv6 — the reason this exists, and still the unfinished half + +The `v6:` column is empty and that is correct as of 2026-08-19: **no fleet host +has a global IPv6 address yet.** ESH's `/56` is live only on `esh-cameras`, +NH3's LANs are back to `ipv6_interface_type: none`, the colo has no v6 at all. + +When v6 arrives, **do not paste in whatever `ip -6 addr` shows.** SLAAC gives +hosts either EUI-64 addresses (MAC-coupled) or privacy-extension ones (which +rotate), and UniFi has no v6 equivalent of a DHCP reservation. An address only +belongs in this file once it has been pinned **statically on the host itself**. +A record that silently stops matching reality is worse than no record — the +name keeps resolving and starts lying. + +The suggested convention when that happens: give each server a static address +out of its site's `/64` whose low-order bits echo the v4 host octet +(`esh-docker-vm` at `…::45`), so the addresses are both declarable and +semi-memorable. + +## Not migrated: `matrix.pfi.local` + +`searxng.pfi.local` moved to `searxng.ana.internal` (both names still route, +so nothing breaks mid-migration; drop the fallback `Host()` in +`stacks/searxng/compose.yaml` once the Traefik log shows the old one unused). + +**`matrix.pfi.local` was deliberately left alone.** A Matrix `server_name` is +baked into every user ID, room ID and signing key, and federation identity is +derived from it — renaming it is not a DNS change, it is rebuilding the +homeserver's identity and invalidating its history. It stays on `.local`. + +## Still open + +Colo hosts still point at `1.1.1.1`, so they do not yet *use* the new resolver +— they only get answers if something asks it directly. Repointing a whole +site's DNS is a bigger change than standing the service up, so it is a separate +operator-approved step. diff --git a/dns/internal.yaml b/dns/internal.yaml new file mode 100644 index 0000000..2763c02 --- /dev/null +++ b/dns/internal.yaml @@ -0,0 +1,110 @@ +# Fleet internal DNS — the source of truth for *.internal names. +# +# THIS FILE IS AUTHORITATIVE. `scripts/dns-sync.sh` reconciles every resolver +# against it: names here are created, names removed here are deleted, and +# names edited here are updated. Do NOT add .internal names in the AdGuard UI +# — the next sync will delete them. +# +# WHAT THE SYNC WILL NOT TOUCH: any rewrite outside the `.internal` zone. The +# ESH resolver carries hand-made `esteban.net` entries that predate this file +# and are deliberately left alone. Authority is scoped to the zone, not to the +# resolver's whole table. +# +# NAMING: ..internal, sites `ana` / `esh` / `nh3` (operator, +# 2026-08-19). `.internal` is ICANN-reserved for exactly this use since 2024, +# which is why it is used here rather than `.local` (reserved for mDNS) or a +# made-up TLD that could later collide with a real one. +# +# EVERY name is published to EVERY resolver, so `ana-docker.ana.internal` +# resolves from ESH and NH3 too. The site label says where a host IS, not +# which resolver knows about it. +# +# ⚠️ THE v6 COLUMN IS EMPTY ON PURPOSE, AND MUST STAY DECLARATIVE. +# No fleet host has a global IPv6 address today (verified 2026-08-19: ESH's +# /56 is live only on esh-cameras, NH3's LANs are back to ipv6_interface_type +# none, the colo has no v6 at all). When v6 lands, do NOT paste in whatever +# `ip -6 addr` happens to show: SLAAC addresses are either EUI-64 (MAC-coupled) +# or privacy-extension (they rotate), and UniFi has no v6 equivalent of a DHCP +# reservation. A v6 address only belongs in this file once it has been pinned +# STATICALLY on the host itself — otherwise the record rots silently and the +# name starts lying, which is worse than having no record. + +zone: internal + +sites: + ana: + subnet: 10.250.0.0/16 + resolver: 10.250.50.70 # ana-docker — AdGuard #3, stood up for this + # ⚠️ NOT 8080. ana-docker already has :8080 and :3000 taken, so this + # AdGuard's API is on 8053. The port lives here rather than in the script + # precisely so the odd one out cannot be forgotten. + api_port: 8053 + description: Anaheim colo + esh: + subnet: 10.0.0.0/16 + resolver: 10.0.50.45 # esh-docker-vm + api_port: 8080 + description: ESH home lab (esteban.net) + nh3: + subnet: 10.100.0.0/16 + resolver: 10.100.50.40 # nh3-docker + api_port: 8080 + description: NH3 office + +hosts: + # ---- ana: Anaheim colo ---- + - {name: ana-docker, site: ana, v4: 10.250.50.70, note: general-purpose docker host} + - {name: ana-ml2, site: ana, v4: 10.250.50.54, note: GPU inference, dual RTX PRO 6000} + - {name: ana-nas, site: ana, v4: 10.250.50.50, note: CT109 on pfi-pve — NFS/SMB} + - {name: ana-filebot, site: ana, v4: 10.250.50.53, note: file-task automation} + - {name: ana-wg, site: ana, v4: 10.250.50.252, note: WireGuard host} + - {name: corviduo-dev, site: ana, v4: 10.250.50.152, note: Worldtree-team dev VM (PFI-hosted)} + - {name: pbs-ana, site: ana, v4: 10.250.50.90, note: Proxmox Backup Server — fleet primary} + - {name: pfi-ana-webhost, site: ana, v4: 10.250.50.52, note: web workload} + - {name: pfi-postgres, site: ana, v4: 10.250.50.80, note: shared Postgres} + - {name: pfi-pteradactyl, site: ana, v4: 10.250.50.55, note: game panel} + - {name: pfi-tacticalrmm, site: ana, v4: 10.250.50.57, note: TacticalRMM} + - {name: pfi-pve, site: ana, v4: 10.250.250.31, note: Proxmox hypervisor} + - {name: ana-gw, site: ana, v4: 10.250.0.1, note: FortiGate-80F edge} + - {name: pfi-pve-idrac, site: ana, v4: 10.250.250.30, note: iDRAC — OOB for pfi-pve} + - {name: ana-ml2-bmc, site: ana, v4: 10.250.250.50, note: BMC for ana-ml2} + # SureFire tenant hardware — PFI-managed under the hosting agreement. + - {name: sfsrv-ana, site: ana, v4: 10.250.250.115, note: SureFire tenant hypervisor} + - {name: sf-ana-container, site: ana, v4: 10.250.150.100, note: SureFire tenant container host} + - {name: sf-r630-idrac, site: ana, v4: 10.250.250.110, note: SureFire tenant R630 iDRAC} + + # ---- nh3: NH3 office ---- + - {name: nh3-docker, site: nh3, v4: 10.100.50.40, note: general-purpose docker host + AdGuard} + - {name: nh3-dev, site: nh3, v4: 10.100.10.50, note: dev box, fleet sidecars, Claude sessions} + - {name: nh3-extdev, site: nh3, v4: 10.100.50.42, note: manager / external-dev box} + - {name: nh3-nas, site: nh3, v4: 10.100.50.50, note: Synology RS2418+} + - {name: nh3-pve, site: nh3, v4: 10.100.250.60, note: Proxmox hypervisor} + - {name: pbs-nh3, site: nh3, v4: 10.100.50.90, note: Proxmox Backup Server — DR mirror} + - {name: nh3-gw, site: nh3, v4: 10.100.0.1, note: UniFi UDM Pro SE — gateway + controller} + # Irvine is not its own zone: irv-ml1 is reachable only through NH3's + # WireGuard tunnel and is numbered out of NH3's 10.100.79.0/24, so it is + # named under nh3. Revisit if Irvine ever becomes a site in its own right. + - {name: irv-ml1, site: nh3, v4: 10.100.79.3, note: GPU host (Irvine, via WG) — 3090 + A6000} + + # ---- esh: ESH home lab ---- + - {name: esh-docker-vm, site: esh, v4: 10.0.50.45, note: general-purpose docker host + AdGuard} + - {name: esh-nas, site: esh, v4: 10.0.50.50, note: NAS} + - {name: esh-pve, site: esh, v4: 10.0.250.35, note: Proxmox hypervisor} + - {name: esh-pve-nas, site: esh, v4: 10.0.50.55, note: Proxmox hypervisor — storage/media} + - {name: esh-vm-db, site: esh, v4: 10.0.50.60, note: PostgreSQL + MongoDB} + - {name: vm-esh-nas, site: esh, v4: 10.0.50.154, note: NAS-adjacent docker host} + - {name: esh-filebot, site: esh, v4: 10.0.50.70, note: restic / file-sync VM} + - {name: esh-gw, site: esh, v4: 10.0.250.1, note: esh-gw} + - {name: esh-udm, site: esh, v4: 10.0.0.1, note: UniFi UDM Pro Max — gateway + controller} + - {name: plex, site: esh, v4: 10.0.50.56, note: media server} + - {name: jellyfin, site: esh, v4: 10.0.50.57, note: media server} + - {name: brother, site: esh, v4: 10.0.90.125, note: Brother printer} + +# Service aliases — a name that points at whatever host currently runs it, so +# consumers reference the SERVICE rather than the box. Changing where something +# runs becomes a one-line edit here instead of a hunt through configs. +aliases: + - {name: searxng, site: ana, target: ana-docker, note: replaces searxng.pfi.local (.local is mDNS-reserved)} + - {name: gateway, site: ana, target: ana-docker, note: LiteLLM gateway :4000} + - {name: booth, site: nh3, target: nh3-dev, note: The Booth :8090} + - {name: homepage, site: esh, target: esh-docker-vm, note: fleet dashboard :5100} diff --git a/scripts/dns-sync.py b/scripts/dns-sync.py new file mode 100755 index 0000000..63e401e --- /dev/null +++ b/scripts/dns-sync.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""dns-sync.py — reconcile the fleet's AdGuard resolvers against dns/internal.yaml. + +Source of truth is the file; the resolvers are derived state. Same posture as +deploy-stack.sh: show a diff, ask, then apply. + + scripts/dns-sync.py # diff every resolver, prompt before applying + scripts/dns-sync.py --dry-run # diff only, never write + scripts/dns-sync.py --yes # skip the prompt + scripts/dns-sync.py --site esh # one resolver + +AUTHORITY IS SCOPED TO THE ZONE, NOT THE RESOLVER. Only rewrites ending in +`.internal` are considered. The ESH resolver carries hand-made `esteban.net` +entries that predate this system; they are read, ignored, and left alone. If +this ever grows to manage other zones, that scoping is the thing to be careful +with — a resolver-wide authority would silently delete a colleague's work. + +CREDENTIAL: pulled from the vault, never hardcoded. + secret get nh3-dev/adguard-infra-ops-password +The vault appends a trailing newline on read; it is stripped here, because a +password with a stray \\n fails auth in a way that looks like a wrong password. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import pathlib +import subprocess +import sys +import urllib.error +import urllib.request + +REPO = pathlib.Path(__file__).resolve().parent.parent +SPEC = REPO / "dns" / "internal.yaml" +SECRET_CLI = REPO / "services" / "secrets-broker" / "secret" +SECRET_NAME = "nh3-dev/adguard-infra-ops-password" +DEFAULT_API_PORT = 8080 +USER = "infra-ops" +TIMEOUT = 10 + + +def load_spec() -> dict: + import yaml # local import so --help works without the dep + return yaml.safe_load(SPEC.read_text()) + + +def get_password() -> str: + try: + out = subprocess.run([str(SECRET_CLI), "get", SECRET_NAME], + capture_output=True, text=True, timeout=60) + except FileNotFoundError: + sys.exit(f"secret CLI not found at {SECRET_CLI}") + if out.returncode != 0: + sys.exit(f"could not read {SECRET_NAME} from the vault:\n{out.stderr.strip()}") + pw = out.stdout.strip("\n") + if not pw: + sys.exit(f"{SECRET_NAME} came back empty") + return pw + + +def desired_pairs(spec: dict) -> set[tuple[str, str]]: + """The (domain, answer) pairs the zone should contain. + + A pair IS AdGuard's identity for a rewrite, which is why this is a set of + tuples rather than a name->address map: a dual-stack host is two rewrites + that share one name, and a map would silently drop one of them. + + Every name is published to every resolver — the site label says where a + host IS, not which resolver knows about it. + """ + zone = spec["zone"] + hosts = spec.get("hosts") or [] + by_name = {h["name"]: h for h in hosts} + pairs: set[tuple[str, str]] = set() + + def emit(fqdn: str, host: dict) -> None: + for key in ("v4", "v6"): + if host.get(key): + pairs.add((fqdn, str(host[key]))) + + seen: set[str] = set() + for h in hosts: + fqdn = f"{h['name']}.{h['site']}.{zone}" + if fqdn in seen: + sys.exit(f"duplicate name in dns/internal.yaml: {fqdn}") + seen.add(fqdn) + emit(fqdn, h) + + for a in spec.get("aliases") or []: + target = by_name.get(a["target"]) + if target is None: + sys.exit(f"alias {a['name']} points at unknown host {a['target']!r}") + fqdn = f"{a['name']}.{a['site']}.{zone}" + if fqdn in seen: + sys.exit(f"alias {fqdn} collides with a host of the same name") + seen.add(fqdn) + emit(fqdn, target) + + return pairs + + +def api(host: str, path: str, pw: str, payload: dict | None = None, + port: int = DEFAULT_API_PORT): + url = f"http://{host}:{port}/control/{path}" + data = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request(url, data=data, method="POST" if data else "GET") + token = base64.b64encode(f"{USER}:{pw}".encode()).decode() + req.add_header("Authorization", f"Basic {token}") + if data: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + body = r.read().decode().strip() + return json.loads(body) if body else None + except urllib.error.HTTPError as e: + sys.exit(f"{host}: {path} -> HTTP {e.code} {e.reason}\n{e.read().decode()[:200]}") + except urllib.error.URLError as e: + sys.exit(f"{host}: unreachable ({e.reason}). Tried port {port}; " + f"run this from a host that can reach it.") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--site", help="only this site's resolver") + ap.add_argument("--dry-run", action="store_true", help="diff only, never write") + ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt") + args = ap.parse_args() + + spec = load_spec() + zone_suffix = "." + spec["zone"] + want = desired_pairs(spec) + + sites = spec["sites"] + if args.site: + if args.site not in sites: + sys.exit(f"unknown site {args.site!r}; known: {', '.join(sites)}") + sites = {args.site: sites[args.site]} + + pw = get_password() + plans = {} + + for site, cfg in sites.items(): + host = cfg["resolver"] + port = int(cfg.get("api_port", DEFAULT_API_PORT)) + current_all = api(host, "rewrite/list", pw, port=port) or [] + # SCOPE: only our zone. Everything else on this resolver is somebody + # else's and stays untouched. + current = {(r["domain"], r["answer"]) for r in current_all + if r["domain"].endswith(zone_suffix)} + foreign = len(current_all) - len(current) + + add = sorted(want - current) + remove = sorted(current - want) + plans[site] = (host, port, add, remove, foreign) + + print(f"\n=== {site} ({host}:{port}) ===") + print(f" in zone: {len(current)} outside zone (left alone): {foreign}") + for d, a in add: + print(f" + {d:<44} {a}") + for d, a in remove: + print(f" - {d:<44} {a}") + if not add and not remove: + print(" in sync") + + total = sum(len(a) + len(r) for _, _, a, r, _ in plans.values()) + if total == 0: + print("\nnothing to do.") + return + if args.dry_run: + print(f"\n--dry-run: {total} change(s) NOT applied.") + return + if not args.yes: + if input(f"\napply {total} change(s)? [y/N] ").strip().lower() not in ("y", "yes"): + sys.exit("aborted.") + + for site, (host, port, add, remove, _) in plans.items(): + # Delete first: AdGuard tolerates duplicate (domain, answer) pairs, so + # removing before adding keeps a re-pointed name from briefly resolving + # to BOTH its old and new address. + for d, a in remove: + api(host, "rewrite/delete", pw, {"domain": d, "answer": a}, port=port) + for d, a in add: + api(host, "rewrite/add", pw, {"domain": d, "answer": a}, port=port) + print(f"{site}: -{len(remove)} +{len(add)}") + + print("done.") + + +if __name__ == "__main__": + main() diff --git a/stacks/adguard-ana/compose.yaml b/stacks/adguard-ana/compose.yaml new file mode 100644 index 0000000..536d8be --- /dev/null +++ b/stacks/adguard-ana/compose.yaml @@ -0,0 +1,56 @@ +# adguard-ana — the Anaheim colo's resolver. +# +# Stood up 2026-08-19 to close the gap found while designing the .internal +# naming scheme: ESH and NH3 each had an AdGuard, but colo hosts were +# resolving straight against 1.1.1.1, so the colo had no way to answer for +# internal names at all. +# +# ⚠️ PORTS DIFFER FROM THE OTHER TWO SITES. ana-docker is a busy host: :8080 +# and :3000 are already taken (langfuse and friends), so AdGuard's web UI and +# API live on :8053 here, not :8080. Anything that talks to this API — notably +# scripts/dns-sync.py — reads the port from dns/internal.yaml rather than +# assuming the ESH/NH3 value. +# +# ⚠️ THIS IS NOT YET THE COLO'S RESOLVER-OF-RECORD. Standing the service up and +# repointing a whole site's DNS are two different changes with two different +# blast radii. Colo hosts still use 1.1.1.1; the cutover is a separate, +# operator-approved step. Until then this answers only what asks it directly. +# +# ESH and NH3 run their own, older, unmanaged compose files. They were left +# alone deliberately — adopting three live resolvers into this repo while also +# introducing a new naming system is two risky changes at once. Worth doing +# later, as its own piece of work. + +name: adguard-ana + +services: + adguardhome: + image: adguard/adguardhome:latest + container_name: ${AG_CONTAINER:-adguardhome} + restart: unless-stopped + ports: + - "${AG_DNS_PORT:-53}:53/tcp" + - "${AG_DNS_PORT:-53}:53/udp" + # Web UI + REST API. 8053, not 8080 — see the note above. + - "${AG_WEB_PORT:-8053}:80/tcp" + volumes: + - adguard_work:/opt/adguardhome/work + - adguard_conf:/opt/adguardhome/conf + networks: + - tnet + labels: + - homepage.group=Service Networking + - homepage.name=AdGuard (ANA) + - homepage.icon=mdi-dns + - homepage.description=DNS resolver + .internal zone (colo) + - homepage.href=http://10.250.50.70:${AG_WEB_PORT:-8053} + - homepage.siteMonitor=http://10.250.50.70:${AG_WEB_PORT:-8053} + +volumes: + adguard_work: {} + adguard_conf: {} + +networks: + tnet: + name: traefik-net + external: true diff --git a/stacks/adguard-ana/conf/AdGuardHome.seed.yaml b/stacks/adguard-ana/conf/AdGuardHome.seed.yaml new file mode 100644 index 0000000..3761bc9 --- /dev/null +++ b/stacks/adguard-ana/conf/AdGuardHome.seed.yaml @@ -0,0 +1,42 @@ +# FIRST-BOOT SEED ONLY — AdGuard rewrites this file at runtime. +# +# It exists so the container comes up already configured instead of sitting in +# the setup wizard waiting for a human. After first boot the live copy is in +# the adguard-ana_adguard_conf volume and diverges from this file; treat this +# as the bootstrap, not as ongoing config. Notably the `rewrites:` list stays +# EMPTY here — scripts/dns-sync.py owns it from dns/internal.yaml. +# +# ⚠️ NO BLOCKLISTS, DELIBERATELY. The other two AdGuards filter ads for human +# browsing; this one resolves for a rack of servers. A blocklist false-positive +# here breaks service-to-service calls at 3am for no upside, so filtering is +# off and this instance is a plain recursive resolver plus the .internal zone. +http: + address: 0.0.0.0:80 +users: + - name: infra-ops + password: $2a$05$v7d1ASBg9qprZNx3Y50YqempdiL.Lo8.jMYxyVr6MroaGF0w5tQje +auth_attempts: 5 +block_auth_min: 15 +language: en +theme: auto +dns: + bind_hosts: + - 0.0.0.0 + port: 53 + upstream_dns: + - 1.1.1.1 + - 1.0.0.1 + bootstrap_dns: + - 1.1.1.1 + - 8.8.8.8 + protection_enabled: true + filtering_enabled: false + safebrowsing_enabled: false + parental_enabled: false + rewrites: [] +filters: [] +whitelist_filters: [] +user_rules: [] +log: + verbose: false +schema_version: 29 diff --git a/stacks/searxng/compose.yaml b/stacks/searxng/compose.yaml index 4306e08..97cb302 100644 --- a/stacks/searxng/compose.yaml +++ b/stacks/searxng/compose.yaml @@ -23,7 +23,12 @@ services: # ------------------------------------------------------------------ environment: - SEARXNG_SECRET=${SEARXNG_SECRET} - - BASE_URL=https://searxng.pfi.local/ + # searxng.ana.internal, not the old searxng.pfi.local (migrated + # 2026-08-19). `.local` is reserved for mDNS, so the old name was a + # standards collision that happened to work; `.internal` is ICANN- + # reserved for exactly this. The name is served by the fleet's AdGuard + # resolvers from dns/internal.yaml — see scripts/dns-sync.py. + - BASE_URL=https://searxng.ana.internal/ - INSTANCE_NAME=SearXNG # ------------------------------------------------------------------ # Resource limits — tune for VM 102's available RAM/CPU @@ -72,7 +77,12 @@ services: labels: # Traefik configuration — auto-discovery via Docker provider - traefik.enable=true - - traefik.http.routers.searxng.rule=Host(`searxng.pfi.local`) + # Both names during the migration: `.internal` is the real one now, and + # the old `.pfi.local` is kept as a fallback so anything still pointing + # at it (a bookmark, a hardcoded config elsewhere) does not break the + # day the name changes. Drop the second Host() once nothing uses it — + # the Traefik access log will tell you when that is. + - traefik.http.routers.searxng.rule=Host(`searxng.ana.internal`) || Host(`searxng.pfi.local`) - traefik.http.routers.searxng.entrypoints=websecure - traefik.http.routers.searxng.tls=true - traefik.http.routers.searxng.service=searxng