diff --git a/clusters/base/homepage.yaml b/clusters/base/homepage.yaml new file mode 100644 index 0000000..716b83d --- /dev/null +++ b/clusters/base/homepage.yaml @@ -0,0 +1,42 @@ +--- +# Flux Kustomization: Homepage landing dashboard — reconciles ./platform/homepage. +# +# POST-PLATFORM TIER (dependsOn platform-config). Homepage is a LEAF landing UI at the apex +# home.keithjackson.dev. It depends on the trusted wildcard TLS cert + the shared Gateway (incl. the +# `https-apex` listener) + split-horizon DNS — all brought up by `platform` (controllers) and +# `platform-config` (the wildcard Certificate, cilium L2 VIP, Gateway). It therefore MUST NOT live in +# the gating `platform` tier: a UI whose pod can be unhealthy would keep `platform` not-Ready, and +# while `platform` is not-Ready NOTHING downstream reconciles — the exact incident behind the +# headlamp/oauth2-proxy moves (DECISIONS-LOG: "Headlamp gating stall"). Same lesson, same fix: a leaf +# that can be legitimately unhealthy reconciles in its OWN post-platform Kustomization. +# +# NON-GATING (wait:false): nothing in the graph dependsOn homepage (apps waves gate on +# platform-config, not on this), and a dashboard pod can be long-not-Ready during a cold start. +# wait:false guarantees its health can NEVER gate anything, even if a future resource is (mistakenly) +# pointed dependsOn it. +# +# NO SOPS decryption stanza: this component carries no encrypted Secret (plain manifests + a config +# ConfigMap with no API keys), so — unlike headlamp/oauth2-proxy — clusters/dev does NOT patch it. +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: homepage + namespace: flux-system +spec: + interval: 10m + retryInterval: 2m + timeout: 5m + prune: true + wait: false + dependsOn: + - name: platform-config # wildcard TLS + Gateway (incl. https-apex listener) + split-horizon VIP first + sourceRef: + kind: GitRepository + name: flux-system + path: ./platform/homepage + # Homepage references no ${VARS} today; the substitution is retained for parity with the other + # tiers / future overlay knobs. + postBuild: + substituteFrom: + - kind: ConfigMap + name: cluster-vars diff --git a/clusters/base/kustomization.yaml b/clusters/base/kustomization.yaml index 295dca1..e23664b 100644 --- a/clusters/base/kustomization.yaml +++ b/clusters/base/kustomization.yaml @@ -26,4 +26,8 @@ resources: # sit in the gating `platform` tier, where its unhealth stalled platform-config + oauth2-proxy + # every apps wave/vm on "dependency not ready" (DECISIONS-LOG: "Headlamp gating stall"). - headlamp.yaml # Flux Kustomization -> ./platform/headlamp (dependsOn platform-config) + # Homepage apex landing dashboard — POST-platform tier (dependsOn platform-config, wait:false). + # A leaf UI at home.keithjackson.dev; same non-gating rationale as headlamp/oauth2-proxy (a UI must + # never gate the platform chain). Carries no SOPS secret, so clusters/dev adds no decryption patch. + - homepage.yaml # Flux Kustomization -> ./platform/homepage (dependsOn platform-config) - cluster-vars.yaml # overlay knob surface (base defaults) diff --git a/platform/gateway/gateway.yaml b/platform/gateway/gateway.yaml index 5a4bc80..5e9ba65 100644 --- a/platform/gateway/gateway.yaml +++ b/platform/gateway/gateway.yaml @@ -37,6 +37,37 @@ spec: selector: matchLabels: gateway-access: "shared" + # APEX listeners. The wildcard listeners above match ONLY subdomains: `*.home.keithjackson.dev` + # does NOT intersect the bare apex `home.keithjackson.dev`, so an HTTPRoute for the apex host + # (the Homepage landing dashboard, clusters/base/homepage.yaml) would bind to NO listener and + # never serve. These two listeners give the apex its own hostname. TLS is the SAME wildcard cert: + # `home.keithjackson.dev` is already a SAN on wildcard-certificate.yaml, so `wildcard-home-tls` + # terminates the apex too — no extra Certificate needed. Same gateway-access=shared opt-in. + - name: https-apex + protocol: HTTPS + port: 443 + hostname: "home.keithjackson.dev" + tls: + mode: Terminate + certificateRefs: + - kind: Secret + name: wildcard-home-tls # apex is a SAN on the wildcard cert (wildcard-certificate.yaml) + allowedRoutes: + namespaces: + from: Selector + selector: + matchLabels: + gateway-access: "shared" + - name: http-apex-redirect + protocol: HTTP + port: 80 + hostname: "home.keithjackson.dev" + allowedRoutes: + namespaces: + from: Selector + selector: + matchLabels: + gateway-access: "shared" # TODO(WS-NET, Phase 2): pin the L2-announced VIP / IP pool; add HTTP->HTTPS redirect HTTPRoute; # confirm cross-namespace ReferenceGrant pattern with WS-APPS. Ingress opens NORTH-SOUTH only — # it does not relax east-west default-deny (docs/09). diff --git a/platform/homepage/README.md b/platform/homepage/README.md new file mode 100644 index 0000000..26e6e2f --- /dev/null +++ b/platform/homepage/README.md @@ -0,0 +1,55 @@ +# platform/homepage + +**Homepage** (gethomepage) — the apex landing dashboard at **https://home.keithjackson.dev**, +aggregating links to the cluster's services. + +- **Serves:** a single pane of glass for the homelab (docs/09 apex UI). +- **Tier:** non-gating post-platform Flux Kustomization + ([`clusters/base/homepage.yaml`](../../clusters/base/homepage.yaml), `dependsOn: platform-config`, + `wait: false`) — mirrors `headlamp` / `oauth2-proxy`. A leaf UI must never gate the `platform` + tier (DECISIONS-LOG "Headlamp gating stall"). +- **Namespace:** `homepage` — a PLATFORM namespace (no `app-namespace=true`), so the Kyverno + app-scoped policies (default-deny gen, auth-mode, egress-class) do not apply; we ship an explicit + default-deny + Cilium ingress policy as defense-in-depth. + +## What this component owns + +Plain manifests (not a HelmRelease — the app is one stateless Deployment + a config ConfigMap): + +| File | Purpose | +|---|---| +| `namespace.yaml` | `homepage` ns, `gateway-access=shared` | +| `configmap.yaml` | Homepage config (settings/services/bookmarks/widgets + empty docker/kubernetes + custom.css/js) | +| `deployment.yaml` | `ghcr.io/gethomepage/homepage` (non-root, subPath config mounts, `HOMEPAGE_ALLOWED_HOSTS`) | +| `service.yaml` | ClusterIP 80 -> 3000 | +| `networkpolicy.yaml` | default-deny (DNS-only egress) + Cilium Gateway-only ingress on 3000 | +| `httproute.yaml` | apex ingress on the Gateway `https-apex` listener | + +## Apex nuances (why this isn't just another `*.home` route) + +1. **TLS:** the apex `home.keithjackson.dev` is already a **SAN** on the wildcard Certificate + (`platform/gateway/wildcard-certificate.yaml`) — no new cert. +2. **Gateway listener:** the wildcard listeners' hostname `*.home.keithjackson.dev` does **not** + match the bare apex. This component's route binds a dedicated **`https-apex`** listener added to + the shared Gateway (`platform/gateway/gateway.yaml`); `http-apex-redirect` covers :80. +3. **`HOMEPAGE_ALLOWED_HOSTS`:** Homepage >= 1.0 returns 403 for any Host not in this list — set to + `home.keithjackson.dev` + the pod IP (for kubelet probes). + +## Forward-auth — FOLLOW-UP (not yet enforced) + +The HTTPRoute is annotated `auth-mode: forward-auth` (intended posture: oauth2-proxy -> Keycloak). +**The Gateway ext-authz attachment that actually enforces forward-auth does not exist yet** — it is a +Phase-6 WS-NET TODO (`platform/oauth2-proxy` is the authenticator; there is no `CiliumEnvoyConfig` / +ext-authz wiring anywhere in the repo). Until that mechanism lands, Homepage — like **every** other +`forward-auth` app (jackett, qbittorrent, pihole, radarr, sonarr) — is reachable unauthenticated. +When the Gateway ext-authz pattern is built, attach it to this route (and the others) with +`allowed-groups: platform-admins`. **PM decision flagged** in the deploy report. + +## Deliberately deferred (avoid scope creep) + +- **Service-status widgets** (Grafana/Plex/Sonarr/… API tiles) need per-service API-key **secrets** — + not wired; tiles are simple links. Add as SOPS secrets + `widgets.yaml` later. +- **Kubernetes widget** would need a read-only ServiceAccount + RBAC — skipped (`kubernetes.yaml` + empty, `automountServiceAccountToken: false`). +- **Brand icons** (selfh.st/dashboard-icons) need world:443 egress — we use bundled `mdi-*` icons so + egress stays DNS-only. diff --git a/platform/homepage/configmap.yaml b/platform/homepage/configmap.yaml new file mode 100644 index 0000000..a59ed38 --- /dev/null +++ b/platform/homepage/configmap.yaml @@ -0,0 +1,135 @@ +--- +# Homepage configuration (gethomepage). Each key is mounted read-only into /app/config via a subPath +# (deployment.yaml) — the exact file set the app expects (services/settings/bookmarks/widgets + +# docker/kubernetes + custom.css/js). Plain link tiles only: NO service-widget API keys are wired +# here (that needs per-service secrets — flagged as a follow-up), so nothing sensitive lives in this +# ConfigMap. Icons use the app-BUNDLED Material Design set (`mdi-*`) so no icon CDN is fetched and the +# pod's egress stays DNS-only (networkpolicy.yaml). Brand icons (selfh.st/dashboard-icons) would need +# world:443 egress — deliberately not used. +apiVersion: v1 +kind: ConfigMap +metadata: + name: homepage + namespace: homepage + labels: + app.kubernetes.io/name: homepage +data: + settings.yaml: | + --- + title: Home Network + headerStyle: clean + theme: dark + color: slate + hideVersion: true + layout: + Platform: + style: row + columns: 3 + Media: + style: row + columns: 3 + Network & Infra: + style: row + columns: 3 + + services.yaml: | + --- + - Platform: + - Headlamp: + href: https://headlamp.home.keithjackson.dev + description: Kubernetes dashboard (per-user OIDC RBAC) + icon: mdi-kubernetes + - Grafana: + href: https://grafana.home.keithjackson.dev + description: Metrics, logs & dashboards (fronts Prometheus/Loki/Alertmanager) + icon: mdi-chart-areaspline + - Semaphore: + href: https://semaphore.home.keithjackson.dev + description: Ansible automation & CI + icon: mdi-cog-play + - Gitea: + href: https://gitea.home.keithjackson.dev + description: Git hosting & Actions (GitOps source of truth) + icon: mdi-git + - Keycloak: + href: https://keycloak.home.keithjackson.dev + description: Identity provider (SSO / OIDC) + icon: mdi-account-key + + - Media: + - Plex: + href: https://plex.home.keithjackson.dev + description: Media streaming + icon: mdi-plex + - Overseerr: + href: https://seerr.home.keithjackson.dev + description: Media requests + icon: mdi-play-box-multiple + - Sonarr: + href: https://sonarr.home.keithjackson.dev + description: TV library manager + icon: mdi-television-classic + - Radarr: + href: https://radarr.home.keithjackson.dev + description: Movie library manager + icon: mdi-movie-open + - Jackett: + href: https://jackett.home.keithjackson.dev + description: Indexer proxy + icon: mdi-magnify + - qBittorrent: + href: https://qbittorrent.home.keithjackson.dev + description: Download client (VPN egress) + icon: mdi-download-network + + - Network & Infra: + - Pi-hole: + href: https://pihole.home.keithjackson.dev + description: Network-wide DNS & ad-blocking + icon: mdi-shield-check + - UniFi: + href: https://unifi.home.keithjackson.dev:11443 + description: Network controller (UOS) + icon: mdi-router-network + - Nextcloud: + href: https://nextcloud.home.keithjackson.dev + description: Files, calendar & contacts + icon: mdi-cloud + + bookmarks.yaml: | + --- + - Operations: + - Gitea (external): + - abbr: GT + href: https://gitea.keithjackson.dev + - Auth portal: + - abbr: SSO + href: https://auth.home.keithjackson.dev + - Reference: + - Homepage docs: + - abbr: HP + href: https://gethomepage.dev + + widgets.yaml: | + --- + - datetime: + text_size: xl + format: + dateStyle: long + timeStyle: short + hour12: false + - search: + provider: duckduckgo + target: _blank + + # No Docker integration (Homepage does not reach the Docker socket in-cluster). + docker.yaml: | + --- + + # No Kubernetes integration (no SA / cluster RBAC granted — avoids scope creep; the kubernetes + # widget would need a read-only ServiceAccount. Left empty deliberately). + kubernetes.yaml: | + --- + + custom.css: "" + custom.js: "" diff --git a/platform/homepage/deployment.yaml b/platform/homepage/deployment.yaml new file mode 100644 index 0000000..1486d1d --- /dev/null +++ b/platform/homepage/deployment.yaml @@ -0,0 +1,101 @@ +--- +# Homepage (gethomepage) — the apex landing dashboard at https://home.keithjackson.dev. Plain +# manifests (not a HelmRelease): the whole app is a single stateless Deployment + a config ConfigMap, +# and hand-authoring the ConfigMap gives exact control over the service links without guessing a +# chart's values schema (same plain-manifest style as apps/vms/unifi). Rendered by its OWN non-gating +# post-platform Flux Kustomization (clusters/base/homepage.yaml, dependsOn platform-config, +# wait:false) — a leaf UI must never sit in the gating `platform` tier (DECISIONS-LOG: "Headlamp +# gating stall"). +apiVersion: apps/v1 +kind: Deployment +metadata: + name: homepage + namespace: homepage + labels: + app.kubernetes.io/name: homepage +spec: + replicas: 1 + revisionHistoryLimit: 3 + selector: + matchLabels: + app.kubernetes.io/name: homepage + template: + metadata: + labels: + app.kubernetes.io/name: homepage + annotations: + # Re-roll the pod when the config changes (ConfigMap subPath mounts do NOT hot-reload). + checksum/config: config-v1 + spec: + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: homepage + # Renovate maintains this pin (ghcr.io/gethomepage/homepage). Config lives in /app/config. + image: ghcr.io/gethomepage/homepage:v2.1.2 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 3000 + protocol: TCP + env: + # Referenced by HOMEPAGE_ALLOWED_HOSTS below so the in-pod liveness/readiness probes + # (which hit the pod IP, not the public host) are accepted by Homepage's host guard. + - name: MY_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + # REQUIRED by Homepage >= 1.0 — requests whose Host header is not in this list get 403. + # The public apex host (served via the Gateway) + the pod IP (for kubelet probes). + - name: HOMEPAGE_ALLOWED_HOSTS + value: "home.keithjackson.dev,$(MY_POD_IP):3000" + resources: + requests: + cpu: 25m + memory: 128Mi + limits: + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + # NOTE: readOnlyRootFilesystem intentionally left unset — Homepage (Next.js) writes a + # runtime build/cache under /app/.next at boot. Same deferred-hardening note as headlamp. + livenessProbe: + httpGet: + path: /api/healthcheck + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /api/healthcheck + port: http + initialDelaySeconds: 5 + periodSeconds: 15 + volumeMounts: + # Homepage expects each config file present; mount them read-only by subPath so the rest + # of /app/config stays writable (it writes ./logs there at runtime). + - { name: homepage-config, mountPath: /app/config/settings.yaml, subPath: settings.yaml, readOnly: true } + - { name: homepage-config, mountPath: /app/config/services.yaml, subPath: services.yaml, readOnly: true } + - { name: homepage-config, mountPath: /app/config/bookmarks.yaml, subPath: bookmarks.yaml, readOnly: true } + - { name: homepage-config, mountPath: /app/config/widgets.yaml, subPath: widgets.yaml, readOnly: true } + - { name: homepage-config, mountPath: /app/config/docker.yaml, subPath: docker.yaml, readOnly: true } + - { name: homepage-config, mountPath: /app/config/kubernetes.yaml, subPath: kubernetes.yaml, readOnly: true } + - { name: homepage-config, mountPath: /app/config/custom.css, subPath: custom.css, readOnly: true } + - { name: homepage-config, mountPath: /app/config/custom.js, subPath: custom.js, readOnly: true } + # Writable scratch for Homepage's runtime logs (it writes /app/config/logs). + - { name: logs, mountPath: /app/config/logs } + volumes: + - name: homepage-config + configMap: + name: homepage + - name: logs + emptyDir: {} diff --git a/platform/homepage/httproute.yaml b/platform/homepage/httproute.yaml new file mode 100644 index 0000000..3b64a53 --- /dev/null +++ b/platform/homepage/httproute.yaml @@ -0,0 +1,39 @@ +--- +# North-south ingress for Homepage at the APEX home.keithjackson.dev. Attaches to the shared Gateway +# via the dedicated `https-apex` listener (sectionName) — the wildcard `https` listener's hostname +# `*.home.keithjackson.dev` does NOT match the bare apex, so this route MUST bind the apex listener +# added in platform/gateway/gateway.yaml. TLS is terminated at the Gateway by the wildcard cert (the +# apex is a SAN on it). Same-namespace backend, so no ReferenceGrant; the parentRef is permitted +# because the homepage namespace carries gateway-access=shared. +# +# auth-mode=forward-auth records the INTENDED posture: Homepage is a bare UI with no native OIDC, so +# it should sit behind oauth2-proxy forward-auth at the Gateway (R-IDP-02). NOTE: the Gateway +# ext-authz ATTACHMENT that actually enforces forward-auth is a Phase-6 WS-NET TODO that is NOT yet +# built anywhere in the repo (platform/oauth2-proxy is the authenticator only; no CiliumEnvoyConfig / +# ext-authz exists). Until that mechanism lands, this route — like every other forward-auth app +# (jackett, qbittorrent, pihole, radarr, …) — is reachable without auth. See README.md "Forward-auth". +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: homepage + namespace: homepage + labels: + app.kubernetes.io/name: homepage + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: platform-admins +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https-apex # the apex listener (wildcard `https` won't match the bare apex) + hostnames: + - home.keithjackson.dev + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: homepage + port: 80 diff --git a/platform/homepage/kustomization.yaml b/platform/homepage/kustomization.yaml new file mode 100644 index 0000000..f23275a --- /dev/null +++ b/platform/homepage/kustomization.yaml @@ -0,0 +1,18 @@ +--- +# Homepage — the apex landing dashboard (home.keithjackson.dev). Rendered by its OWN non-gating +# post-platform Flux Kustomization (clusters/base/homepage.yaml, dependsOn platform-config, +# wait:false) — NOT by the gating `platform` tier, where a leaf UI's unhealth stalled the whole chain +# (DECISIONS-LOG "Headlamp gating stall"). Plain manifests (no HelmRelease): a single Deployment + +# a hand-authored config ConfigMap. The HTTPRoute travels WITH the component (the gateway.networking +# CRDs are registered by platform, Ready before this tier's platform-config dependency completes). +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: homepage +resources: + - namespace.yaml + - configmap.yaml + - deployment.yaml + - service.yaml + # default-deny baseline + Cilium gateway-ingress allow (DNS-only egress from the baseline). + - networkpolicy.yaml + - httproute.yaml # apex ingress (binds the Gateway `https-apex` listener) diff --git a/platform/homepage/namespace.yaml b/platform/homepage/namespace.yaml new file mode 100644 index 0000000..390f453 --- /dev/null +++ b/platform/homepage/namespace.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: homepage + labels: + # Expose Homepage at the APEX home.keithjackson.dev via the shared Gateway (docs/09). The apex is + # a SAN on the *.home.keithjackson.dev wildcard cert, and the Gateway carries a dedicated + # `https-apex` listener for it (platform/gateway/gateway.yaml). This label opts the namespace in + # to attach HTTPRoutes to the shared Gateway (httproute.yaml). + gateway-access: "shared" + # NOTE: intentionally NOT homelab.keithjackson.dev/app-namespace=true. Homepage is a PLATFORM + # landing UI (like Grafana in `monitoring` / Headlamp in `headlamp`), so Kyverno does not generate + # the app default-deny baseline here and the app-scoped auth-mode / egress-class policies do not + # apply. We still ship an explicit default-deny NetworkPolicy + a CiliumNetworkPolicy + # (networkpolicy.yaml) as defense-in-depth: Gateway-only ingress, DNS-only egress. diff --git a/platform/homepage/networkpolicy.yaml b/platform/homepage/networkpolicy.yaml new file mode 100644 index 0000000..f253b4a --- /dev/null +++ b/platform/homepage/networkpolicy.yaml @@ -0,0 +1,51 @@ +--- +# Default-deny baseline (R-NET-01): deny all ingress + egress, allow only DNS. `homepage` is a +# PLATFORM namespace so Kyverno does not auto-generate this — we ship it explicitly (defense in +# depth). Same shape as the Kyverno-generated app baseline. Cilium enforces the UNION of this +# NetworkPolicy and the CiliumNetworkPolicy below (allow if ANY policy allows). +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny + namespace: homepage + 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 +--- +# Gateway ingress allow (R-NET-01/02): only the shared Gateway (the Cilium `ingress` entity) may +# reach Homepage, on its container port 3000. Homepage is a pure link dashboard — it makes NO +# outbound calls (bundled `mdi-*` icons, no service-status widgets), so DNS-only egress from the +# baseline above is sufficient and no egress CNP is added (minimal egress). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: homepage-allow-gateway-ingress + namespace: homepage + labels: + app.kubernetes.io/name: homepage +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: homepage + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - port: "3000" + protocol: TCP diff --git a/platform/homepage/service.yaml b/platform/homepage/service.yaml new file mode 100644 index 0000000..2afc4e7 --- /dev/null +++ b/platform/homepage/service.yaml @@ -0,0 +1,19 @@ +--- +# ClusterIP fronting Homepage. The shared Gateway routes the apex host to this Service (httproute.yaml); +# Service port 80 -> container port 3000. +apiVersion: v1 +kind: Service +metadata: + name: homepage + namespace: homepage + labels: + app.kubernetes.io/name: homepage +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: homepage + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP