Compare commits

..

6 Commits

Author SHA1 Message Date
Andrew Stoltz
90599b0413 fix(auth): harden public infra routes 2026-06-04 13:20:16 -05:00
Andrew Stoltz
81a3ddac4c fix(auth): mark OIDC healthz probes anonymous 2026-06-04 11:03:20 -05:00
300f8ad546 fix(monitoring): probe OIDC-safe health routes
Sprint 58 Cx-12. Rebased over OIDC GitOps main; YAML parse and focused bluejay-infra lint tests passed.
2026-06-04 06:45:34 +00:00
fe38c2641f Merge pull request 'fix(auth): deploy distribution root anonymous image' (#38) from codex/s58-distribution-root-anon-gitops into main 2026-06-04 06:20:09 +00:00
Andrew Stoltz
3b40dfb185 fix(auth): deploy distribution root anonymous image 2026-06-04 01:19:16 -05:00
103878671c Merge pull request 'fix(auth): deploy Distribution OIDC image tag' (#37) from codex/s58-oidc-proper into main 2026-06-04 06:05:15 +00:00
20 changed files with 312 additions and 34 deletions

View File

@@ -201,6 +201,8 @@ spec:
metadata: metadata:
labels: labels:
app: andrew-web app: andrew-web
annotations:
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
containers: containers:
- name: nginx - name: nginx
@@ -225,12 +227,18 @@ spec:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 3 initialDelaySeconds: 3
periodSeconds: 5 periodSeconds: 5
volumes: volumes:
@@ -265,7 +273,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`bluejay.dev`) || Host(`www.bluejay.dev`) - match: (Host(`bluejay.dev`) || Host(`www.bluejay.dev`)) && (Method(`GET`) || Method(`HEAD`))
kind: Rule kind: Rule
services: services:
- name: andrew-web - name: andrew-web

View File

@@ -201,6 +201,8 @@ spec:
metadata: metadata:
labels: labels:
app: dustin-web app: dustin-web
annotations:
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
containers: containers:
- name: nginx - name: nginx
@@ -225,12 +227,18 @@ spec:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 3 initialDelaySeconds: 3
periodSeconds: 5 periodSeconds: 5
volumes: volumes:
@@ -265,7 +273,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`timeforta.co`) || Host(`www.timeforta.co`) - match: (Host(`timeforta.co`) || Host(`www.timeforta.co`)) && (Method(`GET`) || Method(`HEAD`))
kind: Rule kind: Rule
services: services:
- name: dustin-web - name: dustin-web

View File

@@ -201,6 +201,8 @@ spec:
metadata: metadata:
labels: labels:
app: erik-web app: erik-web
annotations:
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
containers: containers:
- name: nginx - name: nginx
@@ -225,12 +227,18 @@ spec:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 3 initialDelaySeconds: 3
periodSeconds: 5 periodSeconds: 5
volumes: volumes:
@@ -265,7 +273,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`erckak.dev`) || Host(`www.erckak.dev`) - match: (Host(`erckak.dev`) || Host(`www.erckak.dev`)) && (Method(`GET`) || Method(`HEAD`))
kind: Rule kind: Rule
services: services:
- name: erik-web - name: erik-web

View File

@@ -109,6 +109,7 @@ spec:
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
prometheus.io/port: "8080" prometheus.io/port: "8080"
prometheus.io/path: "/metrics" prometheus.io/path: "/metrics"
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
# Synology NFS export `/volume1/kubernetes` ACL only allows rke2-server # Synology NFS export `/volume1/kubernetes` ACL only allows rke2-server
# (10.0.56.11) right now. Until the ACL is widened in DSM (admin only), # (10.0.56.11) right now. Until the ACL is widened in DSM (admin only),
@@ -126,7 +127,7 @@ spec:
# dotnet.exe publish -c Release -o deploy/app \ # dotnet.exe publish -c Release -o deploy/app \
# src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj # src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj
# podman build -t localhost/fc-distribution:v<tag> -f deploy/Dockerfile.deploy deploy # podman build -t localhost/fc-distribution:v<tag> -f deploy/Dockerfile.deploy deploy
image: localhost/fc-distribution:v20260604-oidc-proper image: localhost/fc-distribution:v20260604-oidc-root-anon
imagePullPolicy: Never imagePullPolicy: Never
ports: ports:
- containerPort: 8080 - containerPort: 8080

View File

@@ -101,6 +101,7 @@ spec:
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
prometheus.io/port: "5320" prometheus.io/port: "5320"
prometheus.io/path: "/metrics/prometheus" prometheus.io/path: "/metrics/prometheus"
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
serviceAccountName: dns-web serviceAccountName: dns-web
securityContext: securityContext:

View File

@@ -203,6 +203,8 @@ spec:
metadata: metadata:
labels: labels:
app: fc-landing app: fc-landing
annotations:
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
containers: containers:
- name: nginx - name: nginx
@@ -227,12 +229,18 @@ spec:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 3 initialDelaySeconds: 3
periodSeconds: 5 periodSeconds: 5
volumes: volumes:
@@ -298,7 +306,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`flowercore.io`) || Host(`www.flowercore.io`) - match: (Host(`flowercore.io`) || Host(`www.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`))
kind: Rule kind: Rule
services: services:
- name: fc-landing - name: fc-landing
@@ -316,7 +324,7 @@ spec:
entryPoints: entryPoints:
- web - web
routes: routes:
- match: Host(`flowercore.io`) || Host(`www.flowercore.io`) - match: (Host(`flowercore.io`) || Host(`www.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`))
kind: Rule kind: Rule
services: services:
- name: fc-landing - name: fc-landing

View File

@@ -131,6 +131,7 @@ spec:
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
prometheus.io/port: "5200" prometheus.io/port: "5200"
prometheus.io/path: "/metrics" prometheus.io/path: "/metrics"
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
nodeSelector: nodeSelector:
kubernetes.io/hostname: rke2-server kubernetes.io/hostname: rke2-server

View File

@@ -201,6 +201,8 @@ spec:
metadata: metadata:
labels: labels:
app: fit-web app: fit-web
annotations:
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
containers: containers:
- name: nginx - name: nginx
@@ -225,12 +227,18 @@ spec:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 3 initialDelaySeconds: 3
periodSeconds: 5 periodSeconds: 5
volumes: volumes:
@@ -265,7 +273,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`flowerinsider.xyz`) || Host(`www.flowerinsider.xyz`) - match: (Host(`flowerinsider.xyz`) || Host(`www.flowerinsider.xyz`)) && (Method(`GET`) || Method(`HEAD`))
kind: Rule kind: Rule
services: services:
- name: fit-web - name: fit-web

View File

@@ -257,6 +257,8 @@ spec:
metadata: metadata:
labels: labels:
app: flowercore-web app: flowercore-web
annotations:
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
containers: containers:
- name: nginx - name: nginx
@@ -281,12 +283,18 @@ spec:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 3 initialDelaySeconds: 3
periodSeconds: 5 periodSeconds: 5
volumes: volumes:

View File

@@ -11,7 +11,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`gitea.flowercore.io`) - match: Host(`gitea.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
kind: Rule kind: Rule
services: services:
- name: gitea-http - name: gitea-http

View File

@@ -93,6 +93,7 @@ spec:
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
prometheus.io/port: "8080" prometheus.io/port: "8080"
prometheus.io/path: "/metrics" prometheus.io/path: "/metrics"
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
@@ -123,9 +124,9 @@ spec:
value: "Production" value: "Production"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT - name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "false" value: "false"
# AuthentiK/OIDC is wired but not enforced until the # AuthentiK/OIDC is enforced. /healthz stays anonymous by contract;
# knowledge-oidc-client Secret is provisioned and # see flowercore.io/healthz-auth-policy above and the Sprint 58
# FlowerCore__Auth__Enabled is flipped to true. # OIDC readiness probe audit.
- name: FlowerCore__Auth__Enabled - name: FlowerCore__Auth__Enabled
value: "true" value: "true"
- name: FlowerCore__Auth__Oidc__Enabled - name: FlowerCore__Auth__Oidc__Enabled

View File

@@ -243,7 +243,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`webmail.flowercore.io`) - match: Host(`webmail.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
kind: Rule kind: Rule
services: services:
- name: mail-webmail - name: mail-webmail

View File

@@ -479,7 +479,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`element.flowercore.io`) - match: Host(`element.flowercore.io`) && (Method(`GET`) || Method(`HEAD`))
kind: Rule kind: Rule
services: services:
- name: element-web - name: element-web
@@ -497,7 +497,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: Host(`matrix.flowercore.io`) - match: Host(`matrix.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
kind: Rule kind: Rule
services: services:
- name: synapse - name: synapse

View File

@@ -481,22 +481,25 @@ data:
- "https://intranet.iamworkin.lan/" - "https://intranet.iamworkin.lan/"
- "https://signage.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200 - "https://signage.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200
- "https://kiosk.iamworkin.lan/" - "https://kiosk.iamworkin.lan/"
- "https://media.iamworkin.lan/healthz" # root auth-gated by OIDC; /healthz anon 200 - "https://media.iamworkin.lan/healthz" # root auth-gated by OIDC; /healthz anonymous 200
- "https://mysql.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200 - "https://mysql.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200
- "https://php.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200 - "https://php.iamworkin.lan/healthz" # root 401 auth-gated 2026-06-01; /healthz anon 200
- "https://zabbix.iamworkin.lan/" - "https://zabbix.iamworkin.lan/"
- "https://desktop.iamworkin.lan/" - "https://desktop.iamworkin.lan/"
- "https://print.iamworkin.lan/" - "https://print.iamworkin.lan/healthz" # root 401 behind API key auth; /healthz anonymous 200
- "https://dns.iamworkin.lan/healthz" # root auth-gated by OIDC; /healthz anon 200 - "https://dns.iamworkin.lan/healthz" # root auth-gated by OIDC; /healthz anonymous 200
- "https://chat.iamworkin.lan/" - "https://chat.iamworkin.lan/healthz" # OIDC staged; keep blackbox off root before enforcement flips
- "https://dist.iamworkin.lan/healthz" # root/admin auth-gated by OIDC; /healthz anon 200 - "https://dist.iamworkin.lan/healthz" # root/admin auth-gated by OIDC; /healthz anonymous 200
- "https://dms.iamworkin.lan/" - "https://dms.iamworkin.lan/healthz" # future OIDC posture; health route is already anonymous/live
- "https://menuboard.iamworkin.lan/" - "https://menuboard.iamworkin.lan/"
- "https://messageboard.iamworkin.lan/" - "https://messageboard.iamworkin.lan/"
- "https://presentations.iamworkin.lan/" - "https://presentations.iamworkin.lan/"
- "https://retail.iamworkin.lan/" - "https://retail.iamworkin.lan/"
- "https://ttsreader.iamworkin.lan/" - "https://ttsreader.iamworkin.lan/"
# Explicit healthcheck paths # Explicit healthcheck paths
- "https://library.iamworkin.lan/health"
- "https://aistation.iamworkin.lan/healthz"
- "https://knowledge.iamworkin.lan/healthz"
- "https://fc-llm-bridge.iamworkin.lan/healthz" - "https://fc-llm-bridge.iamworkin.lan/healthz"
- "https://acme.iamworkin.lan/health" - "https://acme.iamworkin.lan/health"
# NOTE: services intentionally NOT in this probe surface # NOTE: services intentionally NOT in this probe surface
@@ -1020,7 +1023,12 @@ data:
- name: kubernetes-state - name: kubernetes-state
rules: rules:
- alert: KubeContainerRestartingFrequently - alert: KubeContainerRestartingFrequently
expr: increase(kube_pod_container_status_restarts_total[1h]) > 5 # Exclude github-runner: ephemeral runners register, run one job,
# exit cleanly, and restart by design. Also require kube_pod_info so
# deleted rollout pods do not keep firing from retained restart series.
expr: |
increase(kube_pod_container_status_restarts_total{namespace!="github-runner"}[1h]) > 5
and on(namespace, pod) kube_pod_info
for: 15m for: 15m
labels: labels:
severity: warning severity: warning
@@ -1029,7 +1037,12 @@ data:
description: "Container {{ $labels.container }} in pod {{ $labels.namespace }}/{{ $labels.pod }} has restarted {{ $value | printf \"%.0f\" }} times in the last hour. Check 'kubectl describe pod' + last-state termination reason." description: "Container {{ $labels.container }} in pod {{ $labels.namespace }}/{{ $labels.pod }} has restarted {{ $value | printf \"%.0f\" }} times in the last hour. Check 'kubectl describe pod' + last-state termination reason."
- alert: KubeContainerCrashLooping - alert: KubeContainerCrashLooping
expr: increase(kube_pod_container_status_restarts_total[15m]) > 3 # Same github-runner/delete-retention exclusions as the hourly
# restart rule above; real runner failures are covered by the
# dedicated LinuxRunnerOffline/MacMiniRunnerOffline alerts.
expr: |
increase(kube_pod_container_status_restarts_total{namespace!="github-runner"}[15m]) > 3
and on(namespace, pod) kube_pod_info
for: 5m for: 5m
labels: labels:
severity: critical severity: critical
@@ -1057,7 +1070,10 @@ data:
description: "Pod can't pull image. Check the image ref (often a stale tag or unreachable registry) and clean up if it's an orphan." description: "Pod can't pull image. Check the image ref (often a stale tag or unreachable registry) and clean up if it's an orphan."
- alert: KubeDeploymentReplicasMismatch - alert: KubeDeploymentReplicasMismatch
expr: kube_deployment_spec_replicas != kube_deployment_status_replicas_available # github-runner has explicit runner-offline alerts; the generic
# replica-mismatch rule should not page on intentionally ephemeral
# 0/1 runner churn between CI jobs.
expr: kube_deployment_spec_replicas{namespace!="github-runner"} != kube_deployment_status_replicas_available{namespace!="github-runner"}
for: 15m for: 15m
labels: labels:
severity: warning severity: warning

View File

@@ -134,6 +134,8 @@ spec:
metadata: metadata:
labels: labels:
app: pki-web app: pki-web
annotations:
flowercore.io/healthz-auth-policy: "allow-anonymous"
spec: spec:
containers: containers:
- name: nginx - name: nginx
@@ -158,12 +160,18 @@ spec:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 5 initialDelaySeconds: 5
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /healthz path: /healthz
port: 80 port: 80
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 3 initialDelaySeconds: 3
periodSeconds: 5 periodSeconds: 5
volumes: volumes:
@@ -201,6 +209,7 @@ spec:
dnsNames: dnsNames:
- pki.iamworkin.lan - pki.iamworkin.lan
--- ---
# Internal-only route: if a public twin is ever operator-approved, gate it with Host(`<public-host>`) && (Method(`GET`) || Method(`HEAD`)).
# Traefik IngressRoute # Traefik IngressRoute
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute

View File

@@ -207,12 +207,18 @@ spec:
httpGet: httpGet:
path: /health path: /health
port: 5100 port: 5100
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /health path: /health
port: 5100 port: 5100
httpHeaders:
- name: X-Forwarded-Proto
value: https
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 5 periodSeconds: 5
volumes: volumes:
@@ -256,12 +262,12 @@ spec:
- websecure - websecure
routes: routes:
- kind: Rule - kind: Rule
match: Host(`telephony.flowercore.io`) match: Host(`telephony.flowercore.io`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
services: services:
- name: telephony-web - name: telephony-web
port: 5100 port: 5100
- kind: Rule - kind: Rule
match: Host(`telephony.iamwork.in`) match: Host(`telephony.iamwork.in`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
services: services:
- name: telephony-web - name: telephony-web
port: 5100 port: 5100

View File

@@ -20,10 +20,11 @@ metadata:
spec: spec:
basicAuth: basicAuth:
secret: traefik-dashboard-auth secret: traefik-dashboard-auth
--- ---
# Dashboard IngressRoute # Internal-only route: if a public twin is ever operator-approved, gate it with Host(`<public-host>`) && (Method(`GET`) || Method(`HEAD`)).
apiVersion: traefik.io/v1alpha1 # Dashboard IngressRoute
kind: IngressRoute apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata: metadata:
name: traefik-dashboard name: traefik-dashboard
namespace: traefik-system namespace: traefik-system

View File

@@ -66,7 +66,7 @@ spec:
- websecure - websecure
routes: routes:
- kind: Rule - kind: Rule
match: Host(`voice.bluejay.dev`) match: Host(`voice.bluejay.dev`) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))
services: services:
- name: voice-bridge - name: voice-bridge
port: 8766 port: 8766
@@ -84,7 +84,7 @@ spec:
- websecure - websecure
routes: routes:
- kind: Rule - kind: Rule
match: Host(`voice-ws.bluejay.dev`) match: Host(`voice-ws.bluejay.dev`) && (Method(`GET`) || Method(`HEAD`))
services: services:
- name: voice-bridge - name: voice-bridge
port: 8765 port: 8765

View File

@@ -344,6 +344,7 @@ spec:
dnsNames: dnsNames:
- zabbix.iamworkin.lan - zabbix.iamworkin.lan
--- ---
# Internal-only route: if a public twin is ever operator-approved, gate it with Host(`<public-host>`) && (Method(`GET`) || Method(`HEAD`)).
# Traefik IngressRoute # Traefik IngressRoute
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute

View File

@@ -13,8 +13,20 @@ public sealed class FleetManifestLintTests
private static readonly HashSet<string> PublicReadOnlyHosts = new(StringComparer.Ordinal) private static readonly HashSet<string> PublicReadOnlyHosts = new(StringComparer.Ordinal)
{ {
"bluejay.dev",
"brochure.flowercore.io", "brochure.flowercore.io",
"dist.flowercore.io", "dist.flowercore.io",
"element.flowercore.io",
"erckak.dev",
"flowercore.io",
"flowerinsider.xyz",
"timeforta.co",
"voice-ws.bluejay.dev",
"www.bluejay.dev",
"www.erckak.dev",
"www.flowercore.io",
"www.flowerinsider.xyz",
"www.timeforta.co",
}; };
// Public hosts that allow a tightly bounded write surface in addition to // Public hosts that allow a tightly bounded write surface in addition to
@@ -28,10 +40,40 @@ public sealed class FleetManifestLintTests
// same bounded read-write allowlist as the LAN pair. // same bounded read-write allowlist as the LAN pair.
private static readonly HashSet<string> PublicReadWriteAllowlistHosts = new(StringComparer.Ordinal) private static readonly HashSet<string> PublicReadWriteAllowlistHosts = new(StringComparer.Ordinal)
{ {
"chat.flowercore.io",
"gitea.flowercore.io",
"matrix.flowercore.io",
"telephony.flowercore.io",
"telephony.iamwork.in",
"updatecenter.iamworkin.lan", "updatecenter.iamworkin.lan",
"updates.iamworkin.lan", "updates.iamworkin.lan",
"update.flowercore.io", "update.flowercore.io",
"updates.flowercore.io", "updates.flowercore.io",
"voice.bluejay.dev",
"webmail.flowercore.io",
};
private static readonly IReadOnlyDictionary<string, string> InfraHealthzProbeDeployments = new Dictionary<string, string>(StringComparer.Ordinal)
{
["andrew"] = "andrew-web",
["dustin"] = "dustin-web",
["erik"] = "erik-web",
["fc-landing"] = "fc-landing",
["fit"] = "fit-web",
["flowercore"] = "flowercore-web",
["pki-web"] = "pki-web",
};
private static readonly IReadOnlyDictionary<string, string> InfraForwardedProtoProbeDeployments = new Dictionary<string, string>(StringComparer.Ordinal)
{
["andrew"] = "andrew-web",
["dustin"] = "dustin-web",
["erik"] = "erik-web",
["fc-landing"] = "fc-landing",
["fit"] = "fit-web",
["flowercore"] = "flowercore-web",
["pki-web"] = "pki-web",
["telephony"] = "telephony-web",
}; };
private static readonly HashSet<string> ApiKeyProtectedDeployments = new(StringComparer.Ordinal) private static readonly HashSet<string> ApiKeyProtectedDeployments = new(StringComparer.Ordinal)
@@ -131,8 +173,13 @@ public sealed class FleetManifestLintTests
})) }))
.Where(entry => PublicReadOnlyHosts.Any(host => entry.Match.Contains($"Host(`{host}`)", StringComparison.Ordinal))) .Where(entry => PublicReadOnlyHosts.Any(host => entry.Match.Contains($"Host(`{host}`)", StringComparison.Ordinal)))
.Where(entry => !entry.Match.Contains("Method(`GET`)", StringComparison.Ordinal) .Where(entry => !entry.Match.Contains("Method(`GET`)", StringComparison.Ordinal)
|| !entry.Match.Contains("Method(`HEAD`)", StringComparison.Ordinal)) || !entry.Match.Contains("Method(`HEAD`)", StringComparison.Ordinal)
.Select(entry => $"{entry.Document.Descriptor} is missing an explicit GET/HEAD method allowlist.") || entry.Match.Contains("Method(`POST`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`PUT`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`PATCH`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`DELETE`)", StringComparison.Ordinal)
|| entry.Match.Contains("Method(`OPTIONS`)", StringComparison.Ordinal))
.Select(entry => $"{entry.Document.Descriptor} must explicitly allow GET/HEAD only on a public read-only host.")
.ToList(); .ToList();
violations.Should().BeEmpty(); violations.Should().BeEmpty();
@@ -423,6 +470,125 @@ public sealed class FleetManifestLintTests
monitoring.Should().Contain("alert_channel: irc"); monitoring.Should().Contain("alert_channel: irc");
} }
[Fact]
public void Monitoring_GenericKubernetesAlerts_MustExcludeEphemeralGithubRunnerNamespace()
{
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
monitoring.Should().Contain("kube_pod_container_status_restarts_total{namespace!=\"github-runner\"}");
monitoring.Should().Contain("and on(namespace, pod) kube_pod_info");
monitoring.Should().Contain("kube_deployment_spec_replicas{namespace!=\"github-runner\"} != kube_deployment_status_replicas_available{namespace!=\"github-runner\"}");
monitoring.Should().Contain("dedicated LinuxRunnerOffline/MacMiniRunnerOffline alerts");
}
[Fact]
public void Monitoring_BlackboxTargetsForOidcSensitiveServices_MustUseAnonymousHealthRoutesWhenAvailable()
{
var monitoring = File.ReadAllText(Path.Combine(Inventory.BluejayRoot, "apps", "monitoring", "noc-monitoring.yaml"));
monitoring.Should().Contain("https://chat.iamworkin.lan/healthz");
monitoring.Should().Contain("https://dist.iamworkin.lan/healthz");
monitoring.Should().Contain("https://dms.iamworkin.lan/healthz");
monitoring.Should().Contain("https://print.iamworkin.lan/healthz");
monitoring.Should().Contain("https://knowledge.iamworkin.lan/healthz");
monitoring.Should().Contain("https://library.iamworkin.lan/health");
monitoring.Should().Contain("https://aistation.iamworkin.lan/healthz");
monitoring.Should().NotContain("https://print.iamworkin.lan/\"");
}
[Fact]
public void OidcEnforcedDeployments_WithHttpHealthzProbes_MustDeclareAnonymousHealthzContract()
{
var violations = Inventory.Documents
.Where(document => document.Kind == "Deployment")
.SelectMany(document => document.MainContainerMappings()
.Where(container => string.Equals(EnvValue(container, "FlowerCore__Auth__Enabled"), "true", StringComparison.OrdinalIgnoreCase))
.Where(container => string.Equals(EnvValue(container, "FlowerCore__Auth__Oidc__Enabled"), "true", StringComparison.OrdinalIgnoreCase))
.Where(container => ProbeHttpGetPath(container, "readinessProbe") == "/healthz"
|| ProbeHttpGetPath(container, "startupProbe") == "/healthz")
.Where(_ => !string.Equals(
PodAnnotation(document, "flowercore.io/healthz-auth-policy"),
"allow-anonymous",
StringComparison.Ordinal))
.Select(container =>
{
var containerName = ManifestNodeExtensions.Scalar(container, "name") ?? "<unnamed>";
return $"{document.Descriptor} container '{containerName}' enforces OIDC while probing /healthz but lacks flowercore.io/healthz-auth-policy: allow-anonymous.";
}))
.ToList();
violations.Should().BeEmpty();
}
[Fact]
public void AuthSafeInfraHealthzProbes_MustDeclareAnonymousHealthzContract()
{
var violations = InfraHealthzProbeDeployments.SelectMany(expected =>
{
var deployment = AppDocuments(expected.Key)
.Single(document => document.Kind == "Deployment" && document.Name == expected.Value);
var hasHealthzProbe = deployment.MainContainerMappings()
.Any(container => ProbeHttpGetPath(container, "readinessProbe") == "/healthz"
|| ProbeHttpGetPath(container, "startupProbe") == "/healthz"
|| ProbeHttpGetPath(container, "livenessProbe") == "/healthz");
return hasHealthzProbe
&& !string.Equals(PodAnnotation(deployment, "flowercore.io/healthz-auth-policy"), "allow-anonymous", StringComparison.Ordinal)
? new[] { $"{deployment.Descriptor} probes /healthz but lacks flowercore.io/healthz-auth-policy: allow-anonymous." }
: Array.Empty<string>();
}).ToList();
violations.Should().BeEmpty();
}
[Fact]
public void AuthSafeInfraHttpProbes_MustSendForwardedProtoHttpsHeader()
{
var violations = InfraForwardedProtoProbeDeployments.SelectMany(expected =>
{
var deployment = AppDocuments(expected.Key)
.Single(document => document.Kind == "Deployment" && document.Name == expected.Value);
return deployment.MainContainerMappings()
.SelectMany(container => new[] { "startupProbe", "readinessProbe", "livenessProbe" }
.Where(probeKey => ProbeHttpGetPath(container, probeKey) is "/healthz" or "/health")
.Where(probeKey => !string.Equals(ProbeHttpGetHeaderValue(container, probeKey, "X-Forwarded-Proto"), "https", StringComparison.Ordinal))
.Select(probeKey =>
{
var containerName = ManifestNodeExtensions.Scalar(container, "name") ?? "<unnamed>";
return $"{deployment.Descriptor} container '{containerName}' {probeKey} is missing X-Forwarded-Proto=https.";
}));
}).ToList();
violations.Should().BeEmpty();
}
[Fact]
public void Knowledge_OidcEnforcement_MustKeepHealthzAnonymousContractVisibleInManifest()
{
var knowledge = Inventory.Documents
.Single(document => document.Kind == "Deployment" && document.Namespace == "knowledge" && document.Name == "knowledge-web");
var container = knowledge.MainContainerMappings().Should().ContainSingle().Subject;
EnvValue(container, "FlowerCore__Auth__Enabled").Should().Be("true");
EnvValue(container, "FlowerCore__Auth__Oidc__Enabled").Should().Be("true");
ProbeHttpGetPath(container, "readinessProbe").Should().Be("/healthz");
PodAnnotation(knowledge, "flowercore.io/healthz-auth-policy").Should().Be("allow-anonymous");
}
[Fact]
public void Distribution_OidcEnforcement_MustKeepHealthzAnonymousContractVisibleInManifest()
{
var distribution = Inventory.Documents
.Single(document => document.Kind == "Deployment" && document.Namespace == "fc-distribution" && document.Name == "fc-distribution");
var container = distribution.MainContainerMappings().Should().ContainSingle().Subject;
EnvValue(container, "FlowerCore__Auth__Oidc__Enabled").Should().Be("true");
EnvValue(container, "FlowerCore__Auth__Enabled").Should().Be("true");
ProbeHttpGetPath(container, "readinessProbe").Should().Be("/healthz");
PodAnnotation(distribution, "flowercore.io/healthz-auth-policy").Should().Be("allow-anonymous");
}
[Fact] [Fact]
public void StatefulSets_WithVolumeClaimTemplates_MustDeclareFilesystemDefaults() public void StatefulSets_WithVolumeClaimTemplates_MustDeclareFilesystemDefaults()
{ {
@@ -795,7 +961,7 @@ public sealed class FleetManifestLintTests
.Single(document => document.Kind == "Deployment" && document.Name == "fc-distribution"); .Single(document => document.Kind == "Deployment" && document.Name == "fc-distribution");
var distributionContainer = distributionDeployment.MainContainerMappings().Should().ContainSingle().Subject; var distributionContainer = distributionDeployment.MainContainerMappings().Should().ContainSingle().Subject;
ManifestNodeExtensions.Scalar(distributionContainer, "image").Should().Be("localhost/fc-distribution:v20260604-oidc-proper"); ManifestNodeExtensions.Scalar(distributionContainer, "image").Should().Be("localhost/fc-distribution:v20260604-oidc-root-anon");
} }
[Fact] [Fact]
@@ -926,6 +1092,33 @@ public sealed class FleetManifestLintTests
.SingleOrDefault(env => string.Equals(ManifestNodeExtensions.Scalar(env, "name"), name, StringComparison.Ordinal)); .SingleOrDefault(env => string.Equals(ManifestNodeExtensions.Scalar(env, "name"), name, StringComparison.Ordinal));
} }
private static string? PodAnnotation(ManifestDocument document, string name)
{
return document.Scalar("spec", "template", "metadata", "annotations", name);
}
private static string? ProbeHttpGetPath(YamlMappingNode container, string probeKey)
{
return ManifestNodeExtensions.TryGetMapping(container, probeKey, out var probe)
&& ManifestNodeExtensions.TryGetMapping(probe, "httpGet", out var httpGet)
? ManifestNodeExtensions.Scalar(httpGet, "path")
: null;
}
private static string? ProbeHttpGetHeaderValue(YamlMappingNode container, string probeKey, string name)
{
if (!ManifestNodeExtensions.TryGetMapping(container, probeKey, out var probe)
|| !ManifestNodeExtensions.TryGetMapping(probe, "httpGet", out var httpGet))
{
return null;
}
return ManifestNodeExtensions.MappingSequence(httpGet, "httpHeaders")
.Where(header => string.Equals(ManifestNodeExtensions.Scalar(header, "name"), name, StringComparison.Ordinal))
.Select(header => ManifestNodeExtensions.Scalar(header, "value"))
.SingleOrDefault();
}
private static IReadOnlyList<ManifestDocument> FcDeviceManagementDocuments() private static IReadOnlyList<ManifestDocument> FcDeviceManagementDocuments()
{ {
return Inventory.Documents return Inventory.Documents