# Felhom Hub — Multi-customer dashboard # Dashboard: https://hub.felhom.eu # API: POST /api/v1/report (Bearer token auth) # # Receives health reports from customer controllers and displays # a centralized overview dashboard for the operator (Viktor). # # Namespace: felhom-system (shared with healthchecks and other felhom infra) # # PREREQUISITES: # 1. Build and push the hub image: # cd ~/build/felhom-hub && ./build.sh v0.2.0 --push # # 2. Generate a bcrypt password hash for dashboard login: # htpasswd -nbBC 10 "" "your-password" | cut -d: -f2 # Update the ConfigMap password_hash field below. # # 3. Create the operator/global bearer key Secret (out-of-band, NEVER committed): # openssl rand -hex 32 # mint # kubectl -n felhom-system create secret generic report-api \ # --from-literal=REPORT_API_KEY= # (Customer boxes use per-customer/per-host keys generated by the hub — the global # key is the operator's own, e.g. felhom-ops -hub-key.) # # 4. Apply this manifest: # kubectl apply -f manifests/hub.yaml # # 5. Configure DNS: # Add hub.felhom.eu → k3s cluster IP in Cloudflare # # DEBUGGING: # kubectl logs -n felhom-system deploy/hub -f # kubectl exec -it -n felhom-system deploy/hub -- ls /data/ # kubectl describe ingress -n felhom-system hub # ============================================================================= # PERSISTENT STORAGE # ============================================================================= --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: hub-data namespace: felhom-system labels: app: hub recurring-job-group.longhorn.io/default: disabled spec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 1Gi # ============================================================================= # CONFIGURATION # ============================================================================= --- apiVersion: v1 kind: ConfigMap metadata: name: hub-config namespace: felhom-system data: hub.yaml: | auth: # Bcrypt hash for dashboard login (Viktor only) # Generate: htpasswd -nbBC 10 "" "your-password" | cut -d: -f2 password_hash: "$2y$10$N5.O9jBnc.1tIlJT/irx3OlVjJQemlCHRnfqIJg/EyZofnzXSCpeG" api: # Operator/global bearer key. NOT stored here since v0.53.0 — injected at runtime from # Secret/report-api via the REPORT_API_KEY env var (see Deployment below). The Secret is # created out-of-band and NOT committed (documentation/runbooks/secrets.md); the previously # committed literal is retired by ROTATION (see the publish-runbook notes). Leave empty. report_api_key: "" retention: max_days: 90 prune_schedule: "04:30" alerting: stale_threshold: "30m" notifications: # Resend API key is NOT stored here. It is injected at runtime from Secret/resend-api # via the RESEND_API_KEY env var (see Deployment below). The Secret is created out-of-band # and is NOT committed — see documentation/runbooks/secrets.md. Leave this empty. resend_api_key: "" # Operator alert recipient + enable. WITHOUT both, Dispatcher.processOperator returns early and # NO operator email is ever sent — the self-health pipeline (probe→report→checker→dispatch) stops # one hop short of the inbox (TESTRUN finding: the unproven hop). The address is the operator's own # and is not a secret. from_email defaults to monitoring@felhom.eu. operator_email: "admin@felhom.eu" operator_enabled: true registry: image: "gitea.dooplex.hu/admin/felhom-controller" # username + token injected via REGISTRY_USERNAME / REGISTRY_TOKEN env vars # from Secret/gitea-creds (see Deployment below) check_interval: "6h" template_interval: "1h" server: listen: ":8080" data_dir: "/data" # ============================================================================= # DEPLOYMENT # ============================================================================= --- apiVersion: apps/v1 kind: Deployment metadata: name: hub namespace: felhom-system labels: app: hub spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: hub template: metadata: labels: app: hub spec: containers: - name: hub image: gitea.dooplex.hu/admin/felhom-hub:0.97.0 ports: - containerPort: 8080 name: http env: - name: TZ value: "Europe/Budapest" # Phase 2 managed updates: global controller-version FLOOR fallback. Any reporting box below # this auto-updates to it (unless a per-customer override is set via the operator UI). - name: DEFAULT_MIN_CONTROLLER_VERSION value: "0.120.0" # Resend API key — injected from the out-of-band Secret/resend-api (NOT committed). # See documentation/runbooks/secrets.md. Overrides the empty ConfigMap placeholder. - name: RESEND_API_KEY valueFrom: secretKeyRef: name: resend-api key: RESEND_API_KEY # Operator/global bearer key — injected from the out-of-band Secret/report-api # (NOT committed; documentation/runbooks/secrets.md). Deliberately NOT optional: # a missing Secret must fail the pod Ready rather than boot an unauthenticatable # hub with an empty bearer key. Create the Secret BEFORE syncing this manifest. - name: REPORT_API_KEY valueFrom: secretKeyRef: name: report-api key: REPORT_API_KEY - name: REGISTRY_USERNAME valueFrom: secretKeyRef: name: gitea-creds key: username - name: REGISTRY_TOKEN valueFrom: secretKeyRef: name: gitea-creds key: password # S1 offsite connectivity: the WG peer-sync push channel (doc 06 §5 + runbook # offsite-endpoint.md). Addr is dev-phase literal (the throwaway endpoint); the SSH # private key + (non-secret) pinned host key come from Secret/wg-endpoint-ssh, # created out-of-band in runbook step 6 — optional so the pod starts before it # exists (the hub logs peer-sync disabled until then). - name: WG_ENDPOINT_SSH_ADDR value: "167.233.158.164:22" - name: WG_ENDPOINT_SSH_USER value: "felhom-peersync" - name: WG_ENDPOINT_SSH_KEY_FILE value: "/etc/hub-secrets/wg-endpoint-ssh/key" - name: WG_ENDPOINT_SSH_HOSTKEY valueFrom: secretKeyRef: name: wg-endpoint-ssh key: hostkey optional: true # PBS DR tier (SLICE 1): the tenantsync channel — same endpoint + pinned host key as # peersync (env above), its OWN private key from Secret/tenantsync (out-of-band, # runbook offsite-endpoint.md §10). Optional: absent → the hub logs tenantsync disabled. - name: TENANTSYNC_SSH_KEY_FILE value: "/etc/hub-secrets/tenantsync/key" # Agent-plane immediate-sync (Direction-2a, v0.59.0): the poke sender — same endpoint + # pinned host key + peersync user as above, its OWN forced-command key from Secret/agent-poke # (out-of-band; the ep0 authorized_keys line carries the PUBLIC half, command="felhom-poke"). # Optional: absent → the hub logs the poke disabled; agent-plane saves still reconcile in # ≤15 min. See documentation/runbooks/offsite-endpoint.md (poke section). - name: POKE_SSH_KEY_FILE value: "/etc/hub-secrets/agent-poke/key" # Offsite provisioning (SLICE 1+2): Hetzner Storage Box API token + the NUMERIC id of the # pool box, from the out-of-band Secret/storagebox (NOT committed). The token MUST be scoped # to the dedicated storage project — NEVER the shared-project token (it can touch ep0). # HETZNER_POOL_BOX_ID is the numeric box id (console #id), not the box name. Optional so the # pod starts before the secret exists (hub then logs offsite provisioning disabled). - name: HETZNER_POOL_BOX_ID valueFrom: secretKeyRef: name: storagebox key: HETZNER_POOL_BOX_ID optional: true - name: HETZNER_TOKEN valueFrom: secretKeyRef: name: storagebox key: HETZNER_TOKEN optional: true # Non-secret; the hub defaults to fsn1 anyway — explicit for clarity. - name: HETZNER_LOCATION value: "fsn1" resources: requests: memory: "64Mi" cpu: "50m" limits: memory: "256Mi" cpu: "500m" volumeMounts: - name: data mountPath: /data - name: config mountPath: /etc/felhom-hub - name: wg-endpoint-ssh mountPath: /etc/hub-secrets/wg-endpoint-ssh readOnly: true - name: tenantsync mountPath: /etc/hub-secrets/tenantsync readOnly: true - name: agent-poke mountPath: /etc/hub-secrets/agent-poke readOnly: true livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 5 periodSeconds: 30 timeoutSeconds: 5 readinessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 3 periodSeconds: 10 timeoutSeconds: 3 volumes: - name: data persistentVolumeClaim: claimName: hub-data - name: config configMap: name: hub-config - name: wg-endpoint-ssh secret: secretName: wg-endpoint-ssh optional: true items: - key: key path: key mode: 0400 - name: tenantsync secret: secretName: tenantsync optional: true items: - key: key path: key mode: 0400 - name: agent-poke secret: secretName: agent-poke optional: true items: - key: key path: key mode: 0400 # ============================================================================= # SERVICE # ============================================================================= --- apiVersion: v1 kind: Service metadata: name: hub namespace: felhom-system labels: app: hub spec: selector: app: hub ports: - port: 8080 targetPort: 8080 name: http # ============================================================================= # INGRESS — hub.felhom.eu # ============================================================================= --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: hub namespace: felhom-system annotations: cert-manager.io/cluster-issuer: letsencrypt-prod nginx.ingress.kubernetes.io/proxy-body-size: "2m" # Geo-restrict to Hungary (operator-only dashboard) # NOTE: /api/v1/report must also be reachable — all customers are in HU nginx.ingress.kubernetes.io/configuration-snippet: | set $geo_allowed 0; if ($remote_addr ~ "^192\.168\.") { set $geo_allowed 1; } if ($remote_addr ~ "^10\.") { set $geo_allowed 1; } if ($geoip2_country_code = "HU") { set $geo_allowed 1; } if ($geo_allowed = 0) { return 403 "Access restricted to Hungary"; } spec: ingressClassName: nginx-internal tls: - hosts: - hub.felhom.eu secretName: hub-felhom-eu-tls rules: - host: hub.felhom.eu http: paths: - path: / pathType: Prefix backend: service: name: hub port: number: 8080