# FlowerCore.Distribution — edition manifest publisher + content-addressed blob store. # Phase 1 of the USB provisioning architecture: signed edition manifests # (ECDSA P-256 over canonical JSON) published per edition, plus a SHA-256 # content-addressed blob store that USB builders pull from. # # Architecture: FlowerCore.Notes/docs/infrastructure/usb-provisioning-architecture.md # Repo: FlowerCore.Distribution/{README.md,CLAUDE.md} # Shared lib: FlowerCore.Common -> FlowerCore.Shared.Distribution # (manifest schema, canonical JSON, ECDSA P-256 sign/verify) # # Deployment order (see bluejay-infra/README.md and apps/fc-distribution/README.md): # 1. pfSense Unbound DNS override for dist.iamworkin.lan -> 10.0.56.200 # (DONE 2026-04-23 — verify with `python bluejay-infra/scripts/check-pfsense-dns.py`). # 2. 1Password items must exist in vault `IAmWorkin`: # - `FlowerCore Code Signing CA` (informational) # - `FlowerCore Edition Signing Key - edition:kiosk-standard` (3hf33egdvnni6jyuws3r737mqe) # - `FlowerCore Edition Signing Key - edition:aistation-field` (ccxrtsan5samfq4pfuczymacrq) # Each edition item is expected to publish three field labels: # certificate.pem, private-key.pem, chain.pem # 3. Synology NFS export `/volume1/kubernetes` is currently restricted to # rke2-server (10.0.56.11). Pod is pinned via nodeSelector below. The # app writes to subPaths `distribution/data` and `distribution/blobs`. # 4. Build + import image: localhost/fc-distribution:v # Import to rke2-server via `ctr images import` (NFS-pinned, no need # for the agents until ACL is widened — see guacamole pattern). # 5. Bump the image tag below and git push; ArgoCD ApplicationSet picks up # within ~3 minutes and creates `infra-fc-distribution`. # # NOTE on the root trust anchor: # The verifier needs an embedded root CA (`IAmWorkin ACME CA Root CA`). # That root is shipped INSIDE the published image (Phase 2 build step # bakes it into the bundle), NOT mounted from a Secret here. The # `codesigning-root-cert` OnePasswordItem below is informational only — # it gives operators a quick handle to the CA item from the cluster. --- apiVersion: v1 kind: Namespace metadata: name: fc-distribution labels: app.kubernetes.io/part-of: flowercore --- # Informational handle to the FlowerCore Code Signing CA item in 1Password. # Not consumed by the pod at runtime — the root trust anchor is baked into # the published image. Operators can `kubectl -n fc-distribution get secret # codesigning-root-cert` to discover the CA item URL/admin handle. apiVersion: onepassword.com/v1 kind: OnePasswordItem metadata: name: codesigning-root-cert namespace: fc-distribution spec: itemPath: "vaults/IAmWorkin/items/FlowerCore Code Signing CA" --- # Edition signing key + leaf cert + chain for edition:kiosk-standard. # 1Password item id: 3hf33egdvnni6jyuws3r737mqe # Operator syncs each field to a Secret key of the same name. Mounted # read-only at /signing/kiosk-standard inside the pod. apiVersion: onepassword.com/v1 kind: OnePasswordItem metadata: name: edition-kiosk-standard namespace: fc-distribution spec: itemPath: "vaults/IAmWorkin/items/FlowerCore Edition Signing Key - edition:kiosk-standard" --- # Edition signing key + leaf cert + chain for edition:aistation-field. # 1Password item id: ccxrtsan5samfq4pfuczymacrq apiVersion: onepassword.com/v1 kind: OnePasswordItem metadata: name: edition-aistation-field namespace: fc-distribution spec: itemPath: "vaults/IAmWorkin/items/FlowerCore Edition Signing Key - edition:aistation-field" --- apiVersion: onepassword.com/v1 kind: OnePasswordItem metadata: name: distribution-oidc-client namespace: fc-distribution spec: itemPath: "vaults/IAmWorkin/items/distribution-oidc-client" --- apiVersion: apps/v1 kind: Deployment metadata: name: fc-distribution namespace: fc-distribution labels: app.kubernetes.io/name: fc-distribution app.kubernetes.io/part-of: flowercore spec: replicas: 1 revisionHistoryLimit: 3 strategy: # NFS-backed SQLite + blob store on a single node. Recreate avoids any # multi-attach overlap on the same NFS subPath during rollout. type: Recreate selector: matchLabels: app.kubernetes.io/name: fc-distribution template: metadata: labels: app.kubernetes.io/name: fc-distribution app.kubernetes.io/part-of: flowercore annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" prometheus.io/path: "/metrics" spec: # 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), # this Pod must run on rke2-server or NFS mounts will be access-denied. nodeSelector: kubernetes.io/hostname: rke2-server securityContext: runAsNonRoot: true fsGroup: 1654 fsGroupChangePolicy: OnRootMismatch containers: - name: web # Placeholder tag — bump to the image you built + imported to # rke2-server before applying. Build with: # dotnet.exe publish -c Release -o deploy/app \ # src/FlowerCore.Distribution.Web/FlowerCore.Distribution.Web.csproj # podman build -t localhost/fc-distribution:v -f deploy/Dockerfile.deploy deploy image: localhost/fc-distribution:v20260604-oidc-proper imagePullPolicy: Never ports: - containerPort: 8080 name: http env: - name: ASPNETCORE_URLS value: "http://+:8080" - name: ASPNETCORE_ENVIRONMENT value: "Production" - name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT value: "false" # Authentik/OIDC enforcement. Public read/entitlement + the # dist.flowercore.io Method() allowlist stay open; OIDC gates the # operator/admin surface while /healthz remains anonymous. - name: FlowerCore__Auth__Enabled value: "true" - name: FlowerCore__Auth__Oidc__Enabled value: "true" - name: FlowerCore__Auth__Oidc__Authority value: "https://id.iamworkin.lan/application/o/distribution/" - name: FlowerCore__Auth__Oidc__Audience value: "distribution" - name: FlowerCore__Auth__Oidc__ClientId value: "distribution" - name: FlowerCore__Auth__Oidc__ClientSecret valueFrom: secretKeyRef: name: distribution-oidc-client key: client_secret optional: true # SQLite connection (catalog + data-protection keys via FlowerCoreDbContext). # Read by Data/DatabaseProviderExtensions.cs in precedence order; Sqlite key wins. - name: FlowerCore__Database__Provider value: "Sqlite" - name: FlowerCore__Database__ConnectionStrings__Sqlite value: "Data Source=/data/distribution.db" # Content-addressed blob root (SHA-256 sharded on disk). # Bound by Services/NfsPvcBlobProvider.cs under FlowerCore:Distribution:Blobs. - name: FlowerCore__Distribution__Blobs__Root value: "/blobs" # Per-edition signing material — paths into the read-only # secret mounts below. Field labels in 1Password (and therefore # Secret key names) are: certificate.pem, private-key.pem, chain.pem - name: FlowerCore__Distribution__Signing__EditionCerts__kiosk-standard__CertPath value: "/signing/kiosk-standard/chain.pem" - name: FlowerCore__Distribution__Signing__EditionCerts__kiosk-standard__KeyPath value: "/signing/kiosk-standard/private-key.pem" - name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__CertPath value: "/signing/aistation-field/chain.pem" - name: FlowerCore__Distribution__Signing__EditionCerts__aistation-field__KeyPath value: "/signing/aistation-field/private-key.pem" # Public distribution host is GET/HEAD-only at Traefik; this # entitlement list controls which editions are readable there. - name: FlowerCore__Distribution__EntitlementPublic__PublicEditions__0 value: "*" resources: requests: cpu: 100m memory: 256Mi limits: cpu: 500m memory: 512Mi # /healthz is exposed by the scaffold (StartupGateMiddleware-aware). # Liveness uses tcpSocket as a cheap fallback in case a future # middleware change accidentally gates /healthz behind auth # (memory: feedback_k8s_probes_behind_auth_middleware). startupProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 5 failureThreshold: 30 readinessProbe: httpGet: path: /healthz port: 8080 periodSeconds: 10 failureThreshold: 3 livenessProbe: tcpSocket: port: 8080 initialDelaySeconds: 30 periodSeconds: 30 failureThreshold: 3 securityContext: runAsNonRoot: true runAsUser: 1654 runAsGroup: 1654 allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL volumeMounts: - name: sqlite mountPath: /data subPath: distribution/data - name: blobs mountPath: /blobs subPath: distribution/blobs - name: tmp mountPath: /tmp - name: logs mountPath: /app/logs - name: kiosk-standard mountPath: /signing/kiosk-standard readOnly: true - name: aistation-field mountPath: /signing/aistation-field readOnly: true volumes: # Synology NFS at /volume1/kubernetes — same export pattern as # apps/guacamole/guacamole.yaml (recordings volume). Pinned by # ACL to rke2-server. Never mount the subpath as nfs.path — # always mount the export root and use volumeMount.subPath. - name: sqlite nfs: server: 10.0.58.3 path: /volume1/kubernetes - name: blobs nfs: server: 10.0.58.3 path: /volume1/kubernetes - name: tmp emptyDir: {} - name: logs emptyDir: {} - name: kiosk-standard secret: secretName: edition-kiosk-standard defaultMode: 0400 - name: aistation-field secret: secretName: edition-aistation-field defaultMode: 0400 --- apiVersion: v1 kind: Service metadata: name: fc-distribution namespace: fc-distribution labels: app.kubernetes.io/name: fc-distribution app.kubernetes.io/part-of: flowercore spec: type: ClusterIP selector: app.kubernetes.io/name: fc-distribution ports: - name: http port: 80 targetPort: 8080 --- apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: fc-distribution-tls namespace: fc-distribution spec: secretName: fc-distribution-tls-secret issuerRef: name: step-ca-acme kind: ClusterIssuer dnsNames: - dist.iamworkin.lan # step-ca ACME caps lifetime at 30d; requesting 90d silently capped # made renewBefore=cert-lifetime → perpetual renewal loop (10880+ CRs # in 18h on 2026-05-07). Match working 720h/240h pattern from other # FC services. duration: 720h # 30d (step-ca cap) renewBefore: 240h # 10d --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: fc-distribution namespace: fc-distribution spec: entryPoints: - websecure routes: - match: Host(`dist.iamworkin.lan`) kind: Rule services: - name: fc-distribution port: 80 tls: secretName: fc-distribution-tls-secret --- # === dist.flowercore.io public surface (2026-04-24) ========================= # # Shares the Deployment + Service + PVC with the internal IngressRoute above. # The controller's NamedEntitlementResolverRouter picks between the internal # (permissive) and public (strict) StaticTokenEntitlementResolver based on # the X-FC-Distribution-Profile header — which the middleware below injects # on every public-host request after stripping any caller-supplied value. # # Cert is the shared Cloudflare Origin Certificate for *.flowercore.io, literal # bytes copied (matches gitea-public, matrix, telephony, mail, flowercore-landing # pattern — not yet via OnePasswordItem operator). --- apiVersion: v1 kind: Secret metadata: name: cf-origin-flowercore-io namespace: fc-distribution type: kubernetes.io/tls data: tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVvRENDQTRpZ0F3SUJBZ0lVSXN4c1NKV1VRL0tqZ09ldk81YnNuVi9rZVE4d0RRWUpLb1pJaHZjTkFRRUwKQlFBd2dZc3hDekFKQmdOVkJBWVRBbFZUTVJrd0Z3WURWUVFLRXhCRGJHOTFaRVpzWVhKbExDQkpibU11TVRRdwpNZ1lEVlFRTEV5dERiRzkxWkVac1lYSmxJRTl5YVdkcGJpQlRVMHdnUTJWeWRHbG1hV05oZEdVZ1FYVjBhRzl5CmFYUjVNUll3RkFZRFZRUUhFdzFUWVc0Z1JuSmhibU5wYzJOdk1STXdFUVlEVlFRSUV3cERZV3hwWm05eWJtbGgKTUI0WERUSTJNRE14TURFMk16TXdNRm9YRFRReE1ETXdOakUyTXpNd01Gb3dZakVaTUJjR0ExVUVDaE1RUTJ4dgpkV1JHYkdGeVpTd2dTVzVqTGpFZE1Cc0dBMVVFQ3hNVVEyeHZkV1JHYkdGeVpTQlBjbWxuYVc0Z1EwRXhKakFrCkJnTlZCQU1USFVOc2IzVmtSbXhoY21VZ1QzSnBaMmx1SUVObGNuUnBabWxqWVhSbE1JSUJJakFOQmdrcWhraUcKOXcwQkFRRUZBQU9DQVE0QU1JSUJDZ0tDQVFFQXV0QmpkQ0xEdHdMQlZCU0Y1ZU1OMkt3ckIxTmZmRVhRMjlRRAo1aVR0dzJFcEZXNVJJSllkMjNrYUpCMU5jZXpHWlg4a0Q0cGEyWHpFZW1MVEtJNWw0MU11b3FoWjczNVE3U3RWCkVjRFFTT2ZYTkZQdFMwb0hqb0pRdGF2QjM0ZmJNR3l4Mmx0MU9HUzRNMGtLUWpBNWR6OTJQYjNyZ1RKR0JhOW4KeTZtVThncjRuUHRSdklxZ3NxdjRtMFA3dVU1YjE3NzU1Y2JLSDVoMzIxWHVjMDU4Tzl4M2JHQ0NuRUJXWDdqeApjRGhkUEs1Ri9XRjVBQnl5cFhIQ0ZxUUd4M1NVbmtCQ0ZQSmRabnMra3BHVUZWZGhud3B6NjBtNnlJSzQ0eVR4CjZqR3JOTFEyM1dOK2gwU1lCZU5vb2JBWThydkpiVlZEaGJqSVhBTWtFNGQzVll1TlhRSURBUUFCbzRJQklqQ0MKQVI0d0RnWURWUjBQQVFIL0JBUURBZ1dnTUIwR0ExVWRKUVFXTUJRR0NDc0dBUVVGQndNQ0JnZ3JCZ0VGQlFjRApBVEFNQmdOVkhSTUJBZjhFQWpBQU1CMEdBMVVkRGdRV0JCUkt1NkJVUDZ0N2dpbFRPay9FdEdKQ3R6N3dTREFmCkJnTlZIU01FR0RBV2dCUWs2Rk5YWFh3MFFJZXA2NVRidXVFV2VQd3BwREJBQmdnckJnRUZCUWNCQVFRME1ESXcKTUFZSUt3WUJCUVVITUFHR0pHaDBkSEE2THk5dlkzTndMbU5zYjNWa1pteGhjbVV1WTI5dEwyOXlhV2RwYmw5agpZVEFqQmdOVkhSRUVIREFhZ2d3cUxtbGhiWGR2Y21zdWFXNkNDbWxoYlhkdmNtc3VhVzR3T0FZRFZSMGZCREV3Ckx6QXRvQ3VnS1lZbmFIUjBjRG92TDJOeWJDNWpiRzkxWkdac1lYSmxMbU52YlM5dmNtbG5hVzVmWTJFdVkzSnMKTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFDSjMvTGNleE5pb0lWdUxoemhmbTZCeDV2SWk3T25CaHF1WUlDdwplNnArZ0prdE16ZFJQcDV0bk03dllBWmxMajVJOTByWDRuczhJc3dEbzJBN2wwYTRGZVJFclFmRklsZXQzbjIyCjUxVTZYVElCSks5c1FZT0FkU3pJUzV1OUNKSFpBUTF5WmxSd3BBR3RVWnhxL1dpcGFWUTRwNXhrcEJNMVlZSlAKNW1jQ09HcFErSnpORlpQc2daYUJncDBYL1BBZkNJRkkyZld5QWE2elBqRm0rdDVXUXIrZlBaT2VUS2VIbWVzVgo3UlZxUUdEb3Q0eTY1NklEdmdmU2ZLRnFIRW9XNDJVbDBxQ05hMS9keEJld3NIS1VWWE1ETkdiQlNVQjM4TG9YCm1OQ3hJQlVOUjR0TG1CQUxZT3hVMnZhSWRCd0xBc2YrcndnVnVjUGpCUTc2VWMwUQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQzYwR04wSXNPM0FzRlUKRklYbDR3M1lyQ3NIYTE5OFJkRGIxQVBtSk8zRFlTa1ZibEVnbGgzYmVSb2tIVTF4N01abGZ5UVBpbHJaZk1SNgpZdE1vam1YalV5NmlxRm52ZmxEdEsxVVJ3TkJJNTljMFUrMUxTZ2VPZ2xDMXE4SGZoOXN3YkxIYVczVTRaTGd6ClNRcENNRGwzUDNZOXZldUJNa1lGcjJmTHFaVHlDdmljKzFHOGlxQ3lxL2liUS91NVRsdlh2dm5seHNvZm1IZmIKVmU1elRudzczSGRzWUlLY1FGWmZ1UEZ3T0YwOHJrWDlZWGtBSExLbGNjSVdwQWJIZEpTZVFFSVU4bDFtZXo2UwprWlFWVjJHZkNuUHJTYnJJZ3JqakpQSHFNYXMwdERiZFkzNkhSSmdGNDJpaHNCanl1OGx0VlVPRnVNaGNBeVFUCmgzZFZpNDFkQWdNQkFBRUNnZ0VBTGlseXZkNmVTcEYvZUxtV2lhTVV4NUxwa2dhWHpITkxCQnNNZUpqcytLL0EKVVdlZ1crTkVUdmlLalZ5QlI5SzRocG1IYldDa2lPUDBBQUwrQnlKQ3lvekNOQmJTSEdRejlwc1R5dzZBV1ZlUwpuYjlVWGx1VmFQRktKTTRqbXNydERuYjVic25WT2lGblErTDdTalkwNlFMUlFybjBvUWp0ZFJldUdBMFlQVU90CkhSYzNsMFg2ZHJqdkJYY2prWTQwWm9ZYkRrelJnU1JWbWVOUGFIbjZPR0NtYUVUMXVyK01qYVZ2ME9lbEdIWncKVzljSEIxaHNxRzUvMWU3V0RQN0l0cjkwTmg4ay81NVhiK3lQUnhsRFd5bWtZMzIvdFBtZzdESTRKV2tRRWt3cgpIZUtwODVTcE5ta1liRnVpVFppeU8zZDZ0aXZHNHhFZW8rSzFVVFU4c1FLQmdRRFRNSEU1RDFYVC9HbGR5VHNsCllrODRVL1N0NXUrK2RIUEt1Wmw2dVB0UGgxV1lrdnFRcmdrL05YanVud2xGN0Y3b2tWOGdPeWxreTYwYTZkcXIKeXZwN1ZJdXYzekVlc2h2NjNWMlpaVkMzcXZYSzFheit3Zmx3NitCZmVuRlY5S2NENHN0dTdwOFRPWmFGN01CUgo3YXZzaXVXbWtqdmM1TlVLRmVDRTY0SnZFUUtCZ1FEaWMrbWlNLzBodDN1ajhuOXgyMDFQZFNqbEpVaUc1NjNNCnRYZlBCdDJRT0NhaVluUFNFdTdXdm5pQWRFL2xrMm91cFRWam9LYmZPbDFyQjd6UzVhc2kxdVdDZDhlUy9UWGIKdU5iRmlNMDB4L3JxalMydCtQbTd4MVhrYTB4TFNSRDNmZ0tSQldSN3pscStkYWZ1WE1qelUxRnh5dTIycGphRgpIMEl3NEpCUmpRS0JnUUNOaWhMb0Rob1V5RCtKNXJzb00vb3FJMEtDWnB0WlJzendHbkg5cVFwdFk2Ti9iVXBYCk92emhpeUh3czAvUXVEbG5uejVrNktHMmR6Y2VLWXN2eGdzWUt6S3ZmV043VWgya2hVWWM3NlVvWTREMkh6MGgKUkxtNzc2cGg4enNRUTdiSHlQRlUrTUpPYlRNdnNOdTRUUlVEcEplRGl0QnFIRWVYeWMrKzVlUjJNUUtCZ0h2UgptVHVoWlpVYitEVEtrVGkyQ20yWnlBU1RBRGNUVW9xTjVyYUNNSDk4MUZNUnRmWjFkN1pmYXhBQmlQWWtSbmkrCnlKUnk4UXM1cEg2ek9tR3VSb2JFTGJYS3ZJcjRmSXhwWXJXYmVXaVV0L09yd2dCUUZHekNMNHEzeUgyWnMvYy8KSlRRYVdMa0JPY2pPR0VaUzRXVjZkeHZiTTJNZE9zNUxLeXdDZmFhNUFvR0FIQUE1eEN0dndOZE4xeExndkZ3RApPK2lyMDl1bXMxOFBzSVpmK1ZrWGtpcHF4MWNUT0hEanpPR01yWXV0M2FFeE00Zjd2ckFHRFMyY2pwZjM0T1JxCit4Y2gwWlNaQ2FDZmlnZG9OelNkcDFLcmo0cnFKdG5ZdS9CNDlDQlVoSDBNaCtSRWswQ0hHOVE4b3FOWFk0V0wKbVVOVTZMYUkwQWtvSzNVb2tWQVJEYXM9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K --- # Traefik middleware: strips any caller-supplied X-FC-Distribution-Profile, # then sets an authoritative 'public' value so the controller routes to the # strict entitlement resolver. The trust boundary is this middleware — the # internal IngressRoute (dist.iamworkin.lan) does NOT attach it. apiVersion: traefik.io/v1alpha1 kind: Middleware metadata: name: dist-public-profile-header namespace: fc-distribution spec: headers: customRequestHeaders: X-FC-Distribution-Profile: "public" --- # Public IngressRoute: binds dist.flowercore.io (Cloudflare-proxied A record # -> pfSense NAT -> Traefik VIP 10.0.56.200) to the same backend Service that # serves dist.iamworkin.lan. Header-injection middleware ensures the # controller uses the public (strict) entitlement resolver. apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: fc-distribution-public namespace: fc-distribution spec: entryPoints: - websecure routes: # Method allowlist: Host + (GET || HEAD). Anything else misses every # route and Traefik returns 404 before reaching the pod — edge-level # defense-in-depth over the controller's strict-mode entitlement check. # Together these block admin ops (POST /blobs, POST /manifests*) from # ever being processed on the public surface. - match: Host(`dist.flowercore.io`) && (Method(`GET`) || Method(`HEAD`)) kind: Rule middlewares: - name: dist-public-profile-header services: - name: fc-distribution port: 80 tls: secretName: cf-origin-flowercore-io