Files
home-network-k3s-impl/apps/wave-1/semaphore.yaml
jakman85andClaude Opus 5 4080065aa7 feat(dns): exposure: internal can no longer publish a public record
docs/09 R-DNS-03 and schemas/renderer-contract.md both said an `internal`
service renders no public DNS record. Nothing enforced either sentence.
ExternalDNS's `gateway-httproute` source admitted EVERY HTTPRoute on the shared
Gateway, so six `exposure: internal` services — jackett, pihole, qbittorrent,
radarr, semaphore, sonarr — published `<name>.home.keithjackson.dev` into the
public authoritative Cloudflare zone answering with the Gateway VIP
192.168.2.20. That published the internal subnet, the Gateway VIP and a full
service inventory to anyone who could guess a label, and made each of those
names a DNS-rebind primitive — which is why the SOHO router strips RFC1918
answers and LAN clients need per-client dnsmasq overrides to reach their own
services.

THE MECHANISM, established against the DEPLOYED version rather than from
memory. The live prod Deployment runs
registry.k8s.io/external-dns/external-dns:v0.21.0. In v0.21.0,
source/annotations/processors.go IsControllerMismatch() skips a source object
whose `external-dns.alpha.kubernetes.io/controller` annotation is present and
!= its own `dns-controller`, and source/gateway.go applies that check inside
Endpoints() BEFORE any hostname is resolved; source/service.go applies the
identical check, covering the second configured source. catalog/render.py now
emits that annotation on every HTTPRoute it renders for an `internal` service,
derived from `exposure` alone — no per-service list — so a service that omits
`exposure` inherits the internal default and is unpublishable too. Verified
LIVE on prod on a throwaway hostname with no existing record at risk: the
annotated route published nothing, and adding the annotation to a route that
already had a record produced action=DELETE for both its A record and its `a-`
TXT registry entry one ~60s reconcile later, moving nothing else in the zone.

WHY PER-RESOURCE AND NOT A CLUSTER-WIDE OPT-IN FILTER. `labelFilter` is the
stronger default posture and v0.21.0 supports it for both configured sources,
but nine publishing objects here are hand-authored platform/apps manifests with
no `exposure` field, seven of which hold live records (auth, crafty, crafty-mc,
grafana, headlamp, the apex, unifi). An opt-in filter deletes all seven at
once, and whether each should be publicly resolvable is an operator decision
per name. Per-resource exclusion has exactly the blast radius of the defect;
the seven are enumerated in a shrink-only register so the unadjudicated set
cannot grow silently, and the filter stays available as hardening afterwards.

`policy: sync`, `txtOwnerId` and `domainFilters` are UNTOUCHED. Per-resource
exclusion only ever removes an object from this instance's view, so nothing
here makes prod capable of deleting a record it does not own.

WHAT THIS DELETES, and it is not reversible by a revert alone: 12 records — the
A record and the `a-`-prefixed TXT registry entry for each of jackett, pihole,
qbittorrent, radarr, semaphore and sonarr. The zone has no
`*.home.keithjackson.dev` wildcard and `*.keithjackson.dev` does not match two
labels, so each becomes a public NXDOMAIN with no fallback. The complete
pre-state is captured at the authority and committed as a recovery baseline
(.standup-dns/dns-20260914-pre-externaldns-scope.txt), with the restore
procedure for both halves of a record.

MERGE ORDER IS NOT OPTIONAL: feat/pihole-lan-dns-authority must land and be
verified first. Removing these records while the LAN still depends on them,
with the router stripping RFC1918 and no internal authority yet, breaks name
resolution outright.

The five `exposure: public` services are LEFT UNCHANGED and still answer with
the Gateway VIP, so they remain publicly unreachable. That is two open operator
decisions, both written up in platform/externaldns/helmrelease.yaml: what
address they should answer with (136.51.115.252 is the legacy non-k3s stack
serving a placeholder cert, not established as correct for this cluster), and
how to separate them from the non-public names that share the Gateway —
because in v0.21.0 the target annotation is read only from the GATEWAY and a
ListenerSet, never from an HTTPRoute, so a per-service override is not
expressible while they all attach to `shared`. No knob is added for it yet: one
that cannot take effect invites someone to set it and expect one.

Guard: conformance/tests/l0-static/internal-services-publish-no-public-dns, a
whole-repo-scoped L0 static check (no ci/l0.sh stage — the
whole-repo-invariants-wired meta-test forbids one). It asserts both directions
(present on internal, absent on public), the `service`-source half, that the
renderer DERIVES the annotation from `exposure` and cannot emit external-dns's
own claiming value, proves the shipped renderer end-to-end on
internal/public/omitted-field fixtures, and ratchets the non-catalog register.
Proven to fail on the pre-fix tree (9 of 53 assertions, naming all six
services) and pass after (56/56).

Schema 1.6.0 -> 1.7.0: no field changes, a mapping change to `exposure`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 00:43:12 -05:00

269 lines
7.1 KiB
YAML

---
# Namespace (trust zone; default-deny + policies apply)
apiVersion: v1
kind: Namespace
metadata:
name: semaphore
labels:
homelab.keithjackson.dev/app-namespace: 'true'
gateway-access: shared
annotations:
homelab.keithjackson.dev/backup-policy: daily-offcluster
---
# Default-deny baseline (R-NET-01): deny all, allow only DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: semaphore
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# PVC semaphore-config (R-STATE)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: semaphore-config
namespace: semaphore
labels:
app.kubernetes.io/name: semaphore
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 1Gi
---
# Deployment (R-PLAT-02)
apiVersion: apps/v1
kind: Deployment
metadata:
name: semaphore
namespace: semaphore
labels:
app.kubernetes.io/name: semaphore
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: semaphore
template:
metadata:
labels:
app.kubernetes.io/name: semaphore
homelab.keithjackson.dev/egress-class: internet
homelab.keithjackson.dev/auth-mode: oidc
annotations:
homelab.keithjackson.dev/auth-mode: oidc
homelab.keithjackson.dev/auth-groups: platform-admins
spec:
containers:
- name: semaphore
image: semaphoreui/semaphore:v2.19.10
ports:
- containerPort: 3000
env:
- name: SEMAPHORE_DB_DIALECT
value: postgres
- name: SEMAPHORE_DB_HOST
value: semaphore-db-rw.semaphore-db.svc.cluster.local
- name: SEMAPHORE_DB_PORT
value: '5432'
- name: SEMAPHORE_DB
value: semaphore
- name: SEMAPHORE_DB_USER
value: semaphore
- name: SEMAPHORE_DB_PASS
valueFrom:
secretKeyRef:
name: semaphore-bootstrap
key: db-password
- name: SEMAPHORE_ADMIN
valueFrom:
secretKeyRef:
name: semaphore-bootstrap
key: admin-username
- name: SEMAPHORE_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: semaphore-bootstrap
key: admin-password
- name: SEMAPHORE_ADMIN_NAME
value: Semaphore Admin
- name: SEMAPHORE_ADMIN_EMAIL
value: admin@${BASE_DOMAIN}
- name: SEMAPHORE_ACCESS_KEY_ENCRYPTION
valueFrom:
secretKeyRef:
name: semaphore-bootstrap
key: access-key-encryption
- name: SEMAPHORE_PORT
value: :3000
- name: SEMAPHORE_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: semaphore-bootstrap
key: oidc-client-secret
- name: SEMAPHORE_OIDC_PROVIDERS
value: '{"keycloak":{"display_name":"Keycloak","provider_url":"https://keycloak.${BASE_DOMAIN}/realms/homelab","client_id":"semaphore","client_secret":"$(SEMAPHORE_OIDC_CLIENT_SECRET)","redirect_url":"https://semaphore.${BASE_DOMAIN}/api/auth/oidc/keycloak/redirect","scopes":["openid","profile","email"],"username_claim":"preferred_username","email_claim":"email","name_claim":"name","order":1}}'
volumeMounts:
- name: config
mountPath: /etc/semaphore
securityContext:
runAsUser: 1001
runAsGroup: 0
runAsNonRoot: true
fsGroup: 1001
volumes:
- name: config
persistentVolumeClaim:
claimName: semaphore-config
---
# Service
apiVersion: v1
kind: Service
metadata:
name: semaphore
namespace: semaphore
labels:
app.kubernetes.io/name: semaphore
spec:
selector:
app.kubernetes.io/name: semaphore
ports:
- name: port-3000
port: 3000
targetPort: 3000
---
# CiliumNetworkPolicy egress allow (R-NET-02): semaphore-allow-semaphore-db-5432
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: semaphore-allow-semaphore-db-5432
namespace: semaphore
labels:
app.kubernetes.io/name: semaphore
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: semaphore
egress:
- toEndpoints:
- matchLabels:
app.kubernetes.io/name: semaphore-db
k8s:io.kubernetes.pod.namespace: semaphore-db
toPorts:
- ports:
- port: '5432'
protocol: TCP
---
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): semaphore-allow-gateway-ingress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: semaphore-allow-gateway-ingress
namespace: semaphore
labels:
app.kubernetes.io/name: semaphore
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: semaphore
ingress:
- fromEntities:
- ingress
toPorts:
- ports:
- port: '3000'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): semaphore-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: semaphore-allow-internet-egress
namespace: semaphore
labels:
app.kubernetes.io/name: semaphore
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: semaphore
egress:
- toEntities:
- world
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: keycloak
app: keycloak
---
# HTTPRoute semaphore-3000 (R-TLS/R-DNS)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: semaphore-3000
namespace: semaphore
labels:
app.kubernetes.io/name: semaphore
annotations:
homelab.keithjackson.dev/auth-mode: oidc
homelab.keithjackson.dev/auth-groups: platform-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
namespace: gateway
sectionName: https
hostnames:
- semaphore.${BASE_DOMAIN}
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: semaphore
port: 3000
---
# PrometheusRule SLI alerts (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: semaphore-slis
namespace: semaphore
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: semaphore
spec:
groups:
- name: semaphore.slis
rules:
- alert: semaphore-down
expr: kube_deployment_status_replicas_available{namespace="semaphore", deployment="semaphore"} == 0 or absent(kube_deployment_status_replicas_available{namespace="semaphore", deployment="semaphore"})
for: 5m
labels:
severity: critical
service: semaphore
annotations:
summary: semaphore is down
description: 'semaphore: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available).'