From b9f727bc979fbbcbe18702474e409e1c59fb1c5f Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Wed, 26 Aug 2026 21:47:03 +1000 Subject: [PATCH] watchstate: expose externally at watchstate.unkin.net (#420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why WatchState currently only has an internal front door (watchstate.k8s.syd1.au.unkin.net, PR #419, merged). This adds a public front door at **watchstate.unkin.net** via the external (DMZ) Traefik so the admin UI is reachable off-cluster, still fully gated by oauth2-proxy/Authentik. The internal gateway and routes are untouched; oauth2-proxy now fronts BOTH hostnames. Stacked base note: the intended base (benvin/watchstate-deploy, PR #419) merged into main before this PR opened, so this targets `main`. ## How - **DNS**: `watchstate-dns-internal` DNSRecord in `apps/base/bind-internal/authoritative/records.yaml` (unkin.net zone, bind-internal) -> `A 198.18.199.0`, the traefik-external VIP. Mirrors the `arrstack-dns-internal` precedent (external front door via bind, not external-dns). Kept in the central bind-internal location, so no AppProject destination widening is needed. - **Gateway**: new `watchstate-external` Gateway (`gatewayClassName: traefik-external`), http + https listeners on hostname `watchstate.unkin.net`. TLS terminated with the Let's Encrypt `*.unkin.net` wildcard secret `wildcard-unkin-net-tls` — so **no cert-manager and no external-dns annotations**. - **Routes**: `watchstate-external-http-redirect` (http->https 301) and `watchstate-external` (https -> `watchstate-oauth2:80`), mirroring the arrproxy/cheeztv external patterns. - **oauth2-proxy dual-host**: `OAUTH2_PROXY_REDIRECT_URL` changed from the pinned cluster callback to the **relative** `/oauth2/callback`. With `OAUTH2_PROXY_REVERSE_PROXY=true` (already set), oauth2-proxy derives scheme+host per request from `X-Forwarded-Proto/Host` (verified in oauth2-proxy v7.15.3 `getOAuthRedirectURI`: host-less redirectURL falls through to request-derived), so the callback works on both `watchstate.unkin.net` and `watchstate.k8s.syd1.au.unkin.net`. `COOKIE_DOMAINS` and `WHITELIST_DOMAINS` now list both hosts (one cookie per host; a single parent-domain cookie can't cleanly span unkin.net vs k8s.syd1.au.unkin.net). - Dropped the no-op `argocd.argoproj.io/sync-wave: "0"` annotation on the `default` VaultAuth. New resources intentionally carry no sync-wave annotations. ## Validation - `kustomize build --enable-helm` on `apps/overlays/au-syd1/watchstate` and `.../bind-internal`: OK. - kubeconform (repo CI config, k8s 1.33.7): watchstate overlay 15/15 valid (2 Gateways, 4 HTTPRoutes); bind-internal overlay 67/67 valid (incl. `watchstate-dns-internal`). ## Dependencies - **Reflector allow-list (argocd-apps PR #418)**: reflects `wildcard-unkin-net-tls` into the `watchstate` namespace. This PR references that secret as if present. - **Authentik (terraform-authentik, separate PR)**: register both `https://watchstate.unkin.net/oauth2/callback` and `https://watchstate.k8s.syd1.au.unkin.net/oauth2/callback` as redirect URIs on the watchstate provider. Reviewed-on: https://git.unkin.net/unkin/argocd-apps/pulls/420 Co-authored-by: unkin-agent Co-committed-by: unkin-agent --- .../bind-internal/authoritative/records.yaml | 15 ++++++ apps/base/watchstate/gateway-external.yaml | 40 +++++++++++++++ apps/base/watchstate/httproute-external.yaml | 49 +++++++++++++++++++ apps/base/watchstate/kustomization.yaml | 2 + .../watchstate/oauth2-proxy-configmap.yaml | 15 ++++-- apps/base/watchstate/vaultauth.yaml | 2 - 6 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 apps/base/watchstate/gateway-external.yaml create mode 100644 apps/base/watchstate/httproute-external.yaml diff --git a/apps/base/bind-internal/authoritative/records.yaml b/apps/base/bind-internal/authoritative/records.yaml index 0f7fac8..e9be28c 100644 --- a/apps/base/bind-internal/authoritative/records.yaml +++ b/apps/base/bind-internal/authoritative/records.yaml @@ -147,3 +147,18 @@ spec: # traefik-internal gateway VIP; the cheeztv Gateway serves cheeztv.unkin.net # there. - 198.18.200.4 +--- +apiVersion: bind.unkin.net/v1alpha1 +kind: DNSRecord +metadata: + name: watchstate-dns-internal + namespace: bind-internal +spec: + zoneRef: unkin-net + name: watchstate + type: A + ttl: 600 + values: + # traefik-EXTERNAL (DMZ) gateway VIP; the watchstate-external Gateway serves + # the watchstate.unkin.net front door (oauth2-proxy) there. + - 198.18.199.0 diff --git a/apps/base/watchstate/gateway-external.yaml b/apps/base/watchstate/gateway-external.yaml new file mode 100644 index 0000000..7e87de3 --- /dev/null +++ b/apps/base/watchstate/gateway-external.yaml @@ -0,0 +1,40 @@ +--- +# External (DMZ) front for the WatchState admin UI, served on watchstate.unkin.net +# via the external Traefik (LB VIP 198.18.199.0). TLS is terminated with the real +# Let's Encrypt *.unkin.net wildcard (Certificate wildcard-unkin-net in the +# cert-manager namespace, reflected into this namespace as wildcard-unkin-net-tls +# by the emberstack reflector), so there is no cert-manager annotation here. The +# apex watchstate.unkin.net A record lives in the bind-operator unkin.net zone +# (bind-internal/authoritative), NOT external-dns, so no external-dns annotation +# either. The internal watchstate Gateway (watchstate.k8s.syd1.au.unkin.net) is +# untouched; oauth2-proxy fronts both hostnames. +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + labels: + traefik.io/instance: external + name: watchstate-external + namespace: watchstate +spec: + gatewayClassName: traefik-external + listeners: + - name: http + port: 80 + protocol: HTTP + hostname: watchstate.unkin.net + allowedRoutes: + namespaces: + from: Same + - name: https + port: 443 + protocol: HTTPS + hostname: watchstate.unkin.net + allowedRoutes: + namespaces: + from: Same + tls: + mode: Terminate + certificateRefs: + - group: "" + kind: Secret + name: wildcard-unkin-net-tls diff --git a/apps/base/watchstate/httproute-external.yaml b/apps/base/watchstate/httproute-external.yaml new file mode 100644 index 0000000..70b0a8a --- /dev/null +++ b/apps/base/watchstate/httproute-external.yaml @@ -0,0 +1,49 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: watchstate-external-http-redirect + namespace: watchstate +spec: + hostnames: + - watchstate.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: watchstate-external + sectionName: http + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 + matches: + - path: + type: PathPrefix + value: / +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: watchstate-external + namespace: watchstate +spec: + hostnames: + - watchstate.unkin.net + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: watchstate-external + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: watchstate-oauth2 + port: 80 + weight: 1 + matches: + - path: + type: PathPrefix + value: / diff --git a/apps/base/watchstate/kustomization.yaml b/apps/base/watchstate/kustomization.yaml index d0036e8..bc5cde3 100644 --- a/apps/base/watchstate/kustomization.yaml +++ b/apps/base/watchstate/kustomization.yaml @@ -13,3 +13,5 @@ resources: - service.yaml - gateway.yaml - httproute.yaml + - gateway-external.yaml + - httproute-external.yaml diff --git a/apps/base/watchstate/oauth2-proxy-configmap.yaml b/apps/base/watchstate/oauth2-proxy-configmap.yaml index ee514dd..d11d8ae 100644 --- a/apps/base/watchstate/oauth2-proxy-configmap.yaml +++ b/apps/base/watchstate/oauth2-proxy-configmap.yaml @@ -13,7 +13,12 @@ data: OAUTH2_PROXY_HTTP_ADDRESS: "0.0.0.0:4180" OAUTH2_PROXY_PROVIDER: "oidc" OAUTH2_PROXY_OIDC_ISSUER_URL: "https://identity.k8s.syd1.au.unkin.net/application/o/watchstate/" - OAUTH2_PROXY_REDIRECT_URL: "https://watchstate.k8s.syd1.au.unkin.net/oauth2/callback" + # Relative (host-less) redirect URL: with reverse-proxy mode on, oauth2-proxy + # derives scheme+host per request from X-Forwarded-Proto/Host, so the same + # deployment serves BOTH the external watchstate.unkin.net and internal + # watchstate.k8s.syd1.au.unkin.net callbacks. Both absolute callback URIs are + # registered on the Authentik provider (terraform-authentik, separate PR). + OAUTH2_PROXY_REDIRECT_URL: "/oauth2/callback" OAUTH2_PROXY_UPSTREAMS: "http://watchstate.watchstate.svc.cluster.local:8080/" OAUTH2_PROXY_SCOPE: "openid email profile ak_groups" OAUTH2_PROXY_OIDC_GROUPS_CLAIM: "ak_groups" @@ -23,8 +28,12 @@ data: # enforced Authentik-side, so accepting the unverified email is safe. OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL: "true" OAUTH2_PROXY_COOKIE_SECURE: "true" - OAUTH2_PROXY_COOKIE_DOMAINS: "watchstate.k8s.syd1.au.unkin.net" - OAUTH2_PROXY_WHITELIST_DOMAINS: "watchstate.k8s.syd1.au.unkin.net" + # One cookie domain per host (a single parent-domain cookie can't span + # unkin.net and k8s.syd1.au.unkin.net cleanly); oauth2-proxy picks the domain + # matching the request host. Whitelist both so post-auth `rd` redirects to + # either front door are honoured. + OAUTH2_PROXY_COOKIE_DOMAINS: "watchstate.unkin.net,watchstate.k8s.syd1.au.unkin.net" + OAUTH2_PROXY_WHITELIST_DOMAINS: "watchstate.unkin.net,watchstate.k8s.syd1.au.unkin.net" OAUTH2_PROXY_REVERSE_PROXY: "true" OAUTH2_PROXY_PROVIDER_CA_FILES: "/etc/ssl/combined/ca-certificates.crt" OAUTH2_PROXY_CODE_CHALLENGE_METHOD: "S256" diff --git a/apps/base/watchstate/vaultauth.yaml b/apps/base/watchstate/vaultauth.yaml index ee398eb..2e24e3e 100644 --- a/apps/base/watchstate/vaultauth.yaml +++ b/apps/base/watchstate/vaultauth.yaml @@ -4,8 +4,6 @@ kind: VaultAuth metadata: name: default namespace: watchstate - annotations: - argocd.argoproj.io/sync-wave: "0" spec: allowedNamespaces: - watchstate