Compare commits

..
Author SHA1 Message Date
security-bot 1bd3111912 chore(image-automation): bump ghcr.io/music-assistant/server:2.10.8
Updates managed by image-automation-controller.
2026-10-23 14:49:29 +00:00
security-bot 0e12e90509 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757
ci/woodpecker/cron/e2e Pipeline failed
ci/woodpecker/cron/security-scan Pipeline was successful
Updates managed by image-automation-controller.
2026-10-23 08:02:02 +00:00
k.renner 613e1da011 Merge pull request 'fix(authentik-gate-sync): embedded outpost redirects browsers to the public URL' (#1430) from fix/gate-embedded-browser-host into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1430
2026-10-23 07:57:11 +00:00
claude-botandClaude Opus 5.5 6998845854 fix(authentik-gate-sync): embedded outpost redirects browsers to the public URL
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
The embedded outpost builds the sign-in redirect from authentik_host, not authentik_host_browser. Setting it to the in-cluster Service sent every gate's login to http://authentik-server.authentik.svc.cluster.local/. authentik_host is now https://auth.renner.uno/, Authentik's default for the embedded outpost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:55:33 +02:00
security-bot 430c710fd0 chore(image-automation): bump gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0748
Updates managed by image-automation-controller.
2026-10-23 07:51:29 +00:00
k.renner 6accd1d3cd Merge pull request 'feat(authentik-gate): Traefik ForwardAuth, gate config on the Ingress' (#1429) from feat/authentik-gate-forwardauth into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1429
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-23 07:48:22 +00:00
security-bot 7bf1681c2d chore(image-automation): bump gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23
Updates managed by image-automation-controller.
2026-10-23 05:53:33 +00:00
k.renner c9cd0783df Merge pull request 'fix(hermes): gitea-webhook-sync covers every konairius repo via team membership' (#1413) from fix/gitea-webhook-sync-org-scope into main
ci/woodpecker/push/build-ci-images Pipeline was successful
Reviewed-on: #1413
Reviewed-by: Review Bot <review-bot@renner.uno>
2026-10-23 05:34:08 +00:00
k.renner 7e57b42316 Merge pull request 'docs: drop references to the deleted *.renner.uno wildcard' (#1428) from docs/drop-wildcard-references into main
ci/woodpecker/push/deploy-claude-workstation Pipeline failed
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-netbird-proxy Pipeline was canceled
Reviewed-on: #1428
2026-10-23 05:33:31 +00:00
security-bot 9afb56536c chore(image-automation): bump docker.gitea.com/gitea-mcp-server:1.11.0
Updates managed by image-automation-controller.
2026-10-23 00:49:33 +00:00
security-bot 23c089c8b7 chore(image-automation): bump ghcr.io/paperless-ngx/paperless-ngx:3.3.4
Updates managed by image-automation-controller.
2026-10-22 22:49:23 +00:00
claude-botandClaude Opus 5.5 a3eb69ee20 docs: drop references to the deleted *.renner.uno wildcard
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
The public Cloudflare wildcard CNAME *.renner.uno -> proxy.renner.uno and
its carve-out TXTs were removed on 2026-10-22. Public hosts now carry an
explicit CNAME to proxy.renner.uno created by external-dns from
dns.renner.uno/public; UDM records are opt-in via dns.renner.uno/internal.

Comment/doc text only; no config value changes:
- KumaMonitor, Ingress, HelmRelease and tofu comments that said hosts
  resolve via the wildcard or that external-dns-unifi "skips" public hosts.
- PrometheusRule descriptions (k8s-gateway, dns-resolution) and the
  matching promtool test strings.
- k8s-gateway cache comment: the cache tuning stays (dnsmasq refusal
  bursts, 5s lease TTLs); the wildcard-trap rationale is gone.
- hermes skill references, claude-workstation runbook, crowdsec bouncer
  defaults.

Not changed: kubernetes/apps/home-assistant/* (README rule 1 window) and
the wildcard sentence inside the CoreDNS Corefile data
(infrastructure/coredns-config), where an edit changes the ConfigMap.

Verified live before the edit: all 30 Kuma DNS monitors and all blackbox
DNS/HTTP probes green after the wildcard removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:08:07 +02:00
hermes-bot 08004a508a Merge remote-tracking branch 'origin/main' into fix/gitea-webhook-sync-org-scope
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
# Conflicts:
#	kubernetes/apps/hermes/skills/gitea-issues/SKILL.md
2026-10-22 06:01:33 +00:00
hermes-bot 3d3510b7bd fix(hermes): list hermes-bot's orgs via the admin route the PAT's scopes cover
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
review-bot: /users/{u}/orgs requires the user and organization scope
categories (Gitea 1.27.3 api.go, HasScope needs all), and the sync's PAT
has write:admin,write:organization,write:issue,read:repository, no
read:user. /admin/users/{u}/orgs needs only the admin category and lists
private orgs for a site admin.

Also: drop paged()'s dead /repos/search unwrapping, test the team-mode
fallback of team_writes_issues, and say in SKILL.md that direct-collaborator
and user repos still get cards via the system hook.
2026-10-21 07:47:13 +00:00
hermes-bot bd9f0cb18d fix(hermes): gitea-webhook-sync covers every konairius repo via team membership
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was canceled
The sync reached only konairius/homelab ("repos: 1 with issues writable by
hermes-bot") although hermes-bot has write on all 10 org repos through the
org team Bots (includes_all_repositories, repo.issues write).

Cause: GET /repos/{o}/{r}/collaborators/{user}/permission (Gitea 1.27.3
GetRepoPermissions) returns permission.AccessMode, the repo-wide mode from
the collaborator/access level. Rights from a unit-based team land only in
the per-unit modes and are not part of that field, so it said "none" for
the 9 repos where hermes-bot is not a direct collaborator (checked live:
homelab "write", shelly-manager and ical-proxy "none", while
/orgs/konairius/repos reports push: true for all 10).

Scope is now derived from org membership: orgs of hermes-bot
(/users/hermes-bot/orgs) -> teams granting repo.issues write that it is a
member of -> all org repos (includes_all_repositories) or the team's repos,
non-archived with issues on. User-owned repos are out of scope, so labels
are org labels only and the repo-label branch is gone.

Tests: team scope, read-only team skipped, selected-repo team, user repos
out of scope, failed team lookups, org-labels-only. Live read-only probe
with hermes-bot's token: 10 repos, label target konairius.
2026-10-21 07:40:33 +00:00
43 changed files with 258 additions and 186 deletions
+2 -3
View File
@@ -95,9 +95,8 @@ the VM enrolled as `claude-ws-190-112.nb.renner.uno` / `100.96.190.112` (+ IPv6)
> inventory targets the tunnel DHCP IP.
> **DNS trap.** `claude-ws.obg.renner.uno` resolves to the VM's lease **only at
> the off-site DNS** (`@10.3.40.1`). The home resolver catch-alls `*.renner.uno`
> to the public proxy (`162.55.50.97`, the Netbird VPS) — so from home the FQDN
> lands on `root@netbird`, **not** the VM. Never use it as `ansible_host`.
> the off-site DNS** (`@10.3.40.1`). From home this FQDN does not resolve (it
> exists only at the off-site DNS @10.3.40.1). Never use it as `ansible_host`.
**Optional — mesh route to the k8s API (redundant read path):** to reach the API
over the mesh too, add a Netbird dashboard **network route** advertising the
@@ -3,7 +3,7 @@
#
# Enforces the decisions the in-cluster CrowdSec already detects, at the point
# where public traffic actually enters: these hosts terminate TLS for every
# *.renner.uno name. A drop here never reaches the mesh, the relay or the
# public renner.uno name. A drop here never reaches the mesh, the relay or the
# cluster.
#
# Detection stays in the cluster. The reverse-proxy produces no parseable
+4 -3
View File
@@ -210,9 +210,10 @@ def resolve_backend(gate, service):
def embedded_config(config):
"""The embedded outpost's config with the hosts the gates depend on: the API
over the in-cluster Service, the browser sent to the public URL."""
return {**(config or {}), "authentik_host": AK_URL, "authentik_host_insecure": False,
"""The embedded outpost's config with the host the gates depend on. The
embedded outpost builds the sign-in redirect from authentik_host, so it is
the public URL, which is also Authentik's default for it."""
return {**(config or {}), "authentik_host": AK_BROWSER_URL, "authentik_host_insecure": False,
"authentik_host_browser": AK_BROWSER_URL}
@@ -167,8 +167,8 @@ class Objects(unittest.TestCase):
self.assertEqual(c.drift({"a": 1, "b": 2}, {"a": 1, "b": 3}), {"b": 3})
def test_embedded_config_keeps_other_keys(self):
cfg = c.embedded_config({"log_level": "debug", "authentik_host": "https://auth.renner.uno/"})
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_URL,
cfg = c.embedded_config({"log_level": "debug", "authentik_host": c.AK_URL})
self.assertEqual(cfg, {"log_level": "debug", "authentik_host": c.AK_BROWSER_URL,
"authentik_host_insecure": False, "authentik_host_browser": c.AK_BROWSER_URL})
@@ -303,7 +303,7 @@ class Reconcile(unittest.TestCase):
c.ensure_embedded_config(self.ak)
cfg = self.ak.get(f"outposts/instances/{self.embedded['pk']}/")["config"]
self.assertEqual((cfg["log_level"], cfg["authentik_host"], cfg["authentik_host_browser"]),
("info", c.AK_URL, c.AK_BROWSER_URL))
("info", c.AK_BROWSER_URL, c.AK_BROWSER_URL))
before = len(self.ak.calls)
c.ensure_embedded_config(self.ak)
self.assertEqual(self.ak.calls[before:], [])
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: audiobookshelf
namespace: audiobookshelf
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: audiobooks.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+1 -1
View File
@@ -105,7 +105,7 @@ spec:
type: RuntimeDefault
containers:
- name: controller
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-22-1858 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
image: gitea.renner.uno/gitea_admin/authentik-gate-sync:2026-10-23-0757 # {"$imagepolicy": "flux-system:authentik-gate-sync"}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
+2 -2
View File
@@ -161,8 +161,8 @@ spec:
enabled: true
ingressClassName: traefik
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the
# UDM external-dns instance skips it, LAN uses the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: auth.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: gatus
namespace: apps
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: status.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
type: RuntimeDefault
containers:
- name: gitea-mcp
image: docker.gitea.com/gitea-mcp-server:1.10.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
image: docker.gitea.com/gitea-mcp-server:1.11.0 # {"$imagepolicy": "flux-system:gitea-mcp"}
# Image has no ENTRYPOINT (binary is in CMD), so args alone would replace it.
command:
- /app/gitea-mcp
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: gitea-mcp.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: git.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -90,7 +90,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: gitea.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -19,8 +19,11 @@ expert's PR goes back to the card that opened it, without a manual step.
labels, PR reviews, PR comments, PR open/close) posts to
`http://hermes.hermes.svc.cluster.local:8644/webhooks/gitea-issues`.
The CronJob `gitea-webhook-sync` (namespace hermes, every 15 min) keeps that hook in
place, creates the domain labels and backfills, all only in repos where hermes-bot
has issue write (org labels for their orgs, repo labels for user-owned ones): open
place, creates the domain labels and backfills, all in every non-archived repo with
issues of the orgs hermes-bot belongs to, where an org team grants it issue write
(today: all repos of `konairius` through team `Bots`). Labels are org labels only.
User-owned repos and repos where hermes-bot is only a direct collaborator get neither
labels nor backfill; their new issues still become cards through the system hook. Backfill: open
issues without a card comment are replayed to the route as signed `opened`
deliveries, at most 10 per run, and the reviews of open hermes-bot PRs are replayed
(step 8). Its site-admin PAT lives at OpenBao `apps/gitea-webhook-sync/admin`,
@@ -7,10 +7,10 @@
active system hook carries the desired name, a new one is created first and every
other hook with the prefix is deleted afterwards.
2. Domain labels. hestia/atlas/apollo/clio/daedalus are created only where Hermes
works issues: repos with an issue tracker in which BOT_LOGIN (hermes-bot) has
issue write. They become org labels in each org owning such a repo and repo labels
in such user-owned repos (org labels do not reach those). Existing labels are left
as they are; only missing ones are created.
works issues: the non-archived org repos with an issue tracker in which BOT_LOGIN
(hermes-bot) may write issues through an org team (it labels and comments). They
are org labels of those orgs, never repo labels; user-owned repos are out of scope.
Existing labels are left as they are; only missing ones are created.
3. Backfill. Open issues of the same repos without the bridge's card comment are
replayed to the Hermes webhook route as signed `opened` deliveries, at most
BACKFILL_LIMIT per run and none started after RUN_BUDGET seconds, in random order
@@ -60,9 +60,10 @@ BACKFILL_SPACING = float(os.environ.get("BACKFILL_SPACING_SECONDS", "3"))
RUN_BUDGET = float(os.environ.get("RUN_BUDGET_SECONDS", "900"))
DELIVER_TIMEOUT = 60
CARD_MARKER = "<!-- hermes-kanban-card:"
# Labels and backfill only where this account may write issues (it labels and comments).
# Labels and backfill only in org repos this account may write issues in (it labels and comments).
BOT_LOGIN = os.environ.get("BOT_LOGIN", "hermes-bot")
BOT_WRITE = ("write", "admin", "owner")
ISSUES_UNIT = "repo.issues"
STARTED = time.monotonic()
# Review replay (step 4): reviews by these accounts on open BOT_LOGIN PRs.
REVIEW_BOT_LOGIN = os.environ.get("REVIEW_BOT_LOGIN", "review-bot")
@@ -110,13 +111,11 @@ def api(method: str, path: str, token: str, body: dict | None = None):
def paged(path: str, token: str) -> list[dict]:
"""All pages of a list endpoint; /repos/search wraps its list in {"data": [...]}."""
"""All pages of a list endpoint."""
out, page = [], 1
sep = "&" if "?" in path else "?"
while True:
batch = api("GET", f"{path}{sep}page={page}&limit=50", token) or []
if isinstance(batch, dict):
batch = batch.get("data") or []
out.extend(batch)
if len(batch) < 50:
return out
@@ -160,25 +159,56 @@ def reconcile(token: str, secret: str) -> str:
# --- 2. domain labels --------------------------------------------------------
def team_writes_issues(team: dict) -> bool:
"""Issue write from the team's unit map (Gitea >= 1.20), else its team-wide mode."""
if team.get("permission") in ("admin", "owner"):
return True
units = team.get("units_map") or {}
return (units.get(ISSUES_UNIT) if units else team.get("permission")) in BOT_WRITE
def issue_repos(token: str, errors: list[str] | None = None) -> list[dict]:
"""Repos whose issues Hermes works: issue tracker on, not archived, and hermes-bot may
write issues there (it sets the labels and comments). A site admin sees every repo.
A repo whose permission lookup fails is skipped and named in `errors`."""
out = []
for r in paged("/repos/search", token):
if not r.get("has_issues", True) or r.get("archived"):
"""Repos whose issues Hermes works: in each org BOT_LOGIN belongs to, the repos of its
teams that grant issue write (every org repo for an includes_all_repositories team),
issue tracker on, not archived. Not /collaborators/{user}/permission: that reports
the repo-wide access mode, which stays "none" when the write comes from a unit-based
team. The admin route lists private orgs too and needs only the PAT's admin scope
(/users/{u}/orgs also requires read:user). An org or team whose lookup fails is
skipped and named in `errors`."""
def failed(what: str, exc: RuntimeError) -> None:
if errors is None:
raise exc
errors.append(f"{what}: {exc}")
found: dict[str, dict] = {}
for org in paged(f"/admin/users/{q(BOT_LOGIN)}/orgs", token):
name = org.get("username") or org.get("name") or ""
if not name:
continue
owner, _, name = r["full_name"].partition("/")
try:
perm = api("GET", f"/repos/{q(owner)}/{q(name)}/collaborators/{q(BOT_LOGIN)}/permission", token)
teams = paged(f"/orgs/{q(name)}/teams", token)
except RuntimeError as exc:
if errors is None:
raise
errors.append(f"{r['full_name']}: {exc}")
failed(name, exc)
continue
if (perm or {}).get("permission") in BOT_WRITE:
out.append(r)
return out
for team in teams:
if not team_writes_issues(team):
continue
try:
try:
api("GET", f"/teams/{team['id']}/members/{q(BOT_LOGIN)}", token)
except RuntimeError as exc:
if str(exc).endswith("HTTP 404"):
continue # not a member of this team
raise
repos = paged(f"/orgs/{q(name)}/repos" if team.get("includes_all_repositories")
else f"/teams/{team['id']}/repos", token)
except RuntimeError as exc:
failed(f"{name} team {team.get('name')}", exc)
continue
for r in repos:
if r.get("has_issues", True) and not r.get("archived"):
found.setdefault(r["full_name"], r)
return [found[k] for k in sorted(found)]
def ensure_labels_at(path: str, token: str) -> list[str]:
@@ -192,18 +222,14 @@ def ensure_labels_at(path: str, token: str) -> list[str]:
def ensure_labels(token: str, repos: list[dict]) -> str:
"""Org labels in each org that owns one of `repos`, repo labels in the user-owned ones."""
orgs = {n for o in paged("/admin/orgs", token) if (n := o.get("username") or o.get("name"))}
owners = {(r.get("owner") or {}).get("login") for r in repos}
targets = [f"/orgs/{q(org)}/labels" for org in sorted(orgs & owners)]
targets += [f"/repos/{q(r['owner']['login'])}/{q(r['name'])}/labels" for r in repos
if (r.get("owner") or {}).get("login") not in orgs]
"""Org labels in each org that owns one of `repos` (all org repos); never repo labels."""
orgs = sorted({o for r in repos if (o := (r.get("owner") or {}).get("login"))})
actions = []
for path in targets:
created = ensure_labels_at(path, token)
for org in orgs:
created = ensure_labels_at(f"/orgs/{q(org)}/labels", token)
if created:
actions.append(f"{path.rsplit('/labels', 1)[0]}: created {','.join(created)}")
return "; ".join(actions) or f"labels present in {len(targets)} orgs/repos"
actions.append(f"/orgs/{org}: created {','.join(created)}")
return "; ".join(actions) or f"labels present in {len(orgs)} orgs"
# --- 3. backfill -------------------------------------------------------------
@@ -372,9 +398,9 @@ def main() -> int:
def load_repos() -> str:
errors: list[str] = []
repos.extend(issue_repos(token, errors))
msg = f"{len(repos)} with issues writable by {BOT_LOGIN}"
msg = f"{len(repos)} org repos with issues writable by {BOT_LOGIN}"
if errors: # the other repos still get labels and backfill; the Job fails
raise RuntimeError(f"{msg}; permission lookup failed, skipped: {'; '.join(errors)}")
raise RuntimeError(f"{msg}; team lookup failed, skipped: {'; '.join(errors)}")
return msg
failures = 0
@@ -5,8 +5,12 @@
is a TLS-passthrough service on :443 with a PROXY v2 header, going to Traefik `websecure`.
There are no UDM port forwards and no other public L4 path. LB VIPs (`bgp-pool`,
`fd99::/64`, `10.2.99.0/24`) are LAN/mesh-only.
- DNS labels on an Ingress: `dns.renner.uno/internal` writes the UDM (LAN) record
(opt-in), `dns.renner.uno/public` writes the Cloudflare CNAME to `proxy.renner.uno`, and
`netbird.renner.uno/public` makes `netbird-service-sync` publish the proxy service. A
public host needs both public labels.
- `netbird-service-sync` publishes **only hosts on Ingresses** labelled
`dns.renner.uno/public` (or `netbird.renner.uno/public`), and it reads no other kind.
`netbird.renner.uno/public`, and it reads no other kind.
To publish a TLS host served by an `IngressRouteTCP` (TLS terminated by Traefik), put the
host on its **own** labelled Ingress annotated
`traefik.ingress.kubernetes.io/router.entrypoints: web` with **no** `tls:` section, and
@@ -16,15 +16,15 @@ monitor, and its history starts over.
the KumaMonitor `healthCheckExprs` entry with `current: "true"` (see SKILL.md pitfalls).
## Which hosts are public
The public set is exactly the hosts on Ingresses labelled `dns.renner.uno/public` or
`netbird.renner.uno/public`. To confirm it, do a TLS handshake against the proxy-1 and
A public host needs both labels on its Ingress: `netbird.renner.uno/public` for the proxy
service and `dns.renner.uno/public` for the Cloudflare CNAME. To confirm it, do a TLS handshake against the proxy-1 and
proxy-2 addresses (resolved via DoH) with each candidate hostname as the SNI. Only public hosts
return a CA-verified certificate. The proxies do not answer for their own names, so a
handshake with `proxy-N.renner.uno` as the SNI fails.
## Checks per host (owner's spec: DNS + HTTP reachability + response validation)
- **DNS.** A cluster host has no record of its own and resolves through the `*.renner.uno`
wildcard. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
- **DNS.** A cluster host's public record is a CNAME to `proxy.renner.uno` created by
external-dns from `dns.renner.uno/public`. Check `resolveType: CNAME` with `conditions: [{operator: equals, value:
proxy.renner.uno}]`. An external host checks its own AAAA record, or A when it has no
AAAA. Use `resolveServer: "2606:4700:4700::1111,1.1.1.1"` so IPv6 comes first; Kuma
fails over through the list in order.
@@ -71,7 +71,7 @@ reached Kuma.
parent exists and is a group.
- Emulate each http/keyword/json-query check the way Kuma does it: axios, Kuma's
`evaluateJsonQuery` logic via `jsonata`, and a custom `lookup` that resolves through DoH,
because this pod's DNS is split-horizon. Run each wildcard host pinned to proxy-1 and to
because this pod's DNS is split-horizon. Run each public host pinned to proxy-1 and to
proxy-2.
- DNS monitors can't be checked from the cluster, because outbound :53 to external
resolvers is blocked. After merge, read `monitor_status` from Prometheus instead
@@ -701,62 +701,120 @@ def repo(full, has_issues=True, archived=False):
"has_issues": has_issues, "archived": archived}
def team(tid, name, issues="write", all_repos=True, permission="write"):
return {"id": tid, "name": name, "includes_all_repositories": all_repos,
"permission": permission, "units_map": {"repo.issues": issues, "repo.code": "write"}}
def fake_gitea(orgs, teams, members, repos, fail=()):
"""api() stand-in: orgs of the bot, teams per org, bot's team ids, repos per org or team."""
seen = []
def fake(method, path, token, body=None):
p = path.split("?")[0]
seen.append(p)
if p in fail:
raise RuntimeError(f"GET {p}: HTTP 500")
if p == "/admin/users/hermes-bot/orgs":
return [{"username": o} for o in orgs]
if p.startswith("/orgs/") and p.endswith("/teams"):
return teams.get(p.split("/")[2], [])
if p.startswith("/teams/") and "/members/" in p:
if int(p.split("/")[2]) in members:
return {"login": "hermes-bot"}
raise RuntimeError(f"GET {p}: HTTP 404")
if p.endswith("/repos"):
return repos.get(p, [])
raise AssertionError(f"unexpected {method} {p}")
return fake, seen
class DomainLabels(unittest.TestCase):
def test_labels_only_for_orgs_and_user_repos_hermes_works(self):
def test_org_labels_only_never_repo_labels(self):
calls = []
def fake(method, path, token, body=None):
calls.append((method, path, body))
if path.startswith("/admin/orgs"):
return [{"username": "konairius"}, {"username": "unrelated-org"}]
if path.startswith("/orgs/konairius/labels"):
return [{"name": "Atlas"}, {"name": "bug"}]
return []
repos = [repo("konairius/homelab"), repo("k.renner/turm1388")]
repos = [repo("konairius/homelab"), repo("konairius/shelly-manager")]
with mock.patch.object(sync, "api", side_effect=fake):
msg = sync.ensure_labels("tok", repos)
posts = [(p, b["name"]) for m, p, b in calls if m == "POST"]
self.assertEqual(sorted(n for p, n in posts if p == "/orgs/konairius/labels"),
["apollo", "clio", "daedalus", "hestia"]) # atlas exists (any case)
self.assertEqual(len([p for p, _ in posts if p == "/repos/k.renner/turm1388/labels"]), 5)
self.assertFalse([p for p, _ in posts if p.startswith("/repos/konairius/")]) # org labels reach it
self.assertFalse([c for c in calls if "unrelated-org" in c[1]]) # no repo of it is worked
self.assertFalse([c for c in calls if c[1].startswith("/repos/")]) # org labels reach them
self.assertFalse([c for c in calls if c[1].startswith("/admin/")])
self.assertIn("created", msg)
def test_repos_without_issues_archived_or_bot_write_are_skipped(self):
perms = {"a/x": "write", "a/w": "admin", "gitea_admin/_cargo-index": "read", "a/n": "none"}
seen = []
def fake(method, path, token, body=None):
if path.startswith("/repos/search"):
return {"ok": True, "data": [
repo("a/x"), repo("a/w"), repo("gitea_admin/_cargo-index"), repo("a/n"),
repo("a/y", has_issues=False), repo("a/z", archived=True)]}
seen.append(path)
full = path.split("/repos/")[1].split("/collaborators/")[0]
return {"permission": perms[full]}
def test_team_write_covers_every_org_repo_without_collaborator(self):
# Live 2026-10-21: hermes-bot is only in team Bots (all repos, issues write);
# /collaborators/hermes-bot/permission says "none" for 9 of the 10 repos.
org_repos = [repo("konairius/homelab"), repo("konairius/shelly-manager"),
repo("konairius/ical-proxy"), repo("konairius/off", has_issues=False),
repo("konairius/old", archived=True)]
fake, seen = fake_gitea(
orgs=["konairius"],
teams={"konairius": [team(1, "Owners", "owner", permission="owner"), team(2, "Bots"),
team(5, "Reviewers", "read", permission="none")]},
members={2},
repos={"/orgs/konairius/repos": org_repos})
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok")]
self.assertEqual(got, ["a/x", "a/w"])
self.assertIn("/repos/a/x/collaborators/hermes-bot/permission", seen)
self.assertFalse([p for p in seen if p.startswith(("/repos/a/y/", "/repos/a/z/"))])
self.assertEqual(got, ["konairius/homelab", "konairius/ical-proxy", "konairius/shelly-manager"])
self.assertFalse([p for p in seen if "/collaborators/" in p or p.startswith("/repos/")])
self.assertNotIn("/teams/5/members/hermes-bot", seen) # read-only team is not even checked
def test_failed_permission_lookup_skips_only_that_repo(self):
def fake(method, path, token, body=None):
if path.startswith("/repos/search"):
return {"data": [repo("a/x"), repo("a/broken"), repo("a/w")]}
if path.startswith("/repos/a/broken/"):
raise RuntimeError("GET /repos/a/broken/...: HTTP 500")
return {"permission": "write"}
def test_team_with_selected_repos_and_no_issue_write(self):
fake, seen = fake_gitea(
orgs=["a", "b"],
teams={"a": [team(3, "Some", all_repos=False)],
"b": [team(4, "CodeOnly", issues="read")]},
members={3, 4},
repos={"/teams/3/repos": [repo("a/x"), repo("a/y")],
"/orgs/a/repos": [repo("a/x"), repo("a/y"), repo("a/not-in-team")],
"/orgs/b/repos": [repo("b/z")]})
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok")]
self.assertEqual(got, ["a/x", "a/y"])
self.assertNotIn("/orgs/b/repos", seen)
def test_team_issue_write_from_units_map_or_team_mode(self):
self.assertTrue(sync.team_writes_issues(team(2, "Bots")))
self.assertFalse(sync.team_writes_issues(team(5, "Reviewers", "read", permission="none")))
self.assertTrue(sync.team_writes_issues(team(1, "Owners", "read", permission="owner")))
self.assertTrue(sync.team_writes_issues({"permission": "write"})) # no units_map
self.assertFalse(sync.team_writes_issues({"permission": "read", "units_map": {}}))
def test_user_repos_are_out_of_scope(self):
fake, seen = fake_gitea(orgs=[], teams={}, members=set(), repos={})
with mock.patch.object(sync, "api", side_effect=fake):
self.assertEqual(sync.issue_repos("tok"), [])
self.assertEqual(seen, ["/admin/users/hermes-bot/orgs"]) # no /repos/search over user repos
def test_failed_team_lookup_skips_only_that_team(self):
fake, _ = fake_gitea(
orgs=["a", "broken"],
teams={"a": [team(3, "Bots", all_repos=False), team(6, "More", all_repos=False)]},
members={3, 6},
repos={"/teams/3/repos": [repo("a/x")], "/teams/6/repos": [repo("a/w"), repo("a/x")]},
fail={"/orgs/broken/teams", "/teams/6/members/hermes-bot"})
errors = []
with mock.patch.object(sync, "api", side_effect=fake):
got = [r["full_name"] for r in sync.issue_repos("tok", errors)]
self.assertEqual(got, ["a/x", "a/w"])
self.assertEqual(len(errors), 1)
self.assertIn("a/broken", errors[0])
self.assertEqual(got, ["a/x"])
self.assertEqual(len(errors), 2)
self.assertIn("a team More", errors[0])
self.assertTrue(errors[1].startswith("broken:"))
def test_failed_lookup_raises_without_error_list(self):
fake, _ = fake_gitea(orgs=["broken"], teams={}, members=set(), repos={},
fail={"/orgs/broken/teams"})
with mock.patch.object(sync, "api", side_effect=fake), self.assertRaises(RuntimeError):
sync.issue_repos("tok")
class Backfill(unittest.TestCase):
@@ -90,8 +90,7 @@ spec:
# BLE radios: ESP32 esphome bluetooth_proxy nodes (e.g. the M5 Atom
# Echo) are AUTO-DISCOVERED on the IoT VLAN via mDNS
# (_esphomelib._tcp) over the same net1 macvlan route as the Shellys —
# no static hostname/DNS (which the *.renner.uno wildcard would poison
# over IPv6). The shared fleet Noise key (= the dashboard's
# no static hostname/DNS. The shared fleet Noise key (= the dashboard's
# shelly_ble_proxy_api_key) lets shelly-manager complete the encrypted
# native-API handshake to each discovered proxy: DeviceInfo on
# /ble-radios today, the BLE scan/GATT provisioning path next.
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: immich
namespace: immich
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: immich.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+2 -3
View File
@@ -15,9 +15,8 @@ metadata:
name: llama-server
namespace: ai
labels:
# Public: Cloudflare wildcard → tunnel → NetBird proxy → Traefik. The UDM
# external-dns instance skips anything carrying this, and LAN clients reach
# the same hostname through the hairpin NAT.
# Public: Cloudflare CNAME → NetBird proxy → Traefik. No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: llm.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+10 -9
View File
@@ -1,8 +1,9 @@
# Public (REN-663): each Ingress carries dns.renner.uno/public, so
# netbird-service-sync publishes its host through the NetBird proxy
# (Cloudflare wildcard -> proxy.renner.uno -> TLS passthrough to Traefik) and
# external-dns-unifi skips it; LAN clients follow the public answer and the UDM
# hairpins back in. Same path as audiobooks/immich. Federation stays off.
# (explicit Cloudflare CNAME -> proxy.renner.uno -> TLS passthrough to Traefik)
# and there is no dns.renner.uno/internal label, so external-dns-unifi writes
# no UDM record; LAN clients follow the public answer and the UDM hairpins
# back in. Same path as audiobooks/immich. Federation stays off.
#
# Synapse: only the client-facing paths. /_synapse/admin stays off the
# Ingress (in-cluster only); /_matrix/federation and /_matrix/key are not
@@ -13,8 +14,8 @@ metadata:
name: synapse
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -123,8 +124,8 @@ metadata:
name: element-web
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -154,8 +155,8 @@ metadata:
name: mas
namespace: matrix
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+5 -5
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -136,7 +136,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: account.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -202,7 +202,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: element.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -289,7 +289,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: rtc.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -354,7 +354,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: turn.matrix.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -25,8 +25,8 @@ spec:
summary: "{{ $labels.dns_name }} no longer resolves to its LAN address"
description: >-
The UDM stopped answering for a name external-dns publishes from
a LoadBalancer Service, or answered with the public
*.renner.uno wildcard instead of the record. Clients that use
a LoadBalancer Service, or answered without the
record (no answer, or a public CNAME). Clients that use
the name rather than the address lose the service while
everything in the cluster stays healthy — nothing else in the
stack reports this. Check the external-dns alerts first, then
@@ -31,8 +31,9 @@ spec:
{{ $value | humanizePercentage }} of probes in the last 10m got
the Ingress address for {{ $labels.dns_name }} ({{ $labels.record }})
from k8s-gateway. Pods then resolve Ingress names through the UDM
and the public wildcard (CNAME proxy.renner.uno) and leave the
cluster. Check the rendered Corefile in the k8s-gateway ConfigMap
and the public zone (CNAME proxy.renner.uno for public hosts)
and leave the cluster. Check the rendered Corefile in the
k8s-gateway ConfigMap
(kube-system) for the renner.uno zone, the k8s-gateway logs, and
whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml
still matches the dns_k8s_gateway_auth_* modules in
@@ -30,7 +30,7 @@ tests:
record: A
exp_annotations:
summary: "k8s-gateway does not answer auth.renner.uno A with the Traefik VIP"
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
description: "20% of probes in the last 10m got the Ingress address for auth.renner.uno (A) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
- eval_time: 17m
alertname: K8sGatewayProbesAbsent
exp_alerts: []
@@ -53,7 +53,7 @@ tests:
record: AAAA
exp_annotations:
summary: "k8s-gateway does not answer auth.renner.uno AAAA with the Traefik VIP"
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public wildcard (CNAME proxy.renner.uno) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
description: "50% of probes in the last 10m got the Ingress address for auth.renner.uno (AAAA) from k8s-gateway. Pods then resolve Ingress names through the UDM and the public zone (CNAME proxy.renner.uno for public hosts) and leave the cluster. Check the rendered Corefile in the k8s-gateway ConfigMap (kube-system) for the renner.uno zone, the k8s-gateway logs, and whether the Traefik VIP in infrastructure/traefik/helmrelease.yaml still matches the dns_k8s_gateway_auth_* modules in apps/monitoring/helmrelease-blackbox-exporter.yaml. It also fails when the auth.renner.uno Ingress (authentik) is gone."
# Two consecutive failures in an otherwise healthy hour stay silent
# (average exactly 0.8).
@@ -78,11 +78,10 @@ spec:
# passed per scrape. The Probes in monitoring-rules/probes-dns.yaml
# point these at the UDM.
#
# Both answer checks are load-bearing. valid_rcodes alone passes a
# deleted record two labels deep (NOERROR, no answer), and the public
# *.renner.uno wildcard answers one label deep with a CNAME to
# proxy.renner.uno — NOERROR with the WAN proxy behind it. Requiring
# an A and rejecting a CNAME separates a real record from both.
# Both answer checks are load-bearing: valid_rcodes alone passes a
# deleted record (NOERROR, no answer, any depth). Requiring an A
# separates a real record from that; a CNAME means the UDM answered with a
# public-zone target instead of the LAN address.
dns_lan_ha:
prober: dns
timeout: 5s
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# target of the *.renner.uno wildcard
# target of every public CNAME (NetBird proxy load balancer)
hostname: proxy.renner.uno
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -228,7 +228,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# Cloudflare-proxied apex, not the wildcard
# Cloudflare-proxied apex
hostname: renner.uno
resolveType: AAAA
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -52,7 +52,7 @@ spec:
# stdlib only; Flux keeps the tag in lockstep via the shared image
# policy) — no extra dependency.
- name: reconcile-publish-ip
image: ghcr.io/music-assistant/server:2.10.7 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
command: ["/app/venv/bin/python", "/scripts/reconcile.py"]
volumeMounts:
- name: data
@@ -62,7 +62,7 @@ spec:
readOnly: true
containers:
- name: music-assistant
image: ghcr.io/music-assistant/server:2.10.7 # {"$imagepolicy": "flux-system:music-assistant"}
image: ghcr.io/music-assistant/server:2.10.8 # {"$imagepolicy": "flux-system:music-assistant"}
env:
- name: LOG_LEVEL
value: info
+2 -2
View File
@@ -4,8 +4,8 @@ metadata:
name: music-assistant
namespace: music-assistant
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: music.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: navidrome.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -101,7 +101,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: lidarr.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -22,8 +22,8 @@ metadata:
name: docs
namespace: paperless
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+2 -2
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: docs.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -88,7 +88,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: paperless.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
+3 -3
View File
@@ -182,7 +182,7 @@ spec:
drop: ["ALL"]
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:3.3.3 # {"$imagepolicy": "flux-system:paperless-ngx"}
image: ghcr.io/paperless-ngx/paperless-ngx:3.3.4 # {"$imagepolicy": "flux-system:paperless-ngx"}
ports:
- name: http
containerPort: 8000
@@ -354,8 +354,8 @@ metadata:
name: paperless
namespace: paperless
labels:
# Registered publicly (Cloudflare wildcard -> NetBird proxy); the UDM
# external-dns instance skips it, LAN reaches it via the hairpin NAT.
# Registered publicly (explicit CNAME -> NetBird proxy). No
# dns.renner.uno/internal label: external-dns-unifi writes no UDM record.
dns.renner.uno/public: "true"
netbird.renner.uno/public: "true"
annotations:
+1 -1
View File
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: www.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -23,7 +23,7 @@ spec:
retryInterval: 60
maxRetries: 1
dns:
# cluster services have no own record; they resolve through the *.renner.uno wildcard
# public CNAME to the NetBird proxy, created by external-dns (dns.renner.uno/public)
hostname: crowdsec.renner.uno
resolveType: CNAME
resolveServer: "2606:4700:4700::1111,1.1.1.1"
@@ -67,22 +67,10 @@ spec:
parameters: ":8181"
# Survive the UDM's intermittent DNS outages (issue #393).
#
# The UDM's dnsmasq refuses connections in bursts — 330 "connection
# refused" errors in the last 3000 log lines here, on BOTH 10.2.40.1 and
# fd00::1. That alone would only be a blip, but it combines with a
# wildcard trap: the public renner.uno zone has a *.renner.uno CNAME to
# proxy.renner.uno that matches at EVERY depth, so renner.uno never
# returns NXDOMAIN. Any name that fails locally resolves publicly to the
# WAN proxy instead of failing honestly.
#
# The TTLs make it stick: the correct answer is a dnsmasq lease record
# with TTL 5, the wrong wildcard answer has TTL 300 — 60x more durable.
# One 5-second UDM blip therefore pins a wrong answer for 5 minutes.
#
# `success ... 60` clamps that 5s TTL up to a 60s floor, and
# `serve_stale 1h immediate` keeps answering from cache while the
# upstream is refusing connections, so a blip stops becoming a lookup
# failure that the wildcard can capture.
# The UDM's dnsmasq refuses connections in bursts and answers with
# 5s-TTL lease records; `success ... 60` raises the TTL to a 60s
# floor and `serve_stale 1h immediate` keeps serving through an
# upstream outage.
- name: cache
parameters: "300"
configBlock: |-
@@ -71,7 +71,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-22 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -133,7 +133,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-22 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
@@ -195,7 +195,7 @@ spec:
type: RuntimeDefault
containers:
- name: rotate
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-22 # {"$imagepolicy": "flux-system:secret-rotator"}
image: gitea.renner.uno/gitea_admin/secret-rotator:2026-10-23 # {"$imagepolicy": "flux-system:secret-rotator"}
args: ["ha-token"]
securityContext:
allowPrivilegeEscalation: false
+10 -16
View File
@@ -7,9 +7,9 @@ locals {
# www.renner.uno returns the public answer (proxy.renner.uno) — so this
# block was drift waiting to be re-applied.
#
# The correct end state is exactly this absence: www.renner.uno is labelled
# dns.renner.uno/public, so external-dns-unifi deliberately writes no UDM
# record and LAN clients follow the public path (wildcard -> proxy) and
# The correct end state is exactly this absence: www.renner.uno has no
# dns.renner.uno/internal label, so external-dns-unifi writes no UDM record
# and LAN clients follow the public path (explicit CNAME -> proxy) and
# hairpin back in. Re-adding an A record here would point the house at a
# server that is being decommissioned.
#
@@ -27,21 +27,15 @@ locals {
# AAAA Records
# UniFi auto-creates only the A record for the UDM's own hostname
# (udm -> 192.168.8.1) and forwards AAAA queries upstream, where the
# *.renner.uno wildcard answers with proxy.renner.uno (the public
# reverse proxy). IPv6-first clients — notably Home Assistant's UniFi
# Network integration — then connect to the proxy, which rejects the
# unknown SNI with `tlsv1 alert internal error`, flapping the
# integration into setup_error. Pin the AAAA to the UDM's own
# management ULA (fd00::1, serves the controller cert CN=udm.renner.uno
# and is reachable from the cluster via its v6 default route) so v6
# resolution stays local instead of falling through to the proxy.
# (udm -> 192.168.8.1). Pin the AAAA to the UDM's management ULA
# (fd00::1, serves the controller cert CN=udm.renner.uno) so IPv6-first
# clients such as Home Assistant's UniFi Network integration connect
# over v6.
"udm" = { type = "AAAA", value = "fd00::1" }
# Same failure mode as `udm` above: the UniFi captive-portal hostname
# auto-resolves to the UDM console (captive -> 192.168.8.1) over v4 but
# has no AAAA, so v6 queries fall through to the *.renner.uno wildcard
# (proxy.renner.uno). Pin the AAAA to the same UDM management ULA.
# The captive-portal hostname auto-resolves to the UDM console
# (captive -> 192.168.8.1) over v4 but has no AAAA; pin the AAAA to the
# same management ULA.
"captive" = { type = "AAAA", value = "fd00::1" }
}
}