Compare commits

...

66 Commits

Author SHA1 Message Date
Robot
5ce4f0d1e7 deploy(gx10): add DeviceManagement enrollment CA runtime 2026-06-19 06:45:09 -05:00
Andrew Stoltz
4c369cc7ec deploy(kiosk): bump GX10 web image for KI admin 2026-06-19 05:15:43 -05:00
Robot
299ce5aeed deploy(gx10): accept DER agent client cert headers 2026-06-19 01:58:12 -05:00
Robot
57a1afe159 deploy(gx10): bump DeviceManagement enrollment fix 2026-06-19 01:21:47 -05:00
Robot
0d71a789c2 deploy(gx10): add DeviceManagement agent mTLS route 2026-06-19 00:51:01 -05:00
Robot
14d89ba49d deploy(gx10): restore DeviceManagement agent heartbeat auth 2026-06-19 00:22:31 -05:00
Robot
0eda4362ce deploy(gx10): restore DeviceManagement agent cert auth 2026-06-19 00:05:00 -05:00
Andrew Stoltz
6f12ace02d deploy(knowledge): SEC-3 Search/Editions authorize + rebuild_index gate -> v20260619-sec3-6370c95
Removes [AllowAnonymous] bypass on Search/Editions + role-gates rebuild_index (PR #14, 6370c95). Image built+imported (RKE2 socket). Fail-open while auth off (inert until SEC-1); image now carries the hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:58:30 -05:00
Andrew Stoltz
0c03e53df9 deploy(chat): SEC-3 /api/memory + MCP write-tool auth -> v20260619-sec3-5a8859b
Closes the live anon /api/memory GET leak (PR #25, 5a8859b). Image built+imported (RKE2 socket). 0 anon consumers verified; UI reads via DI. Fail-closed 401, scheme reg'd unconditionally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:53:42 -05:00
Robot
62a3e75ddc deploy(gx10): roll DeviceManagement REST auth hardening 2026-06-18 23:53:18 -05:00
Andrew Stoltz
4bbd157c8f deploy(php): enable generated route WAF 2026-06-18 23:47:04 -05:00
Andrew Stoltz
1969285e4f deploy(gateway): SEC-3 /api/gateway auth -> v20260619-sec3-429e6cf
Closes the live anon /api/gateway/* REST bypass (PR #2, 429e6cf). Image built+imported to GX10 containerd. No consumers of the REST group; agent-zero uses /mcp (keyed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:44:25 -05:00
Andrew Stoltz
68a5f1ac5d deploy(php): allow manager DELETE through WAF 2026-06-18 20:37:47 -05:00
Andrew Stoltz
f0b122bac7 deploy(php): bump HM-4 Drupal ready image 2026-06-18 20:33:18 -05:00
Andrew Stoltz
c9538eeeef deploy(php): bump HM-4 probe fix image 2026-06-18 20:13:49 -05:00
Andrew Stoltz
c968e1c4d9 deploy(gx10): roll php web scoped templates 2026-06-18 19:11:14 -05:00
Robot
bc39da26a1 deploy(gx10): roll DeviceManagement auth challenge image 2026-06-18 19:09:22 -05:00
Robot
984e3423db deploy(gx10): roll DeviceManagement auth401 common image 2026-06-18 19:00:27 -05:00
Andrew Stoltz
5d0baa0fdd deploy(gx10): roll php web site-id recovery 2026-06-18 18:56:52 -05:00
Robot
f594d82c65 deploy(gx10): bump DeviceManagement auth status image 2026-06-18 18:43:06 -05:00
Andrew Stoltz
0b7d0fa476 deploy(gx10): roll php web tenant header fix 2026-06-18 18:30:25 -05:00
Andrew Stoltz
500b2484ab deploy(gx10): bump DeviceManagement web readiness image 2026-06-18 18:23:17 -05:00
Andrew Stoltz
c0a0341cef fix(gx10): route php operator to in-cluster manager 2026-06-18 18:16:42 -05:00
Robot
adafbb41f7 secure gx10 device management writes 2026-06-18 18:15:14 -05:00
Andrew Stoltz
09dce583bb deploy(gx10): roll mysql web tenant namespace fix 2026-06-18 18:05:12 -05:00
Andrew Stoltz
6d0464ec17 fix(gx10): add default tenant namespace 2026-06-18 17:40:38 -05:00
Andrew Stoltz
3b96a6272a deploy(gx10): restart php web for autodns config 2026-06-18 17:35:47 -05:00
Andrew Stoltz
061a0d61a8 fix(gx10): point php autodns at gx10 vip 2026-06-18 17:34:07 -05:00
Andrew Stoltz
ae6dfe9144 deploy: bump GX10 PHP and MySQL bypass proof images 2026-06-18 17:22:49 -05:00
Robot
28f9ac2ef9 platform: use current MetalLB VIP annotations 2026-06-18 16:44:49 -05:00
Andrew Stoltz
a7ba47e307 platform: dedicate GX10 Gitea SSH VIP 2026-06-18 16:40:50 -05:00
Andrew Stoltz
2e8cabcd63 platform: keep GX10 shared VIP traffic policy aligned 2026-06-18 16:30:24 -05:00
Andrew Stoltz
3948350ac2 platform: align GX10 Traefik source policy with live chart 2026-06-18 16:26:47 -05:00
Andrew Stoltz
ac153248c2 platform: preserve GX10 Traefik client source IP 2026-06-18 16:25:46 -05:00
Andrew Stoltz
9cef99739a security: add tenant allowlist and WAF canary proof 2026-06-18 16:21:08 -05:00
Robot
bd050c3d9b deploy(devicemgmt): roll command result hotfix 2026-06-18 15:02:50 -05:00
Robot
a41b22bca4 deploy(devicemgmt): roll APK artifact endpoint image 2026-06-18 14:39:48 -05:00
Andrew Stoltz
38590d3d5a deploy(knowledge): roll qwen3 canary profile image 2026-06-18 14:21:40 -05:00
Andrew Stoltz
27815cefca deploy(knowledge): roll catalog filter image 2026-06-18 14:12:04 -05:00
Andrew Stoltz
6e0d33b5b9 deploy(tenant): add bluejay.dev edge controls 2026-06-18 12:56:41 -05:00
Andrew Stoltz
b015c8a8e1 deploy(updater): roll feed signed manifest image 2026-06-18 12:42:42 -05:00
Andrew Stoltz
d51e55c78d deploy(updater): roll corrected GX10 containment image 2026-06-18 11:26:01 -05:00
Robot
f78e6747b4 deploy(apple-mdm): route scep to noc1 ca
Adds the GX10 /scep route to the noc1 Apple MDM SCEP CA without exposing NanoHUB APIs.
2026-06-18 11:23:00 -05:00
Andrew Stoltz
e543018bdc deploy(updater): recover GX10 image after packaging failure 2026-06-18 11:20:11 -05:00
Andrew Stoltz
d0c9717d90 deploy(updater): roll GX10 containment image 2026-06-18 11:08:12 -05:00
Andrew Stoltz
2c1aa3f0c8 deploy(updater): contain public UpdateCenter on GX10 2026-06-18 10:55:50 -05:00
Robot
aba9d7c995 deploy(gx10): pin DeviceManagement MDM-N8 image 2026-06-18 09:45:14 -05:00
Robot
a56e98422f deploy(gx10): wire Apple MDM runtime secret keys 2026-06-18 08:41:44 -05:00
Robot
27600b8b99 deploy(gx10): roll DeviceManagement InstallProfile payloads 2026-06-18 07:55:48 -05:00
Robot
9929a91812 deploy(gx10): roll DeviceManagement MDM policy payloads 2026-06-18 07:03:58 -05:00
Robot
5af4d9077a deploy(gx10): roll DeviceManagement readiness status 2026-06-18 05:55:52 -05:00
Robot
efa0434b9b deploy(gx10): roll DeviceManagement signed profiles 2026-06-18 05:38:01 -05:00
Robot
ad709e2317 deploy(gx10): configure DeviceManagement Apple MDM trust anchor 2026-06-18 04:58:06 -05:00
Robot
f636b5092c deploy(gx10): roll DeviceManagement MDM profile payloads 2026-06-18 04:56:05 -05:00
Robot
82d9d66f62 deploy(gx10): roll DeviceManagement app catalog endpoint 2026-06-18 01:14:54 -05:00
Robot
8b1f8df3dd deploy(gx10): add DeviceManagement enrollment profile endpoint 2026-06-18 00:22:03 -05:00
Andrew Stoltz
65af283aea deploy(updater): roll public exposure fix-forward image 2026-06-17 23:58:54 -05:00
Andrew Stoltz
b7d34da3d6 deploy(updater): gate public UpdateCenter host 2026-06-17 23:47:18 -05:00
Robot
63fde0a593 deploy(gx10): enable DeviceManagement NanoHUB bridge 2026-06-17 23:45:55 -05:00
Robot
764e4a8f49 deploy(gx10): use complete DeviceManagement NanoHUB image 2026-06-17 23:43:38 -05:00
Robot
3bdb9eee81 deploy(gx10): bump DeviceManagement web for NanoHUB bridge 2026-06-17 23:38:25 -05:00
Robot
83db2bbe6b deploy(gx10): add NanoHUB Apple MDM workload 2026-06-17 22:45:10 -05:00
Andrew Stoltz
c32327cdee deploy(devicemgmt): roll responsive enrollment page on GX10 2026-06-17 21:45:06 -05:00
Andrew Stoltz
818463562e deploy(devicemgmt): roll enrollment admin polish on GX10 2026-06-17 21:26:41 -05:00
Robot
f821c0e661 fc-devicemgmt: pin adopted GX10 prune hotpatch image 2026-06-17 20:51:58 -05:00
Robot
6c519bfd6a fc-devicemgmt: pin GX10 prune hotpatch image 2026-06-17 20:30:54 -05:00
39 changed files with 1417 additions and 185 deletions

View File

@@ -101,7 +101,7 @@ curl -sk -X DELETE https://dns.iamworkin.lan/api/v1/servers/<serverId>/zones/iam
- **StatefulSet PVC drift**: `volumeClaimTemplates` needs explicit `volumeMode: Filesystem` or ArgoCD SSA self-heals forever. See memory `feedback_argocd_statefulset_pvc_drift.md`. - **StatefulSet PVC drift**: `volumeClaimTemplates` needs explicit `volumeMode: Filesystem` or ArgoCD SSA self-heals forever. See memory `feedback_argocd_statefulset_pvc_drift.md`.
- **IngressRoute namespace split**: this RKE2 Traefik install does not allow cross-namespace service refs. Keep the `IngressRoute`, backend `Service`, and TLS secret in the same namespace; if one host is shared across namespaces, duplicate the `Certificate` and move the route next to the destination service. - **IngressRoute namespace split**: this RKE2 Traefik install does not allow cross-namespace service refs. Keep the `IngressRoute`, backend `Service`, and TLS secret in the same namespace; if one host is shared across namespaces, duplicate the `Certificate` and move the route next to the destination service.
- **Public read-only hosts**: if a public host fronts a service that also exposes admin writes internally, add a Traefik route match like `Host(...) && (Method(GET) || Method(HEAD))` on the public edge instead of trusting the app to reject unsafe methods. - **Public read-only hosts**: if a public host fronts a service that also exposes admin writes internally, add a Traefik route match like `Host(...) && (Method(GET) || Method(HEAD))` on the public edge instead of trusting the app to reject unsafe methods.
- **Public read-write allowlist hosts**: if a public host accepts a tightly bounded write surface (e.g. bootstrap-JWT POST), pin the allowlist as `(Method(GET) || Method(HEAD) || Method(POST) || Method(OPTIONS))`. PUT/PATCH/DELETE must still 404 at the route. Track A's `updatecenter.iamworkin.lan` / `updates.iamworkin.lan` are the canonical example. The lint test enforces this invariant. - **Public read-write allowlist hosts**: if a public host accepts a tightly bounded write surface (e.g. bootstrap-JWT POST), pin the allowlist as `(Method(GET) || Method(HEAD) || Method(POST) || Method(OPTIONS))`. PUT/PATCH/DELETE must still 404 at the route. Internal UpdateCenter hosts (`updatecenter.iamworkin.lan` / `updates.iamworkin.lan`) are the canonical example. Public UpdateCenter delivery hosts (`update.flowercore.io` / `updates.flowercore.io`) stay GET/HEAD-only and share-link gated until an explicit operator decision changes that posture.
- **Traefik VIP netpols**: when a `NetworkPolicy` allows `10.0.56.200`, also allow the post-DNAT backend ports (`8443` for TLS plus `8080` or `8000` for HTTP) or Calico will drop the rewritten flow. - **Traefik VIP netpols**: when a `NetworkPolicy` allows `10.0.56.200`, also allow the post-DNAT backend ports (`8443` for TLS plus `8080` or `8000` for HTTP) or Calico will drop the rewritten flow.
- **Auth-safe probes**: services behind API-key or global auth middleware should prefer `tcpSocket` probes unless `/health` is explicitly exempted before the middleware runs. - **Auth-safe probes**: services behind API-key or global auth middleware should prefer `tcpSocket` probes unless `/health` is explicitly exempted before the middleware runs.
- **ArgoCD must use internal Gitea URL**: `http://gitea-clusterip.gitea.svc.cluster.local:3000/bluejay/bluejay-infra.git`, not the external HTTPS URL (step-ca cert isn't trusted by ArgoCD). The `ApplicationSet` and any hand-created `Application` must both use the internal URL. - **ArgoCD must use internal Gitea URL**: `http://gitea-clusterip.gitea.svc.cluster.local:3000/bluejay/bluejay-infra.git`, not the external HTTPS URL (step-ca cert isn't trusted by ArgoCD). The `ApplicationSet` and any hand-created `Application` must both use the internal URL.

View File

@@ -0,0 +1,61 @@
# FlowerCore Apple MDM on GX10
This directory deploys the NanoHUB `v0.2.0` substrate for Apple MDM protocol
traffic at `https://mdm.iamworkin.lan`.
## Runtime
- Namespace: `fc-apple-mdm`
- Image: `localhost/fc-apple-mdm-nanohub:v0.2.0-20260617`
- Upstream digest: `ghcr.io/micromdm/nanohub:latest@sha256:e36a50db2dc3d2bf736645e58712f622c04b05b28487390981905ef4d0be5fbd`
- Persistent state: `fc-apple-mdm-data` on `local-path`, mounted at `/var/lib/nanohub`
- File backend DSN: `/var/lib/nanohub/db`
- Required secret: `Secret/fc-apple-mdm-runtime`, key `NANOHUB_API_KEY`
- Optional later bridge secret: `NANOHUB_WEBHOOK_URL`
- Required CA mount: `ConfigMap/fc-apple-mdm-root-ca`, key `root_ca.crt`
- SCEP backend: noc1 systemd service `step-ca-apple-mdm-scep`, forwarded through
selectorless `Service/fc-apple-mdm-scep` and `EndpointSlice/fc-apple-mdm-scep-noc1`
to `10.0.56.10:9080`
NanoHUB API authentication is HTTP Basic with username `nanohub` and password
from `NANOHUB_API_KEY`.
## Public Surface
The Traefik route intentionally exposes only:
- `/version`
- `/mdm`
- `/checkin`
- `/scep`
NanoHUB APIs under `/api/v1/*` stay cluster-internal for MDM-N1. The
DeviceManagement bridge can use the ClusterIP service directly once its NanoHUB
client lane lands.
SCEP is backed by the dedicated Apple-MDM-specific RSA step-ca hierarchy on
noc1, not by the IAmWorkin ACME CA. The live profile URL is:
```text
https://mdm.iamworkin.lan/scep/apple-mdm-scep
```
Do not point `APPLE_MDM_SCEP_URL` at a placeholder URL or at the ECDSA
IAmWorkin ACME CA; Smallstep SCEP requires an RSA intermediate/decrypter path.
## Deployment Notes
1. Create or refresh the runtime Kubernetes Secret from the 1Password item
`FlowerCore Apple MDM Runtime` before sync. GX10 does not yet depend on the
1Password operator for this workload.
2. Import `localhost/fc-apple-mdm-nanohub:v0.2.0-20260617` into GX10 containerd
before ArgoCD syncs. The deployment uses `imagePullPolicy: Never`.
3. Ensure `mdm.iamworkin.lan` resolves to the GX10 Traefik VIP `10.0.57.202`
before cert-manager requests `Certificate/fc-apple-mdm-tls`.
4. Prove `https://mdm.iamworkin.lan/version` after ArgoCD converges.
5. Prove SCEP CA publication with
`curl -sk -o /dev/null -w '%{http_code} %{size_download}\n' 'https://mdm.iamworkin.lan/scep/apple-mdm-scep?operation=GetCACert'`.
This lane does not create an APNs MDM push certificate, enrollment profile,
managed Wi-Fi payload, managed app install, or supervised iPad enrollment. Those
remain MDM-N2 through MDM-N8.

View File

@@ -0,0 +1,322 @@
# FlowerCore Apple MDM NanoHUB workload for the GX10 cluster.
# Secret values are copied into Kubernetes Secrets out of band until the
# 1Password operator exists on GX10; never commit secret data here.
---
apiVersion: v1
kind: Namespace
metadata:
name: fc-apple-mdm
labels:
app.kubernetes.io/part-of: flowercore
---
apiVersion: v1
kind: ConfigMap
metadata:
name: fc-apple-mdm-root-ca
namespace: fc-apple-mdm
data:
root_ca.crt: |
-----BEGIN CERTIFICATE-----
MIIBxDCCAWqgAwIBAgIRAPY357G6ow6zMAL5+4bS2kkwCgYIKoZIzj0EAwIwQDEa
MBgGA1UEChMRSUFtV29ya2luIEFDTUUgQ0ExIjAgBgNVBAMTGUlBbVdvcmtpbiBB
Q01FIENBIFJvb3QgQ0EwHhcNMjYwMzA4MTgwNzExWhcNMzYwMzA1MTgwNzExWjBA
MRowGAYDVQQKExFJQW1Xb3JraW4gQUNNRSBDQTEiMCAGA1UEAxMZSUFtV29ya2lu
IEFDTUUgQ0EgUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ2n04X1
JZo5Zdq/i1Idv8+fqwZyAzBh7whbqj0SWsJL8UWRabCMqYCs7+dXO0xRSzqkwFDL
x+vooOai8RgRNhajRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/
AgEBMB0GA1UdDgQWBBRnuPPQR6iM/H6vOluiU3Sygayz8jAKBggqhkjOPQQDAgNI
ADBFAiEArQK9dYPGmAZsdYnjziuFVVE5NKZUcceYvGfGC+tLXUsCIAudF2zJrCRq
3mK50ZZET/fwTkJwiEF4824mjP8p1CKM
-----END CERTIFICATE-----
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: fc-apple-mdm-data
namespace: fc-apple-mdm
labels:
app: fc-apple-mdm
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/part-of: flowercore
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 2Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fc-apple-mdm
namespace: fc-apple-mdm
labels:
app: fc-apple-mdm
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
app.kubernetes.io/part-of: flowercore
spec:
replicas: 1
revisionHistoryLimit: 3
strategy:
type: Recreate
selector:
matchLabels:
app: fc-apple-mdm
template:
metadata:
labels:
app: fc-apple-mdm
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/component: mdm
app.kubernetes.io/part-of: flowercore
annotations:
fc.flowercore.io/healthz-anon: "true"
fc.flowercore.io/probe-path: "/version"
flowercore.io/audit-trace-id: "apple-mdm-nanohub-runtime-trace"
flowercore.io/root-ca-sha256: "a9120c88fa3ec735d790aa4cfeb61ac2946730338969015bebaccc08fe10535e"
prometheus.io/scrape: "false"
spec:
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
fsGroup: 1654
fsGroupChangePolicy: OnRootMismatch
containers:
- name: nanohub
image: localhost/fc-apple-mdm-nanohub:v0.2.0-20260617
imagePullPolicy: Never
ports:
- name: http
containerPort: 9004
protocol: TCP
env:
- name: HOME
value: "/var/lib/nanohub"
- name: NANOHUB_LISTEN
value: ":9004"
- name: NANOHUB_STORAGE
value: "file"
- name: NANOHUB_STORAGE_DSN
value: "/var/lib/nanohub/db"
- name: NANOHUB_CHECKIN
value: "true"
- name: NANOHUB_CA
value: "/etc/nanohub/ca/root_ca.crt"
- name: NANOHUB_API_KEY
valueFrom:
secretKeyRef:
name: fc-apple-mdm-runtime
key: NANOHUB_API_KEY
- name: NANOHUB_WEBHOOK_URL
valueFrom:
secretKeyRef:
name: fc-apple-mdm-runtime
key: NANOHUB_WEBHOOK_URL
optional: true
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
startupProbe:
httpGet:
path: /version
port: 9004
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 30
readinessProbe:
httpGet:
path: /version
port: 9004
periodSeconds: 10
failureThreshold: 3
livenessProbe:
tcpSocket:
port: 9004
initialDelaySeconds: 30
periodSeconds: 30
failureThreshold: 3
securityContext:
runAsNonRoot: true
runAsUser: 1654
runAsGroup: 1654
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
volumeMounts:
- name: data
mountPath: /var/lib/nanohub
- name: tmp
mountPath: /tmp
- name: root-ca
mountPath: /etc/nanohub/ca
readOnly: true
volumes:
- name: data
persistentVolumeClaim:
claimName: fc-apple-mdm-data
- name: tmp
emptyDir: {}
- name: root-ca
configMap:
name: fc-apple-mdm-root-ca
items:
- key: root_ca.crt
path: root_ca.crt
---
apiVersion: v1
kind: Service
metadata:
name: fc-apple-mdm
namespace: fc-apple-mdm
labels:
app: fc-apple-mdm
app.kubernetes.io/name: fc-apple-mdm
app.kubernetes.io/part-of: flowercore
spec:
type: ClusterIP
selector:
app: fc-apple-mdm
ports:
- name: http
port: 80
targetPort: 9004
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: fc-apple-mdm-scep
namespace: fc-apple-mdm
labels:
app: fc-apple-mdm-scep
app.kubernetes.io/name: fc-apple-mdm-scep
app.kubernetes.io/part-of: flowercore
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: 9080
protocol: TCP
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: fc-apple-mdm-scep-noc1
namespace: fc-apple-mdm
labels:
kubernetes.io/service-name: fc-apple-mdm-scep
app.kubernetes.io/name: fc-apple-mdm-scep
app.kubernetes.io/part-of: flowercore
addressType: IPv4
endpoints:
- addresses:
- 10.0.56.10
conditions:
ready: true
ports:
- name: http
port: 9080
protocol: TCP
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: fc-apple-mdm-tls
namespace: fc-apple-mdm
annotations:
flowercore.io/dns-preflight: "mdm.iamworkin.lan must resolve to 10.0.57.202 before ACME sync"
spec:
secretName: fc-apple-mdm-tls
issuerRef:
name: step-ca-acme
kind: ClusterIssuer
dnsNames:
- mdm.iamworkin.lan
duration: 720h
renewBefore: 240h
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: fc-apple-mdm
namespace: fc-apple-mdm
spec:
entryPoints:
- websecure
routes:
- match: Host(`mdm.iamworkin.lan`) && PathPrefix(`/scep`)
kind: Rule
services:
- name: fc-apple-mdm-scep
port: 80
- match: Host(`mdm.iamworkin.lan`) && (PathPrefix(`/mdm`) || PathPrefix(`/checkin`) || PathPrefix(`/version`))
kind: Rule
services:
- name: fc-apple-mdm
port: 80
tls:
secretName: fc-apple-mdm-tls
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: fc-apple-mdm-netpol
namespace: fc-apple-mdm
spec:
podSelector:
matchLabels:
app: fc-apple-mdm
policyTypes:
- Ingress
- Egress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik-system
ports:
- port: 9004
protocol: TCP
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-devicemgmt
ports:
- port: 9004
protocol: TCP
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
- to:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- port: 443
protocol: TCP
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fc-devicemgmt
ports:
- port: 80
protocol: TCP
- port: 8080
protocol: TCP

View File

@@ -0,0 +1,6 @@
# ArgoCD discovers apps-gx10/* directories on the GX10 GitOps branch.
# This kustomization is for local previews and single-app validation.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- fc-apple-mdm.yaml

View File

@@ -83,7 +83,7 @@
} }
} }
], ],
"image": "localhost/fc-chat-web:v20260617-chatfix-54fd549", "image": "localhost/fc-chat-web:v20260619-sec3-5a8859b",
"imagePullPolicy": "Never", "imagePullPolicy": "Never",
"livenessProbe": { "livenessProbe": {
"failureThreshold": 3, "failureThreshold": 3,
@@ -98,22 +98,22 @@
"timeoutSeconds": 5 "timeoutSeconds": 5
}, },
"name": "chat-web", "name": "chat-web",
"ports": [ "ports": [
{ {
"containerPort": 8080, "containerPort": 8080,
"name": "http", "name": "http",
"protocol": "TCP" "protocol": "TCP"
} }
], ],
"securityContext": { "securityContext": {
"allowPrivilegeEscalation": false, "allowPrivilegeEscalation": false,
"capabilities": { "capabilities": {
"drop": [ "drop": [
"ALL" "ALL"
] ]
}, },
"readOnlyRootFilesystem": true "readOnlyRootFilesystem": true
}, },
"readinessProbe": { "readinessProbe": {
"failureThreshold": 6, "failureThreshold": 6,
"httpGet": { "httpGet": {
@@ -138,49 +138,49 @@
}, },
"terminationMessagePath": "/dev/termination-log", "terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File", "terminationMessagePolicy": "File",
"volumeMounts": [ "volumeMounts": [
{ {
"mountPath": "/data", "mountPath": "/data",
"name": "data" "name": "data"
}, },
{ {
"mountPath": "/tmp", "mountPath": "/tmp",
"name": "temp" "name": "temp"
}, },
{ {
"mountPath": "/app/logs", "mountPath": "/app/logs",
"name": "logs" "name": "logs"
} }
] ]
} }
], ],
"dnsPolicy": "ClusterFirst", "dnsPolicy": "ClusterFirst",
"restartPolicy": "Always", "restartPolicy": "Always",
"schedulerName": "default-scheduler", "schedulerName": "default-scheduler",
"securityContext": { "securityContext": {
"fsGroup": 1654, "fsGroup": 1654,
"fsGroupChangePolicy": "OnRootMismatch", "fsGroupChangePolicy": "OnRootMismatch",
"runAsGroup": 1654, "runAsGroup": 1654,
"runAsNonRoot": true, "runAsNonRoot": true,
"runAsUser": 1654 "runAsUser": 1654
}, },
"terminationGracePeriodSeconds": 30, "terminationGracePeriodSeconds": 30,
"volumes": [ "volumes": [
{ {
"name": "data", "name": "data",
"persistentVolumeClaim": { "persistentVolumeClaim": {
"claimName": "chat-web-data" "claimName": "chat-web-data"
} }
}, },
{ {
"emptyDir": {}, "emptyDir": {},
"name": "temp" "name": "temp"
}, },
{ {
"emptyDir": {}, "emptyDir": {},
"name": "logs" "name": "logs"
} }
] ]
} }
} }
} }

View File

@@ -0,0 +1,69 @@
# FlowerCore DeviceManagement on GX10
This adopted GX10 app hosts `FlowerCore.DeviceManagement.Web` at
`https://devices.iamworkin.lan`. Agent-only REST/SignalR callbacks can use
`https://devices-agent.iamworkin.lan`, which is a separate Traefik router that
requires a TLS client certificate and forwards the presented certificate to the
app. Traefik v3.6 currently forwards raw base64 DER in
`X-Forwarded-Tls-Client-Cert`; the app also accepts URL-escaped PEM for
compatibility with older/alternate Traefik shapes.
## Apple MDM Runtime Contract
Apple MDM is enabled in NanoHUB mode, but enrollment remains unavailable until
the runtime secret contains real Apple-side material. Do not use placeholder
values to clear readiness checks.
`Secret/fc-devicemgmt-runtime` supports these Apple MDM keys:
| Key | Purpose |
| --- | --- |
| `DEVICE_MANAGEMENT_OPERATOR_API_KEY` | Required operator API key for authenticated REST/MCP write operations, including Android command queueing. |
| `DEVICE_MANAGEMENT_ADMIN_API_KEY` | Required admin API key for privileged DeviceManagement operations. |
| `DEVICE_MANAGEMENT_AGENT_API_KEY` | Required scoped agent credential for REST agent callbacks when TLS terminates before Kestrel; maps to `Auth:AgentApiKey` and `FlowerCore:Auth:AgentApiKey`. |
| `DEVICE_MANAGEMENT_ENROLLMENT_CA_CERTIFICATE_PEM` | Optional persistent enrollment CA certificate PEM; maps to `FlowerCore:DeviceManagement:EnrollmentCertificateAuthorityCertificatePem`. Required before ingress can verify agent client-cert chains. |
| `DEVICE_MANAGEMENT_ENROLLMENT_CA_PRIVATE_KEY_PEM` | Optional private key PEM matching the persistent enrollment CA certificate; maps to `FlowerCore:DeviceManagement:EnrollmentCertificateAuthorityPrivateKeyPem`. |
| `NANOHUB_API_KEY` | NanoHUB API password for HTTP Basic user `nanohub`. |
| `APPLE_MDM_APNS_TOPIC` | MDM APNs topic returned after uploading the Apple MDM push certificate to NanoHUB/NanoMDM. |
| `APPLE_MDM_SCEP_URL` | Live SCEP URL included in the enrollment profile. |
| `APPLE_MDM_SCEP_CHALLENGE` | SCEP challenge shared with the SCEP provisioner. |
| `APPLE_MDM_PROFILE_SIGNING_CERTIFICATE_PEM` | PEM certificate used to CMS-sign `.mobileconfig` profiles. |
| `APPLE_MDM_PROFILE_SIGNING_PRIVATE_KEY_PEM` | PEM private key matching the profile-signing certificate. |
| `APPLE_MDM_REQUIRE_MANAGED_WIFI_PAYLOAD` | Set to `true` only when Wi-Fi payload delivery should gate enrollment readiness. |
| `APPLE_MDM_MANAGED_WIFI_SSID` | Managed Wi-Fi SSID for the iPad profile. |
| `APPLE_MDM_MANAGED_WIFI_PASSWORD` | Managed Wi-Fi password when the network is not open. |
Non-secret profile constants stay in GitOps: NanoHUB base URL, MDM server URL,
check-in URL, organization/display names, the HTTPS trust anchor certificate,
managed Wi-Fi encryption type, auto-join, and MAC-randomization disablement.
DeviceManagement auth is enabled on GX10. The deployment maps
`DEVICE_MANAGEMENT_OPERATOR_API_KEY` to both `Auth__ApiKey` and
`FlowerCore__Auth__ApiKey`; the unprefixed key keeps the MCP API key post-config
path aligned with REST auth. Agent heartbeat, inventory, command poll, app-catalog,
and command-result callbacks use the agent-specific authorization boundary: the
server validates a direct device client certificate when Kestrel receives one,
validates Traefik-forwarded client certificates only on
`devices-agent.iamworkin.lan`, and also accepts only the scoped
`DEVICE_MANAGEMENT_AGENT_API_KEY` via `Authorization: Bearer` or
`X-Agent-Api-Key` as the fallback path. Operator write endpoints must use
`X-Api-Key`.
The agent-only Traefik route currently uses `RequireAnyClientCert`; the
application remains the authorization boundary by matching the forwarded client
certificate thumbprint to the enrolled device record. Once
`DEVICE_MANAGEMENT_ENROLLMENT_CA_CERTIFICATE_PEM` and
`DEVICE_MANAGEMENT_ENROLLMENT_CA_PRIVATE_KEY_PEM` are present and newly enrolled
agents prove they chain to that CA, create the matching Traefik CA secret and
switch this TLSOption to `RequireAndVerifyClientCert`.
## Readiness Check
After changing the runtime secret and letting the pod roll, verify:
```bash
curl -sk https://devices.iamworkin.lan/api/v1/apple-mdm/enrollment-profile/status
```
Configurator enrollment must wait until this status reports `available=true`
and an empty `missingRequirements` array.

View File

@@ -0,0 +1,18 @@
{
"apiVersion": "cert-manager.io/v1",
"kind": "Certificate",
"metadata": {
"name": "fc-devicemgmt-agent-tls",
"namespace": "fc-devicemgmt"
},
"spec": {
"dnsNames": [
"devices-agent.iamworkin.lan"
],
"issuerRef": {
"kind": "ClusterIssuer",
"name": "step-ca-acme"
},
"secretName": "fc-devicemgmt-agent-tls"
}
}

View File

@@ -88,21 +88,260 @@
"name": "FlowerCore__Database__ConnectionStrings__Sqlite", "name": "FlowerCore__Database__ConnectionStrings__Sqlite",
"value": "Data Source=/data/devicemgmt.db" "value": "Data Source=/data/devicemgmt.db"
}, },
{ {
"name": "FlowerCore__Database__Password", "name": "FlowerCore__Database__Password",
"valueFrom": { "valueFrom": {
"secretKeyRef": { "secretKeyRef": {
"key": "DB-Password", "key": "DB-Password",
"name": "fc-devicemgmt-runtime" "name": "fc-devicemgmt-runtime"
} }
} }
}, },
{ {
"name": "FlowerCore__EventBus__Redis__Configuration", "name": "FlowerCore__Auth__Enabled",
"value": "redis.fc-redis.svc:6379" "value": "true"
} },
], {
"image": "localhost/fc-devicemgmt-web:v20260617-an13-b9c79c4", "name": "Auth__ApiKey",
"valueFrom": {
"secretKeyRef": {
"key": "DEVICE_MANAGEMENT_OPERATOR_API_KEY",
"name": "fc-devicemgmt-runtime"
}
}
},
{
"name": "FlowerCore__Auth__ApiKey",
"valueFrom": {
"secretKeyRef": {
"key": "DEVICE_MANAGEMENT_OPERATOR_API_KEY",
"name": "fc-devicemgmt-runtime"
}
}
},
{
"name": "Auth__AdminApiKey",
"valueFrom": {
"secretKeyRef": {
"key": "DEVICE_MANAGEMENT_ADMIN_API_KEY",
"name": "fc-devicemgmt-runtime"
}
}
},
{
"name": "FlowerCore__Auth__AdminApiKey",
"valueFrom": {
"secretKeyRef": {
"key": "DEVICE_MANAGEMENT_ADMIN_API_KEY",
"name": "fc-devicemgmt-runtime"
}
}
},
{
"name": "Auth__AgentApiKey",
"valueFrom": {
"secretKeyRef": {
"key": "DEVICE_MANAGEMENT_AGENT_API_KEY",
"name": "fc-devicemgmt-runtime"
}
}
},
{
"name": "FlowerCore__Auth__AgentApiKey",
"valueFrom": {
"secretKeyRef": {
"key": "DEVICE_MANAGEMENT_AGENT_API_KEY",
"name": "fc-devicemgmt-runtime"
}
}
},
{
"name": "FlowerCore__DeviceManagement__EnrollmentCertificateAuthorityCertificatePem",
"valueFrom": {
"secretKeyRef": {
"key": "DEVICE_MANAGEMENT_ENROLLMENT_CA_CERTIFICATE_PEM",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__EnrollmentCertificateAuthorityPrivateKeyPem",
"valueFrom": {
"secretKeyRef": {
"key": "DEVICE_MANAGEMENT_ENROLLMENT_CA_PRIVATE_KEY_PEM",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AgentMtls__ForwardedCertificateHosts__0",
"value": "devices-agent.iamworkin.lan"
},
{
"name": "FlowerCore__DeviceManagement__AgentMtls__ForwardedCertificateHeader",
"value": "X-Forwarded-Tls-Client-Cert"
},
{
"name": "FlowerCore__EventBus__Redis__Configuration",
"value": "redis.fc-redis.svc:6379"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__Enabled",
"value": "true"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__GatewayMode",
"value": "nanohub"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__NanoHubBaseUrl",
"value": "http://fc-apple-mdm.fc-apple-mdm.svc"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__NanoHubApiUserName",
"value": "nanohub"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__NanoHubNanoMdmApiPath",
"value": "/api/v1/nanomdm/"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__EnrollmentProfileDownloadUrl",
"value": "https://devices.iamworkin.lan/api/v1/apple-mdm/enrollment-profile.mobileconfig"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__MdmServerUrl",
"value": "https://mdm.iamworkin.lan/mdm"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__MdmCheckInUrl",
"value": "https://mdm.iamworkin.lan/checkin"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__Organization",
"value": "FlowerCore"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__EnrollmentDisplayName",
"value": "FlowerCore Apple MDM"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ScepName",
"value": "FlowerCore Apple MDM Device Identity"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__TrustAnchorDisplayName",
"value": "IAmWorkin ACME CA Root CA"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__TrustAnchorCertificatePem",
"value": "-----BEGIN CERTIFICATE-----\nMIIBxDCCAWqgAwIBAgIRAPY357G6ow6zMAL5+4bS2kkwCgYIKoZIzj0EAwIwQDEa\nMBgGA1UEChMRSUFtV29ya2luIEFDTUUgQ0ExIjAgBgNVBAMTGUlBbVdvcmtpbiBB\nQ01FIENBIFJvb3QgQ0EwHhcNMjYwMzA4MTgwNzExWhcNMzYwMzA1MTgwNzExWjBA\nMRowGAYDVQQKExFJQW1Xb3JraW4gQUNNRSBDQTEiMCAGA1UEAxMZSUFtV29ya2lu\nIEFDTUUgQ0EgUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ2n04X1\nJZo5Zdq/i1Idv8+fqwZyAzBh7whbqj0SWsJL8UWRabCMqYCs7+dXO0xRSzqkwFDL\nx+vooOai8RgRNhajRTBDMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/\nAgEBMB0GA1UdDgQWBBRnuPPQR6iM/H6vOluiU3Sygayz8jAKBggqhkjOPQQDAgNI\nADBFAiEArQK9dYPGmAZsdYnjziuFVVE5NKZUcceYvGfGC+tLXUsCIAudF2zJrCRq\n3mK50ZZET/fwTkJwiEF4824mjP8p1CKM\n-----END CERTIFICATE-----"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__NanoHubApiKey",
"valueFrom": {
"secretKeyRef": {
"key": "NANOHUB_API_KEY",
"name": "fc-devicemgmt-runtime"
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ApnsTopic",
"valueFrom": {
"secretKeyRef": {
"key": "APPLE_MDM_APNS_TOPIC",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ScepUrl",
"valueFrom": {
"secretKeyRef": {
"key": "APPLE_MDM_SCEP_URL",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ScepChallenge",
"valueFrom": {
"secretKeyRef": {
"key": "APPLE_MDM_SCEP_CHALLENGE",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ProfileSigningCertificatePem",
"valueFrom": {
"secretKeyRef": {
"key": "APPLE_MDM_PROFILE_SIGNING_CERTIFICATE_PEM",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ProfileSigningPrivateKeyPem",
"valueFrom": {
"secretKeyRef": {
"key": "APPLE_MDM_PROFILE_SIGNING_PRIVATE_KEY_PEM",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__RequireManagedWifiPayload",
"valueFrom": {
"secretKeyRef": {
"key": "APPLE_MDM_REQUIRE_MANAGED_WIFI_PAYLOAD",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ManagedWifiSsid",
"valueFrom": {
"secretKeyRef": {
"key": "APPLE_MDM_MANAGED_WIFI_SSID",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ManagedWifiPassword",
"valueFrom": {
"secretKeyRef": {
"key": "APPLE_MDM_MANAGED_WIFI_PASSWORD",
"name": "fc-devicemgmt-runtime",
"optional": true
}
}
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ManagedWifiEncryptionType",
"value": "WPA2"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ManagedWifiAutoJoin",
"value": "true"
},
{
"name": "FlowerCore__DeviceManagement__AppleMdm__ManagedWifiDisableAssociationMacRandomization",
"value": "true"
}
],
"image": "localhost/fc-devicemgmt-web:v20260619-enrollca-c54623d",
"imagePullPolicy": "Never", "imagePullPolicy": "Never",
"livenessProbe": { "livenessProbe": {
"failureThreshold": 3, "failureThreshold": 3,

View File

@@ -0,0 +1,38 @@
{
"apiVersion": "traefik.io/v1alpha1",
"kind": "IngressRoute",
"metadata": {
"name": "devicemgmt-agent-mtls",
"namespace": "fc-devicemgmt"
},
"spec": {
"entryPoints": [
"websecure"
],
"routes": [
{
"kind": "Rule",
"match": "Host(`devices-agent.iamworkin.lan`)",
"middlewares": [
{
"name": "devicemgmt-agent-pass-client-cert",
"namespace": "fc-devicemgmt"
}
],
"services": [
{
"name": "fc-devicemgmt-web",
"port": 80
}
]
}
],
"tls": {
"options": {
"name": "devicemgmt-agent-mtls",
"namespace": "fc-devicemgmt"
},
"secretName": "fc-devicemgmt-agent-tls"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"apiVersion": "traefik.io/v1alpha1",
"kind": "Middleware",
"metadata": {
"name": "devicemgmt-agent-pass-client-cert",
"namespace": "fc-devicemgmt"
},
"spec": {
"passTLSClientCert": {
"pem": true
}
}
}

View File

@@ -0,0 +1,13 @@
{
"apiVersion": "traefik.io/v1alpha1",
"kind": "TLSOption",
"metadata": {
"name": "devicemgmt-agent-mtls",
"namespace": "fc-devicemgmt"
},
"spec": {
"clientAuth": {
"clientAuthType": "RequireAnyClientCert"
}
}
}

View File

@@ -45,7 +45,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
containers: containers:
- name: web - name: web
image: localhost/fc-gateway:v20260617-hm1-gateway-e0627e3 image: localhost/fc-gateway:v20260619-sec3-429e6cf
imagePullPolicy: Never imagePullPolicy: Never
ports: ports:
- containerPort: 8080 - containerPort: 8080

View File

@@ -86,7 +86,7 @@
"value": "mysql" "value": "mysql"
} }
], ],
"image": "localhost/fc-mysql-web:v20260617-sec4-storage-6fc3739", "image": "localhost/fc-mysql-web:v20260618-hm4-tenant-84dc65c",
"imagePullPolicy": "Never", "imagePullPolicy": "Never",
"livenessProbe": { "livenessProbe": {
"failureThreshold": 3, "failureThreshold": 3,

View File

@@ -1,7 +1,7 @@
{ {
"apiVersion": "v1", "apiVersion": "v1",
"data": { "data": {
"appsettings.Production.json": "{\"PhpManager\":{\"Namespace\":\"fc-php\",\"Slowlog\":{\"Path\":\"/var/log/apache2/php-fpm-slow.log\",\"Sidecar\":{\"Enabled\":true,\"Image\":\"\"}},\"PoolConfig\":{\"StartServers\":null,\"MinSpareServers\":null,\"MaxSpareServers\":null,\"ProcessIdleTimeoutSeconds\":10,\"RequestTerminateTimeoutSeconds\":30},\"Certificates\":{\"TlsInspector\":{\"LogGracefulDegradeWarnings\":false}},\"Backups\":{\"StoragePath\":\"/data/backups\"},\"Ingress\":{\"DefaultMiddlewares\":[{\"Name\":\"php-tenant-rate-limit\",\"Namespace\":\"fc-php\"},{\"Name\":\"php-tenant-secure-headers\",\"Namespace\":\"fc-php\"}],\"TlsOption\":{\"Name\":\"php-tenant-tls13\",\"Namespace\":\"fc-php\"}}},\"ApplicationArchives\":{\"WordPressCoreUrl\":\"http://php-web.fc-php.svc.cluster.local.:5400/api/v1/application-archives/wordpress/latest.tar.gz\",\"WordPressProxySourceUrl\":\"https://wordpress.org/latest.tar.gz\",\"WordPressLocalArchivePath\":\"/data/application-archives/latest.tar.gz\",\"MyBbCoreUrl\":\"http://php-web.fc-php.svc.cluster.local.:5400/api/v1/application-archives/mybb/latest.zip\",\"MyBbProxySourceUrl\":\"https://mybb.com/download/\",\"MyBbLocalArchivePath\":\"/data/application-archives/mybb-latest.zip\",\"MediaWikiCoreUrl\":\"http://php-web.fc-php.svc.cluster.local.:5400/api/v1/application-archives/mediawiki/latest.tar.gz\",\"MediaWikiProxySourceUrl\":\"https://releases.wikimedia.org/mediawiki/1.45/mediawiki-1.45.3.tar.gz\",\"MediaWikiLocalArchivePath\":\"/data/application-archives/mediawiki-latest.tar.gz\",\"DrupalCoreUrl\":\"http://php-web.fc-php.svc.cluster.local.:5400/api/v1/application-archives/drupal/latest.tar.gz\",\"DrupalProxySourceUrl\":\"https://ftp.drupal.org/files/projects/drupal-11.3.8.tar.gz\",\"DrupalLocalArchivePath\":\"/data/application-archives/drupal-latest.tar.gz\",\"BypassUpstreamTls\":true},\"ContainerBackend\":{\"Default\":\"Kubernetes\"},\"FlowerCore\":{\"Auth\":{\"Provider\":\"Oidc\",\"Enabled\":false,\"Oidc\":{\"Enabled\":true,\"Authority\":\"https://id.iamworkin.lan/application/o/php/\",\"Audience\":\"php\",\"ClientId\":\"php\",\"ClientSecret\":\"\"},\"Impersonation\":{\"Enabled\":false,\"DebugMode\":false}},\"Tenant\":{\"StrictMode\":false,\"JwtClaimsEnabled\":false,\"TenantClaimType\":\"fc:tenant\",\"ActorIdClaimType\":\"flowercore_actor_id\"},\"Account\":{\"AppId\":\"php\",\"DefaultTenantId\":\"default\",\"Impersonation\":{\"Enabled\":false,\"StrictMode\":false,\"TechSupportRoles\":[\"tech-support\"],\"Targets\":[]}},\"Hosting\":{\"AutoDns\":{\"Enabled\":true,\"DnsManagerBaseUrl\":\"https://dns.iamworkin.lan/\",\"ZoneName\":\"iamworkin.lan\",\"RecordType\":\"A\",\"TargetAddress\":\"10.0.56.200\",\"Ttl\":300,\"BypassTls\":true}},\"Database\":{\"Provider\":\"Sqlite\",\"ConnectionStrings\":{\"Sqlite\":\"Data Source=/data/php-manager.db\"}}}}" "appsettings.Production.json": "{\"PhpManager\":{\"Namespace\":\"fc-php\",\"Slowlog\":{\"Path\":\"/var/log/apache2/php-fpm-slow.log\",\"Sidecar\":{\"Enabled\":true,\"Image\":\"\"}},\"PoolConfig\":{\"StartServers\":null,\"MinSpareServers\":null,\"MaxSpareServers\":null,\"ProcessIdleTimeoutSeconds\":10,\"RequestTerminateTimeoutSeconds\":30},\"Certificates\":{\"TlsInspector\":{\"LogGracefulDegradeWarnings\":false}},\"Backups\":{\"StoragePath\":\"/data/backups\"},\"Ingress\":{\"DefaultMiddlewares\":[{\"Name\":\"php-tenant-rate-limit\",\"Namespace\":\"fc-php\"},{\"Name\":\"php-tenant-secure-headers\",\"Namespace\":\"fc-php\"}],\"TlsOption\":{\"Name\":\"php-tenant-tls13\",\"Namespace\":\"fc-php\"},\"Waf\":{\"Enabled\":true,\"Image\":\"owasp/modsecurity-crs:4.25-nginx-alpine-lts@sha256:88b59911549723e71beabf3b4aa47bbd31b00e79401f442e65ddfc430ae46343\",\"AllowedMethods\":\"GET HEAD POST OPTIONS DELETE\"}}},\"ApplicationArchives\":{\"WordPressCoreUrl\":\"http://php-web.fc-php.svc.cluster.local.:5400/api/v1/application-archives/wordpress/latest.tar.gz\",\"WordPressProxySourceUrl\":\"https://wordpress.org/latest.tar.gz\",\"WordPressLocalArchivePath\":\"/data/application-archives/latest.tar.gz\",\"MyBbCoreUrl\":\"http://php-web.fc-php.svc.cluster.local.:5400/api/v1/application-archives/mybb/latest.zip\",\"MyBbProxySourceUrl\":\"https://mybb.com/download/\",\"MyBbLocalArchivePath\":\"/data/application-archives/mybb-latest.zip\",\"MediaWikiCoreUrl\":\"http://php-web.fc-php.svc.cluster.local.:5400/api/v1/application-archives/mediawiki/latest.tar.gz\",\"MediaWikiProxySourceUrl\":\"https://releases.wikimedia.org/mediawiki/1.45/mediawiki-1.45.3.tar.gz\",\"MediaWikiLocalArchivePath\":\"/data/application-archives/mediawiki-latest.tar.gz\",\"DrupalCoreUrl\":\"http://php-web.fc-php.svc.cluster.local.:5400/api/v1/application-archives/drupal/latest.tar.gz\",\"DrupalProxySourceUrl\":\"https://ftp.drupal.org/files/projects/drupal-11.3.8.tar.gz\",\"DrupalLocalArchivePath\":\"/data/application-archives/drupal-latest.tar.gz\",\"BypassUpstreamTls\":true},\"ContainerBackend\":{\"Default\":\"Kubernetes\"},\"FlowerCore\":{\"Auth\":{\"Provider\":\"Oidc\",\"Enabled\":false,\"Oidc\":{\"Enabled\":true,\"Authority\":\"https://id.iamworkin.lan/application/o/php/\",\"Audience\":\"php\",\"ClientId\":\"php\",\"ClientSecret\":\"\"},\"Impersonation\":{\"Enabled\":false,\"DebugMode\":false}},\"Tenant\":{\"StrictMode\":false,\"JwtClaimsEnabled\":false,\"TenantClaimType\":\"fc:tenant\",\"ActorIdClaimType\":\"flowercore_actor_id\"},\"Account\":{\"AppId\":\"php\",\"DefaultTenantId\":\"default\",\"Impersonation\":{\"Enabled\":false,\"StrictMode\":false,\"TechSupportRoles\":[\"tech-support\"],\"Targets\":[]}},\"Hosting\":{\"AutoDns\":{\"Enabled\":true,\"DnsManagerBaseUrl\":\"https://dns.iamworkin.lan/\",\"ZoneName\":\"iamworkin.lan\",\"RecordType\":\"A\",\"TargetAddress\":\"10.0.57.202\",\"Ttl\":300,\"BypassTls\":true}},\"Database\":{\"Provider\":\"Sqlite\",\"ConnectionStrings\":{\"Sqlite\":\"Data Source=/data/php-manager.db\"}}}}"
}, },
"kind": "ConfigMap", "kind": "ConfigMap",
"metadata": { "metadata": {

View File

@@ -67,6 +67,10 @@
"name": "MODSEC_AUDIT_LOG_TYPE", "name": "MODSEC_AUDIT_LOG_TYPE",
"value": "Serial" "value": "Serial"
}, },
{
"name": "ALLOWED_METHODS",
"value": "GET HEAD POST OPTIONS DELETE"
},
{ {
"name": "LOGLEVEL", "name": "LOGLEVEL",
"value": "warn" "value": "warn"

View File

@@ -24,7 +24,7 @@
"template": { "template": {
"metadata": { "metadata": {
"annotations": { "annotations": {
"kubectl.kubernetes.io/restartedAt": "2026-06-13T01:59:27-05:00", "kubectl.kubernetes.io/restartedAt": "2026-06-19T00:00:00-05:00",
"prometheus.io/path": "/metrics/prometheus", "prometheus.io/path": "/metrics/prometheus",
"prometheus.io/port": "5400", "prometheus.io/port": "5400",
"prometheus.io/scrape": "true" "prometheus.io/scrape": "true"
@@ -86,7 +86,7 @@
"value": "php" "value": "php"
} }
], ],
"image": "localhost/fc-php-web:v20260617-whc4-edge-638b3b3", "image": "localhost/fc-php-web:v20260619-whc4-generated-waf-147f02a",
"imagePullPolicy": "Never", "imagePullPolicy": "Never",
"livenessProbe": { "livenessProbe": {
"failureThreshold": 3, "failureThreshold": 3,

View File

@@ -106,7 +106,7 @@
} }
} }
], ],
"image": "localhost/fc-kiosk-web:gx10-v1", "image": "localhost/fc-kiosk-web:v20260619-kiadmin-7cc83fd",
"imagePullPolicy": "Never", "imagePullPolicy": "Never",
"livenessProbe": { "livenessProbe": {
"failureThreshold": 6, "failureThreshold": 6,
@@ -195,7 +195,7 @@
"-c", "-c",
"mkdir -p /profiles/data && chown -R 1654:1654 /profiles/data && chmod -R u+rwX,g+rwX /profiles/data" "mkdir -p /profiles/data && chown -R 1654:1654 /profiles/data && chmod -R u+rwX,g+rwX /profiles/data"
], ],
"image": "localhost/fc-kiosk-web:gx10-v1", "image": "localhost/fc-kiosk-web:v20260619-kiadmin-7cc83fd",
"imagePullPolicy": "Never", "imagePullPolicy": "Never",
"name": "fix-profile-perms", "name": "fix-profile-perms",
"resources": {}, "resources": {},

View File

@@ -66,11 +66,11 @@
}, },
{ {
"name": "PhpManager__BaseUrl", "name": "PhpManager__BaseUrl",
"value": "https://php.iamworkin.lan/" "value": "http://php-web.fc-php.svc.cluster.local:5400/"
}, },
{ {
"name": "PhpManager__BypassTls", "name": "PhpManager__BypassTls",
"value": "true" "value": "false"
} }
], ],
"image": "localhost/fc-php-operator:v20260617-sec5-0bfbf42", "image": "localhost/fc-php-operator:v20260617-sec5-0bfbf42",

View File

@@ -1,8 +1,8 @@
{ {
"apiVersion": "v1", "apiVersion": "v1",
"data": { "data": {
"default.conf": "server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n location / { try_files $uri $uri/ =404; }\n location /healthz { access_log off; return 200 \"ok\"; add_header Content-Type text/plain; }\n}\n" "default.conf": "server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n location / { try_files $uri $uri/ =404; }\n location = /lamp-canary/index.php { add_header Content-Type text/plain; return 200 \"lamp-index-ok\\n\"; }\n location = /lamp-canary/wp-login.php { add_header Content-Type text/plain; return 200 \"wp-login-ok\\n\"; }\n location = /lamp-canary/mediawiki/index.php { add_header Content-Type text/plain; return 200 \"mediawiki-ok\\n\"; }\n location = /admin-allowlist-proof { add_header Content-Type text/plain; return 200 \"admin-allowlist-ok\\n\"; }\n location /healthz { access_log off; return 200 \"ok\"; add_header Content-Type text/plain; }\n}\n"
}, },
"kind": "ConfigMap", "kind": "ConfigMap",
"metadata": { "metadata": {
"name": "andrew-web-nginx-conf", "name": "andrew-web-nginx-conf",

View File

@@ -24,12 +24,15 @@
}, },
"type": "RollingUpdate" "type": "RollingUpdate"
}, },
"template": { "template": {
"metadata": { "metadata": {
"labels": { "annotations": {
"app": "andrew-web" "flowercore.io/config-revision": "whc4-lamp-allowlist-20260618"
} },
}, "labels": {
"app": "andrew-web"
}
},
"spec": { "spec": {
"containers": [ "containers": [
{ {

View File

@@ -11,8 +11,18 @@
], ],
"routes": [ "routes": [
{ {
"kind": "Rule", "kind": "Rule",
"match": "Host(`bluejay.dev`) || Host(`www.bluejay.dev`)", "match": "Host(`bluejay.dev`) || Host(`www.bluejay.dev`)",
"middlewares": [
{
"name": "andrew-tenant-rate-limit",
"namespace": "fc-tenant-andrew"
},
{
"name": "andrew-tenant-secure-headers",
"namespace": "fc-tenant-andrew"
}
],
"priority": 100, "priority": 100,
"services": [ "services": [
{ {
@@ -20,10 +30,39 @@
"port": 8080 "port": 8080
} }
] ]
},
{
"kind": "Rule",
"match": "(Host(`bluejay.dev`) || Host(`www.bluejay.dev`)) && PathPrefix(`/admin-allowlist-proof`)",
"middlewares": [
{
"name": "andrew-admin-ip-allowlist",
"namespace": "fc-tenant-andrew"
},
{
"name": "andrew-tenant-rate-limit",
"namespace": "fc-tenant-andrew"
},
{
"name": "andrew-tenant-secure-headers",
"namespace": "fc-tenant-andrew"
}
],
"priority": 300,
"services": [
{
"name": "andrew-web-waf",
"port": 8080
}
]
} }
], ],
"tls": { "tls": {
"secretName": "cf-origin-bluejay-dev" "options": {
} "name": "andrew-tenant-tls13",
} "namespace": "fc-tenant-andrew"
} },
"secretName": "cf-origin-bluejay-dev"
}
}
}

View File

@@ -0,0 +1,15 @@
{
"apiVersion": "traefik.io/v1alpha1",
"kind": "Middleware",
"metadata": {
"name": "andrew-admin-ip-allowlist",
"namespace": "fc-tenant-andrew"
},
"spec": {
"ipAllowList": {
"sourceRange": [
"10.0.56.14/32"
]
}
}
}

View File

@@ -0,0 +1,15 @@
{
"apiVersion": "traefik.io/v1alpha1",
"kind": "Middleware",
"metadata": {
"name": "andrew-tenant-rate-limit",
"namespace": "fc-tenant-andrew"
},
"spec": {
"rateLimit": {
"average": 120,
"burst": 240,
"period": "1m"
}
}
}

View File

@@ -0,0 +1,18 @@
{
"apiVersion": "traefik.io/v1alpha1",
"kind": "Middleware",
"metadata": {
"name": "andrew-tenant-secure-headers",
"namespace": "fc-tenant-andrew"
},
"spec": {
"headers": {
"contentTypeNosniff": true,
"browserXssFilter": true,
"referrerPolicy": "strict-origin-when-cross-origin",
"stsSeconds": 31536000,
"stsIncludeSubdomains": true,
"stsPreload": false
}
}
}

View File

@@ -0,0 +1,11 @@
{
"apiVersion": "traefik.io/v1alpha1",
"kind": "TLSOption",
"metadata": {
"name": "andrew-tenant-tls13",
"namespace": "fc-tenant-andrew"
},
"spec": {
"minVersion": "VersionTLS13"
}
}

View File

@@ -0,0 +1,11 @@
{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {
"labels": {
"app.kubernetes.io/managed-by": "flowercore",
"flowercore.io/tenant": "default"
},
"name": "fc-tenant-default"
}
}

View File

@@ -53,13 +53,17 @@
"name": "FlowerCore__Updater__BundleStorage__LocalFs__RootDirectory", "name": "FlowerCore__Updater__BundleStorage__LocalFs__RootDirectory",
"value": "/data/bundles" "value": "/data/bundles"
}, },
{ {
"name": "FlowerCore__Updater__PublicShares__RequirePublicVisibilityOnPublicHosts", "name": "FlowerCore__Updater__PublicShares__RequirePublicVisibilityOnPublicHosts",
"value": "true" "value": "true"
}, },
{ {
"name": "FlowerCore__Updater__PublicShares__Links__0__Code", "name": "FlowerCore__Updater__PublicShares__RequireShareLinkOnPublicHosts",
"value": "8f3c2a9e7d41" "value": "true"
},
{
"name": "FlowerCore__Updater__PublicShares__Links__0__Code",
"value": "8f3c2a9e7d41"
}, },
{ {
"name": "FlowerCore__Updater__PublicShares__Links__0__AppId", "name": "FlowerCore__Updater__PublicShares__Links__0__AppId",
@@ -195,7 +199,7 @@
"value": "26843545600" "value": "26843545600"
} }
], ],
"image": "localhost/fc-updater-web:v20260617-sec5-913c6a9", "image": "localhost/fc-updater-web:v20260618-feed-signed-9cc9942",
"imagePullPolicy": "Never", "imagePullPolicy": "Never",
"securityContext": { "securityContext": {
"allowPrivilegeEscalation": false, "allowPrivilegeEscalation": false,

View File

@@ -12,7 +12,7 @@
"routes": [ "routes": [
{ {
"kind": "Rule", "kind": "Rule",
"match": "(Host(`update.flowercore.io`) || Host(`updates.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`))", "match": "(Host(`update.flowercore.io`) || Host(`updates.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`))",
"priority": 100, "priority": 100,
"services": [ "services": [
{ {

View File

@@ -147,7 +147,7 @@
"value": "/data/vector-stores/corpus-cache" "value": "/data/vector-stores/corpus-cache"
} }
], ],
"image": "localhost/fc-knowledge-web:gx10-v1", "image": "localhost/fc-knowledge-web:v20260619-sec3-6370c95",
"imagePullPolicy": "Never", "imagePullPolicy": "Never",
"livenessProbe": { "livenessProbe": {
"failureThreshold": 3, "failureThreshold": 3,

View File

@@ -10,8 +10,9 @@
# Phase 1 production uses a Longhorn RWO PVC at /data/devicemgmt.db. The # Phase 1 production uses a Longhorn RWO PVC at /data/devicemgmt.db. The
# 1Password runtime item stays mounted through env for future MySQL/API-key # 1Password runtime item stays mounted through env for future MySQL/API-key
# cutover, but MySQL is not required for this first product-host rollout. # cutover, but MySQL is not required for this first product-host rollout.
# Image v20260613-g2-66a43c1 is built from FlowerCore.DeviceManagement master # Image v20260618-prune-18c7449-livebase is derived from the 2026-06-17 AN-13
# 66a43c1, carrying edge enrollment network completion and SQLite-safe trust-bundle smoke coverage. # live base with the Mac fleet SQLite snapshot-prune hotfix from
# FlowerCore.DeviceManagement PR #49.
--- ---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
@@ -83,7 +84,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch fsGroupChangePolicy: OnRootMismatch
containers: containers:
- name: web - name: web
image: localhost/fc-devicemgmt-web:v20260614-regroup-c5b8f82 image: localhost/fc-devicemgmt-web:v20260618-prune-18c7449-livebase
imagePullPolicy: Never imagePullPolicy: Never
ports: ports:
- name: http - name: http

View File

@@ -43,5 +43,6 @@ shared origin cert must exist in every namespace that serves a
```powershell ```powershell
kubectl.exe --kubeconfig C:\Users\AndrewStoltz\.kube\rke2.yaml -n argocd get application infra-fc-updater kubectl.exe --kubeconfig C:\Users\AndrewStoltz\.kube\rke2.yaml -n argocd get application infra-fc-updater
kubectl.exe --kubeconfig C:\Users\AndrewStoltz\.kube\rke2.yaml -n fc-updater get deploy,svc,ingressroute,certificate,pvc kubectl.exe --kubeconfig C:\Users\AndrewStoltz\.kube\rke2.yaml -n fc-updater get deploy,svc,ingressroute,certificate,pvc
curl.exe -sk https://update.flowercore.io/api/v1/manifests/_schema curl.exe -sk https://update.flowercore.io/
curl.exe -sk -o NUL -w "%{http_code}`n" https://update.flowercore.io/login
``` ```

View File

@@ -61,7 +61,7 @@ spec:
nodeName: rke2-server nodeName: rke2-server
containers: containers:
- name: web - name: web
image: localhost/fc-updater-web:v20260614-regroup-bdf4a4a image: localhost/fc-updater-web:v20260618-feed-signed-9cc9942
imagePullPolicy: Never imagePullPolicy: Never
ports: ports:
- containerPort: 8080 - containerPort: 8080
@@ -266,7 +266,7 @@ spec:
entryPoints: entryPoints:
- websecure - websecure
routes: routes:
- match: (Host(`update.flowercore.io`) || Host(`updates.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`) || Method(`POST`) || Method(`OPTIONS`)) - match: (Host(`update.flowercore.io`) || Host(`updates.flowercore.io`)) && (Method(`GET`) || Method(`HEAD`))
kind: Rule kind: Rule
services: services:
- name: updatecenter-web - name: updatecenter-web

View File

@@ -8,7 +8,8 @@ auto-deploy them there. Once ArgoCD is stood up on the GX10, a GX10-only
ApplicationSet (`apps-gx10/*`) will own these. ApplicationSet (`apps-gx10/*`) will own these.
- `step-ca-acme.yaml` — cert-manager ClusterIssuer (ACME → noc1 step-ca, in-spec caBundle). APPLIED + Ready. - `step-ca-acme.yaml` — cert-manager ClusterIssuer (ACME → noc1 step-ca, in-spec caBundle). APPLIED + Ready.
- `traefik-helmchart.yaml` — Traefik v3.6.10 (chart 39.0.5) via the RKE2 HelmChart CRD, LoadBalancer VIP 10.0.57.202 (prod-pool; temp parallel-run VIP — canonical .200 reclaimed at cutover). APPLIED. - `traefik-helmchart.yaml` — Traefik v3.6.10 (chart 39.0.5) via the RKE2 HelmChart CRD, LoadBalancer VIP 10.0.57.202 (prod-pool; temp parallel-run VIP — canonical .200 reclaimed at cutover), with `externalTrafficPolicy: Local` so tenant IP allowlists see client source IP instead of the GX10 node hop. APPLIED.
- `gitea-ssh-service.yaml` — Gitea SSH LoadBalancer service on `10.0.57.206:22` with `externalTrafficPolicy: Local`; HTTPS Gitea remains behind the Traefik VIP at `10.0.57.202`. APPLIED.
cert-manager v1.17.2 was installed separately (upstream static manifest). See cert-manager v1.17.2 was installed separately (upstream static manifest). See
`docs/ai-agents/gx10-migration-continuation-2026-06-14.md` + memory `docs/ai-agents/gx10-migration-continuation-2026-06-14.md` + memory

View File

@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: gitea-ssh
namespace: gitea
annotations:
metallb.io/loadBalancerIPs: 10.0.57.206
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: gitea
ports:
- name: ssh
port: 22
protocol: TCP
targetPort: 2222

View File

@@ -10,72 +10,27 @@ spec:
targetNamespace: traefik-system targetNamespace: traefik-system
createNamespace: true createNamespace: true
valuesContent: | valuesContent: |
deployment: service:
replicas: 1 type: LoadBalancer
additionalArguments: spec:
- "--api.dashboard=true" externalTrafficPolicy: Local
- "--log.level=INFO" annotations:
- "--providers.kubernetescrd" metallb.io/loadBalancerIPs: 10.0.57.202
- "--providers.kubernetesingress" ingressClass:
- "--providers.kubernetescrd.allowEmptyServices=true" enabled: true
- "--providers.kubernetesingress.allowEmptyServices=true" isDefaultClass: false
- "--providers.kubernetesingress.ingressendpoint.publishedservice=traefik-system/traefik" providers:
kubernetesCRD:
enabled: true
allowEmptyServices: true
kubernetesIngress:
enabled: true
allowEmptyServices: true
publishedService:
enabled: true
ingressRoute: ingressRoute:
dashboard: dashboard:
enabled: false enabled: false
rbac: logs:
enabled: true general:
service: level: INFO
type: LoadBalancer
annotations:
metallb.io/loadBalancerIPs: "10.0.57.202"
metallb.io/address-pool: "prod-pool"
ports:
web:
port: 8000
exposedPort: 80
protocol: TCP
websecure:
port: 8443
exposedPort: 443
protocol: TCP
tls:
enabled: true
irc:
port: 6667
exposedPort: 6667
protocol: TCP
expose:
default: true
irctls:
port: 6697
exposedPort: 6697
protocol: TCP
expose:
default: true
traefik:
port: 8080
exposedPort: 8080
protocol: TCP
expose:
default: false
metrics:
port: 9100
exposedPort: 9100
protocol: TCP
expose:
default: false
metrics:
prometheus:
entryPoint: metrics
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
tolerations:
- key: "node-role.kubernetes.io/control-plane"
operator: "Exists"
effect: "NoSchedule"

View File

@@ -16,6 +16,8 @@ public sealed class FleetManifestLintTests
{ {
"brochure.flowercore.io", "brochure.flowercore.io",
"dist.flowercore.io", "dist.flowercore.io",
"update.flowercore.io",
"updates.flowercore.io",
}; };
// Hosts that allow a tightly bounded write surface in addition to GET/HEAD. // Hosts that allow a tightly bounded write surface in addition to GET/HEAD.
@@ -247,6 +249,22 @@ public sealed class FleetManifestLintTests
violations.Should().BeEmpty(); violations.Should().BeEmpty();
} }
[Fact]
public void Gx10PublicLoadBalancers_MustPreserveClientSourceIp()
{
var traefikPath = Path.Combine(Inventory.BluejayRoot, "gx10", "platform", "traefik-helmchart.yaml");
var traefik = File.ReadAllText(traefikPath);
traefik.Should().Contain("metallb.io/loadBalancerIPs: 10.0.57.202");
traefik.Should().Contain("spec:\n externalTrafficPolicy: Local");
var giteaPath = Path.Combine(Inventory.BluejayRoot, "gx10", "platform", "gitea-ssh-service.yaml");
var gitea = File.ReadAllText(giteaPath);
gitea.Should().Contain("metallb.io/loadBalancerIPs: 10.0.57.206");
gitea.Should().Contain("externalTrafficPolicy: Local");
}
[Fact] [Fact]
public void ApiKeyProtectedDeployments_MustUseTcpSocketHealthProbes() public void ApiKeyProtectedDeployments_MustUseTcpSocketHealthProbes()
{ {
@@ -981,6 +999,32 @@ public sealed class FleetManifestLintTests
gatewayManifest.Should().Contain("port: 5400"); gatewayManifest.Should().Contain("port: 5400");
} }
[Fact]
public void Gx10DeviceManagementWriteApis_RequireRuntimeBackedOperatorAuth()
{
var web = Gx10DeploymentContainer("fc-devicemgmt", "deployment-fc-devicemgmt-web.json");
JsonEnvValue(web, "FlowerCore__Auth__Enabled").Should().Be("true");
JsonEnvSecretName(web, "Auth__ApiKey").Should().Be("fc-devicemgmt-runtime");
JsonEnvSecretKey(web, "Auth__ApiKey").Should().Be("DEVICE_MANAGEMENT_OPERATOR_API_KEY");
JsonEnvSecretOptional(web, "Auth__ApiKey").Should().BeNull();
JsonEnvSecretName(web, "FlowerCore__Auth__ApiKey").Should().Be("fc-devicemgmt-runtime");
JsonEnvSecretKey(web, "FlowerCore__Auth__ApiKey").Should().Be("DEVICE_MANAGEMENT_OPERATOR_API_KEY");
JsonEnvSecretOptional(web, "FlowerCore__Auth__ApiKey").Should().BeNull();
JsonEnvSecretName(web, "Auth__AdminApiKey").Should().Be("fc-devicemgmt-runtime");
JsonEnvSecretKey(web, "Auth__AdminApiKey").Should().Be("DEVICE_MANAGEMENT_ADMIN_API_KEY");
JsonEnvSecretOptional(web, "Auth__AdminApiKey").Should().BeNull();
JsonEnvSecretName(web, "FlowerCore__Auth__AdminApiKey").Should().Be("fc-devicemgmt-runtime");
JsonEnvSecretKey(web, "FlowerCore__Auth__AdminApiKey").Should().Be("DEVICE_MANAGEMENT_ADMIN_API_KEY");
JsonEnvSecretOptional(web, "FlowerCore__Auth__AdminApiKey").Should().BeNull();
JsonEnvSecretName(web, "FlowerCore__DeviceManagement__EnrollmentCertificateAuthorityCertificatePem").Should().Be("fc-devicemgmt-runtime");
JsonEnvSecretKey(web, "FlowerCore__DeviceManagement__EnrollmentCertificateAuthorityCertificatePem").Should().Be("DEVICE_MANAGEMENT_ENROLLMENT_CA_CERTIFICATE_PEM");
JsonEnvSecretOptional(web, "FlowerCore__DeviceManagement__EnrollmentCertificateAuthorityCertificatePem").Should().BeTrue();
JsonEnvSecretName(web, "FlowerCore__DeviceManagement__EnrollmentCertificateAuthorityPrivateKeyPem").Should().Be("fc-devicemgmt-runtime");
JsonEnvSecretKey(web, "FlowerCore__DeviceManagement__EnrollmentCertificateAuthorityPrivateKeyPem").Should().Be("DEVICE_MANAGEMENT_ENROLLMENT_CA_PRIVATE_KEY_PEM");
JsonEnvSecretOptional(web, "FlowerCore__DeviceManagement__EnrollmentCertificateAuthorityPrivateKeyPem").Should().BeTrue();
}
[Fact] [Fact]
public void Gx10PhpTenantRoutes_HaveEdgeControlSubstrate() public void Gx10PhpTenantRoutes_HaveEdgeControlSubstrate()
{ {
@@ -1108,9 +1152,10 @@ public sealed class FleetManifestLintTests
servicePort.GetProperty("targetPort").GetInt32().Should().Be(8080); servicePort.GetProperty("targetPort").GetInt32().Should().Be(8080);
using var ingressRoute = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "ingressroute-andrew-web.json"))); using var ingressRoute = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "ingressroute-andrew-web.json")));
var serviceRef = ingressRoute.RootElement var route = ingressRoute.RootElement
.GetProperty("spec") .GetProperty("spec")
.GetProperty("routes")[0] .GetProperty("routes")[0];
var serviceRef = route
.GetProperty("services") .GetProperty("services")
.EnumerateArray() .EnumerateArray()
.Should() .Should()
@@ -1118,6 +1163,70 @@ public sealed class FleetManifestLintTests
.Subject; .Subject;
serviceRef.GetProperty("name").GetString().Should().Be("andrew-web-waf"); serviceRef.GetProperty("name").GetString().Should().Be("andrew-web-waf");
serviceRef.GetProperty("port").GetInt32().Should().Be(8080); serviceRef.GetProperty("port").GetInt32().Should().Be(8080);
route.GetProperty("middlewares")
.EnumerateArray()
.Select(item => item.GetProperty("name").GetString())
.Should()
.Equal("andrew-tenant-rate-limit", "andrew-tenant-secure-headers");
var adminRoute = ingressRoute.RootElement
.GetProperty("spec")
.GetProperty("routes")
.EnumerateArray()
.Single(route => route.GetProperty("match").GetString()!.Contains("PathPrefix(`/admin-allowlist-proof`)", StringComparison.Ordinal));
adminRoute.GetProperty("priority").GetInt32().Should().Be(300);
adminRoute.GetProperty("services").EnumerateArray().Should().ContainSingle().Subject
.GetProperty("name").GetString().Should().Be("andrew-web-waf");
adminRoute.GetProperty("middlewares")
.EnumerateArray()
.Select(item => item.GetProperty("name").GetString())
.Should()
.Equal("andrew-admin-ip-allowlist", "andrew-tenant-rate-limit", "andrew-tenant-secure-headers");
using var rateLimit = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "middleware-andrew-tenant-rate-limit.json")));
rateLimit.RootElement.GetProperty("spec").GetProperty("rateLimit").GetProperty("average").GetInt32().Should().Be(120);
using var allowlist = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "middleware-andrew-admin-ip-allowlist.json")));
allowlist.RootElement.GetProperty("kind").GetString().Should().Be("Middleware");
allowlist.RootElement.GetProperty("spec").GetProperty("ipAllowList").GetProperty("sourceRange")
.EnumerateArray()
.Select(item => item.GetString())
.Should()
.Equal("10.0.56.14/32");
using var nginxConfig = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "configmap-andrew-web-nginx-conf.json")));
var nginx = nginxConfig.RootElement.GetProperty("data").GetProperty("default.conf").GetString();
nginx.Should().Contain("location = /lamp-canary/index.php");
nginx.Should().Contain("location = /lamp-canary/wp-login.php");
nginx.Should().Contain("location = /lamp-canary/mediawiki/index.php");
nginx.Should().Contain("location = /admin-allowlist-proof");
using var webDeployment = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "deployment-andrew-web.json")));
webDeployment.RootElement.GetProperty("spec")
.GetProperty("template")
.GetProperty("metadata")
.GetProperty("annotations")
.GetProperty("flowercore.io/config-revision")
.GetString()
.Should()
.Be("whc4-lamp-allowlist-20260618");
using var headers = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "middleware-andrew-tenant-secure-headers.json")));
var headerSpec = headers.RootElement.GetProperty("spec").GetProperty("headers");
headerSpec.GetProperty("contentTypeNosniff").GetBoolean().Should().BeTrue();
headerSpec.GetProperty("stsSeconds").GetInt32().Should().Be(31536000);
using var tlsOption = JsonDocument.Parse(File.ReadAllText(Path.Combine(appRoot, "tlsoption-andrew-tenant-tls13.json")));
tlsOption.RootElement.GetProperty("spec").GetProperty("minVersion").GetString().Should().Be("VersionTLS13");
ingressRoute.RootElement
.GetProperty("spec")
.GetProperty("tls")
.GetProperty("options")
.GetProperty("name")
.GetString()
.Should()
.Be("andrew-tenant-tls13");
} }
[Fact] [Fact]
@@ -1221,6 +1330,39 @@ public sealed class FleetManifestLintTests
match.Should().NotContain("Method(`POST`)"); match.Should().NotContain("Method(`POST`)");
} }
[Fact]
public void UpdateCenterPublicIngress_KeepsDeliveryOnlyGetHeadMethodAllowlist()
{
var publicIngress = AppDocuments("fc-updater")
.Single(document => document.Kind == "IngressRoute" && document.Name == "updatecenter-web-public");
var route = publicIngress.MappingSequence("spec", "routes").Should().ContainSingle().Subject;
var match = ManifestNodeExtensions.Scalar(route, "match");
match.Should().Contain("Host(`update.flowercore.io`)");
match.Should().Contain("Host(`updates.flowercore.io`)");
match.Should().Contain("Method(`GET`)");
match.Should().Contain("Method(`HEAD`)");
match.Should().NotContain("Method(`POST`)");
match.Should().NotContain("Method(`OPTIONS`)");
}
[Fact]
public void Gx10UpdateCenterPublicIngress_StaysGetHeadOnlyAndUsesContainmentImage()
{
var appRoot = Path.Combine(Inventory.BluejayRoot, "apps-gx10", "fc-updater");
var publicRoute = File.ReadAllText(Path.Combine(appRoot, "ingressroute-updatecenter-web-public-gx10.json"));
var deployment = File.ReadAllText(Path.Combine(appRoot, "deployment-updatecenter-web.json"));
publicRoute.Should().Contain("Host(`update.flowercore.io`)");
publicRoute.Should().Contain("Host(`updates.flowercore.io`)");
publicRoute.Should().Contain("Method(`GET`)");
publicRoute.Should().Contain("Method(`HEAD`)");
publicRoute.Should().NotContain("Method(`POST`)");
publicRoute.Should().NotContain("Method(`OPTIONS`)");
deployment.Should().Contain("localhost/fc-updater-web:v");
deployment.Should().NotContain("localhost/fc-updater-web:v20260614-regroup-bdf4a4a");
}
[Fact] [Fact]
public void DnsAndMediaIngressRoutes_MatchLiveInternalHosts() public void DnsAndMediaIngressRoutes_MatchLiveInternalHosts()
{ {
@@ -1332,9 +1474,13 @@ public sealed class FleetManifestLintTests
private static bool? JsonEnvSecretOptional(JsonElement container, string name) private static bool? JsonEnvSecretOptional(JsonElement container, string name)
{ {
return JsonEnvMapping(container, name) is { } env if (JsonEnvMapping(container, name) is not { } env)
? env.GetProperty("valueFrom").GetProperty("secretKeyRef").GetProperty("optional").GetBoolean() {
: null; return null;
}
var secretKeyRef = env.GetProperty("valueFrom").GetProperty("secretKeyRef");
return secretKeyRef.TryGetProperty("optional", out var optional) ? optional.GetBoolean() : null;
} }
private static string? JsonEnvValue(JsonElement container, string name) private static string? JsonEnvValue(JsonElement container, string name)

View File

@@ -0,0 +1,208 @@
using FluentAssertions;
using Xunit;
using YamlDotNet.RepresentationModel;
namespace BluejayInfraLint.Tests;
[Trait("Category", "Unit")]
public sealed class Gx10AppleMdmNanohubTests
{
private static readonly string Root = FindRepoRoot();
private static readonly string AppRoot = Path.Combine(Root, "apps-gx10", "fc-apple-mdm");
private static readonly IReadOnlyList<YamlMappingNode> Documents = LoadDocuments();
[Fact]
public void Manifest_DeclaresLockedDownNanoHubRuntime()
{
Documents.Should().Contain(document => Is(document, "Namespace", "fc-apple-mdm"));
Documents.Should().Contain(document => Is(document, "ConfigMap", "fc-apple-mdm-root-ca"));
Documents.Should().Contain(document => Is(document, "Service", "fc-apple-mdm"));
Documents.Should().Contain(document => Is(document, "Service", "fc-apple-mdm-scep"));
Documents.Should().Contain(document => Is(document, "EndpointSlice", "fc-apple-mdm-scep-noc1"));
Documents.Should().Contain(document => Is(document, "NetworkPolicy", "fc-apple-mdm-netpol"));
Documents.Should().NotContain(document => (document.Scalar("kind") ?? string.Empty) == "Secret");
Documents.Should().NotContain(document => (document.Scalar("kind") ?? string.Empty) == "OnePasswordItem");
var pvc = Single("PersistentVolumeClaim", "fc-apple-mdm-data");
pvc.Scalar("spec", "storageClassName").Should().Be("local-path");
pvc.Scalar("spec", "resources", "requests", "storage").Should().Be("2Gi");
var deployment = Single("Deployment", "fc-apple-mdm");
deployment.Scalar("spec", "strategy", "type").Should().Be("Recreate");
deployment.Scalar("spec", "template", "metadata", "annotations", "fc.flowercore.io/probe-path").Should().Be("/version");
deployment.Scalar("spec", "template", "metadata", "annotations", "flowercore.io/root-ca-sha256")
.Should()
.Be("a9120c88fa3ec735d790aa4cfeb61ac2946730338969015bebaccc08fe10535e");
var maybePodSpec = deployment.Mapping("spec", "template", "spec");
maybePodSpec.Should().NotBeNull();
var podSpec = maybePodSpec!;
podSpec.Scalar("enableServiceLinks").Should().Be("false");
podSpec.Scalar("securityContext", "runAsUser").Should().Be("1654");
podSpec.Scalar("securityContext", "runAsNonRoot").Should().Be("true");
var container = podSpec.MappingSequence("containers").Should().ContainSingle().Subject;
container.Scalar("name").Should().Be("nanohub");
container.Scalar("image").Should().Be("localhost/fc-apple-mdm-nanohub:v0.2.0-20260617");
container.Scalar("imagePullPolicy").Should().Be("Never");
container.Scalar("securityContext", "readOnlyRootFilesystem").Should().Be("true");
container.Scalar("securityContext", "allowPrivilegeEscalation").Should().Be("false");
EnvValue(container, "NANOHUB_LISTEN").Should().Be(":9004");
EnvValue(container, "NANOHUB_STORAGE").Should().Be("file");
EnvValue(container, "NANOHUB_STORAGE_DSN").Should().Be("/var/lib/nanohub/db");
EnvValue(container, "NANOHUB_CHECKIN").Should().Be("true");
EnvValue(container, "NANOHUB_CA").Should().Be("/etc/nanohub/ca/root_ca.crt");
EnvSecretName(container, "NANOHUB_API_KEY").Should().Be("fc-apple-mdm-runtime");
EnvSecretKey(container, "NANOHUB_API_KEY").Should().Be("NANOHUB_API_KEY");
EnvSecretName(container, "NANOHUB_WEBHOOK_URL").Should().Be("fc-apple-mdm-runtime");
EnvSecretKey(container, "NANOHUB_WEBHOOK_URL").Should().Be("NANOHUB_WEBHOOK_URL");
EnvSecretOptional(container, "NANOHUB_WEBHOOK_URL").Should().Be("true");
VolumeMount(container, "data").Scalar("mountPath").Should().Be("/var/lib/nanohub");
VolumeMount(container, "root-ca").Scalar("mountPath").Should().Be("/etc/nanohub/ca");
VolumeMount(container, "root-ca").Scalar("readOnly").Should().Be("true");
ProbePath(container, "startupProbe").Should().Be("/version");
ProbePath(container, "readinessProbe").Should().Be("/version");
container.Scalar("livenessProbe", "tcpSocket", "port").Should().Be("9004");
}
[Fact]
public void Manifest_ExposesOnlyMdmCheckinVersionAndScepPaths()
{
var certificate = Single("Certificate", "fc-apple-mdm-tls");
certificate.Scalar("spec", "issuerRef", "name").Should().Be("step-ca-acme");
certificate.Scalar("spec", "issuerRef", "kind").Should().Be("ClusterIssuer");
certificate.ScalarSequence("spec", "dnsNames").Should().ContainSingle("mdm.iamworkin.lan");
var scepService = Single("Service", "fc-apple-mdm-scep");
scepService.Scalar("spec", "type").Should().Be("ClusterIP");
var scepServicePort = scepService.MappingSequence("spec", "ports").Should().ContainSingle().Subject;
scepServicePort.Scalar("name").Should().Be("http");
scepServicePort.Scalar("port").Should().Be("80");
scepServicePort.Scalar("targetPort").Should().Be("9080");
var scepEndpointSlice = Single("EndpointSlice", "fc-apple-mdm-scep-noc1");
scepEndpointSlice.Scalar("addressType").Should().Be("IPv4");
scepEndpointSlice.Scalar("metadata", "labels", "kubernetes.io/service-name").Should().Be("fc-apple-mdm-scep");
var scepEndpoint = scepEndpointSlice.MappingSequence("endpoints").Should().ContainSingle().Subject;
scepEndpoint.ScalarSequence("addresses").Should().ContainSingle("10.0.56.10");
var scepEndpointPort = scepEndpointSlice.MappingSequence("ports").Should().ContainSingle().Subject;
scepEndpointPort.Scalar("name").Should().Be("http");
scepEndpointPort.Scalar("port").Should().Be("9080");
var ingress = Single("IngressRoute", "fc-apple-mdm");
var routes = ingress.MappingSequence("spec", "routes");
routes.Should().HaveCount(2);
var scepRoute = routes.Single(route => route.Scalar("match")?.Contains("PathPrefix(`/scep`)") == true);
var nanohubRoute = routes.Single(route => route.Scalar("match")?.Contains("PathPrefix(`/mdm`)") == true);
var match = nanohubRoute.Scalar("match");
match.Should().Contain("Host(`mdm.iamworkin.lan`)");
match.Should().Contain("PathPrefix(`/mdm`)");
match.Should().Contain("PathPrefix(`/checkin`)");
match.Should().Contain("PathPrefix(`/version`)");
match.Should().NotContain("/api/v1");
match.Should().NotContain("PathPrefix(`/api`)");
scepRoute.Scalar("match").Should().Contain("Host(`mdm.iamworkin.lan`)");
scepRoute.Scalar("match").Should().Contain("PathPrefix(`/scep`)");
var scepRouteService = scepRoute.MappingSequence("services").Should().ContainSingle().Subject;
scepRouteService.Scalar("name").Should().Be("fc-apple-mdm-scep");
scepRouteService.Scalar("port").Should().Be("80");
}
[Fact]
public void Readme_DocumentsSecretImportAndSupportBoundary()
{
var readme = File.ReadAllText(Path.Combine(AppRoot, "README.md"));
readme.Should().Contain("FlowerCore Apple MDM Runtime");
readme.Should().Contain("Secret/fc-apple-mdm-runtime");
readme.Should().Contain("imagePullPolicy: Never");
readme.Should().Contain("10.0.57.202");
readme.Should().Contain("https://mdm.iamworkin.lan/scep/apple-mdm-scep");
readme.Should().Contain("Smallstep SCEP requires an RSA intermediate");
readme.Should().Contain("does not create an APNs MDM push certificate");
readme.Should().Contain("managed Wi-Fi payload");
}
private static YamlMappingNode Single(string kind, string name)
{
return Documents.Single(document => Is(document, kind, name));
}
private static bool Is(YamlMappingNode document, string kind, string name)
{
return document.Scalar("kind") == kind
&& document.Scalar("metadata", "name") == name;
}
private static string? EnvValue(YamlMappingNode container, string name)
{
return EnvMapping(container, name)?.Scalar("value");
}
private static string? EnvSecretName(YamlMappingNode container, string name)
{
return EnvMapping(container, name)?.Scalar("valueFrom", "secretKeyRef", "name");
}
private static string? EnvSecretKey(YamlMappingNode container, string name)
{
return EnvMapping(container, name)?.Scalar("valueFrom", "secretKeyRef", "key");
}
private static string? EnvSecretOptional(YamlMappingNode container, string name)
{
return EnvMapping(container, name)?.Scalar("valueFrom", "secretKeyRef", "optional");
}
private static string? ProbePath(YamlMappingNode container, string probeKey)
{
return container.Scalar(probeKey, "httpGet", "path");
}
private static YamlMappingNode VolumeMount(YamlMappingNode container, string name)
{
return container.MappingSequence("volumeMounts")
.Single(mount => mount.Scalar("name") == name);
}
private static YamlMappingNode? EnvMapping(YamlMappingNode container, string name)
{
return container.MappingSequence("env")
.SingleOrDefault(env => env.Scalar("name") == name);
}
private static IReadOnlyList<YamlMappingNode> LoadDocuments()
{
var stream = new YamlStream();
using var reader = File.OpenText(Path.Combine(AppRoot, "fc-apple-mdm.yaml"));
stream.Load(reader);
return stream.Documents
.Select(document => document.RootNode)
.OfType<YamlMappingNode>()
.Where(mapping => !string.IsNullOrWhiteSpace(mapping.Scalar("kind")))
.ToList();
}
private static string FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (Directory.Exists(Path.Combine(current.FullName, "apps-gx10"))
&& Directory.Exists(Path.Combine(current.FullName, "tests"))
&& File.Exists(Path.Combine(current.FullName, "README.md")))
{
return current.FullName;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not find bluejay-infra root.");
}
}

View File

@@ -1,6 +1,12 @@
package bluejayinfra.public_method_allowlist package bluejayinfra.public_method_allowlist
public_hosts := {"brochure.flowercore.io", "dist.flowercore.io", "dns.iamworkin.lan"} public_hosts := {
"brochure.flowercore.io",
"dist.flowercore.io",
"dns.iamworkin.lan",
"update.flowercore.io",
"updates.flowercore.io",
}
deny[msg] { deny[msg] {
input.kind == "IngressRoute" input.kind == "IngressRoute"

View File

@@ -9,8 +9,6 @@ package bluejayinfra.public_readwrite_allowlist
public_readwrite_hosts := { public_readwrite_hosts := {
"updatecenter.iamworkin.lan", "updatecenter.iamworkin.lan",
"updates.iamworkin.lan", "updates.iamworkin.lan",
"update.flowercore.io",
"updates.flowercore.io",
} }
required_methods := {"GET", "HEAD", "POST", "OPTIONS"} required_methods := {"GET", "HEAD", "POST", "OPTIONS"}