diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 2768b7c42..09e72a9d7 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -261,7 +261,7 @@ reviews: - label: "area: specs" instructions: Apply when changes touch specs/ or spec.md files (specs/done_008-hardened-github-actions/, design contracts, decision records). - label: "area: shared" - instructions: Apply when changes touch shared Go packages (netguard/, gameaction/, gameproto/, svcutil/) used by operator, api, and agent. + instructions: Apply when changes touch shared Go packages (netguard/, gameaction/, gameproto/, svcutil/, telemetryschema/) used by operator, api, agent, and telemetry-receiver. - label: "area: optional-components" instructions: Apply when changes touch optional or sidecar components (sentinel/, capture-sidecar/, mcp-server/, tunnel/, audit-syslog-bridge/, telemetry-receiver/). - label: "breaking" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 6d63e9189..40ecb6e9f 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -76,7 +76,7 @@ log "fetching envtest binaries for K8s 1.36.2" # ---------- Go module cache warmup ---------- # Go modules from go.work. Derived from 'use' entries in go.work. # Keep in sync with go.work when adding/removing modules. -for m in agent api audit-syslog-bridge capture-sidecar gameaction gameproto mcp-server netguard operator sentinel svcutil telemetry-receiver test/e2e tunnel; do +for m in agent api audit-syslog-bridge capture-sidecar gameaction gameproto mcp-server netguard operator sentinel svcutil telemetry-receiver telemetryschema test/e2e tunnel; do log "go mod download ($m)" ( cd "$m" && go mod download ) done diff --git a/.github/actions/build-e2e-images/action.yml b/.github/actions/build-e2e-images/action.yml index cf41c7db2..e8239747c 100644 --- a/.github/actions/build-e2e-images/action.yml +++ b/.github/actions/build-e2e-images/action.yml @@ -1,9 +1,9 @@ name: build e2e images description: > - Build the operator/api/agent/sentinel/capture-sidecar/fakeoidc (+ gameprobe) + Build the operator/api/agent/sentinel/capture-sidecar/telemetry-receiver/fakeoidc (+ gameprobe) images the kind e2e suites load into the cluster, tagged gameplane-test/:e2e exactly like make e2e-images, via buildx bake - — then `docker save` all seven into a single tar uploaded as an artifact + — then `docker save` all eight into a single tar uploaded as an artifact (e2e-images-). The heavy e2e jobs load that tar instead of each recompiling the same binaries. This is the ONLY builder, so it always writes the layer cache back (the old single-writer export dance is gone). @@ -16,7 +16,7 @@ runs: using: composite steps: - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - name: bake e2e images (operator/api/agent/sentinel/capture-sidecar/fakeoidc) + - name: bake e2e images (operator/api/agent/sentinel/capture-sidecar/telemetry-receiver/fakeoidc) uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0 with: files: docker-bake.hcl @@ -28,12 +28,14 @@ runs: e2e-agent.cache-from=type=gha,scope=e2e-agent-${{ inputs.arch }} e2e-sentinel.cache-from=type=gha,scope=e2e-sentinel-${{ inputs.arch }} e2e-capture-sidecar.cache-from=type=gha,scope=e2e-capture-sidecar-${{ inputs.arch }} + e2e-telemetry-receiver.cache-from=type=gha,scope=e2e-telemetry-receiver-${{ inputs.arch }} e2e-fakeoidc.cache-from=type=gha,scope=e2e-fakeoidc-${{ inputs.arch }} e2e-operator.cache-to=type=gha,mode=max,scope=e2e-operator-${{ inputs.arch }} e2e-api.cache-to=type=gha,mode=max,scope=e2e-api-${{ inputs.arch }} e2e-agent.cache-to=type=gha,mode=max,scope=e2e-agent-${{ inputs.arch }} e2e-sentinel.cache-to=type=gha,mode=max,scope=e2e-sentinel-${{ inputs.arch }} e2e-capture-sidecar.cache-to=type=gha,mode=max,scope=e2e-capture-sidecar-${{ inputs.arch }} + e2e-telemetry-receiver.cache-to=type=gha,mode=max,scope=e2e-telemetry-receiver-${{ inputs.arch }} e2e-fakeoidc.cache-to=type=gha,mode=max,scope=e2e-fakeoidc-${{ inputs.arch }} - name: bake gameprobe image uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0 @@ -53,6 +55,7 @@ runs: gameplane-test/agent:e2e \ gameplane-test/sentinel:e2e \ gameplane-test/capture-sidecar:e2e \ + gameplane-test/telemetry-receiver:e2e \ gameplane-test/fakeoidc:e2e \ gameplane-test/gameprobe:e2e \ -o "$RUNNER_TEMP/e2e-images.tar" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index db5f61800..c6f851104 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -172,6 +172,20 @@ updates: svcutil-minor-patch: update-types: ["minor", "patch"] + - package-ecosystem: "gomod" + directory: "/telemetryschema" + schedule: + interval: "weekly" + day: "monday" + time: "03:00" + commit-message: + prefix: "chore(deps)" + include: "scope" + open-pull-requests-limit: 5 + groups: + telemetryschema-minor-patch: + update-types: ["minor", "patch"] + - package-ecosystem: "gomod" directory: "/telemetry-receiver" schedule: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index efe32636e..77dcb7887 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -118,6 +118,7 @@ jobs: - 'agent/**' - 'audit-syslog-bridge/**' - 'telemetry-receiver/**' + - 'telemetryschema/**' - 'sentinel/**' - 'mcp-server/**' - 'capture-sidecar/**' @@ -189,6 +190,9 @@ jobs: specs: - '**/specs.md' - 'hack/check-specs.sh' + - 'hack/check-telemetry-catalog.sh' + - 'telemetryschema/catalog.txt' + - 'charts/gameplane/values.yaml' - 'hack/check-dev-load-images.sh' - 'hack/test-up-registry.sh' - 'deploy/kind/up.sh' @@ -463,6 +467,8 @@ jobs: run: make test-doc-versions - name: check specs compliance run: make check-specs + - name: check telemetry catalog + run: make check-telemetry-catalog - name: check dev-load image coverage run: make check-dev-load-images - name: test up.sh registry/context bootstrap @@ -479,7 +485,7 @@ jobs: strategy: fail-fast: false matrix: - module: [netguard, gameaction, gameproto, operator, agent, api, sentinel, audit-syslog-bridge, telemetry-receiver, mcp-server, capture-sidecar, svcutil, tunnel, gp-module, test/e2e] + module: [netguard, gameaction, gameproto, telemetryschema, operator, agent, api, sentinel, audit-syslog-bridge, telemetry-receiver, mcp-server, capture-sidecar, svcutil, tunnel, gp-module, test/e2e] exclude: ${{ fromJSON(needs.changes.outputs.lint-exclude) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -529,6 +535,21 @@ jobs: version: v2.12.2 working-directory: ${{ matrix.module }} + # Merge gate for spec 022: the project default telemetry destination + # (DefaultEndpoint in api/internal/telemetry/destination.go) must be an https + # URL. OPEN-DECISIONS OD-1 is ruled, so it passes; it keeps FR-002 enforced. Deliberately + # separate: not part of `make lint`, and not in the `report` job's needs. + telemetry-default-gate: + name: telemetry default gate + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: check the default telemetry destination + run: sh hack/check-telemetry-default.sh + go: name: go (${{ matrix.module }} / ${{ matrix.arch }}) needs: [changes] @@ -543,7 +564,7 @@ jobs: strategy: fail-fast: false matrix: - module: [netguard, gameaction, gameproto, operator, api, agent, audit-syslog-bridge, telemetry-receiver, sentinel, mcp-server, capture-sidecar, svcutil, tunnel, gp-module] + module: [netguard, gameaction, gameproto, telemetryschema, operator, api, agent, audit-syslog-bridge, telemetry-receiver, sentinel, mcp-server, capture-sidecar, svcutil, tunnel, gp-module] exclude: ${{ fromJSON(needs.changes.outputs.go-exclude) }} arch: [amd64, arm64] steps: @@ -774,12 +795,16 @@ jobs: # telemetry-receiver needs its own ServiceMonitor, and both it and # the agent PodMonitor need a NetworkPolicy exception for the # configured Prometheus namespace once one is set via - # serviceMonitors.scrapeNamespaceSelector. + # serviceMonitors.scrapeNamespaceSelector. Spec 022: the receiver + # serves /metrics only on its dashboard port 8081 behind a Bearer + # token, so its ServiceMonitor and NetworkPolicy rule need + # api.telemetry.receiver.dashboard.tokenSecretRef.name. run: | scrape_ns='serviceMonitors.scrapeNamespaceSelector={"matchLabels":{"kubernetes.io/metadata.name":"monitoring"}}' helm template gameplane charts/gameplane \ --set serviceMonitors.enabled=true \ --set api.telemetry.receiver.enabled=true \ + --set api.telemetry.receiver.dashboard.tokenSecretRef.name=receiver-dashboard \ --set-json "$scrape_ns" \ --show-only templates/servicemonitors.yaml > /tmp/servicemonitors.yaml awk '/kind: PodMonitor/,0' /tmp/servicemonitors.yaml | grep -q 'port: metrics' @@ -792,6 +817,24 @@ jobs: exit 1 fi grep -q 'name: gameplane-telemetry-receiver' /tmp/servicemonitors.yaml + # The receiver serves /metrics only on its dashboard listener (8081) + # behind the dashboard token, so the ServiceMonitor scrapes that + # port with the token Secret as bearerTokenSecret. + awk '/name: gameplane-telemetry-receiver/,/^---$/' /tmp/servicemonitors.yaml > /tmp/receiver-servicemonitor.yaml + grep -q 'port: dashboard' /tmp/receiver-servicemonitor.yaml + grep -q 'bearerTokenSecret:' /tmp/receiver-servicemonitor.yaml + grep -q 'name: receiver-dashboard' /tmp/receiver-servicemonitor.yaml + # No dashboard token: no receiver ServiceMonitor (it could only scrape + # a permanently-down target); NOTES.txt carries the warning. + helm template gameplane charts/gameplane \ + --set serviceMonitors.enabled=true \ + --set api.telemetry.receiver.enabled=true \ + --set-json "$scrape_ns" \ + --show-only templates/servicemonitors.yaml > /tmp/servicemonitors-notoken.yaml + if grep -q 'name: gameplane-telemetry-receiver' /tmp/servicemonitors-notoken.yaml; then + echo "::error::receiver ServiceMonitor rendered without api.telemetry.receiver.dashboard.tokenSecretRef.name" + exit 1 + fi helm template gameplane charts/gameplane \ --set serviceMonitors.enabled=true \ --set-json "$scrape_ns" \ @@ -801,12 +844,24 @@ jobs: helm template gameplane charts/gameplane \ --set serviceMonitors.enabled=true \ --set api.telemetry.receiver.enabled=true \ + --set api.telemetry.receiver.dashboard.tokenSecretRef.name=receiver-dashboard \ --set-json "$scrape_ns" \ --show-only templates/telemetry-receiver.yaml > /tmp/telemetry-receiver-scrape.yaml - awk '/kind: NetworkPolicy/,0' /tmp/telemetry-receiver-scrape.yaml \ + awk '/kind: NetworkPolicy/,/^---$/' /tmp/telemetry-receiver-scrape.yaml > /tmp/telemetry-receiver-netpol.yaml + # The Prometheus namespace is admitted on the dashboard port 8081, + # not on the public ingest port 8080 (which admits only the API). + awk '/namespaceSelector:/,0' /tmp/telemetry-receiver-netpol.yaml \ | grep -q 'kubernetes.io/metadata.name: monitoring' + awk '/namespaceSelector:/,0' /tmp/telemetry-receiver-netpol.yaml \ + | grep -q 'port: 8081' + if awk '/namespaceSelector:/,0' /tmp/telemetry-receiver-netpol.yaml | grep -q 'port: 8080'; then + echo "::error::the Prometheus namespace must not be admitted on the receiver ingest port 8080" + exit 1 + fi awk '/kind: Service/,/^---$/' /tmp/telemetry-receiver-scrape.yaml \ | grep -q 'app.kubernetes.io/name: gameplane-telemetry-receiver' + awk '/kind: Service/,/^---$/' /tmp/telemetry-receiver-scrape.yaml \ + | grep -q 'name: dashboard' # scrapeNamespaceSelector unset (the chart default): neither # exception renders, matching the pre-fix behavior. helm template gameplane charts/gameplane \ @@ -816,6 +871,94 @@ jobs: echo "::error::allow-prometheus-to-agent rendered without serviceMonitors.scrapeNamespaceSelector set" exit 1 fi + - name: telemetry install values render (spec 022 FR-005, FR-006) + # api.telemetry.enabled=false renders --telemetry-disabled; the bundled + # receiver auto-wires the API and marks it bundled; a custom endpoint + # is used as-is without the bundled marker; defaults render no endpoint. + run: | + # api.telemetry.enabled=false is the hard off switch. + helm template gameplane charts/gameplane \ + --set api.telemetry.enabled=false \ + --show-only templates/api.yaml > /tmp/api-telemetry-off.yaml + grep -q -- '--telemetry-disabled' /tmp/api-telemetry-off.yaml + # The bundled receiver auto-wires the API and marks it bundled. + helm template gameplane charts/gameplane \ + --set api.telemetry.receiver.enabled=true \ + --show-only templates/api.yaml > /tmp/api-telemetry-bundled.yaml + grep -q -- '--telemetry-endpoint=http://gameplane-telemetry-receiver' /tmp/api-telemetry-bundled.yaml + grep -q 'GAMEPLANE_TELEMETRY_BUNDLED' /tmp/api-telemetry-bundled.yaml + if grep -q -- '--telemetry-disabled' /tmp/api-telemetry-bundled.yaml; then + echo "::error::--telemetry-disabled rendered with api.telemetry.enabled left at its default" + exit 1 + fi + # A custom endpoint is used as-is and is never marked bundled. + helm template gameplane charts/gameplane \ + --set api.telemetry.endpoint=https://telemetry.example.com/ingest \ + --set api.telemetry.receiver.enabled=true \ + --show-only templates/api.yaml > /tmp/api-telemetry-custom.yaml + grep -q -- '--telemetry-endpoint=https://telemetry.example.com/ingest' /tmp/api-telemetry-custom.yaml + if grep -q 'GAMEPLANE_TELEMETRY_BUNDLED' /tmp/api-telemetry-custom.yaml; then + echo "::error::GAMEPLANE_TELEMETRY_BUNDLED rendered for a custom telemetry endpoint" + exit 1 + fi + # Defaults render no endpoint (the API falls back to its compiled-in default). + helm template gameplane charts/gameplane \ + --show-only templates/api.yaml > /tmp/api-telemetry-default.yaml + if grep -q -- '--telemetry-endpoint' /tmp/api-telemetry-default.yaml; then + echo "::error::default values rendered a --telemetry-endpoint" + exit 1 + fi + grep -q -- '--official-module-source=default' /tmp/api-telemetry-default.yaml + - name: telemetry receiver proof-of-work render (spec 022 FR-039, OD-5) + # api.telemetry.receiver.ingestPow renders INGEST_POW*: off with the + # OD-5 defaults, the configured values when enabled, and a failed + # render for a value the receiver would refuse to start with (the + # difficulty cap is the hardest challenge an install solves). + run: | + pow_env() { + # pow_env FILE WANT...: INGEST_POW, _TARGET_PER_MIN, _MIN_BITS and + # _MAX_BITS of the rendered receiver Deployment must equal WANT. + python3 - "$@" <<'PY' + import sys, yaml + path, *want = sys.argv[1:] + docs = [d for d in yaml.safe_load_all(open(path)) if d] + env = {e['name']: e['value'] for d in docs if d['kind'] == 'Deployment' + for e in d['spec']['template']['spec']['containers'][0]['env'] if 'value' in e} + got = [env[k] for k in ('INGEST_POW', 'INGEST_POW_TARGET_PER_MIN', 'INGEST_POW_MIN_BITS', 'INGEST_POW_MAX_BITS')] + assert got == want, f"INGEST_POW env = {got}, want {want}" + PY + } + pow_fails() { + # pow_fails MESSAGE --set ...: the render must fail with MESSAGE. + message="$1"; shift + if helm template gameplane charts/gameplane --set api.telemetry.receiver.enabled=true "$@" \ + --show-only templates/telemetry-receiver.yaml > /dev/null 2> /tmp/pow-fail.err; then + echo "::error::receiver rendered for $*" + exit 1 + fi + if ! grep -q -- "$message" /tmp/pow-fail.err; then + echo "::error::render for $* failed without '$message'" + cat /tmp/pow-fail.err + exit 1 + fi + } + pow=api.telemetry.receiver.ingestPow + # Defaults: proof-of-work off, with the OD-5 values. + helm template gameplane charts/gameplane \ + --set api.telemetry.receiver.enabled=true \ + --show-only templates/telemetry-receiver.yaml > /tmp/pow-default.yaml + pow_env /tmp/pow-default.yaml false 60 0 22 + # Enabled with its own values. + helm template gameplane charts/gameplane \ + --set api.telemetry.receiver.enabled=true \ + --set $pow.enabled=true --set $pow.targetPerMinute=30 \ + --set $pow.minBits=8 --set $pow.maxBits=26 \ + --show-only templates/telemetry-receiver.yaml > /tmp/pow-enabled.yaml + pow_env /tmp/pow-enabled.yaml true 30 8 26 + # Values the receiver would refuse to start with fail the render. + pow_fails 'ingestPow.targetPerMinute must be at least 1' --set $pow.targetPerMinute=0 + pow_fails 'ingestPow.maxBits must be at most 26' --set $pow.maxBits=27 + pow_fails 'ingestPow.maxBits must be at least minBits' --set $pow.minBits=10 --set $pow.maxBits=9 - name: games Namespace carries the keep policy # F-212: `helm uninstall` used to delete the games Namespace and # every GameServer/StatefulSet/PVC in it. Assert the Namespace is @@ -890,6 +1033,33 @@ jobs: assert ('NetworkPolicy', 'allow-api-to-agent') in resources assert ('NetworkPolicy', 'allow-api-to-capture') not in resources, "legacy values without capture enabled its ingress" PY + # Spec 022: with the bundled receiver and ServiceMonitors switched on + # over beta.8's values (no persistence, dashboard, interval or enabled + # keys), the chart still renders: no receiver PVC is added, no + # receiver ServiceMonitor renders without a token, and the API + # keeps working without api.telemetry.enabled. + helm template gameplane charts/gameplane \ + --set api.telemetry.receiver.enabled=true \ + --set serviceMonitors.enabled=true > /tmp/rendered-reuse-values-receiver.yaml + python3 - <<'PY' + import yaml + docs = [d for d in yaml.safe_load_all(open('/tmp/rendered-reuse-values-receiver.yaml')) if d] + resources = {(d['kind'], d['metadata']['name']) for d in docs} + assert ('Deployment', 'gameplane-telemetry-receiver') in resources + assert ('PersistentVolumeClaim', 'gameplane-telemetry-receiver-data') not in resources, "--reuse-values added receiver storage" + assert ('ServiceMonitor', 'gameplane-telemetry-receiver') not in resources, "receiver ServiceMonitor rendered without a dashboard token" + PY + # Spec 022 FR-039: beta.8's values have no ingestPow block, so the + # receiver renders the OD-5 defaults, with proof-of-work off. + python3 - <<'PY' + import yaml + docs = [d for d in yaml.safe_load_all(open('/tmp/rendered-reuse-values-receiver.yaml')) if d] + env = {e['name']: e['value'] for d in docs + if d['kind'] == 'Deployment' and d['metadata']['name'] == 'gameplane-telemetry-receiver' + for e in d['spec']['template']['spec']['containers'][0]['env'] if 'value' in e} + got = [env[k] for k in ('INGEST_POW', 'INGEST_POW_TARGET_PER_MIN', 'INGEST_POW_MIN_BITS', 'INGEST_POW_MAX_BITS')] + assert got == ['false', '60', '0', '22'], f"--reuse-values changed the INGEST_POW defaults: {got}" + PY - name: crd-manifests in sync with crds # The CRD-apply hook serves CRDs from crd-manifests/ (crds/ isn't # readable by Helm .Files). make manifests keeps both in sync; guard @@ -1137,7 +1307,7 @@ jobs: # (operator does zero logins and is wait-dominated, so it runs # wide; the api buckets stay within the login limiter's budget). # The arm64 job below uses the same steps and per-bucket settings. - bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods] + bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry] arch: [amd64] include: - bucket: operator @@ -1165,6 +1335,10 @@ jobs: parallel: 2 test_timeout: 15m job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite. + - bucket: telemetry + parallel: 1 + test_timeout: 45m # the lifecycle test waits on 1m report intervals for every subtest + job_timeout: 75 # Boots a kind cluster, installs the chart, runs the bucket's suite. env: CLUSTER: gameplane-e2e CTX: kind-gameplane-e2e @@ -1228,7 +1402,7 @@ jobs: # (operator does zero logins and is wait-dominated, so it runs # wide; the api buckets stay within the login limiter's budget). # Mirrors e2e-go's steps and per-bucket settings. - bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods] + bucket: [operator, api-auth, api-roles, api-rbac, api-agent, api-mods, telemetry] arch: [arm64] include: - bucket: operator @@ -1256,6 +1430,10 @@ jobs: parallel: 2 test_timeout: 15m job_timeout: 60 # Boots a kind cluster, installs the chart, runs the bucket's suite. + - bucket: telemetry + parallel: 1 + test_timeout: 45m # the lifecycle test waits on 1m report intervals for every subtest + job_timeout: 75 # Boots a kind cluster, installs the chart, runs the bucket's suite. env: CLUSTER: gameplane-e2e CTX: kind-gameplane-e2e @@ -1805,8 +1983,8 @@ jobs: 'go-e2e-unit': (n) => n === 'go (test/e2e)', 'api-postgres': (n) => n === 'api (postgres)', 'e2e-buckets': (n) => n === 'e2e bucket coverage', - 'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods) \//.test(n) && n.includes('/ amd64 '), - 'e2e-go-arm64': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods) \//.test(n) && n.includes('/ arm64 '), + 'e2e-go': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry) \//.test(n) && n.includes('/ amd64 '), + 'e2e-go-arm64': (n) => /^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry) \//.test(n) && n.includes('/ arm64 '), 'e2e-multicluster': (n) => n.startsWith('e2e multicluster /') && n.includes('/ amd64 '), 'e2e-multicluster-arm64': (n) => n.startsWith('e2e multicluster /') && n.includes('/ arm64 '), 'e2e-upgrade': (n) => n.startsWith('e2e upgrade /') && n.includes('/ amd64 '), @@ -1953,7 +2131,7 @@ jobs: const bucketSet = new Set(); for (const j of apiJobs) { const n = j.name || ''; - const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods) \//); + const m = n.match(/^e2e (operator|api-auth|api-roles|api-rbac|api-agent|api-mods|telemetry) \//); if (m) bucketSet.add(m[1]); if (/^e2e multicluster \//.test(n)) bucketSet.add('multicluster'); if (/^e2e upgrade \//.test(n)) bucketSet.add('upgrade'); diff --git a/.github/workflows/publish-edge.yaml b/.github/workflows/publish-edge.yaml index 7d3901b3e..dccb430ce 100644 --- a/.github/workflows/publish-edge.yaml +++ b/.github/workflows/publish-edge.yaml @@ -28,6 +28,7 @@ on: - "netguard/**" - "gameaction/**" - "gameproto/**" + - "telemetryschema/**" - "svcutil/**" - "gp-module/**" - "audit-syslog-bridge/**" diff --git a/CHANGELOG.md b/CHANGELOG.md index feb6e9757..6883164b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,105 @@ reaches `1.0.0`. Pre-1.0 minor versions may contain breaking changes. ## [Unreleased] +### Upgrade Notes + +- **Telemetry is on by default for new installs, after a first-login + notice:** a new install with a telemetry destination in effect now shares + basic and extended data by default; the stock destination is the project's + receiver at telemetry.gameplane.net. The first admin to sign in sees a + notice that lists every field of both tiers and names the destination; + nothing is sent before then. Basic is `version`, `servers` and + `templates`. Extended adds a random install ID, the Kubernetes minor + version, a distribution category, node architectures and a node-count + band, server counts per official module plus one `custom` count, feature + flags (wake-on-connect, tunnel types, packet capture, backups, single + sign-on, audit forwarding), a cluster-count band, the database kind, the + UI language, the install's public signing key and the send time. Each + tier has its own toggle in **Admin Settings → Telemetry**, which also + shows the destination, a preview of the next report and an install-ID + reset. The data-handling statement is at + . See + [`docs/install.md`](docs/install.md#telemetry). +- **Installs whose basic toggle was already on start sending to the + project's default receiver:** an install that saved **Send anonymous usage + metrics** while no destination was configured (so it had no effect) starts + sending basic reports to the project's default receiver + (telemetry.gameplane.net) after upgrade. + Extended stays off until an admin turns it on, and installs that never + saved a choice stay off. Installs already using a custom + `api.telemetry.endpoint` or the bundled receiver keep sending only there. + Set `api.telemetry.enabled=false` before upgrading to prevent any + sending. +- **Empty `api.telemetry.endpoint` now means the project default:** + It now means the project's default receiver + (https://telemetry.gameplane.net/ingest), so the admin toggles have an + effect on a stock install. Set `api.telemetry.enabled=false` for a hard off (air-gapped + clusters, privacy-sensitive installs); it overrides the endpoint, the + bundled receiver and the admin toggles, and hides the notice. +- **The receiver's `/metrics` moved to the dashboard port and needs the + dashboard token:** the receiver's Prometheus endpoint is no longer on + `:8080`; it is on `:8081` and accepts only + `Authorization: Bearer `. Without + `api.telemetry.receiver.dashboard.tokenSecretRef.name` there is no + dashboard listener and no metrics endpoint, and the chart renders no + `ServiceMonitor` for the receiver. Update your Prometheus scrape config + or set the token Secret. See + [`docs/telemetry-provider.md`](docs/telemetry-provider.md). +- **The bundled receiver now stores data:** with + `api.telemetry.receiver.persistence.enabled` (default `true`, 1Gi) it + keeps daily aggregates for 24 months and activity records for 90 days in + SQLite on a PVC, and runs a single replica with a `Recreate` strategy. + `helm upgrade --reuse-values` from 0.3.0 needs no new values; with no + `persistence` block the receiver keeps using an `emptyDir`, as before. +- **The receiver's `DASHBOARD_TOKEN` must be at least 32 characters:** a + receiver with `DASHBOARD_TOKEN` set to a shorter value now exits at + startup (`DASHBOARD_TOKEN must be at least 32 characters`). Replace the + Secret behind `api.telemetry.receiver.dashboard.tokenSecretRef` with a + value generated from 32 random bytes, for example + `openssl rand -base64 32`, before upgrading. Replacing the token + invalidates browser sessions and loses no data. Receivers with no token + (no dashboard) are unaffected. See + [`docs/telemetry-provider.md`](docs/telemetry-provider.md#secrets). + +### Added + +- **Extended telemetry tier:** a second, separately consented tier with a + random install ID, environment, game-usage and feature-adoption + categories. Every value comes from a fixed set, a band or a number; no + names, namespaces, hostnames, addresses, player counts or custom module + names. Reports are signed (Ed25519, key derived from a per-install secret + and the install ID); a receiver binds each ID to its first key and refuses + forged, stale and replayed reports. See + [`docs/security.md`](docs/security.md#telemetry). +- **Telemetry receiver dashboard and public summary:** a private, + token-protected dashboard (`DASHBOARD_LISTEN_ADDR`, default `:8081`) with + 7-, 30-, 90- and 365-day views, durable daily aggregates + (`RETENTION_DAYS`, default `730`, minimum `365`), per-install activity + records that expire (`ACTIVITY_EXPIRY_DAYS`, default `90`, minimum `31`), + a per-source daily ingest limit (`INGEST_SOURCE_DAILY_LIMIT`, default + `20`), `TRUSTED_PROXY_CIDRS`, an `ID_PEPPER` secret and an optional public + `GET /v1/summary` (`PUBLIC_SUMMARY=true`). Helm values: + `api.telemetry.enabled`, `api.telemetry.interval`, + `api.telemetry.receiver.{persistence,dashboard,publicSummary,pepperSecretRef,retentionDays,activityExpiryDays,ingestSourceDailyLimit,trustedProxyCIDRs}`. +- **Proof-of-work on telemetry ingestion (off by default):** a receiver with + `INGEST_POW=true` serves `GET /v1/challenge` and requires a solved + challenge on `/ingest` (`428 pow_required` / `pow_invalid`, `503 pow_busy` + when its used-challenge set is full). The difficulty is zero up to + `INGEST_POW_TARGET_PER_MIN` challenges a minute (default `60`), then rises + with the rate between `INGEST_POW_MIN_BITS` (default `0`) and + `INGEST_POW_MAX_BITS` (default `22`, at most `26`), and decays one bit + every five minutes. New metrics: `gameplane_telemetry_pow_challenges_total` + and `gameplane_telemetry_pow_bits`; `gameplane_telemetry_refused_total` + gains the reasons `pow_required`, `pow_invalid` and `pow_busy`, and + `gameplane_telemetry_rate_limited_total` the route `challenge`. Helm values: + `api.telemetry.receiver.ingestPow.{enabled,targetPerMinute,minBits,maxBits}`. + The API reporter solves a challenge when its destination offers one and + sends without it otherwise. See + [`docs/telemetry-provider.md`](docs/telemetry-provider.md#proof-of-work-on-ingest). +- **Provider runbook:** [`docs/telemetry-provider.md`](docs/telemetry-provider.md) + covers deploying the receiver, TLS, keeping `:8081` private, secrets and + rotation, retention, scraping `/metrics` and backing up `telemetry.db`. + ## [0.3.0] — 2026-10-05 The first Gameplane release without a pre-release suffix, a pre-v1 release. It diff --git a/CLAUDE.md b/CLAUDE.md index 7f580dd9d..417c83565 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Run every session. netguard/ SSRF dial-guard: IsAllowed (operator module sources, API sinks, agent loopback RCON); IsPublic (agent mod downloads, API Steam resolver) gameaction/ console-injection guard + command renderer (api, agent) gameproto/ Minecraft/Terraria handshake parser (sentinel) +telemetryschema/ shared telemetry report contract (api, telemetry-receiver) gp-module/ module authoring CLI: init/validate/preview/package operator/ controller-runtime operator api/v1alpha1/ CRD types (edit here, then `make generate manifests`); zz_generated.deepcopy.go GENERATED @@ -59,7 +60,7 @@ test/e2e/ Kind E2E suite (//go:build e2e) docs/ architecture, security, modules design.pen canonical Pencil dashboard design cosign.pub image + module signature key -go.work links all 15 Go modules (incl. test/e2e) +go.work links all 16 Go modules (incl. test/e2e) Makefile canonical task runner ``` @@ -68,13 +69,13 @@ After cloning: `git submodule update --init` (`modules/` required for `make dev- ## Commands (always via Makefile) - **Dev:** `make dev-up` (Kind + OCI registry :5001 + Helm), `make web-dev` (Vite, proxies in-cluster API), `make dev-load` (load built images; run `make images` first), `make dev-install` (Helm upgrade), `make dev-down`. -- **Build:** `make build` (all Go + web), `make build-go` (14 modules in GO_MODULES, all but test/e2e), `make build-web` (`npm ci && npm run build`), `make images`. +- **Build:** `make build` (all Go + web), `make build-go` (15 modules in GO_MODULES, all but test/e2e), `make build-web` (`npm ci && npm run build`), `make images`. - **Codegen:** `make generate` (deepcopy), `make manifests` (CRD/RBAC YAML, synced to `charts/gameplane/crds/`), `make modules-push`, `make tidy`. - **Tests (CI only — never run locally, see Rule 8):** `make test`, `make test-integration` (envtest operator+api), `make test-e2e` (~10–20 min), `make test-e2e-bucket BUCKET=(operator|api-auth|api-roles|api-rbac|api-agent|api-mods|ratelimit|bot-fast|bot-heavy|multicluster|upgrade)`. - **E2E conventions:** register new tests in `test/e2e/buckets.sh`; `t.Parallel()` + unique names; guard shared resources (`ociPushMu` for module pushes, `ensureResticRepo(t)` for shared backup repos). Rate limits per cluster: IP burst 10 (5/min), user burst 6 (3/min) — cap an API bucket at ~7 admin logins. - **Lint:** `make lint` = gofmt, go vet, golangci-lint, ESLint, `check-specs`, `check-doc-versions`, `check-links`. -**Coverage minimums:** netguard 91, gameaction 91, gameproto 90, gp-module 80, operator 72, api 80, agent 90, svcutil 90; audit-syslog-bridge, telemetry-receiver, sentinel, capture-sidecar, mcp-server, tunnel 70; web 92 lines / 76 functions / 82 branches / 92 statements. +**Coverage minimums:** netguard 91, gameaction 91, gameproto 90, telemetryschema 90, gp-module 80, operator 72, api 80, agent 90, svcutil 90; audit-syslog-bridge, telemetry-receiver, sentinel, capture-sidecar, mcp-server, tunnel 70; web 92 lines / 76 functions / 82 branches / 92 statements. ## Core rules @@ -114,6 +115,7 @@ After cloning: `git submodule update --init` (`modules/` required for `make dev- | netguard | Go | dial-time SSRF prevention: `IsAllowed` (operator/API module sources and sinks, agent loopback RCON), `IsPublic` (agent mod downloads, API Steam resolver) | | gameaction | Go | validates console input against schemas; escapes injection | | gameproto | Go | Minecraft/Terraria wire parser for connection filtering | +| telemetryschema | Go | stdlib-only shared telemetry report contract: types, strict decode, official module catalog, report signing (api, telemetry-receiver) | | gp-module | Go | module CLI: scaffold, offline validate, dry-run preview, OCI package | | svcutil | Go | stdlib-only env + graceful shutdown (`RunHTTP`) | | operator | Go, controller-runtime | authoritative reconciler for 9 CRDs (`GameServer`, `GameTemplate`, …) | diff --git a/Makefile b/Makefile index 35ab20984..2fb3e2882 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ KUBECONFIG_ENV := KUBE_CONTEXT := kind-$(KIND_CLUSTER) endif -GO_MODULES := netguard gameaction gameproto gp-module operator api agent audit-syslog-bridge telemetry-receiver sentinel mcp-server capture-sidecar svcutil tunnel +GO_MODULES := netguard gameaction gameproto telemetryschema gp-module operator api agent audit-syslog-bridge telemetry-receiver sentinel mcp-server capture-sidecar svcutil tunnel GO_INTEGRATION_MODULES := operator api # test/e2e is a separate module (its own go.mod, excluded from the coverage @@ -187,6 +187,7 @@ e2e-images: ## Build operator/api/agent/fakeoidc images tagged for e2e docker build -t gameplane-test/operator:$(KIND_E2E_TAG) -f operator/Dockerfile . docker build -t gameplane-test/api:$(KIND_E2E_TAG) -f api/Dockerfile . docker build -t gameplane-test/agent:$(KIND_E2E_TAG) -f agent/Dockerfile . + docker build -t gameplane-test/telemetry-receiver:$(KIND_E2E_TAG) -f telemetry-receiver/Dockerfile . docker build -t gameplane-test/fakeoidc:$(KIND_E2E_TAG) -f test/e2e/Dockerfile.fakeoidc . .PHONY: test-e2e @@ -194,22 +195,22 @@ test-e2e: ## Run E2E tests (CLUSTER=kind spins an ephemeral cluster; CLUSTER=rem ifeq ($(CLUSTER),remote) cd test/e2e && GAMEPLANE_E2E_REUSE_CLUSTER=1 \ GAMEPLANE_E2E_CONTEXT=$(REMOTE_CONTEXT) KUBECONFIG=$(REMOTE_KUBECONFIG) \ - go test -tags=e2e -timeout 35m -v ./... + go test -tags=e2e -timeout 50m -v ./... else $(MAKE) e2e-images cd test/e2e && GAMEPLANE_E2E_CLUSTER=$(KIND_E2E_CLUSTER) GAMEPLANE_E2E_TAG=$(KIND_E2E_TAG) \ - go test -tags=e2e -timeout 35m -v ./... + go test -tags=e2e -timeout 50m -v ./... endif .PHONY: test-e2e-keep test-e2e-keep: ## Re-run E2E tests against an already-up cluster (skip create/destroy) cd test/e2e && GAMEPLANE_E2E_REUSE_CLUSTER=1 GAMEPLANE_E2E_CLUSTER=$(KIND_E2E_CLUSTER) \ - go test -tags=e2e -timeout 35m -v ./... + go test -tags=e2e -timeout 50m -v ./... .PHONY: test-e2e-bucket test-e2e-bucket: ## Run one CI e2e bucket against an already-up cluster (BUCKET=operator|api-auth|api-roles|api-rbac|api-agent|api-mods|ratelimit|bot-fast|bot-heavy|multicluster|upgrade). BUCKET=multicluster additionally needs a second kind cluster up and GAMEPLANE_E2E_CLUSTER_B set to its name (default gameplane-e2e-b). cd test/e2e && GAMEPLANE_E2E_REUSE_CLUSTER=1 GAMEPLANE_E2E_CLUSTER=$(KIND_E2E_CLUSTER) \ - go test -tags=e2e -timeout 35m -v -run "$$(./buckets.sh regex $(BUCKET))" ./... + go test -tags=e2e -timeout 50m -v -run "$$(./buckets.sh regex $(BUCKET))" ./... .PHONY: e2e-up e2e-up: e2e-images ## Bring up the e2e kind cluster + install chart (no tests) @@ -254,6 +255,10 @@ test-doc-versions: ## Run the fixture tests for hack/check-doc-versions.sh check-links: ## Verify internal documentation links and anchors resolve hack/check-links.sh +.PHONY: check-telemetry-catalog +check-telemetry-catalog: ## Verify telemetryschema/catalog.txt matches the chart's official module list + hack/check-telemetry-catalog.sh + .PHONY: check-dev-load-images check-dev-load-images: ## Verify `dev-load` loads every image `images` builds hack/check-dev-load-images.sh @@ -263,7 +268,7 @@ test-up-registry: ## Run the fixture tests for deploy/kind/up.sh's registry/cont hack/test-up-registry.sh .PHONY: lint -lint: check-doc-versions check-links check-specs check-dev-load-images test-up-registry lint-go lint-web ## Run all linters +lint: check-doc-versions check-links check-specs check-telemetry-catalog check-dev-load-images test-up-registry lint-go lint-web ## Run all linters .PHONY: lint-go lint-go: ## Run golangci-lint across all modules @@ -413,12 +418,15 @@ dev-install: ## Install Gameplane Helm chart into the selected cluster # not just this target. On a first install the hook also fires on # pre-install, but only over leftover CRDs whose bundle stamp is stale or # missing (an earlier, uninstalled release's); fresh CRDs come from crds/. + # Deploy the bundled telemetry receiver and point the API at it: never let + # CI or dev installs reach the project provider (spec 022 R17). $(KUBECONFIG_ENV) helm upgrade --install $(CHART_RELEASE) $(CHART_DIR) \ --kube-context $(KUBE_CONTEXT) \ --namespace $(NAMESPACE) --create-namespace \ --set image.tag=$(TAG) \ --set image.registry=$(REGISTRY) \ --set operator.addressManager=$(ADDRESS_MANAGER) \ + --set api.telemetry.receiver.enabled=true \ --set defaultModuleSource.type=oci \ --set defaultModuleSource.oci.url=$(MODULE_SOURCE_URL) \ --set defaultModuleSource.oci.insecure=$(MODULE_SOURCE_INSECURE) diff --git a/README.md b/README.md index 9c2e0aeb5..11d53ffa3 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ its own operator and storage; users and authorization stay central. See | `gameaction/` | Go | Security guard and command renderer for custom module admin actions. | | `netguard/` | Go | SSRF protection layer for outgoing mod downloads and OCI module fetches. | | `svcutil/` | Go | Shared HTTP server lifecycle and environment configuration utilities. | +| `telemetryschema/` | Go | Shared telemetry report contract (types, strict decoder, official module catalog, report signing) used by the API and `telemetry-receiver`. | | `audit-syslog-bridge/` | Go | HTTP-JSON to syslog relay [optional] for audit logging infrastructure. | | `telemetry-receiver/` | Go | Collector [optional] for anonymous daily usage reports. | | `mcp-server/` | Go | Strictly read-only Model Context Protocol server [optional] for AI tools. | @@ -190,6 +191,7 @@ Gameplane extends Kubernetes using custom resources under `gameplane.local/v1alp ├── gameaction/ # Console injection guard & action command renderer ├── netguard/ # SSRF dial-guard library ├── svcutil/ # Shared HTTP server & env utilities +├── telemetryschema/ # Shared telemetry report contract (types, decoder, signing) ├── audit-syslog-bridge/ # Optional syslog relay for audit logs ├── telemetry-receiver/ # Optional anonymous usage telemetry receiver ├── mcp-server/ # Optional read-only Model Context Protocol server diff --git a/api/Dockerfile b/api/Dockerfile index d0358d958..1d2e26994 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,12 +1,13 @@ FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build WORKDIR /src -# netguard, gameaction, and gp-module are in-repo modules the API depends on via local -# replaces (../netguard, ../gameaction, ../gp-module); copy them first so `go mod -# download` and the build resolve them. +# netguard, gameaction, gp-module, and telemetryschema are in-repo modules the API depends +# on via local replaces (../netguard, ../gameaction, ../gp-module, ../telemetryschema); +# copy them first so `go mod download` and the build resolve them. COPY netguard/ ./netguard/ COPY gameaction/ ./gameaction/ COPY gp-module/ ./gp-module/ +COPY telemetryschema/ ./telemetryschema/ COPY api/go.mod api/go.sum* ./api/ WORKDIR /src/api RUN go mod download diff --git a/api/cmd/main.go b/api/cmd/main.go index d513a86b8..5626a7382 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -154,6 +154,20 @@ func main() { } cfg.trustedProxies = validProxies + // Resolve where telemetry goes once, now: a bad default endpoint or an + // interval below one minute is a configuration error, not something to + // discover at the first report. + if cfg.telemetryInterval < time.Minute { + logger.Error("invalid --telemetry-interval (minimum 1m)", "value", cfg.telemetryInterval.String()) + os.Exit(1) + } + telemetryDest, err := telemetry.ResolveDestination(cfg.telemetryDisabled, cfg.telemetryEndpoint, cfg.telemetryBundled) + if err != nil { + logger.Error("resolve telemetry destination", "err", err) + os.Exit(1) + } + logger.Info("telemetry destination", "kind", telemetryDest.Kind, "host", telemetryDest.Host) + store, err := db.Open(ctx, cfg.dbDriver, cfg.dbDSN) if err != nil { logger.Error("open db", "err", err) @@ -323,6 +337,21 @@ func main() { // Public share link endpoints (unauthenticated, token-guessable) handlers.MountPublicShares(r, reg, store, auth.ShareLimiter) + // What the telemetry reporter and the admin preview both collect from. + telemetryDeps := telemetry.Deps{ + Kube: k8s, + Store: store, + Version: Version, + Flags: telemetry.Flags{ + CaptureEnabled: cfg.captureFeatureEnabled, + OIDCConfigured: cfg.oidcIssuer != "", + AuditWebhook: cfg.auditWebhookURL != "", + AuditS3: cfg.auditS3Endpoint != "" && cfg.auditS3Bucket != "", + DBDriver: cfg.dbDriver, + OfficialModuleSource: cfg.officialModuleSource, + }, + } + // Protected API r.Group(func(p chi.Router) { p.Use(sessions.Authenticate) @@ -342,7 +371,9 @@ func main() { handlers.MountUsers(p, store, sessions, reg) handlers.MountRoles(p, store) handlers.MountAudit(p, auditor) - handlers.MountConfig(p, store, auditor, oidcAuth != nil, cfg.gameDataStorageClass, helmPolicy) + telemetrySettings := handlers.TelemetrySettings{Dest: telemetryDest, Interval: cfg.telemetryInterval, Deps: telemetryDeps} + handlers.MountConfigWithTelemetry(p, store, auditor, oidcAuth != nil, cfg.gameDataStorageClass, helmPolicy, telemetrySettings) + handlers.MountTelemetry(p, store, telemetrySettings) handlers.MountNotifications(p, notifier, k8s, cfg.namespace) handlers.MountAuthProviderSecrets(p, k8s, cfg.namespace) handlers.MountCluster(p, reg, store, Version, cfg.clusterOps, cfg.updateChannel) @@ -387,9 +418,16 @@ func main() { ws.Mount(p, reg, cfg.agentCABundle, cfg.agentClientCert, cfg.agentClientKey, ws.AgentGatewayOptions{Namespace: cfg.namespace}) }) - // Opt-in, off-by-default anonymous usage telemetry. No-op unless an - // endpoint is configured AND the admin enabled the sendMetrics toggle. - go telemetry.New(store, k8s, cfg.telemetryEndpoint, cfg.telemetryAuth, Version, 24*time.Hour).Run(ctx) + // Usage telemetry. Run returns at once when the resolved destination is + // disabled or none. Otherwise it sends nothing while the admin's basic + // toggle is off or, on a fresh install, until an admin was shown the + // notice (spec 022 FR-004); each report slot is claimed in the database. + go telemetry.New(telemetry.Config{ + Dest: telemetryDest, + Interval: cfg.telemetryInterval, + Auth: cfg.telemetryAuth, + Deps: telemetryDeps, + }).Run(ctx) // Opt-in audit-event retention. Off by default (0 days = keep forever); // when set, a daily sweep prunes events past the window so the table @@ -483,6 +521,10 @@ type config struct { telemetryEndpoint string telemetryAuth string + telemetryDisabled bool + telemetryBundled bool + telemetryInterval time.Duration + officialModuleSource string clusterOps bool clusterExternalAddress string updateChannel string @@ -538,7 +580,16 @@ func (c *config) bindFlags(fs *flag.FlagSet) { "Comma-separated IdP group(s) seeding the operator role") fs.StringVar(&c.oidcRoleMappingViewer, "oidc-role-mapping-viewer", envOr("GAMEPLANE_OIDC_ROLE_MAPPING_VIEWER", ""), "Comma-separated IdP group(s) seeding the viewer role") - fs.StringVar(&c.telemetryEndpoint, "telemetry-endpoint", envOr("GAMEPLANE_TELEMETRY_ENDPOINT", ""), "URL to POST anonymous usage metrics to (empty = telemetry off)") + fs.StringVar(&c.telemetryEndpoint, "telemetry-endpoint", envOr("GAMEPLANE_TELEMETRY_ENDPOINT", ""), "URL to POST anonymous usage metrics to (empty = the project default)") + fs.BoolVar(&c.telemetryDisabled, "telemetry-disabled", envOr("GAMEPLANE_TELEMETRY_DISABLED", "") == "true", + "hard-disable telemetry: nothing is ever sent, whatever the admin toggles say") + fs.DurationVar(&c.telemetryInterval, "telemetry-interval", envOrDuration("GAMEPLANE_TELEMETRY_INTERVAL", 24*time.Hour), + "spacing between telemetry reports (minimum 1m; shorter values are for testing only)") + fs.StringVar(&c.officialModuleSource, "official-module-source", envOr("GAMEPLANE_OFFICIAL_MODULE_SOURCE", ""), + "name of the official ModuleSource, so telemetry can tell official game modules from custom ones (empty = every module counts as custom)") + // Set only by the chart when it auto-wires the bundled in-cluster + // receiver, so it is read from the environment and has no flag. + c.telemetryBundled = envOr("GAMEPLANE_TELEMETRY_BUNDLED", "") == "true" // Like the audit webhook auth, the telemetry ingest token is a // credential and only comes from the environment (a mounted Secret), // never a flag. @@ -634,6 +685,17 @@ func envOrInt(key string, def int) int { return def } +// envOrDuration reads a duration env var ("24h", "30m"), falling back to def +// when unset or unparseable, like envOrInt. +func envOrDuration(key string, def time.Duration) time.Duration { + if v, ok := os.LookupEnv(key); ok { + if d, err := time.ParseDuration(v); err == nil { + return d + } + } + return def +} + // envOrInt64 reads a 64-bit integer env var, falling back to def when unset or // unparseable (a malformed value shouldn't crash startup — it degrades to // the safe default). diff --git a/api/go.mod b/api/go.mod index 6d40d14fc..6830454d4 100644 --- a/api/go.mod +++ b/api/go.mod @@ -22,6 +22,13 @@ require github.com/GameplanePanel/gameplane/gp-module v0.0.0 // `go build ./api/...`. replace github.com/GameplanePanel/gameplane/gp-module => ../gp-module +require github.com/GameplanePanel/gameplane/telemetryschema v0.0.0 + +// telemetryschema is an in-repo module (no published version); resolve it +// locally both inside the go.work workspace and for standalone +// `go build ./api/...`. +replace github.com/GameplanePanel/gameplane/telemetryschema => ../telemetryschema + require ( github.com/coder/websocket v1.8.15 github.com/coreos/go-oidc/v3 v3.21.0 diff --git a/api/internal/db/db.go b/api/internal/db/db.go index c92e61d65..999a7efe6 100644 --- a/api/internal/db/db.go +++ b/api/internal/db/db.go @@ -95,7 +95,9 @@ func (s *Store) LockUserManagement() (unlock func()) { // written per dialect), then the shared set (migrations/common/, 013 // onward, portable SQL that both drivers run unchanged). See // migrations/README.md. Each file runs in a single transaction; failures -// are fatal. +// are fatal. Afterwards it seeds the telemetry_state row once (see +// seedTelemetryState), which is why it first notes whether the database was +// fresh. func (s *Store) Migrate(ctx context.Context) error { if _, err := s.DB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`, @@ -103,6 +105,16 @@ func (s *Store) Migrate(ctx context.Context) error { return fmt.Errorf("create schema_migrations: %w", err) } + // A database whose schema_migrations is still empty is a fresh install; + // the telemetry consent seeding below depends on telling it apart from an + // upgrade. Counting here, before anything is applied, keeps the answer + // right whichever entrypoint (bootstrap-admin or serve) migrates first. + var recorded int + if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations`).Scan(&recorded); err != nil { + return fmt.Errorf("count schema_migrations: %w", err) + } + fresh := recorded == 0 + files, err := migrationFiles(migrations, s.Driver) if err != nil { return err @@ -124,7 +136,7 @@ func (s *Store) Migrate(ctx context.Context) error { return fmt.Errorf("migration %s: %w", f.name, err) } } - return nil + return s.seedTelemetryState(ctx, fresh) } // Migration directories inside the embedded FS. legacyMigrationDirs maps a diff --git a/api/internal/db/migrations/common/015_telemetry_state.sql b/api/internal/db/migrations/common/015_telemetry_state.sql new file mode 100644 index 000000000..0f8328e36 --- /dev/null +++ b/api/internal/db/migrations/common/015_telemetry_state.sql @@ -0,0 +1,30 @@ +-- Telemetry install state (spec 022). One singleton row holds the consent +-- source, the extended-tier install ID, the report schedule and the signing +-- secret. Timestamps are RFC 3339 UTC text bound from Go, so there is no +-- database-side clock and the file runs unchanged on SQLite and PostgreSQL. +-- The row is seeded by Store.Migrate in Go, because seeding has to read the +-- existing JSON value of config key telemetry, which portable SQL cannot do. +CREATE TABLE telemetry_state ( + id TEXT PRIMARY KEY, + consent_source TEXT NOT NULL, + install_id TEXT, + notice_shown_at TEXT, + next_due_at TEXT, + last_attempt_at TEXT, + last_success_at TEXT, + last_outcome TEXT NOT NULL DEFAULT 'never', + consecutive_failures INTEGER NOT NULL DEFAULT 0, + ext_unsupported_until TEXT, + ext_unsupported_endpoint TEXT, + signing_secret TEXT, + last_id_rotation_at TEXT +); + +-- One row per admin who dismissed the consent notice. user_id is users.id. +-- Rows are deleted in Go when the user is removed, because the shipped +-- SQLite DSN runs with foreign keys off. +CREATE TABLE telemetry_notice_acks ( + user_id BIGINT PRIMARY KEY, + acked_at TEXT NOT NULL, + action TEXT NOT NULL +); diff --git a/api/internal/db/telemetry.go b/api/internal/db/telemetry.go new file mode 100644 index 000000000..675ba8f25 --- /dev/null +++ b/api/internal/db/telemetry.go @@ -0,0 +1,322 @@ +package db + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" +) + +// telemetrySingletonID is the primary key of the one telemetry_state row. +const telemetrySingletonID = "singleton" + +// Consent sources recorded in telemetry_state.consent_source. +const ( + // TelemetryConsentDefault marks a fresh install seeded with the project + // default (basic and extended on, reports gated on the notice). + TelemetryConsentDefault = "default" + // TelemetryConsentLegacy marks an install that existed before the + // extended tier; its saved choice is kept and extended stays off. + TelemetryConsentLegacy = "legacy" + // TelemetryConsentAdmin marks an install whose admin made an explicit + // choice. + TelemetryConsentAdmin = "admin" +) + +// TelemetryState is the persisted telemetry machine state. Timestamp fields +// hold RFC 3339 UTC text, and the empty string stands for SQL NULL. The +// signing secret is deliberately not a field: it is reachable only through +// EnsureSigningSecret, so no handler can serialise it by accident. +type TelemetryState struct { + ConsentSource string + InstallID string + NoticeShownAt string + NextDueAt string + LastAttemptAt string + LastSuccessAt string + LastOutcome string + ConsecutiveFailures int + ExtUnsupportedUntil string + ExtUnsupportedEndpoint string + LastIDRotationAt string +} + +// seedTelemetryState inserts the singleton telemetry_state row once and, only +// when this call inserted it, applies the consent seeding. fresh reports that +// schema_migrations held no rows when Migrate started. Running it again, for +// example bootstrap-admin followed by serve, finds the row and changes +// nothing, so a fresh install never flips to legacy. +func (s *Store) seedTelemetryState(ctx context.Context, fresh bool) error { + source := TelemetryConsentLegacy + if fresh { + source = TelemetryConsentDefault + } + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("seed telemetry state: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + + res, err := tx.ExecContext(ctx, + `INSERT INTO telemetry_state(id, consent_source) VALUES (?, ?) ON CONFLICT (id) DO NOTHING`, + telemetrySingletonID, source) + if err != nil { + return fmt.Errorf("seed telemetry state: insert: %w", err) + } + inserted, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("seed telemetry state: rows affected: %w", err) + } + if inserted == 0 { + return nil + } + + if fresh { + if err := upsertConfigTx(ctx, tx, "telemetry", `{"sendMetrics":true,"extended":true}`); err != nil { + return fmt.Errorf("seed telemetry state: %w", err) + } + } else { + raw, ok, err := ConfigValueTx(ctx, tx, "telemetry") + if err != nil { + return fmt.Errorf("seed telemetry state: read config: %w", err) + } + if ok { + if rewritten, changed := withExtendedOff(raw); changed { + if err := upsertConfigTx(ctx, tx, "telemetry", rewritten); err != nil { + return fmt.Errorf("seed telemetry state: %w", err) + } + } + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("seed telemetry state: commit: %w", err) + } + return nil +} + +// withExtendedOff returns raw, a JSON object, with "extended":false added. +// A value that is not a JSON object is left alone (changed is false) rather +// than overwritten. +func withExtendedOff(raw string) (string, bool) { + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &m); err != nil || m == nil { + return raw, false + } + m["extended"] = json.RawMessage("false") + out, err := json.Marshal(m) + if err != nil { + return raw, false + } + return string(out), true +} + +// upsertConfigTx writes one config row inside tx, stamping updated_at in the +// layout the other config writers use. +func upsertConfigTx(ctx context.Context, tx *sql.Tx, key, value string) error { + if _, err := tx.ExecContext(ctx, + `INSERT INTO config(key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + key, value, NowTimestamp()); err != nil { + return fmt.Errorf("write config %q: %w", key, err) + } + return nil +} + +// GetTelemetryState reads the singleton row. NULL columns come back as the +// empty string. A missing row (Migrate not run) wraps sql.ErrNoRows. +func (s *Store) GetTelemetryState(ctx context.Context) (TelemetryState, error) { + var st TelemetryState + err := s.DB.QueryRowContext(ctx, + `SELECT consent_source, COALESCE(install_id, ''), COALESCE(notice_shown_at, ''), + COALESCE(next_due_at, ''), COALESCE(last_attempt_at, ''), COALESCE(last_success_at, ''), + last_outcome, consecutive_failures, COALESCE(ext_unsupported_until, ''), + COALESCE(ext_unsupported_endpoint, ''), COALESCE(last_id_rotation_at, '') + FROM telemetry_state WHERE id = ?`, telemetrySingletonID).Scan( + &st.ConsentSource, &st.InstallID, &st.NoticeShownAt, + &st.NextDueAt, &st.LastAttemptAt, &st.LastSuccessAt, + &st.LastOutcome, &st.ConsecutiveFailures, &st.ExtUnsupportedUntil, + &st.ExtUnsupportedEndpoint, &st.LastIDRotationAt) + if err != nil { + return TelemetryState{}, fmt.Errorf("get telemetry state: %w", err) + } + return st, nil +} + +// UpdateTelemetryState writes every field of st back to the singleton row, +// storing empty strings as NULL. It never touches the signing secret. +func (s *Store) UpdateTelemetryState(ctx context.Context, st TelemetryState) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET + consent_source = ?, install_id = NULLIF(?, ''), notice_shown_at = NULLIF(?, ''), + next_due_at = NULLIF(?, ''), last_attempt_at = NULLIF(?, ''), last_success_at = NULLIF(?, ''), + last_outcome = ?, consecutive_failures = ?, ext_unsupported_until = NULLIF(?, ''), + ext_unsupported_endpoint = NULLIF(?, ''), last_id_rotation_at = NULLIF(?, '') + WHERE id = ?`, + st.ConsentSource, st.InstallID, st.NoticeShownAt, + st.NextDueAt, st.LastAttemptAt, st.LastSuccessAt, + st.LastOutcome, st.ConsecutiveFailures, st.ExtUnsupportedUntil, + st.ExtUnsupportedEndpoint, st.LastIDRotationAt, + telemetrySingletonID) + if err != nil { + return fmt.Errorf("update telemetry state: %w", err) + } + return requireOneRow(res, "update telemetry state") +} + +// ClaimTelemetryDue claims the report slot whose next_due_at equals expected +// by moving it to next and stamping last_attempt_at with now (all RFC 3339 +// UTC text). It reports true only when this call changed the row, so of +// several replicas racing for one slot exactly one proceeds. A NULL +// next_due_at never matches, because the gate-open step sets the first slot. +func (s *Store) ClaimTelemetryDue(ctx context.Context, expected, next, now string) (bool, error) { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET next_due_at = ?, last_attempt_at = ? + WHERE id = ? AND next_due_at = ?`, + next, now, telemetrySingletonID, expected) + if err != nil { + return false, fmt.Errorf("claim telemetry slot: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("claim telemetry slot: rows affected: %w", err) + } + return n == 1, nil +} + +// SetInstallID stores the extended-tier install ID. +func (s *Store) SetInstallID(ctx context.Context, id string) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET install_id = ? WHERE id = ?`, id, telemetrySingletonID) + if err != nil { + return fmt.Errorf("set install id: %w", err) + } + return requireOneRow(res, "set install id") +} + +// ClearInstallID removes the install ID (the extended tier went off). +func (s *Store) ClearInstallID(ctx context.Context) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET install_id = NULL WHERE id = ?`, telemetrySingletonID) + if err != nil { + return fmt.Errorf("clear install id: %w", err) + } + return requireOneRow(res, "clear install id") +} + +// EnsureSigningSecret returns the install's 32-byte signing secret, creating +// it on first use. Concurrent first calls agree on one value because the +// write only lands while the column is NULL and the loser re-reads. The +// secret is for api/internal/telemetry key derivation only: it is never part +// of TelemetryState, an HTTP response, a log line or an audit event. +func (s *Store) EnsureSigningSecret(ctx context.Context) ([]byte, error) { + if secret, err := s.readSigningSecret(ctx); err != nil || secret != nil { + return secret, err + } + fresh := make([]byte, 32) + if _, err := rand.Read(fresh); err != nil { + return nil, fmt.Errorf("generate signing secret: %w", err) + } + if _, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET signing_secret = ? WHERE id = ? AND signing_secret IS NULL`, + base64.StdEncoding.EncodeToString(fresh), telemetrySingletonID); err != nil { + return nil, fmt.Errorf("store signing secret: %w", err) + } + secret, err := s.readSigningSecret(ctx) + if err != nil { + return nil, err + } + if secret == nil { + return nil, errors.New("store signing secret: no telemetry_state row") + } + return secret, nil +} + +// readSigningSecret returns the stored secret, or nil when none is set yet or +// the row is absent. +func (s *Store) readSigningSecret(ctx context.Context) ([]byte, error) { + var enc sql.NullString + err := s.DB.QueryRowContext(ctx, + `SELECT signing_secret FROM telemetry_state WHERE id = ?`, telemetrySingletonID).Scan(&enc) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read signing secret: %w", err) + } + if !enc.Valid || enc.String == "" { + return nil, nil + } + secret, err := base64.StdEncoding.DecodeString(enc.String) + if err != nil { + return nil, fmt.Errorf("decode signing secret: %w", err) + } + return secret, nil +} + +// MarkNoticeShown records when the consent notice was first rendered. The +// value is set once: later calls leave the first timestamp in place. +func (s *Store) MarkNoticeShown(ctx context.Context, now string) error { + if _, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET notice_shown_at = ? WHERE id = ? AND notice_shown_at IS NULL`, + now, telemetrySingletonID); err != nil { + return fmt.Errorf("mark notice shown: %w", err) + } + return nil +} + +// InsertNoticeAck records that userID dismissed the notice with action +// (keep, extended-off or all-off) at ackedAt (RFC 3339 UTC). The first +// dismissal stands; repeats are ignored. +func (s *Store) InsertNoticeAck(ctx context.Context, userID int64, action string, ackedAt string) error { + if _, err := s.DB.ExecContext(ctx, + `INSERT INTO telemetry_notice_acks(user_id, acked_at, action) VALUES (?, ?, ?) + ON CONFLICT (user_id) DO NOTHING`, + userID, ackedAt, action); err != nil { + return fmt.Errorf("insert notice ack: %w", err) + } + return nil +} + +// HasNoticeAck reports whether userID has dismissed the notice. +func (s *Store) HasNoticeAck(ctx context.Context, userID int64) (bool, error) { + var one int + err := s.DB.QueryRowContext(ctx, + `SELECT 1 FROM telemetry_notice_acks WHERE user_id = ?`, userID).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("has notice ack: %w", err) + } + return true, nil +} + +// DeleteNoticeAcksForUser removes a user's notice ack. ex is the open +// transaction of the account removal, or nil to run on the store directly. +// The telemetry_notice_acks table has no foreign key, so removal is explicit. +func (s *Store) DeleteNoticeAcksForUser(ctx context.Context, ex Execer, userID int64) error { + if ex == nil { + ex = s.DB + } + if _, err := ex.ExecContext(ctx, + `DELETE FROM telemetry_notice_acks WHERE user_id = ?`, userID); err != nil { + return fmt.Errorf("delete notice acks: %w", err) + } + return nil +} + +// requireOneRow fails when an UPDATE of the singleton matched no row, which +// means Migrate has not seeded telemetry_state. +func requireOneRow(res sql.Result, op string) error { + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("%s: rows affected: %w", op, err) + } + if n != 1 { + return fmt.Errorf("%s: %w", op, sql.ErrNoRows) + } + return nil +} diff --git a/api/internal/db/telemetry_rotate_test.go b/api/internal/db/telemetry_rotate_test.go new file mode 100644 index 000000000..cde208b02 --- /dev/null +++ b/api/internal/db/telemetry_rotate_test.go @@ -0,0 +1,58 @@ +package db + +import ( + "bytes" + "database/sql" + "errors" + "testing" +) + +// RotateInstallID is the reporter's reaction to a 409 id_claimed (spec 022 +// FR-037, T084). +func TestRotateInstallID_ReplacesIDKeepsSecretAndStampsTheRotation(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + if err := s.SetInstallID(ctx, txTestInstallID); err != nil { + t.Fatalf("set install id: %v", err) + } + secret, err := s.EnsureSigningSecret(ctx) + if err != nil { + t.Fatalf("ensure secret: %v", err) + } + + const next = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d" + if err := s.RotateInstallID(ctx, next, "2026-10-06T09:00:00Z"); err != nil { + t.Fatalf("rotate: %v", err) + } + st, err := s.GetTelemetryState(ctx) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.InstallID != next || st.LastIDRotationAt != "2026-10-06T09:00:00Z" { + t.Fatalf("state = %+v, want the new id and the rotation stamp", st) + } + if again, err := s.EnsureSigningSecret(ctx); err != nil || !bytes.Equal(again, secret) { + t.Fatalf("signing secret changed by the rotation: %v", err) + } +} + +func TestRotateInstallID_RefusesWithoutAnIDOrARow(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + // A fresh install has no ID until the notice is seen, so there is nothing + // to replace and nothing may be written. + if err := s.RotateInstallID(ctx, txTestInstallID, "2026-10-06T09:00:00Z"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("rotate without an id = %v, want sql.ErrNoRows", err) + } + if st, err := s.GetTelemetryState(ctx); err != nil || st.InstallID != "" || st.LastIDRotationAt != "" { + t.Fatalf("state = %+v, %v, want it untouched", st, err) + } + + closed := newRBACStore(t) + if err := closed.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if err := closed.RotateInstallID(ctx, txTestInstallID, "x"); err == nil { + t.Fatal("rotate on a closed database: want an error") + } +} diff --git a/api/internal/db/telemetry_test.go b/api/internal/db/telemetry_test.go new file mode 100644 index 000000000..06a380743 --- /dev/null +++ b/api/internal/db/telemetry_test.go @@ -0,0 +1,549 @@ +package db + +import ( + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "sync" + "testing" +) + +// telemetryConfig returns the raw telemetry config value and whether the key +// exists. +func telemetryConfig(t *testing.T, s *Store) (string, bool) { + t.Helper() + v, ok, err := s.ConfigValue(t.Context(), "telemetry") + if err != nil { + t.Fatalf("read telemetry config: %v", err) + } + return v, ok +} + +// existingInstallStore returns a migrated store rewound to the state of an +// install that ran before migration 015: no telemetry tables, 015 unrecorded, +// and telemetryValue (when non-empty) saved as config key telemetry. The next +// Migrate sees recorded migrations, so it treats the database as existing. +func existingInstallStore(t *testing.T, telemetryValue string) *Store { + t.Helper() + s := newRBACStore(t) + ctx := t.Context() + for _, stmt := range []string{ + `DROP TABLE telemetry_state`, + `DROP TABLE telemetry_notice_acks`, + `DELETE FROM schema_migrations WHERE version = '015_telemetry_state.sql'`, + `DELETE FROM config WHERE key = 'telemetry'`, + } { + if _, err := s.DB.ExecContext(ctx, stmt); err != nil { + t.Fatalf("%s: %v", stmt, err) + } + } + if telemetryValue != "" { + if _, err := s.DB.ExecContext(ctx, + `INSERT INTO config(key, value, updated_at) VALUES ('telemetry', ?, ?)`, + telemetryValue, "2026-01-01 00:00:00"); err != nil { + t.Fatalf("seed telemetry config: %v", err) + } + } + if err := s.Migrate(ctx); err != nil { + t.Fatalf("migrate existing install: %v", err) + } + return s +} + +func TestMigrate_FreshInstallSeedsTelemetryDefault(t *testing.T) { + s := newRBACStore(t) + st, err := s.GetTelemetryState(t.Context()) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.ConsentSource != TelemetryConsentDefault { + t.Errorf("consent source = %q, want default", st.ConsentSource) + } + if st.LastOutcome != "never" || st.ConsecutiveFailures != 0 { + t.Errorf("outcome/failures = %q/%d, want never/0", st.LastOutcome, st.ConsecutiveFailures) + } + if st.InstallID != "" || st.NoticeShownAt != "" || st.NextDueAt != "" { + t.Errorf("a fresh seed must leave id, notice and schedule empty, got %+v", st) + } + v, ok := telemetryConfig(t, s) + if !ok || v != `{"sendMetrics":true,"extended":true}` { + t.Errorf("config telemetry = %q (present=%v), want both tiers on", v, ok) + } +} + +// bootstrap-admin migrates before the first serve; the second Migrate must +// not turn a fresh install into a legacy one or undo an admin's choice. +func TestMigrate_TwiceKeepsFreshInstallDefault(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + if err := s.Migrate(ctx); err != nil { + t.Fatalf("second migrate: %v", err) + } + st, err := s.GetTelemetryState(ctx) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.ConsentSource != TelemetryConsentDefault { + t.Fatalf("consent source after a second migrate = %q, want default", st.ConsentSource) + } + if v, _ := telemetryConfig(t, s); v != `{"sendMetrics":true,"extended":true}` { + t.Fatalf("config after a second migrate = %q", v) + } + + // An admin choice made between the two runs survives a later Migrate. + if _, err := s.DB.ExecContext(ctx, + `UPDATE config SET value = '{"sendMetrics":false,"extended":false}' WHERE key = 'telemetry'`); err != nil { + t.Fatalf("admin save: %v", err) + } + if err := s.Migrate(ctx); err != nil { + t.Fatalf("third migrate: %v", err) + } + if v, _ := telemetryConfig(t, s); v != `{"sendMetrics":false,"extended":false}` { + t.Fatalf("admin choice overwritten: %q", v) + } +} + +func TestMigrate_ExistingInstallWithSendMetricsIsLegacy(t *testing.T) { + s := existingInstallStore(t, `{"sendMetrics":true}`) + st, err := s.GetTelemetryState(t.Context()) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.ConsentSource != TelemetryConsentLegacy { + t.Fatalf("consent source = %q, want legacy", st.ConsentSource) + } + v, ok := telemetryConfig(t, s) + if !ok { + t.Fatal("the saved telemetry config must be kept") + } + var got map[string]bool + if err := json.Unmarshal([]byte(v), &got); err != nil { + t.Fatalf("config %q is not a JSON object of booleans: %v", v, err) + } + if len(got) != 2 || !got["sendMetrics"] || got["extended"] { + t.Fatalf("config = %v, want sendMetrics kept true and extended false", got) + } +} + +func TestMigrate_ExistingInstallWithoutTelemetryRowStaysOff(t *testing.T) { + s := existingInstallStore(t, "") + st, err := s.GetTelemetryState(t.Context()) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.ConsentSource != TelemetryConsentLegacy { + t.Fatalf("consent source = %q, want legacy", st.ConsentSource) + } + if v, ok := telemetryConfig(t, s); ok { + t.Fatalf("no telemetry config row may be created for a legacy install, got %q", v) + } +} + +func TestMigrate_ExistingInstallLeavesUnusableConfigAlone(t *testing.T) { + for _, raw := range []string{`{bad`, `null`, `[1]`} { + s := existingInstallStore(t, raw) + if v, _ := telemetryConfig(t, s); v != raw { + t.Errorf("config %q was rewritten to %q", raw, v) + } + st, err := s.GetTelemetryState(t.Context()) + if err != nil || st.ConsentSource != TelemetryConsentLegacy { + t.Errorf("config %q: state = %+v, err = %v, want a legacy row", raw, st, err) + } + } +} + +func TestMigrate_ExistingInstallTwiceDoesNotRewriteAgain(t *testing.T) { + s := existingInstallStore(t, `{"sendMetrics":true}`) + ctx := t.Context() + // An admin turns the extended tier on after the upgrade. + if _, err := s.DB.ExecContext(ctx, + `UPDATE config SET value = '{"sendMetrics":true,"extended":true}' WHERE key = 'telemetry'`); err != nil { + t.Fatalf("admin save: %v", err) + } + if err := s.Migrate(ctx); err != nil { + t.Fatalf("migrate again: %v", err) + } + if v, _ := telemetryConfig(t, s); v != `{"sendMetrics":true,"extended":true}` { + t.Fatalf("a later migrate rewrote the admin's config: %q", v) + } +} + +func TestTelemetryState_UpdateRoundTripAndNulls(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + want := TelemetryState{ + ConsentSource: TelemetryConsentAdmin, + InstallID: "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + NoticeShownAt: "2026-10-06T09:00:00Z", + NextDueAt: "2026-10-07T09:05:00Z", + LastAttemptAt: "2026-10-06T09:05:00Z", + LastSuccessAt: "2026-10-06T09:05:01Z", + LastOutcome: "ok", + ConsecutiveFailures: 3, + ExtUnsupportedUntil: "2026-10-13T09:05:00Z", + ExtUnsupportedEndpoint: "https://telemetry.example/ingest", + LastIDRotationAt: "2026-10-05T00:00:00Z", + } + if err := s.UpdateTelemetryState(ctx, want); err != nil { + t.Fatalf("update: %v", err) + } + got, err := s.GetTelemetryState(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if got != want { + t.Fatalf("round trip:\n got %+v\nwant %+v", got, want) + } + + // Empty strings are stored as NULL, not as empty text. + cleared := TelemetryState{ConsentSource: TelemetryConsentAdmin, LastOutcome: "never"} + if err := s.UpdateTelemetryState(ctx, cleared); err != nil { + t.Fatalf("update cleared: %v", err) + } + var nulls int + if err := s.DB.QueryRowContext(ctx, + `SELECT COUNT(*) FROM telemetry_state + WHERE install_id IS NULL AND notice_shown_at IS NULL AND next_due_at IS NULL + AND last_attempt_at IS NULL AND last_success_at IS NULL AND ext_unsupported_until IS NULL + AND ext_unsupported_endpoint IS NULL AND last_id_rotation_at IS NULL`).Scan(&nulls); err != nil { + t.Fatalf("count nulls: %v", err) + } + if nulls != 1 { + t.Fatal("empty fields must be stored as NULL") + } +} + +func TestTelemetryState_UpdateKeepsSigningSecret(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + secret, err := s.EnsureSigningSecret(ctx) + if err != nil { + t.Fatalf("ensure secret: %v", err) + } + if err := s.UpdateTelemetryState(ctx, TelemetryState{ConsentSource: TelemetryConsentAdmin, LastOutcome: "ok"}); err != nil { + t.Fatalf("update: %v", err) + } + again, err := s.EnsureSigningSecret(ctx) + if err != nil { + t.Fatalf("ensure again: %v", err) + } + if string(again) != string(secret) { + t.Fatal("UpdateTelemetryState must not change the signing secret") + } +} + +func TestClaimTelemetryDue_MovesSlotOnce(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + const due, next, now = "2026-10-06T10:00:00Z", "2026-10-07T10:03:00Z", "2026-10-06T10:00:05Z" + if err := s.UpdateTelemetryState(ctx, TelemetryState{ + ConsentSource: TelemetryConsentAdmin, LastOutcome: "never", NextDueAt: due, + }); err != nil { + t.Fatalf("seed schedule: %v", err) + } + + if won, err := s.ClaimTelemetryDue(ctx, "2026-01-01T00:00:00Z", next, now); err != nil || won { + t.Fatalf("claim with a stale slot = %v, %v; want false, nil", won, err) + } + if won, err := s.ClaimTelemetryDue(ctx, due, next, now); err != nil || !won { + t.Fatalf("claim of the due slot = %v, %v; want true, nil", won, err) + } + st, err := s.GetTelemetryState(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.NextDueAt != next || st.LastAttemptAt != now { + t.Fatalf("after the claim next/attempt = %q/%q, want %q/%q", st.NextDueAt, st.LastAttemptAt, next, now) + } + if won, err := s.ClaimTelemetryDue(ctx, due, next, now); err != nil || won { + t.Fatalf("second claim of the same slot = %v, %v; want false, nil", won, err) + } +} + +func TestClaimTelemetryDue_NullSlotNeverMatches(t *testing.T) { + s := newRBACStore(t) + if won, err := s.ClaimTelemetryDue(t.Context(), "", "2026-10-07T00:00:00Z", "2026-10-06T00:00:00Z"); err != nil || won { + t.Fatalf("claim while the gate is closed = %v, %v; want false, nil", won, err) + } +} + +func TestClaimTelemetryDue_ConcurrentCallersOneWins(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + const due = "2026-10-06T10:00:00Z" + if err := s.UpdateTelemetryState(ctx, TelemetryState{ + ConsentSource: TelemetryConsentAdmin, LastOutcome: "never", NextDueAt: due, + }); err != nil { + t.Fatalf("seed schedule: %v", err) + } + + const callers = 8 + var ( + wg sync.WaitGroup + mu sync.Mutex + wins int + errs []error + ) + start := make(chan struct{}) + for range callers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + won, err := s.ClaimTelemetryDue(ctx, due, "2026-10-07T10:00:00Z", "2026-10-06T10:00:01Z") + mu.Lock() + defer mu.Unlock() + if err != nil { + errs = append(errs, err) + } + if won { + wins++ + } + }() + } + close(start) + wg.Wait() + if len(errs) != 0 { + t.Fatalf("claim errors: %v", errs) + } + if wins != 1 { + t.Fatalf("%d concurrent claims won, want exactly 1", wins) + } +} + +func TestInstallID_SetAndClear(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + const id = "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10" + if err := s.SetInstallID(ctx, id); err != nil { + t.Fatalf("set: %v", err) + } + if st, _ := s.GetTelemetryState(ctx); st.InstallID != id { + t.Fatalf("install id = %q, want %q", st.InstallID, id) + } + if err := s.ClearInstallID(ctx); err != nil { + t.Fatalf("clear: %v", err) + } + if st, _ := s.GetTelemetryState(ctx); st.InstallID != "" { + t.Fatalf("install id after clear = %q, want empty", st.InstallID) + } +} + +func TestEnsureSigningSecret_CreatedOnceAndStable(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + first, err := s.EnsureSigningSecret(ctx) + if err != nil { + t.Fatalf("ensure: %v", err) + } + if len(first) != 32 { + t.Fatalf("secret length = %d, want 32", len(first)) + } + second, err := s.EnsureSigningSecret(ctx) + if err != nil { + t.Fatalf("ensure again: %v", err) + } + if string(first) != string(second) { + t.Fatal("the secret changed between calls") + } + // Stored as base64 text, and the same value after ID changes. + var enc string + if err := s.DB.QueryRowContext(ctx, `SELECT signing_secret FROM telemetry_state`).Scan(&enc); err != nil { + t.Fatalf("read column: %v", err) + } + if raw, err := base64.StdEncoding.DecodeString(enc); err != nil || string(raw) != string(first) { + t.Fatalf("stored secret %q does not decode to the returned secret (err=%v)", enc, err) + } + if err := s.SetInstallID(ctx, "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10"); err != nil { + t.Fatalf("set id: %v", err) + } + if err := s.ClearInstallID(ctx); err != nil { + t.Fatalf("clear id: %v", err) + } + third, err := s.EnsureSigningSecret(ctx) + if err != nil || string(third) != string(first) { + t.Fatalf("the secret must survive ID changes, got err=%v", err) + } +} + +func TestEnsureSigningSecret_ConcurrentFirstCallsAgree(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + const callers = 6 + results := make([][]byte, callers) + var wg sync.WaitGroup + for i := range callers { + wg.Add(1) + go func() { + defer wg.Done() + secret, err := s.EnsureSigningSecret(ctx) + if err != nil { + t.Errorf("ensure: %v", err) + return + } + results[i] = secret + }() + } + wg.Wait() + for i := 1; i < callers; i++ { + if string(results[i]) != string(results[0]) { + t.Fatalf("caller %d got a different secret", i) + } + } +} + +func TestEnsureSigningSecret_Errors(t *testing.T) { + ctx := t.Context() + + s := newRBACStore(t) + if _, err := s.DB.ExecContext(ctx, `DELETE FROM telemetry_state`); err != nil { + t.Fatalf("delete row: %v", err) + } + if _, err := s.EnsureSigningSecret(ctx); err == nil { + t.Error("want an error when there is no telemetry_state row to hold the secret") + } + + s = newRBACStore(t) + if _, err := s.DB.ExecContext(ctx, `UPDATE telemetry_state SET signing_secret = '!!not base64!!'`); err != nil { + t.Fatalf("corrupt secret: %v", err) + } + if _, err := s.EnsureSigningSecret(ctx); err == nil { + t.Error("want an error for a stored secret that is not base64") + } +} + +func TestMarkNoticeShown_SetsOnce(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + if err := s.MarkNoticeShown(ctx, "2026-10-06T09:00:00Z"); err != nil { + t.Fatalf("first mark: %v", err) + } + if err := s.MarkNoticeShown(ctx, "2026-10-06T12:00:00Z"); err != nil { + t.Fatalf("second mark: %v", err) + } + st, err := s.GetTelemetryState(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.NoticeShownAt != "2026-10-06T09:00:00Z" { + t.Fatalf("notice_shown_at = %q, want the first timestamp", st.NoticeShownAt) + } +} + +func TestNoticeAck_Lifecycle(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + admin := insertUser(t, s, "notice-admin", "admin") + other := insertUser(t, s, "notice-other", "admin") + + if has, err := s.HasNoticeAck(ctx, admin); err != nil || has { + t.Fatalf("before any ack: has=%v err=%v", has, err) + } + if err := s.InsertNoticeAck(ctx, admin, "keep", "2026-10-06T09:00:00Z"); err != nil { + t.Fatalf("insert: %v", err) + } + if has, err := s.HasNoticeAck(ctx, admin); err != nil || !has { + t.Fatalf("after ack: has=%v err=%v", has, err) + } + if has, _ := s.HasNoticeAck(ctx, other); has { + t.Fatal("an ack must belong to one user only") + } + + // The first dismissal stands. + if err := s.InsertNoticeAck(ctx, admin, "all-off", "2026-10-06T10:00:00Z"); err != nil { + t.Fatalf("repeat insert: %v", err) + } + var count int + if err := s.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM telemetry_notice_acks WHERE action = 'keep'`).Scan(&count); err != nil || count != 1 { + t.Fatalf("the first action must be kept: count=%d err=%v", count, err) + } + + if err := s.DeleteNoticeAcksForUser(ctx, nil, admin); err != nil { + t.Fatalf("delete: %v", err) + } + if has, _ := s.HasNoticeAck(ctx, admin); has { + t.Fatal("ack still present after DeleteNoticeAcksForUser") + } +} + +func TestDeleteUser_RemovesNoticeAck(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + gone := insertUser(t, s, "ack-removed", "admin") + kept := insertUser(t, s, "ack-kept", "admin") + for _, id := range []int64{gone, kept} { + if err := s.InsertNoticeAck(ctx, id, "keep", "2026-10-06T09:00:00Z"); err != nil { + t.Fatalf("insert ack: %v", err) + } + } + if err := s.DeleteUser(ctx, gone); err != nil { + t.Fatalf("delete user: %v", err) + } + if has, _ := s.HasNoticeAck(ctx, gone); has { + t.Error("the removed user's ack must be deleted with the account") + } + if has, _ := s.HasNoticeAck(ctx, kept); !has { + t.Error("another user's ack must survive") + } +} + +func TestTelemetryState_MissingRowIsAnError(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + if _, err := s.DB.ExecContext(ctx, `DELETE FROM telemetry_state`); err != nil { + t.Fatalf("delete row: %v", err) + } + if _, err := s.GetTelemetryState(ctx); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("Get on a missing row = %v, want sql.ErrNoRows", err) + } + if err := s.UpdateTelemetryState(ctx, TelemetryState{ConsentSource: "admin", LastOutcome: "never"}); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("Update on a missing row = %v, want sql.ErrNoRows", err) + } + if err := s.SetInstallID(ctx, "x"); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("SetInstallID on a missing row = %v, want sql.ErrNoRows", err) + } + if err := s.ClearInstallID(ctx); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("ClearInstallID on a missing row = %v, want sql.ErrNoRows", err) + } +} + +func TestTelemetryHelpers_ClosedDatabaseErrors(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + if err := s.Close(); err != nil { + t.Fatalf("close: %v", err) + } + checks := map[string]func() error{ + "migrate": func() error { return s.Migrate(ctx) }, + "seed": func() error { return s.seedTelemetryState(ctx, true) }, + "get": func() error { _, err := s.GetTelemetryState(ctx); return err }, + "update": func() error { return s.UpdateTelemetryState(ctx, TelemetryState{}) }, + "claim": func() error { _, err := s.ClaimTelemetryDue(ctx, "a", "b", "c"); return err }, + "setID": func() error { return s.SetInstallID(ctx, "x") }, + "clearID": func() error { return s.ClearInstallID(ctx) }, + "secret": func() error { _, err := s.EnsureSigningSecret(ctx); return err }, + "markNotice": func() error { return s.MarkNoticeShown(ctx, "t") }, + "insertAck": func() error { return s.InsertNoticeAck(ctx, 1, "keep", "t") }, + "hasAck": func() error { _, err := s.HasNoticeAck(ctx, 1); return err }, + "deleteAcks": func() error { return s.DeleteNoticeAcksForUser(ctx, nil, 1) }, + "deleteUser": func() error { return s.DeleteUser(ctx, 1) }, + } + for name, fn := range checks { + if err := fn(); err == nil { + t.Errorf("%s: want an error from a closed database", name) + } + } +} + +func TestWithExtendedOff(t *testing.T) { + got, changed := withExtendedOff(`{"sendMetrics":true,"extra":1}`) + if !changed || got != `{"extended":false,"extra":1,"sendMetrics":true}` { + t.Errorf("object: got %q changed=%v", got, changed) + } + for _, raw := range []string{``, `{bad`, `null`, `"str"`} { + if out, changed := withExtendedOff(raw); changed || out != raw { + t.Errorf("%q: got %q changed=%v, want it left alone", raw, out, changed) + } + } +} diff --git a/api/internal/db/telemetry_tx.go b/api/internal/db/telemetry_tx.go new file mode 100644 index 000000000..297dfcc7a --- /dev/null +++ b/api/internal/db/telemetry_tx.go @@ -0,0 +1,176 @@ +package db + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" +) + +// This file holds the telemetry_state helpers that need an open transaction +// (consent changes must commit with the config row, and a SQLite store has a +// single connection, so a second query on the store would deadlock) and the +// targeted schedule and result writes the reporter uses. Targeted writes +// change only the columns they name, so a reporter tick can't overwrite a +// concurrent consent change with a stale copy of the whole row. + +// TelemetryResult is the outcome of one report attempt. +type TelemetryResult struct { + // Outcome is "ok" or "failed". + Outcome string + // SuccessAt (RFC 3339 UTC) becomes last_success_at; empty keeps the old value. + SuccessAt string + // Failures becomes consecutive_failures. + Failures int + // ExpectedNext is the next_due_at value this attempt claimed. NextDue + // replaces next_due_at only while the column still holds ExpectedNext, so + // a gate that closed during the send (NULL) stays closed. + ExpectedNext string + NextDue string +} + +// UpsertConfigTx writes one config row inside tx. +func UpsertConfigTx(ctx context.Context, tx *sql.Tx, key, value string) error { + return upsertConfigTx(ctx, tx, key, value) +} + +// GetTelemetryStateTx is GetTelemetryState inside tx. +func GetTelemetryStateTx(ctx context.Context, tx *sql.Tx) (TelemetryState, error) { + var st TelemetryState + err := tx.QueryRowContext(ctx, + `SELECT consent_source, COALESCE(install_id, ''), COALESCE(notice_shown_at, ''), + COALESCE(next_due_at, ''), COALESCE(last_attempt_at, ''), COALESCE(last_success_at, ''), + last_outcome, consecutive_failures, COALESCE(ext_unsupported_until, ''), + COALESCE(ext_unsupported_endpoint, ''), COALESCE(last_id_rotation_at, '') + FROM telemetry_state WHERE id = ?`, telemetrySingletonID).Scan( + &st.ConsentSource, &st.InstallID, &st.NoticeShownAt, + &st.NextDueAt, &st.LastAttemptAt, &st.LastSuccessAt, + &st.LastOutcome, &st.ConsecutiveFailures, &st.ExtUnsupportedUntil, + &st.ExtUnsupportedEndpoint, &st.LastIDRotationAt) + if err != nil { + return TelemetryState{}, fmt.Errorf("get telemetry state: %w", err) + } + return st, nil +} + +// UpdateTelemetryConsentTx sets consent_source, install_id and next_due_at +// inside tx. Empty installID and nextDueAt store NULL. +func UpdateTelemetryConsentTx(ctx context.Context, tx *sql.Tx, source, installID, nextDueAt string) error { + res, err := tx.ExecContext(ctx, + `UPDATE telemetry_state + SET consent_source = ?, install_id = NULLIF(?, ''), next_due_at = NULLIF(?, '') + WHERE id = ?`, + source, installID, nextDueAt, telemetrySingletonID) + if err != nil { + return fmt.Errorf("update telemetry consent: %w", err) + } + return requireOneRow(res, "update telemetry consent") +} + +// EnsureSigningSecretTx creates the install's signing secret inside tx when +// none exists yet. It never reads the secret back. +func EnsureSigningSecretTx(ctx context.Context, tx *sql.Tx) error { + fresh := make([]byte, 32) + if _, err := rand.Read(fresh); err != nil { + return fmt.Errorf("generate signing secret: %w", err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE telemetry_state SET signing_secret = ? WHERE id = ? AND signing_secret IS NULL`, + base64.StdEncoding.EncodeToString(fresh), telemetrySingletonID); err != nil { + return fmt.Errorf("store signing secret: %w", err) + } + return nil +} + +// MarkNoticeShownTx is MarkNoticeShown inside tx. +func MarkNoticeShownTx(ctx context.Context, tx *sql.Tx, now string) error { + if _, err := tx.ExecContext(ctx, + `UPDATE telemetry_state SET notice_shown_at = ? WHERE id = ? AND notice_shown_at IS NULL`, + now, telemetrySingletonID); err != nil { + return fmt.Errorf("mark notice shown: %w", err) + } + return nil +} + +// InsertNoticeAckTx is InsertNoticeAck inside tx. +func InsertNoticeAckTx(ctx context.Context, tx *sql.Tx, userID int64, action, ackedAt string) error { + if _, err := tx.ExecContext(ctx, + `INSERT INTO telemetry_notice_acks(user_id, acked_at, action) VALUES (?, ?, ?) + ON CONFLICT (user_id) DO NOTHING`, + userID, ackedAt, action); err != nil { + return fmt.Errorf("insert notice ack: %w", err) + } + return nil +} + +// OpenTelemetrySchedule sets next_due_at to next (RFC 3339 UTC) only while it +// is NULL, and reports whether it changed the row. +func (s *Store) OpenTelemetrySchedule(ctx context.Context, next string) (bool, error) { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET next_due_at = ? WHERE id = ? AND next_due_at IS NULL`, + next, telemetrySingletonID) + if err != nil { + return false, fmt.Errorf("open telemetry schedule: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("open telemetry schedule: rows affected: %w", err) + } + return n == 1, nil +} + +// CloseTelemetrySchedule sets next_due_at to NULL (the gate closed). +func (s *Store) CloseTelemetrySchedule(ctx context.Context) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET next_due_at = NULL WHERE id = ?`, telemetrySingletonID) + if err != nil { + return fmt.Errorf("close telemetry schedule: %w", err) + } + return requireOneRow(res, "close telemetry schedule") +} + +// RecordTelemetryResult stores the outcome of one attempt (see TelemetryResult). +func (s *Store) RecordTelemetryResult(ctx context.Context, r TelemetryResult) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET + last_outcome = ?, consecutive_failures = ?, + last_success_at = COALESCE(NULLIF(?, ''), last_success_at), + next_due_at = CASE WHEN next_due_at = ? THEN ? ELSE next_due_at END + WHERE id = ?`, + r.Outcome, r.Failures, r.SuccessAt, r.ExpectedNext, r.NextDue, telemetrySingletonID) + if err != nil { + return fmt.Errorf("record telemetry result: %w", err) + } + return requireOneRow(res, "record telemetry result") +} + +// SetExtUnsupported records (or, with empty arguments, clears) the marker that +// the endpoint rejected the extended part: until is RFC 3339 UTC. +func (s *Store) SetExtUnsupported(ctx context.Context, until, endpoint string) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state + SET ext_unsupported_until = NULLIF(?, ''), ext_unsupported_endpoint = NULLIF(?, '') + WHERE id = ?`, + until, endpoint, telemetrySingletonID) + if err != nil { + return fmt.Errorf("set ext unsupported: %w", err) + } + return requireOneRow(res, "set ext unsupported") +} + +// RotateInstallID replaces the install ID with id and stamps +// last_id_rotation_at with at (RFC 3339 UTC), after the receiver reported the +// old ID as claimed by another key (FR-037). The signing secret is untouched. +// It changes nothing, and wraps sql.ErrNoRows, when there is no install ID to +// replace, for example because the extended tier was turned off meanwhile. +func (s *Store) RotateInstallID(ctx context.Context, id, at string) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE telemetry_state SET install_id = ?, last_id_rotation_at = ? + WHERE id = ? AND install_id IS NOT NULL`, + id, at, telemetrySingletonID) + if err != nil { + return fmt.Errorf("rotate install id: %w", err) + } + return requireOneRow(res, "rotate install id") +} diff --git a/api/internal/db/telemetry_tx_test.go b/api/internal/db/telemetry_tx_test.go new file mode 100644 index 000000000..7816da863 --- /dev/null +++ b/api/internal/db/telemetry_tx_test.go @@ -0,0 +1,220 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "testing" +) + +// txTestInstallID is a lowercase UUIDv4 for the install ID column. +const txTestInstallID = "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10" + +// inTestTx runs fn in a transaction on s and commits it. +func inTestTx(t *testing.T, s *Store, fn func(ctx context.Context, tx *sql.Tx)) { + t.Helper() + ctx := t.Context() + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin: %v", err) + } + fn(ctx, tx) + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } +} + +func TestTelemetryTx_ConsentRoundTrip(t *testing.T) { + s := newRBACStore(t) + inTestTx(t, s, func(ctx context.Context, tx *sql.Tx) { + if err := UpsertConfigTx(ctx, tx, "telemetry", `{"sendMetrics":true,"extended":false}`); err != nil { + t.Fatalf("upsert config: %v", err) + } + if err := EnsureSigningSecretTx(ctx, tx); err != nil { + t.Fatalf("ensure secret: %v", err) + } + if err := MarkNoticeShownTx(ctx, tx, "2026-10-06T09:00:00Z"); err != nil { + t.Fatalf("mark shown: %v", err) + } + if err := MarkNoticeShownTx(ctx, tx, "2026-10-06T10:00:00Z"); err != nil { + t.Fatalf("mark shown again: %v", err) + } + if err := UpdateTelemetryConsentTx(ctx, tx, TelemetryConsentAdmin, txTestInstallID, "2026-10-06T09:05:00Z"); err != nil { + t.Fatalf("update consent: %v", err) + } + st, err := GetTelemetryStateTx(ctx, tx) + if err != nil { + t.Fatalf("get in tx: %v", err) + } + if st.ConsentSource != TelemetryConsentAdmin || st.InstallID != txTestInstallID || + st.NextDueAt != "2026-10-06T09:05:00Z" || st.NoticeShownAt != "2026-10-06T09:00:00Z" { + t.Fatalf("state in tx = %+v", st) + } + if err := InsertNoticeAckTx(ctx, tx, 7, "keep", "2026-10-06T09:01:00Z"); err != nil { + t.Fatalf("ack: %v", err) + } + if err := InsertNoticeAckTx(ctx, tx, 7, "all-off", "2026-10-06T09:02:00Z"); err != nil { + t.Fatalf("second ack: %v", err) + } + }) + ctx := t.Context() + if v, ok := telemetryConfig(t, s); !ok || v != `{"sendMetrics":true,"extended":false}` { + t.Fatalf("config = %q, %v", v, ok) + } + if secret, err := s.EnsureSigningSecret(ctx); err != nil || len(secret) != 32 { + t.Fatalf("secret = %d bytes, %v; want 32, nil", len(secret), err) + } + if ok, err := s.HasNoticeAck(ctx, 7); err != nil || !ok { + t.Fatalf("HasNoticeAck = %v, %v; want true, nil", ok, err) + } + var action string + if err := s.DB.QueryRowContext(ctx, `SELECT action FROM telemetry_notice_acks WHERE user_id = 7`).Scan(&action); err != nil || action != "keep" { + t.Fatalf("ack action = %q, %v; want the first ack (keep)", action, err) + } + + // Clearing the install ID and the slot stores NULL. + inTestTx(t, s, func(ctx context.Context, tx *sql.Tx) { + if err := UpdateTelemetryConsentTx(ctx, tx, TelemetryConsentAdmin, "", ""); err != nil { + t.Fatalf("clear: %v", err) + } + }) + st, err := s.GetTelemetryState(ctx) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.InstallID != "" || st.NextDueAt != "" { + t.Fatalf("after clearing, install id/slot = %q/%q, want empty", st.InstallID, st.NextDueAt) + } +} + +func TestTelemetrySchedule_OpenAndClose(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + if ok, err := s.OpenTelemetrySchedule(ctx, "2026-10-06T10:00:00Z"); err != nil || !ok { + t.Fatalf("open on a NULL slot = %v, %v; want true, nil", ok, err) + } + if ok, err := s.OpenTelemetrySchedule(ctx, "2026-10-06T11:00:00Z"); err != nil || ok { + t.Fatalf("open on a set slot = %v, %v; want false, nil", ok, err) + } + st, err := s.GetTelemetryState(ctx) + if err != nil || st.NextDueAt != "2026-10-06T10:00:00Z" { + t.Fatalf("slot = %q, %v; want the first value kept", st.NextDueAt, err) + } + if err := s.CloseTelemetrySchedule(ctx); err != nil { + t.Fatalf("close: %v", err) + } + if st, _ = s.GetTelemetryState(ctx); st.NextDueAt != "" { + t.Fatalf("slot after close = %q, want empty", st.NextDueAt) + } +} + +func TestRecordTelemetryResult(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + const claimed = "2026-10-07T10:03:00Z" + if err := s.UpdateTelemetryState(ctx, TelemetryState{ + ConsentSource: TelemetryConsentAdmin, LastOutcome: "never", NextDueAt: claimed, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + // Success moves the slot while it still holds the claimed value. + if err := s.RecordTelemetryResult(ctx, TelemetryResult{ + Outcome: "ok", SuccessAt: "2026-10-06T10:00:09Z", ExpectedNext: claimed, NextDue: "2026-10-07T10:09:00Z", + }); err != nil { + t.Fatalf("record ok: %v", err) + } + st, _ := s.GetTelemetryState(ctx) + if st.LastOutcome != "ok" || st.LastSuccessAt != "2026-10-06T10:00:09Z" || + st.NextDueAt != "2026-10-07T10:09:00Z" || st.ConsecutiveFailures != 0 { + t.Fatalf("after success: %+v", st) + } + + // A failure keeps last_success_at (empty SuccessAt) and counts up. + if err := s.RecordTelemetryResult(ctx, TelemetryResult{ + Outcome: "failed", Failures: 2, ExpectedNext: "2026-10-07T10:09:00Z", NextDue: "2026-10-06T12:00:00Z", + }); err != nil { + t.Fatalf("record failed: %v", err) + } + st, _ = s.GetTelemetryState(ctx) + if st.LastOutcome != "failed" || st.ConsecutiveFailures != 2 || + st.LastSuccessAt != "2026-10-06T10:00:09Z" || st.NextDueAt != "2026-10-06T12:00:00Z" { + t.Fatalf("after failure: %+v", st) + } + + // A slot that changed since the claim (the gate closed, or reopened) is + // left alone, while the outcome is still recorded. + if err := s.CloseTelemetrySchedule(ctx); err != nil { + t.Fatalf("close: %v", err) + } + if err := s.RecordTelemetryResult(ctx, TelemetryResult{ + Outcome: "ok", SuccessAt: "2026-10-06T13:00:00Z", ExpectedNext: "2026-10-06T12:00:00Z", NextDue: "2026-10-07T13:00:00Z", + }); err != nil { + t.Fatalf("record after close: %v", err) + } + st, _ = s.GetTelemetryState(ctx) + if st.NextDueAt != "" || st.LastOutcome != "ok" { + t.Fatalf("after a closed gate: slot %q outcome %q, want empty and ok", st.NextDueAt, st.LastOutcome) + } +} + +func TestSetExtUnsupported_SetAndClear(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + if err := s.SetExtUnsupported(ctx, "2026-10-13T10:00:00Z", "https://old.example/ingest"); err != nil { + t.Fatalf("set: %v", err) + } + st, _ := s.GetTelemetryState(ctx) + if st.ExtUnsupportedUntil != "2026-10-13T10:00:00Z" || st.ExtUnsupportedEndpoint != "https://old.example/ingest" { + t.Fatalf("marker = %q/%q", st.ExtUnsupportedUntil, st.ExtUnsupportedEndpoint) + } + if err := s.SetExtUnsupported(ctx, "", ""); err != nil { + t.Fatalf("clear: %v", err) + } + st, _ = s.GetTelemetryState(ctx) + if st.ExtUnsupportedUntil != "" || st.ExtUnsupportedEndpoint != "" { + t.Fatalf("marker after clear = %q/%q, want empty", st.ExtUnsupportedUntil, st.ExtUnsupportedEndpoint) + } +} + +func TestTelemetryTx_MissingRowAndClosedDatabase(t *testing.T) { + s := newRBACStore(t) + ctx := t.Context() + if _, err := s.DB.ExecContext(ctx, `DELETE FROM telemetry_state`); err != nil { + t.Fatalf("delete row: %v", err) + } + inTestTx(t, s, func(ctx context.Context, tx *sql.Tx) { + if _, err := GetTelemetryStateTx(ctx, tx); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("GetTelemetryStateTx on a missing row = %v, want sql.ErrNoRows", err) + } + if err := UpdateTelemetryConsentTx(ctx, tx, "admin", "", ""); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("UpdateTelemetryConsentTx on a missing row = %v, want sql.ErrNoRows", err) + } + }) + for name, fn := range map[string]func() error{ + "close": func() error { return s.CloseTelemetrySchedule(ctx) }, + "record": func() error { return s.RecordTelemetryResult(ctx, TelemetryResult{Outcome: "ok"}) }, + "marker": func() error { return s.SetExtUnsupported(ctx, "", "") }, + } { + if err := fn(); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("%s on a missing row = %v, want sql.ErrNoRows", name, err) + } + } + + closed := newRBACStore(t) + if err := closed.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if _, err := closed.OpenTelemetrySchedule(ctx, "x"); err == nil { + t.Error("OpenTelemetrySchedule: want an error from a closed database") + } + if err := closed.CloseTelemetrySchedule(ctx); err == nil { + t.Error("CloseTelemetrySchedule: want an error from a closed database") + } + if err := closed.RecordTelemetryResult(ctx, TelemetryResult{}); err == nil { + t.Error("RecordTelemetryResult: want an error from a closed database") + } + if err := closed.SetExtUnsupported(ctx, "", ""); err == nil { + t.Error("SetExtUnsupported: want an error from a closed database") + } +} diff --git a/api/internal/db/users.go b/api/internal/db/users.go index f8a7d998d..fa1ed7544 100644 --- a/api/internal/db/users.go +++ b/api/internal/db/users.go @@ -7,11 +7,12 @@ import ( ) // DeleteUser removes a user account and every row tied to it, in one -// transaction: the account's SSO links, preferences, sessions, API tokens and -// role bindings are deleted, and the share links it created are revoked -// (revocation, not a delete, keeps their audit trail). The rows are removed -// explicitly rather than left to ON DELETE CASCADE, because the shipped -// SQLite DSN runs with foreign keys off. Deleting an unknown id is a no-op. +// transaction: the account's SSO links, preferences, sessions, API tokens, +// role bindings, and telemetry notice acks are deleted, and the share links +// it created are revoked (revocation, not a delete, keeps their audit trail). +// The rows are removed explicitly rather than left to ON DELETE CASCADE, +// because the shipped SQLite DSN runs with foreign keys off. Deleting an +// unknown id is a no-op. func (s *Store) DeleteUser(ctx context.Context, userID int64) error { tx, err := s.DB.BeginTx(ctx, nil) if err != nil { @@ -38,6 +39,9 @@ func (s *Store) DeleteUser(ctx context.Context, userID int64) error { if err := s.DeleteUserBindings(ctx, tx, userID); err != nil { return fmt.Errorf("delete user: %w", err) } + if err := s.DeleteNoticeAcksForUser(ctx, tx, userID); err != nil { + return fmt.Errorf("delete user: %w", err) + } if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil { return fmt.Errorf("delete user: users row: %w", err) } diff --git a/api/internal/handlers/config.go b/api/internal/handlers/config.go index 48090d5a7..975af88f2 100644 --- a/api/internal/handlers/config.go +++ b/api/internal/handlers/config.go @@ -21,6 +21,7 @@ import ( "github.com/GameplanePanel/gameplane/api/internal/httperr" "github.com/GameplanePanel/gameplane/api/internal/notify" "github.com/GameplanePanel/gameplane/api/internal/registry" + "github.com/GameplanePanel/gameplane/api/internal/telemetry" ) // MountConfig exposes the admin config store at /admin/config. @@ -44,12 +45,21 @@ import ( // in installTimeSettings.oidcHelmProvider (the raw seed, not merged with // helmOverride overrides). Passed as *auth.ProviderPolicy from main.go. func MountConfig(r chi.Router, store *db.Store, auditor *audit.Auditor, helmOIDCPresent bool, gameDataStorageClass string, helmPolicy *auth.ProviderPolicy) { + MountConfigWithTelemetry(r, store, auditor, helmOIDCPresent, gameDataStorageClass, helmPolicy, TelemetrySettings{}) +} + +// MountConfigWithTelemetry is MountConfig with the install's telemetry +// setting, which the "telemetry" section's save hook needs to refuse a save +// when the operator disabled telemetry and to schedule the first report. +// MountConfig passes the zero value (no destination restriction, 24h). +func MountConfigWithTelemetry(r chi.Router, store *db.Store, auditor *audit.Auditor, helmOIDCPresent bool, gameDataStorageClass string, helmPolicy *auth.ProviderPolicy, tel TelemetrySettings) { h := &configHandler{ db: store, auditor: auditor, validators: newValidators(helmOIDCPresent), gameDataStorageClass: gameDataStorageClass, helmPolicy: helmPolicy, + telemetry: tel, } r.Route("/admin/config", func(r chi.Router) { r.Get("/", h.getAll) @@ -64,6 +74,7 @@ type configHandler struct { validators map[string]func([]byte) (json.RawMessage, error) gameDataStorageClass string helmPolicy *auth.ProviderPolicy + telemetry TelemetrySettings } func (h *configHandler) getAll(w http.ResponseWriter, req *http.Request) { @@ -132,7 +143,18 @@ func (h *configHandler) put(w http.ResponseWriter, req *http.Request) { auditEvents = h.detectAuthAuditEvents(req.Context(), canon) } - if _, err := h.db.DB.ExecContext(req.Context(), + if section == "telemetry" { + // The telemetry section's post-save hook runs in the same + // transaction as the config write (see saveTelemetry). + if err := h.saveTelemetry(req.Context(), canon); err != nil { + if errors.Is(err, telemetry.ErrOperatorDisabled) { + http.Error(w, err.Error(), http.StatusConflict) + return + } + httperr.Write(w, req, err) + return + } + } else if _, err := h.db.DB.ExecContext(req.Context(), `INSERT INTO config(key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET @@ -158,6 +180,22 @@ func (h *configHandler) put(w http.ResponseWriter, req *http.Request) { writeJSON(w, map[string]any{"section": section, "value": canon}) } +// saveTelemetry persists the validated telemetry section through +// telemetry.ApplyConsent, so one transaction writes the config row, sets +// consent_source to "admin", creates or clears the install ID and opens or +// closes the report schedule. It returns telemetry.ErrOperatorDisabled when +// the operator disabled telemetry at install time. +func (h *configHandler) saveTelemetry(ctx context.Context, canon json.RawMessage) error { + var c telemetryCfg + if err := json.Unmarshal(canon, &c); err != nil { + return fmt.Errorf("decode telemetry config: %w", err) + } + return inTx(ctx, h.db, func(tx *sql.Tx) error { + return telemetry.ApplyConsent(ctx, tx, h.telemetry.Dest, h.telemetry.Interval, + c.SendMetrics, c.Extended, db.TelemetryConsentAdmin) + }) +} + // resetRoleMapping removes a single role's override from helmOverride.roleMappings // in the "auth" config row, restoring the Helm-seeded value for that role. // It is idempotent: returns 200 even if the role had no override. @@ -723,6 +761,7 @@ func validateNotifications(body []byte) (json.RawMessage, error) { type telemetryCfg struct { SendMetrics bool `json:"sendMetrics"` + Extended bool `json:"extended"` } func validateTelemetry(body []byte) (json.RawMessage, error) { @@ -730,6 +769,11 @@ func validateTelemetry(body []byte) (json.RawMessage, error) { if err := json.Unmarshal(body, &c); err != nil { return nil, fmt.Errorf("invalid json: %w", err) } + // Turning basic off turns extended off with it, and the save succeeds + // (FR-003, spec Q7). + if !c.SendMetrics { + c.Extended = false + } return json.Marshal(c) } diff --git a/api/internal/handlers/config_test.go b/api/internal/handlers/config_test.go index 42e4c0576..8655a4eaa 100644 --- a/api/internal/handlers/config_test.go +++ b/api/internal/handlers/config_test.go @@ -65,7 +65,7 @@ func doReq(t *testing.T, method, url string, body any) (int, []byte) { return resp.StatusCode, out } -func TestConfig_GetEmpty(t *testing.T) { +func TestConfig_GetFreshInstallDefaults(t *testing.T) { srv, _ := newConfigServer(t) status, body := doReq(t, "GET", srv.URL+"/admin/config", nil) if status != 200 { @@ -75,8 +75,23 @@ func TestConfig_GetEmpty(t *testing.T) { if err := json.Unmarshal(body, &got); err != nil { t.Fatalf("unmarshal: %v; body=%s", err, body) } - if len(got) != 0 { - t.Fatalf("want empty map, got %v", got) + // Migrate seeds the telemetry default on a fresh install (spec 022 FR-003). + if len(got) != 1 { + t.Fatalf("want one key, got %d: %v", len(got), got) + } + raw, ok := got["telemetry"] + if !ok { + t.Fatalf("want telemetry key, got %v", got) + } + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("unmarshal telemetry: %v", err) + } + if sendMetrics, ok := cfg["sendMetrics"].(bool); !ok || !sendMetrics { + t.Fatalf("want sendMetrics=true, got %v", cfg) + } + if extended, ok := cfg["extended"].(bool); !ok || !extended { + t.Fatalf("want extended=true, got %v", cfg) } } diff --git a/api/internal/handlers/config_validators_test.go b/api/internal/handlers/config_validators_test.go index 7fd6fdd66..7a8e09e65 100644 --- a/api/internal/handlers/config_validators_test.go +++ b/api/internal/handlers/config_validators_test.go @@ -164,9 +164,19 @@ func TestValidateTelemetry(t *testing.T) { if err != nil { t.Fatalf("happy: %v", err) } - if string(canon) != `{"sendMetrics":true}` { + if string(canon) != `{"sendMetrics":true,"extended":false}` { t.Fatalf("canonicalized output: got %s", canon) } + canon, err = validateTelemetry([]byte(`{"sendMetrics":true,"extended":true}`)) + if err != nil || string(canon) != `{"sendMetrics":true,"extended":true}` { + t.Fatalf("both tiers on: got %s, %v", canon, err) + } + // Basic off turns extended off with it and the save is not rejected + // (FR-003, spec Q7). + canon, err = validateTelemetry([]byte(`{"sendMetrics":false,"extended":true}`)) + if err != nil || string(canon) != `{"sendMetrics":false,"extended":false}` { + t.Fatalf("basic off must normalise extended off: got %s, %v", canon, err) + } if _, err := validateTelemetry([]byte(`bogus`)); err == nil { t.Fatal("expected json error") } diff --git a/api/internal/handlers/telemetry.go b/api/internal/handlers/telemetry.go new file mode 100644 index 000000000..cda2728e2 --- /dev/null +++ b/api/internal/handlers/telemetry.go @@ -0,0 +1,360 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/GameplanePanel/gameplane/api/internal/auth" + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/httperr" + "github.com/GameplanePanel/gameplane/api/internal/telemetry" + "github.com/GameplanePanel/gameplane/telemetryschema" +) + +// Notice actions accepted by POST /admin/telemetry/notice. +const ( + noticeSeen = "seen" + noticeKeep = "keep" + noticeExtendedOff = "extended-off" + noticeAllOff = "all-off" +) + +// noticeBasicFields and noticeExtendedFields are the exact report fields the +// first-login notice lists (contracts/report-schema.md). +var ( + noticeBasicFields = []string{"version", "servers", "templates"} + noticeExtendedFields = []string{"installId", "env.k8s", "env.distro", "env.arch", "env.nodes", "games", "features"} +) + +// TelemetrySettings is the install-time telemetry setting the config and +// notice handlers need: where reports go and how far apart they are. +type TelemetrySettings struct { + // Dest is the destination the API resolved at startup. + Dest telemetry.Destination + // Interval is the spacing between reports; zero means 24h. + Interval time.Duration + // Deps is what GET /admin/telemetry collects the preview from; it is the + // same value the reporter uses. A nil Deps.Store is filled with the + // handler's store. + Deps telemetry.Deps +} + +// errExtendedOff is returned when an install-ID reset is asked for while the +// extended tier is off: there is no ID to reset. +var errExtendedOff = errors.New("extended telemetry is off: there is no install id to reset") + +// MountTelemetry exposes the telemetry status at /admin/telemetry, the install +// ID reset at /admin/telemetry/install-id and the first-login notice at +// /admin/telemetry/notice (spec 022, contracts/api-telemetry-http.md). +func MountTelemetry(r chi.Router, store *db.Store, settings TelemetrySettings) { + if settings.Deps.Store == nil { + settings.Deps.Store = store + } + h := &telemetryHandler{db: store, settings: settings} + r.Route("/admin/telemetry", func(r chi.Router) { + r.Get("/", h.getTelemetry) + r.Post("/install-id", h.postInstallID) + r.Get("/notice", h.getNotice) + r.Post("/notice", h.postNotice) + }) +} + +type telemetryHandler struct { + db *db.Store + settings TelemetrySettings +} + +// noticeDestination is the destination as shown to an admin: its kind and +// the URL host only, never the path, query or credentials. +type noticeDestination struct { + Kind string `json:"kind"` + Host string `json:"host"` +} + +// telemetryDestination is noticeDestination for GET /admin/telemetry: host is +// null for the disabled and none kinds. +type telemetryDestination struct { + Kind string `json:"kind"` + Host *string `json:"host"` +} + +type telemetryConsent struct { + Basic bool `json:"basic"` + Extended bool `json:"extended"` + Source string `json:"source"` +} + +// telemetryStatus is the delivery status. It never carries a failure detail, +// and the state's signing secret is not reachable from it. +type telemetryStatus struct { + LastAttemptAt *string `json:"lastAttemptAt"` + LastSuccessAt *string `json:"lastSuccessAt"` + LastOutcome string `json:"lastOutcome"` + LastIDRotationAt *string `json:"lastIdRotationAt"` +} + +type telemetryResponse struct { + Destination telemetryDestination `json:"destination"` + OperatorDisabled bool `json:"operatorDisabled"` + Consent telemetryConsent `json:"consent"` + InstallID *string `json:"installId"` + // Preview is the exact report body the reporter would POST now, or null. + Preview json.RawMessage `json:"preview"` + Status telemetryStatus `json:"status"` +} + +// nullString returns nil for the empty string, so it encodes as JSON null. +func nullString(s string) *string { + if s == "" { + return nil + } + return &s +} + +// destSends reports whether the destination can receive reports at all. +func destSends(d telemetry.Destination) bool { + return d.Kind != telemetry.KindDisabled && d.Kind != telemetry.KindNone +} + +// getTelemetry serves GET /admin/telemetry (config:read). The preview is +// built by telemetry.BuildReport, the function the reporter uses, and encoded +// by telemetryschema.Encode, so it is byte-for-byte what would be POSTed, +// apart from ext.sentAt (SC-006). +func (h *telemetryHandler) getTelemetry(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + st, err := h.db.GetTelemetryState(ctx) + if err != nil { + httperr.Write(w, req, err) + return + } + c, err := telemetry.ReadConsent(ctx, h.db) + if err != nil { + httperr.Write(w, req, err) + return + } + dest := h.settings.Dest + resp := telemetryResponse{ + Destination: telemetryDestination{Kind: dest.Kind}, + OperatorDisabled: dest.Kind == telemetry.KindDisabled, + Consent: telemetryConsent{Basic: c.Basic, Extended: c.Extended, Source: st.ConsentSource}, + Status: telemetryStatus{ + LastAttemptAt: nullString(st.LastAttemptAt), + LastSuccessAt: nullString(st.LastSuccessAt), + LastOutcome: st.LastOutcome, + LastIDRotationAt: nullString(st.LastIDRotationAt), + }, + } + if destSends(dest) { + resp.Destination.Host = nullString(dest.Host) + } + if c.Extended { + resp.InstallID = nullString(st.InstallID) + } + if c.Basic && destSends(dest) { + now := time.Now().UTC() + extended := c.Extended && st.InstallID != "" && !telemetry.ExtWithheld(st, dest.URL, now) + rep, err := telemetry.BuildReport(ctx, h.settings.Deps, extended, now) + if err != nil { + httperr.Write(w, req, err) + return + } + body, err := telemetryschema.Encode(rep) + if err != nil { + httperr.Write(w, req, err) + return + } + resp.Preview = body + } + writeJSON(w, resp) +} + +// postInstallID serves POST /admin/telemetry/install-id (config:manage). It +// replaces the install ID, keeps the signing secret (so the new ID gets a new +// key) and the schedule, and returns the new ID. It returns 409 when extended +// is off or the operator disabled telemetry. +func (h *telemetryHandler) postInstallID(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + var id string + err := inTx(ctx, h.db, func(tx *sql.Tx) error { + if h.settings.Dest.Kind == telemetry.KindDisabled { + return telemetry.ErrOperatorDisabled + } + c, err := telemetry.ReadConsentTx(ctx, tx) + if err != nil { + return err + } + if !c.Extended { + return errExtendedOff + } + st, err := db.GetTelemetryStateTx(ctx, tx) + if err != nil { + return err + } + if id, err = telemetry.NewInstallID(); err != nil { + return err + } + if err := db.EnsureSigningSecretTx(ctx, tx); err != nil { + return err + } + return db.UpdateTelemetryConsentTx(ctx, tx, st.ConsentSource, id, st.NextDueAt) + }) + switch { + case errors.Is(err, telemetry.ErrOperatorDisabled), errors.Is(err, errExtendedOff): + http.Error(w, err.Error(), http.StatusConflict) + case err != nil: + httperr.Write(w, req, err) + default: + writeJSON(w, map[string]string{"installId": id}) + } +} + +type noticeFields struct { + Basic []string `json:"basic"` + Extended []string `json:"extended"` +} + +// noticeResponse omits everything but pending while the notice isn't pending. +type noticeResponse struct { + Pending bool `json:"pending"` + Destination *noticeDestination `json:"destination,omitempty"` + Fields *noticeFields `json:"fields,omitempty"` +} + +// pending reports whether the notice is pending for u: u holds config:manage, +// a destination is in effect, no admin has made a choice yet (consent_source +// is "default"), and u has not dismissed it. +func (h *telemetryHandler) pending(ctx context.Context, u *auth.User) (bool, error) { + if !u.Can("config:manage", false, "", "") { + return false, nil + } + if h.settings.Dest.Kind == telemetry.KindDisabled || h.settings.Dest.Kind == telemetry.KindNone { + return false, nil + } + st, err := h.db.GetTelemetryState(ctx) + if err != nil { + return false, err + } + if st.ConsentSource != db.TelemetryConsentDefault { + return false, nil + } + acked, err := h.db.HasNoticeAck(ctx, u.ID) + if err != nil { + return false, err + } + return !acked, nil +} + +func (h *telemetryHandler) getNotice(w http.ResponseWriter, req *http.Request) { + u := auth.UserFromContext(req.Context()) + pending, err := h.pending(req.Context(), u) + if err != nil { + httperr.Write(w, req, err) + return + } + if !pending { + writeJSON(w, noticeResponse{}) + return + } + writeJSON(w, noticeResponse{ + Pending: true, + Destination: ¬iceDestination{Kind: h.settings.Dest.Kind, Host: h.settings.Dest.Host}, + Fields: ¬iceFields{Basic: noticeBasicFields, Extended: noticeExtendedFields}, + }) +} + +func (h *telemetryHandler) postNotice(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + u := auth.UserFromContext(ctx) + if u == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var body struct { + Action string `json:"action"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, req.Body, 1<<10)).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + switch body.Action { + case noticeSeen, noticeKeep, noticeExtendedOff, noticeAllOff: + default: + http.Error(w, "action must be one of seen|keep|extended-off|all-off", http.StatusBadRequest) + return + } + pending, err := h.pending(ctx, u) + if err != nil { + httperr.Write(w, req, err) + return + } + if !pending { + if body.Action == noticeSeen { + w.WriteHeader(http.StatusNoContent) // a no-op, not an error + return + } + http.Error(w, "telemetry notice is not pending", http.StatusConflict) + return + } + err = inTx(ctx, h.db, func(tx *sql.Tx) error { + return h.apply(ctx, tx, u.ID, body.Action) + }) + switch { + case errors.Is(err, telemetry.ErrOperatorDisabled): + http.Error(w, err.Error(), http.StatusConflict) + case err != nil: + httperr.Write(w, req, err) + default: + w.WriteHeader(http.StatusNoContent) + } +} + +// apply performs one notice action inside tx. seen records no ack; the other +// actions record the caller's ack in the same transaction as their effect. +func (h *telemetryHandler) apply(ctx context.Context, tx *sql.Tx, userID int64, action string) error { + s := h.settings + switch action { + case noticeSeen: + return telemetry.MarkNoticeSeen(ctx, tx, s.Dest, s.Interval) + case noticeExtendedOff: + c, err := telemetry.ReadConsentTx(ctx, tx) + if err != nil { + return err + } + if err := telemetry.ApplyConsent(ctx, tx, s.Dest, s.Interval, c.Basic, false, db.TelemetryConsentAdmin); err != nil { + return err + } + case noticeAllOff: + if err := telemetry.ApplyConsent(ctx, tx, s.Dest, s.Interval, false, false, db.TelemetryConsentAdmin); err != nil { + return err + } + } + if err := db.InsertNoticeAckTx(ctx, tx, userID, action, time.Now().UTC().Format(time.RFC3339)); err != nil { + return err + } + return nil +} + +// inTx runs fn in a transaction on store and commits when fn succeeds. A +// SQLite store has one connection, so work that must commit together has to +// share the transaction rather than reach for the store. +func inTx(ctx context.Context, store *db.Store, fn func(tx *sql.Tx) error) error { + tx, err := store.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + if err := fn(tx); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} diff --git a/api/internal/handlers/telemetry_envtest_test.go b/api/internal/handlers/telemetry_envtest_test.go new file mode 100644 index 000000000..504756bd1 --- /dev/null +++ b/api/internal/handlers/telemetry_envtest_test.go @@ -0,0 +1,777 @@ +//go:build envtest + +package handlers + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/GameplanePanel/gameplane/api/internal/auth" + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/api/internal/rbac" + "github.com/GameplanePanel/gameplane/api/internal/telemetry" +) + +// Telemetry notice and config-hook tests (spec 022, US1). +// +// Each test builds its own router over its own in-memory store, with +// rbac.Middleware in front, because the notice and the config hook change +// the telemetry_state singleton: sharing captureAuditStore would let tests +// disturb each other. Callers are injected with auth.WithUser through +// doConfigAsUser (config_envtest_test.go). + +var telemetryBundledDest = telemetry.Destination{ + Kind: telemetry.KindBundled, URL: "http://receiver.test:8080/ingest", Host: "receiver.test:8080", +} + +// newTelemetryRouter returns a router with the config and telemetry routes +// mounted for dest, and the fresh-install store behind it (consent_source +// "default", basic and extended on, notice not shown). +func newTelemetryRouter(t *testing.T, dest telemetry.Destination) (*chi.Mux, *db.Store) { + t.Helper() + store, err := db.Open(context.Background(), "sqlite", + "file:"+uniqueResourceName("telemetry")+"?mode=memory&cache=shared&_pragma=journal_mode(WAL)") + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + settings := TelemetrySettings{Dest: dest, Interval: time.Hour} + r := chi.NewRouter() + r.Use(rbac.Middleware(kube.NewRegistry("default"))) + MountConfigWithTelemetry(r, store, nil, false, "", nil, settings) + MountTelemetry(r, store, settings) + return r, store +} + +// telemetryAdmin is an admin ("*") with the given user ID. +func telemetryAdmin(id int64) *auth.User { + u := configAdminUser() + u.ID = id + return u +} + +// telemetryReader holds config:read but not config:manage. +func telemetryReader() *auth.User { + return &auth.User{ + ID: 200, + Username: "telemetry-reader", + Perms: map[string]map[string]map[string]struct{}{ + "*": {"*": {"config:read": {}}}, + }, + } +} + +func telemetryState(t *testing.T, store *db.Store) db.TelemetryState { + t.Helper() + st, err := store.GetTelemetryState(context.Background()) + if err != nil { + t.Fatalf("get telemetry state: %v", err) + } + return st +} + +func telemetryConfigValue(t *testing.T, store *db.Store) string { + t.Helper() + v, ok, err := store.ConfigValue(context.Background(), "telemetry") + if err != nil || !ok { + t.Fatalf("read telemetry config: %q, %v, %v", v, ok, err) + } + return v +} + +// getNotice calls GET /admin/telemetry/notice as u and decodes the object. +func getNotice(t *testing.T, r http.Handler, u *auth.User) map[string]any { + t.Helper() + rr := doConfigAsUser(t, r, u, http.MethodGet, "/admin/telemetry/notice", nil) + if rr.Code != http.StatusOK { + t.Fatalf("GET notice = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("decode notice %q: %v", rr.Body.String(), err) + } + return out +} + +func postNotice(t *testing.T, r http.Handler, u *auth.User, action string) *httptest.ResponseRecorder { + t.Helper() + return doConfigAsUser(t, r, u, http.MethodPost, "/admin/telemetry/notice", map[string]string{"action": action}) +} + +func TestTelemetryNotice_PendingOnlyForManagersOnADefaultInstallWithADestination(t *testing.T) { + t.Run("a manager on a default install sees the notice", func(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + got := getNotice(t, r, telemetryAdmin(1)) + if got["pending"] != true { + t.Fatalf("notice = %v, want pending", got) + } + dest, _ := got["destination"].(map[string]any) + if dest["kind"] != "bundled" || dest["host"] != "receiver.test:8080" { + t.Fatalf("destination = %v, want kind bundled and the URL host only", dest) + } + fields, _ := got["fields"].(map[string]any) + basic, _ := fields["basic"].([]any) + extended, _ := fields["extended"].([]any) + if len(basic) != 3 || len(extended) != 7 { + t.Fatalf("fields = %v, want 3 basic and 7 extended", fields) + } + }) + + t.Run("a reader without config:manage gets pending false only", func(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + got := getNotice(t, r, telemetryReader()) + if got["pending"] != false || len(got) != 1 { + t.Fatalf("notice = %v, want exactly {pending:false}", got) + } + }) + + t.Run("a user without config:read is refused by RBAC", func(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + rr := doConfigAsUser(t, r, configOperatorUser(), http.MethodGet, "/admin/telemetry/notice", nil) + if rr.Code != http.StatusForbidden { + t.Fatalf("operator GET notice = %d, want 403", rr.Code) + } + }) + + for _, dest := range []telemetry.Destination{{Kind: telemetry.KindNone}, {Kind: telemetry.KindDisabled}} { + t.Run("not pending with destination "+dest.Kind, func(t *testing.T) { + r, _ := newTelemetryRouter(t, dest) + if got := getNotice(t, r, telemetryAdmin(1)); got["pending"] != false || len(got) != 1 { + t.Fatalf("notice = %v, want exactly {pending:false}", got) + } + }) + } + + t.Run("not pending once an admin made a choice", func(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + rr := doConfigAsUser(t, r, telemetryAdmin(1), http.MethodPut, "/admin/config/telemetry", + map[string]bool{"sendMetrics": true, "extended": true}) + if rr.Code != http.StatusOK { + t.Fatalf("PUT = %d; body=%s", rr.Code, rr.Body.String()) + } + if got := getNotice(t, r, telemetryAdmin(2)); got["pending"] != false { + t.Fatalf("notice = %v, want not pending after an explicit choice", got) + } + if st := telemetryState(t, store); st.ConsentSource != db.TelemetryConsentAdmin { + t.Fatalf("consent source = %q, want admin", st.ConsentSource) + } + }) +} + +func TestTelemetryNotice_SeenSetsNoticeShownOnce(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + + if st := telemetryState(t, store); st.NoticeShownAt != "" || st.NextDueAt != "" { + t.Fatalf("fresh state = %+v, want no notice and no slot", st) + } + if rr := postNotice(t, r, admin, "seen"); rr.Code != http.StatusNoContent { + t.Fatalf("seen = %d, want 204; body=%s", rr.Code, rr.Body.String()) + } + first := telemetryState(t, store) + if first.NoticeShownAt == "" || first.NextDueAt == "" || first.InstallID == "" { + t.Fatalf("state after seen = %+v, want the notice stamped, the schedule open and an install id", first) + } + if first.ConsentSource != db.TelemetryConsentDefault { + t.Fatalf("consent source = %q, seen must leave it as default", first.ConsentSource) + } + if acked, err := store.HasNoticeAck(context.Background(), admin.ID); err != nil || acked { + t.Fatalf("HasNoticeAck = %v, %v; seen must record no ack", acked, err) + } + + if rr := postNotice(t, r, admin, "seen"); rr.Code != http.StatusNoContent { + t.Fatalf("second seen = %d, want 204", rr.Code) + } + if again := telemetryState(t, store); again != first { + t.Fatalf("a second seen changed the state: %+v then %+v", first, again) + } + if got := getNotice(t, r, admin); got["pending"] != true { + t.Fatal("seen alone must not dismiss the notice") + } +} + +func TestTelemetryNotice_SeenIsANoOpWhenNotPending(t *testing.T) { + r, store := newTelemetryRouter(t, telemetry.Destination{Kind: telemetry.KindNone}) + if rr := postNotice(t, r, telemetryAdmin(1), "seen"); rr.Code != http.StatusNoContent { + t.Fatalf("seen = %d, want 204", rr.Code) + } + if st := telemetryState(t, store); st.NoticeShownAt != "" { + t.Fatalf("notice_shown_at = %q, want it untouched when nothing is pending", st.NoticeShownAt) + } +} + +func TestTelemetryNotice_KeepOnlyAcksTheCaller(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + before := telemetryConfigValue(t, store) + + if rr := postNotice(t, r, admin, "keep"); rr.Code != http.StatusNoContent { + t.Fatalf("keep = %d, want 204; body=%s", rr.Code, rr.Body.String()) + } + if got := telemetryConfigValue(t, store); got != before { + t.Fatalf("config = %s, keep must leave consent unchanged (was %s)", got, before) + } + if st := telemetryState(t, store); st.ConsentSource != db.TelemetryConsentDefault { + t.Fatalf("consent source = %q, keep must leave it as default", st.ConsentSource) + } + if acked, err := store.HasNoticeAck(context.Background(), admin.ID); err != nil || !acked { + t.Fatalf("HasNoticeAck = %v, %v; want the caller's ack recorded", acked, err) + } + if got := getNotice(t, r, admin); got["pending"] != false { + t.Fatalf("notice = %v, want it dismissed for the caller", got) + } + if got := getNotice(t, r, telemetryAdmin(2)); got["pending"] != true { + t.Fatalf("notice = %v, want it still pending for another admin", got) + } +} + +func TestTelemetryNotice_ExtendedOffKeepsBasic(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + if rr := postNotice(t, r, admin, "seen"); rr.Code != http.StatusNoContent { + t.Fatalf("seen = %d", rr.Code) + } + if telemetryState(t, store).InstallID == "" { + t.Fatal("setup: seen should have created the install id") + } + + if rr := postNotice(t, r, admin, "extended-off"); rr.Code != http.StatusNoContent { + t.Fatalf("extended-off = %d, want 204; body=%s", rr.Code, rr.Body.String()) + } + if got := telemetryConfigValue(t, store); got != `{"sendMetrics":true,"extended":false}` { + t.Fatalf("config = %s, want basic on and extended off", got) + } + st := telemetryState(t, store) + if st.ConsentSource != db.TelemetryConsentAdmin || st.InstallID != "" || st.NextDueAt == "" { + t.Fatalf("state = %+v, want source admin, the ID deleted and basic reporting still scheduled", st) + } + if acked, err := store.HasNoticeAck(context.Background(), admin.ID); err != nil || !acked { + t.Fatalf("HasNoticeAck = %v, %v; want an ack", acked, err) + } +} + +func TestTelemetryNotice_AllOffStopsEverything(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + if rr := postNotice(t, r, admin, "seen"); rr.Code != http.StatusNoContent { + t.Fatalf("seen = %d", rr.Code) + } + + if rr := postNotice(t, r, admin, "all-off"); rr.Code != http.StatusNoContent { + t.Fatalf("all-off = %d, want 204; body=%s", rr.Code, rr.Body.String()) + } + if got := telemetryConfigValue(t, store); got != `{"sendMetrics":false,"extended":false}` { + t.Fatalf("config = %s, want both tiers off", got) + } + st := telemetryState(t, store) + if st.ConsentSource != db.TelemetryConsentAdmin || st.InstallID != "" || st.NextDueAt != "" { + t.Fatalf("state = %+v, want source admin, no ID and the schedule closed", st) + } + if acked, err := store.HasNoticeAck(context.Background(), admin.ID); err != nil || !acked { + t.Fatalf("HasNoticeAck = %v, %v; want an ack", acked, err) + } +} + +func TestTelemetryNotice_ActionsConflictWhenNotPending(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + if rr := postNotice(t, r, admin, "keep"); rr.Code != http.StatusNoContent { + t.Fatalf("keep = %d", rr.Code) + } + for _, action := range []string{"keep", "extended-off", "all-off"} { + if rr := postNotice(t, r, admin, action); rr.Code != http.StatusConflict { + t.Errorf("%s after dismissal = %d, want 409", action, rr.Code) + } + } + + none, _ := newTelemetryRouter(t, telemetry.Destination{Kind: telemetry.KindNone}) + if rr := postNotice(t, none, admin, "all-off"); rr.Code != http.StatusConflict { + t.Errorf("all-off with no destination = %d, want 409", rr.Code) + } +} + +func TestTelemetryNotice_BadRequestsAndForbidden(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + + if rr := postNotice(t, r, admin, "explode"); rr.Code != http.StatusBadRequest { + t.Errorf("unknown action = %d, want 400", rr.Code) + } + req := httptest.NewRequestWithContext(auth.WithUser(context.Background(), admin), + http.MethodPost, "/admin/telemetry/notice", strings.NewReader("{nope")) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("malformed body = %d, want 400", rr.Code) + } + if rr := postNotice(t, r, telemetryReader(), "keep"); rr.Code != http.StatusForbidden { + t.Errorf("POST without config:manage = %d, want 403", rr.Code) + } +} + +func TestTelemetryConfig_BasicOffStoresExtendedOffAndDeletesID(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + put := func(sendMetrics, extended bool) *httptest.ResponseRecorder { + return doConfigAsUser(t, r, admin, http.MethodPut, "/admin/config/telemetry", + map[string]bool{"sendMetrics": sendMetrics, "extended": extended}) + } + + if rr := put(true, true); rr.Code != http.StatusOK { + t.Fatalf("PUT on = %d; body=%s", rr.Code, rr.Body.String()) + } + if telemetryState(t, store).InstallID == "" { + t.Fatal("setup: turning extended on should create the install id") + } + + rr := put(false, true) + if rr.Code != http.StatusOK { + t.Fatalf("PUT {sendMetrics:false, extended:true} = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + var resp struct { + Section string `json:"section"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp.Section != "telemetry" || string(resp.Value) != `{"sendMetrics":false,"extended":false}` { + t.Fatalf("response = %s, want the normalised value with extended false", rr.Body.String()) + } + if got := telemetryConfigValue(t, store); got != `{"sendMetrics":false,"extended":false}` { + t.Fatalf("stored config = %s, want extended stored off", got) + } + st := telemetryState(t, store) + if st.InstallID != "" || st.NextDueAt != "" || st.ConsentSource != db.TelemetryConsentAdmin { + t.Fatalf("state = %+v, want the ID deleted, the schedule closed and source admin", st) + } +} + +func TestTelemetryConfig_SaveCreatesAndDeletesInstallIDAndSetsAdminSource(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + put := func(sendMetrics, extended bool) { + t.Helper() + rr := doConfigAsUser(t, r, admin, http.MethodPut, "/admin/config/telemetry", + map[string]bool{"sendMetrics": sendMetrics, "extended": extended}) + if rr.Code != http.StatusOK { + t.Fatalf("PUT {%v,%v} = %d; body=%s", sendMetrics, extended, rr.Code, rr.Body.String()) + } + } + + if telemetryState(t, store).ConsentSource != db.TelemetryConsentDefault { + t.Fatal("setup: a fresh install starts with source default") + } + put(true, true) + st := telemetryState(t, store) + if st.ConsentSource != db.TelemetryConsentAdmin || st.InstallID == "" || st.NextDueAt == "" { + t.Fatalf("after extended on: %+v, want source admin, an ID and an open schedule", st) + } + put(true, false) + if st := telemetryState(t, store); st.InstallID != "" || st.NextDueAt == "" { + t.Fatalf("after extended off: %+v, want the ID deleted and basic still scheduled", st) + } + put(true, true) + if again := telemetryState(t, store); again.InstallID == "" || again.InstallID == st.InstallID { + t.Fatalf("after extended on again: id %q (was %q), want a new one", again.InstallID, st.InstallID) + } +} + +func TestTelemetryConfig_OperatorDisabledIsRefusedWith409(t *testing.T) { + r, store := newTelemetryRouter(t, telemetry.Destination{Kind: telemetry.KindDisabled}) + before := telemetryConfigValue(t, store) + + rr := doConfigAsUser(t, r, telemetryAdmin(1), http.MethodPut, "/admin/config/telemetry", + map[string]bool{"sendMetrics": false, "extended": false}) + if rr.Code != http.StatusConflict { + t.Fatalf("PUT = %d, want 409; body=%s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "telemetry is disabled by the operator") { + t.Fatalf("body = %q, want the operator-disabled message", rr.Body.String()) + } + if got := telemetryConfigValue(t, store); got != before { + t.Fatalf("config = %s, a refused save must change nothing (was %s)", got, before) + } + if st := telemetryState(t, store); st.ConsentSource != db.TelemetryConsentDefault { + t.Fatalf("consent source = %q, a refused save must leave it alone", st.ConsentSource) + } +} + +func TestTelemetryConfig_PutNeedsManagePermission(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + rr := doConfigAsUser(t, r, telemetryReader(), http.MethodPut, "/admin/config/telemetry", + map[string]bool{"sendMetrics": false, "extended": false}) + if rr.Code != http.StatusForbidden { + t.Fatalf("PUT without config:manage = %d, want 403", rr.Code) + } +} + +// ---- GET /admin/telemetry and POST /admin/telemetry/install-id (US2, US3) ---- + +// newTelemetryRouterFor is newTelemetryRouter with explicit settings, for +// tests that also need the reporter's Deps (version) or its destination URL. +func newTelemetryRouterFor(t *testing.T, settings TelemetrySettings) (*chi.Mux, *db.Store) { + t.Helper() + store, err := db.Open(context.Background(), "sqlite", + "file:"+uniqueResourceName("telemetry")+"?mode=memory&cache=shared&_pragma=journal_mode(WAL)") + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + r := chi.NewRouter() + r.Use(rbac.Middleware(kube.NewRegistry("default"))) + MountConfigWithTelemetry(r, store, nil, false, "", nil, settings) + MountTelemetry(r, store, settings) + return r, store +} + +// getTelemetry calls GET /admin/telemetry as u and decodes the object. The +// raw body is returned too, for checks on what must not appear in it. +func getTelemetry(t *testing.T, r http.Handler, u *auth.User) (map[string]any, string) { + t.Helper() + rr := doConfigAsUser(t, r, u, http.MethodGet, "/admin/telemetry", nil) + if rr.Code != http.StatusOK { + t.Fatalf("GET /admin/telemetry = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %q: %v", rr.Body.String(), err) + } + return out, rr.Body.String() +} + +func putTelemetryConsent(t *testing.T, r http.Handler, sendMetrics, extended bool) { + t.Helper() + rr := doConfigAsUser(t, r, telemetryAdmin(1), http.MethodPut, "/admin/config/telemetry", + map[string]bool{"sendMetrics": sendMetrics, "extended": extended}) + if rr.Code != http.StatusOK { + t.Fatalf("PUT telemetry {%v,%v} = %d; body=%s", sendMetrics, extended, rr.Code, rr.Body.String()) + } +} + +// previewExt returns the preview's ext object, or nil when there is none. +func previewExt(t *testing.T, got map[string]any) map[string]any { + t.Helper() + preview, ok := got["preview"].(map[string]any) + if !ok { + t.Fatalf("preview = %v, want an object", got["preview"]) + } + ext, _ := preview["ext"].(map[string]any) + return ext +} + +func TestTelemetryGet_EveryDestinationKind(t *testing.T) { + cases := []struct { + name string + dest telemetry.Destination + host any // the expected destination.host, nil for JSON null + disabled bool + wantsPreview bool + }{ + {"default", telemetry.Destination{Kind: telemetry.KindDefault, URL: "https://telemetry.example.org/ingest?k=secret", Host: "telemetry.example.org"}, "telemetry.example.org", false, true}, + {"custom", telemetry.Destination{Kind: telemetry.KindCustom, URL: "http://custom.test:9000/ingest", Host: "custom.test:9000"}, "custom.test:9000", false, true}, + {"bundled", telemetryBundledDest, "receiver.test:8080", false, true}, + {"disabled", telemetry.Destination{Kind: telemetry.KindDisabled}, nil, true, false}, + {"none", telemetry.Destination{Kind: telemetry.KindNone}, nil, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, _ := newTelemetryRouter(t, tc.dest) + got, raw := getTelemetry(t, r, telemetryAdmin(1)) + + dest, _ := got["destination"].(map[string]any) + if dest["kind"] != tc.dest.Kind || dest["host"] != tc.host { + t.Fatalf("destination = %v, want kind %q and host %v (never the path or query)", dest, tc.dest.Kind, tc.host) + } + if strings.Contains(raw, "secret") || strings.Contains(raw, "/ingest") { + t.Fatalf("body leaks the destination URL: %s", raw) + } + if got["operatorDisabled"] != tc.disabled { + t.Fatalf("operatorDisabled = %v, want %v", got["operatorDisabled"], tc.disabled) + } + // The stored choice is reported even when the operator's setting + // overrides it. + consent, _ := got["consent"].(map[string]any) + if consent["basic"] != true || consent["extended"] != true || consent["source"] != "default" { + t.Fatalf("consent = %v, want the fresh-install default", consent) + } + if tc.wantsPreview { + preview, ok := got["preview"].(map[string]any) + if !ok || preview["servers"] != float64(0) || preview["templates"] != float64(0) { + t.Fatalf("preview = %v, want a basic report with zero counts", got["preview"]) + } + // A fresh install has no ID until the notice is seen, so the + // preview carries no ext part yet. + if _, hasExt := preview["ext"]; hasExt || got["installId"] != nil { + t.Fatalf("preview ext / installId = %v / %v, want none before an ID exists", preview["ext"], got["installId"]) + } + } else if got["preview"] != nil { + t.Fatalf("preview = %v, want null for kind %s", got["preview"], tc.name) + } + status, _ := got["status"].(map[string]any) + if status["lastOutcome"] != "never" || status["lastAttemptAt"] != nil || + status["lastSuccessAt"] != nil || status["lastIdRotationAt"] != nil { + t.Fatalf("status = %v, want never with every timestamp null", status) + } + }) + } +} + +func TestTelemetryGet_InstallIDAndPreviewFollowTheTiers(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + + putTelemetryConsent(t, r, true, true) + got, _ := getTelemetry(t, r, admin) + id := telemetryState(t, store).InstallID + if id == "" || got["installId"] != id { + t.Fatalf("installId = %v, want the stored id %q while extended is on", got["installId"], id) + } + ext := previewExt(t, got) + if ext == nil || ext["installId"] != id || ext["key"] == "" || ext["sentAt"] == "" { + t.Fatalf("preview ext = %v, want the id, the public key and a send time", ext) + } + + putTelemetryConsent(t, r, true, false) + got, _ = getTelemetry(t, r, admin) + if got["installId"] != nil || previewExt(t, got) != nil { + t.Fatalf("installId / ext = %v / %v, want none while extended is off (FR-012)", got["installId"], previewExt(t, got)) + } + + putTelemetryConsent(t, r, false, false) + got, _ = getTelemetry(t, r, admin) + if got["preview"] != nil { + t.Fatalf("preview = %v, want null while basic is off", got["preview"]) + } +} + +func TestTelemetryGet_PreviewHonoursTheFallbackMarker(t *testing.T) { + dest := telemetry.Destination{Kind: telemetry.KindCustom, URL: "http://old.test/ingest", Host: "old.test"} + r, store := newTelemetryRouter(t, dest) + putTelemetryConsent(t, r, true, true) + if err := store.SetExtUnsupported(context.Background(), + time.Now().Add(24*time.Hour).UTC().Format(time.RFC3339), dest.URL); err != nil { + t.Fatalf("set marker: %v", err) + } + got, _ := getTelemetry(t, r, telemetryAdmin(1)) + if previewExt(t, got) != nil { + t.Fatal("the preview must be basic only while the endpoint is marked as not understanding ext") + } + if consent, _ := got["consent"].(map[string]any); consent["extended"] != true || got["installId"] == nil { + t.Fatalf("consent / installId = %v / %v, the stored choice and the ID are unaffected", got["consent"], got["installId"]) + } +} + +func TestTelemetryGet_StatusReportsTheStoredDelivery(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + putTelemetryConsent(t, r, true, true) + st := telemetryState(t, store) + st.LastAttemptAt = "2026-10-06T09:12:44Z" + st.LastSuccessAt = "2026-10-05T09:12:44Z" + st.LastOutcome = "failed" + st.LastIDRotationAt = "2026-10-04T08:00:00Z" + if err := store.UpdateTelemetryState(context.Background(), st); err != nil { + t.Fatalf("update state: %v", err) + } + + got, _ := getTelemetry(t, r, telemetryAdmin(1)) + status, _ := got["status"].(map[string]any) + want := map[string]any{ + "lastAttemptAt": "2026-10-06T09:12:44Z", + "lastSuccessAt": "2026-10-05T09:12:44Z", + "lastOutcome": "failed", + "lastIdRotationAt": "2026-10-04T08:00:00Z", + } + if !reflect.DeepEqual(status, want) { + t.Fatalf("status = %v, want %v", status, want) + } +} + +func TestTelemetryGet_NeverExposesTheSigningSecret(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + putTelemetryConsent(t, r, true, true) + secret, err := store.EnsureSigningSecret(context.Background()) + if err != nil || len(secret) == 0 { + t.Fatalf("signing secret = %x, %v, want one created by the save", secret, err) + } + _, raw := getTelemetry(t, r, telemetryAdmin(1)) + for _, enc := range []string{ + base64.StdEncoding.EncodeToString(secret), base64.RawURLEncoding.EncodeToString(secret), + base64.RawStdEncoding.EncodeToString(secret), hex.EncodeToString(secret), "signing", + } { + if strings.Contains(raw, enc) { + t.Fatalf("GET /admin/telemetry leaks the signing secret (%q): %s", enc, raw) + } + } +} + +func TestTelemetryGet_NeedsConfigRead(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + if got, _ := getTelemetry(t, r, telemetryReader()); got["destination"] == nil { + t.Fatalf("a config:read holder must get the status, got %v", got) + } + if rr := doConfigAsUser(t, r, configOperatorUser(), http.MethodGet, "/admin/telemetry", nil); rr.Code != http.StatusForbidden { + t.Fatalf("operator GET = %d, want 403", rr.Code) + } +} + +func TestTelemetryInstallID_ResetReplacesTheIDAndKeepsTheRest(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + admin := telemetryAdmin(1) + putTelemetryConsent(t, r, true, true) + before := telemetryState(t, store) + secretBefore, err := store.EnsureSigningSecret(context.Background()) + if err != nil { + t.Fatalf("secret: %v", err) + } + + rr := doConfigAsUser(t, r, admin, http.MethodPost, "/admin/telemetry/install-id", nil) + if rr.Code != http.StatusOK { + t.Fatalf("reset = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + var resp map[string]string + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil || len(resp) != 1 { + t.Fatalf("reset body = %q (%v), want exactly {installId}", rr.Body.String(), err) + } + after := telemetryState(t, store) + if resp["installId"] == "" || resp["installId"] == before.InstallID || after.InstallID != resp["installId"] { + t.Fatalf("ids: response %q stored %q before %q, want a new id stored and returned", resp["installId"], after.InstallID, before.InstallID) + } + if after.ConsentSource != before.ConsentSource || after.NextDueAt != before.NextDueAt || after.LastIDRotationAt != "" { + t.Fatalf("state = %+v, want the source, the schedule and the rotation stamp unchanged (was %+v)", after, before) + } + if secretAfter, err := store.EnsureSigningSecret(context.Background()); err != nil || string(secretAfter) != string(secretBefore) { + t.Fatalf("a reset must keep the signing secret: %v", err) + } + got, _ := getTelemetry(t, r, admin) + if got["installId"] != resp["installId"] || previewExt(t, got)["installId"] != resp["installId"] { + t.Fatalf("GET after reset = %v, want the preview to show the new id at once", got) + } +} + +func TestTelemetryInstallID_CreatesTheFirstIDOnAFreshInstall(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + // Fresh install: extended is on in the stored choice but no ID exists yet. + if rr := doConfigAsUser(t, r, telemetryAdmin(1), http.MethodPost, "/admin/telemetry/install-id", nil); rr.Code != http.StatusOK { + t.Fatalf("reset = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + if st := telemetryState(t, store); st.InstallID == "" || st.ConsentSource != db.TelemetryConsentDefault { + t.Fatalf("state = %+v, want an ID and the source still default", st) + } +} + +func TestTelemetryInstallID_RefusedWhenExtendedOffOrOperatorDisabled(t *testing.T) { + t.Run("extended off", func(t *testing.T) { + r, store := newTelemetryRouter(t, telemetryBundledDest) + putTelemetryConsent(t, r, true, false) + rr := doConfigAsUser(t, r, telemetryAdmin(1), http.MethodPost, "/admin/telemetry/install-id", nil) + if rr.Code != http.StatusConflict { + t.Fatalf("reset = %d, want 409; body=%s", rr.Code, rr.Body.String()) + } + if telemetryState(t, store).InstallID != "" { + t.Fatal("a refused reset must not create an ID") + } + }) + t.Run("basic off", func(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + putTelemetryConsent(t, r, false, true) + if rr := doConfigAsUser(t, r, telemetryAdmin(1), http.MethodPost, "/admin/telemetry/install-id", nil); rr.Code != http.StatusConflict { + t.Fatalf("reset = %d, want 409", rr.Code) + } + }) + t.Run("operator disabled", func(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetry.Destination{Kind: telemetry.KindDisabled}) + rr := doConfigAsUser(t, r, telemetryAdmin(1), http.MethodPost, "/admin/telemetry/install-id", nil) + if rr.Code != http.StatusConflict || !strings.Contains(rr.Body.String(), "disabled by the operator") { + t.Fatalf("reset = %d %q, want 409 with the operator message", rr.Code, rr.Body.String()) + } + }) + t.Run("needs config:manage", func(t *testing.T) { + r, _ := newTelemetryRouter(t, telemetryBundledDest) + if rr := doConfigAsUser(t, r, telemetryReader(), http.MethodPost, "/admin/telemetry/install-id", nil); rr.Code != http.StatusForbidden { + t.Fatalf("reset by a reader = %d, want 403", rr.Code) + } + }) +} + +// SC-006: the preview is the body the reporter POSTs, apart from the send time. +func TestTelemetryPreview_EqualsTheBodyTheReporterPosts(t *testing.T) { + for _, extended := range []bool{true, false} { + name := "basic only" + if extended { + name = "with the extended part" + } + t.Run(name, func(t *testing.T) { + bodies := make(chan []byte, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodGet { + // The reporter asks for a proof-of-work challenge first (spec 022 + // T105); this provider offers none. + http.NotFound(w, req) + return + } + b, _ := io.ReadAll(req.Body) + bodies <- b + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + dest := telemetry.Destination{Kind: telemetry.KindCustom, URL: srv.URL, Host: "receiver.test"} + r, store := newTelemetryRouterFor(t, TelemetrySettings{ + Dest: dest, Interval: time.Hour, Deps: telemetry.Deps{Version: "v9.9.9"}, + }) + putTelemetryConsent(t, r, true, extended) + + got, _ := getTelemetry(t, r, telemetryAdmin(1)) + // The reporter runs two hours ahead, so the saved slot is due. + rep := telemetry.New(telemetry.Config{ + Dest: dest, Interval: time.Hour, + Deps: telemetry.Deps{Store: store, Version: "v9.9.9"}, + Now: func() time.Time { return time.Now().Add(2 * time.Hour) }, + Rand: func(int64) int64 { return 0 }, + }) + if err := rep.Tick(context.Background()); err != nil { + t.Fatalf("tick: %v", err) + } + var posted map[string]any + select { + case b := <-bodies: + if err := json.Unmarshal(b, &posted); err != nil { + t.Fatalf("decode posted body %q: %v", b, err) + } + case <-time.After(5 * time.Second): + t.Fatal("the reporter posted nothing") + } + + preview, _ := got["preview"].(map[string]any) + for _, m := range []map[string]any{preview, posted} { + if ext, ok := m["ext"].(map[string]any); ok { + delete(ext, "sentAt") + } + } + if !reflect.DeepEqual(preview, posted) { + t.Fatalf("preview != posted body (ignoring ext.sentAt):\npreview %v\nposted %v", preview, posted) + } + if _, hasExt := posted["ext"]; hasExt != extended || posted["version"] != "v9.9.9" { + t.Fatalf("posted = %v, want ext present = %v and the configured version", posted, extended) + } + }) + } +} diff --git a/api/internal/rbac/rbac.go b/api/internal/rbac/rbac.go index 20488f0f2..ff0cbc645 100644 --- a/api/internal/rbac/rbac.go +++ b/api/internal/rbac/rbac.go @@ -225,6 +225,11 @@ var rules = []rule{ // Mod-registry provider key secrets pair with editing the // modRegistries config section. {segment: "admin", prefix: "/admin/registries", perm: "config:manage"}, + // Telemetry status and notice (spec 022): reads need config:read, writes + // config:manage. The notice GET handler itself answers {"pending":false} + // to callers without config:manage. + {method: "GET", segment: "admin", prefix: "/admin/telemetry", perm: "config:read"}, + {segment: "admin", prefix: "/admin/telemetry", perm: "config:manage"}, {segment: "admin", perm: "*"}, // Network capture (feature 003-network-capture-sidecar): admin-only via diff --git a/api/internal/rbac/rbac_test.go b/api/internal/rbac/rbac_test.go index a66756a1e..d3b3aa724 100644 --- a/api/internal/rbac/rbac_test.go +++ b/api/internal/rbac/rbac_test.go @@ -178,6 +178,18 @@ func TestAllow(t *testing.T) { {RoleOperator, "PUT", "/admin/registries/curseforge/secret", scope.DefaultCluster, false}, {RoleAdmin, "PUT", "/admin/registries/curseforge/secret", scope.DefaultCluster, true}, {RoleAdmin, "DELETE", "/admin/registries/curseforge/secret", scope.DefaultCluster, true}, + // Telemetry notice (spec 022): reads need config:read, writes config:manage. + {RoleViewer, "GET", "/admin/telemetry/notice", scope.DefaultCluster, false}, + {RoleAdmin, "GET", "/admin/telemetry/notice", scope.DefaultCluster, true}, + {RoleViewer, "POST", "/admin/telemetry/notice", scope.DefaultCluster, false}, + {RoleOperator, "POST", "/admin/telemetry/notice", scope.DefaultCluster, false}, + {RoleAdmin, "POST", "/admin/telemetry/notice", scope.DefaultCluster, true}, + // Telemetry status and install-ID reset (spec 022, prefix entries in rbac.go). + {RoleViewer, "GET", "/admin/telemetry", scope.DefaultCluster, false}, + {RoleAdmin, "GET", "/admin/telemetry", scope.DefaultCluster, true}, + {RoleViewer, "POST", "/admin/telemetry/install-id", scope.DefaultCluster, false}, + {RoleOperator, "POST", "/admin/telemetry/install-id", scope.DefaultCluster, false}, + {RoleAdmin, "POST", "/admin/telemetry/install-id", scope.DefaultCluster, true}, // Restores: read viewer+, create operator+ (backups:restore). {RoleViewer, "GET", "/restores", scope.DefaultCluster, true}, {RoleViewer, "POST", "/restores", scope.DefaultCluster, false}, diff --git a/api/internal/telemetry/collect.go b/api/internal/telemetry/collect.go new file mode 100644 index 000000000..6cd6552d3 --- /dev/null +++ b/api/internal/telemetry/collect.go @@ -0,0 +1,298 @@ +package telemetry + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/telemetryschema" +) + +// extSchema is the extended-part schema version this build emits. +const extSchema = 1 + +// other is the value every enumerated field falls back to. +const other = "other" + +// nonDigits matches a run of non-digits, which is stripped from the +// discovery minor version ("31+" on EKS and GKE becomes "31"). +var nonDigits = regexp.MustCompile(`[^0-9]+`) + +// Flags are the install settings Collect reads. They come from API flags +// and environment, which live in package main, so the caller copies the +// relevant values in. +type Flags struct { + // CaptureEnabled mirrors --capture-enabled. + CaptureEnabled bool + // OIDCConfigured is true when the Helm OIDC issuer flag is set. + OIDCConfigured bool + // AuditWebhook is true when --audit-webhook-url is set. + AuditWebhook bool + // AuditS3 is true when both the S3 audit endpoint and bucket are set. + AuditS3 bool + // DBDriver is --db-driver, "sqlite" or "postgres". + DBDriver string + // OfficialModuleSource is --official-module-source; empty means every + // module counts as custom. + OfficialModuleSource string +} + +// Deps is what Collect reads from. Kube and Store are required. +type Deps struct { + Kube *kube.Client + Store *db.Store + Flags Flags + Version string + // Extended asks for the extended part (the gate's extendedOn). + Extended bool + // Now returns the current time; nil means time.Now. Tests set it. + Now func() time.Time +} + +// Collect builds the report the reporter would send now, and the same value +// backs the admin preview. The basic counts cover the local cluster only. +// When deps.Extended is set it also fills the extended part from the sources +// in research R14. A field whose source can't be read yields 0, false or +// "other" rather than an error; the only errors are a missing install ID and +// a failure to derive the signing key, because the part can't be signed +// without them. +func Collect(ctx context.Context, deps Deps) (telemetryschema.Report, error) { + servers := listItems(ctx, deps, "servers") + templates := listItems(ctx, deps, "templates") + rep := telemetryschema.Report{ + Version: deps.Version, + Servers: len(servers), + Templates: len(templates), + } + if !deps.Extended { + return rep, nil + } + ext, err := collectExtended(ctx, deps, servers, templates) + if err != nil { + return telemetryschema.Report{}, err + } + rep.Ext = ext + return rep, nil +} + +// listItems lists every object of one kube.GVRs kind across namespaces. A +// failure yields no items, so the count is 0. +func listItems(ctx context.Context, deps Deps, kind string) []unstructured.Unstructured { + gvr, ok := kube.GVRs[kind] + if !ok || deps.Kube == nil || deps.Kube.Dynamic == nil { + return nil + } + list, err := deps.Kube.Dynamic.Resource(gvr).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil + } + return list.Items +} + +// collectExtended builds the extended part and signs it with the key derived +// from the install ID and the stored secret. +func collectExtended(ctx context.Context, deps Deps, servers, templates []unstructured.Unstructured) (*telemetryschema.Extended, error) { + now := time.Now + if deps.Now != nil { + now = deps.Now + } + state, err := deps.Store.GetTelemetryState(ctx) + if err != nil { + return nil, fmt.Errorf("collect telemetry: %w", err) + } + if state.InstallID == "" { + return nil, errors.New("collect telemetry: extended report needs an install id") + } + secret, err := deps.Store.EnsureSigningSecret(ctx) + if err != nil { + return nil, fmt.Errorf("collect telemetry: %w", err) + } + priv, err := telemetryschema.DeriveKey(secret, state.InstallID) + if err != nil { + return nil, fmt.Errorf("collect telemetry: derive key: %w", err) + } + + nodes, nodesOK := listNodes(ctx, deps) + gitVersion, minor := serverVersion(deps) + nodeBand := other + if nodesOK && len(nodes) > 0 { + nodeBand = telemetryschema.NodeBand(len(nodes)) + } + + return &telemetryschema.Extended{ + Schema: extSchema, + InstallID: state.InstallID, + Env: telemetryschema.Env{ + K8s: minor, + Distro: detectDistro(gitVersion, nodes), + Arch: nodeArches(nodes), + Nodes: nodeBand, + }, + Games: countGames(ctx, deps, servers, templates), + Features: collectFeatures(ctx, deps, servers), + Key: telemetryschema.PublicKeyString(priv), + SentAt: now().UTC().Truncate(time.Second), + }, nil +} + +// listNodes lists the local cluster's nodes. ok is false when the call failed. +func listNodes(ctx context.Context, deps Deps) ([]corev1.Node, bool) { + if deps.Kube == nil || deps.Kube.Typed == nil { + return nil, false + } + list, err := deps.Kube.Typed.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, false + } + return list.Items, true +} + +// serverVersion returns the API server's gitVersion (for distro detection) +// and its minor version as "1.N", or "other" when it can't be read or doesn't +// fit the 1.N pattern. Non-digit suffixes such as the "+" of "31+" are +// dropped. +func serverVersion(deps Deps) (gitVersion, minor string) { + if deps.Kube == nil || deps.Kube.Typed == nil { + return "", other + } + v, err := deps.Kube.Typed.Discovery().ServerVersion() + if err != nil || v == nil { + return "", other + } + minor = v.Major + "." + nonDigits.ReplaceAllString(v.Minor, "") + if !telemetryschema.K8sMinorRE.MatchString(minor) { + minor = other + } + return v.GitVersion, minor +} + +// nodeArches returns the sorted, unique CPU architectures of nodes. Values +// outside the enumeration become "other", and a cluster with no readable +// node reports ["other"] because the wire schema rejects an empty list. +func nodeArches(nodes []corev1.Node) []string { + seen := map[string]bool{} + for i := range nodes { + a := nodes[i].Status.NodeInfo.Architecture + if a == "" { + continue + } + seen[telemetryschema.SanitizeEnum(telemetryschema.Arches, a)] = true + } + if len(seen) == 0 { + return []string{other} + } + return sortedKeys(seen) +} + +// sortedKeys returns the keys of set in ascending order. +func sortedKeys(set map[string]bool) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// countGames counts GameServers per official module and the rest as custom. +// A server whose template can't be found, or whose template isn't official +// (see officialModule), counts as custom. Only catalog names ever appear as +// keys. +func countGames(ctx context.Context, deps Deps, servers, templates []unstructured.Unstructured) telemetryschema.Games { + labelsByTemplate := make(map[string]map[string]string, len(templates)) + for i := range templates { + labelsByTemplate[templates[i].GetName()] = templates[i].GetLabels() + } + listed := map[string]bool{} + if deps.Kube != nil { + listed = listedModules(ctx, deps.Kube.Dynamic, deps.Flags.OfficialModuleSource) + } + games := telemetryschema.Games{Official: map[string]int{}} + for i := range servers { + tmplName, _, _ := unstructured.NestedString(servers[i].Object, "spec", "templateRef", "name") + m := officialModule(labelsByTemplate[tmplName], deps.Flags.OfficialModuleSource, listed) + if m == "" { + games.Custom++ + continue + } + games.Official[m]++ + } + return games +} + +// collectFeatures reads the feature flags and bands from the local cluster +// and the API's own configuration. +func collectFeatures(ctx context.Context, deps Deps, servers []unstructured.Unstructured) telemetryschema.Features { + tunnels := map[string]bool{} + wake := false + for i := range servers { + if on, _, _ := unstructured.NestedBool(servers[i].Object, "spec", "idle", "wakeOnConnect"); on { + wake = true + } + if on, _, _ := unstructured.NestedBool(servers[i].Object, "spec", "tunnel", "enabled"); on { + p, _, _ := unstructured.NestedString(servers[i].Object, "spec", "tunnel", "provider") + tunnels[telemetryschema.SanitizeEnum(telemetryschema.Tunnels, p)] = true + } + } + return telemetryschema.Features{ + WakeOnConnect: wake, + Tunnels: sortedKeys(tunnels), + Capture: deps.Flags.CaptureEnabled, + Backups: len(listItems(ctx, deps, "schedules")) > 0, + SSO: deps.Flags.OIDCConfigured || hasSSOProvider(ctx, deps.Store), + AuditForwarding: deps.Flags.AuditWebhook || deps.Flags.AuditS3, + Clusters: telemetryschema.ClusterBand(1 + countClusters(ctx, deps)), + DB: telemetryschema.SanitizeEnum(telemetryschema.DBs, deps.Flags.DBDriver), + Language: "en", + } +} + +// countClusters counts registered Cluster CRs, 0 when they can't be listed. +func countClusters(ctx context.Context, deps Deps) int { + if deps.Kube == nil || deps.Kube.Dynamic == nil { + return 0 + } + list, err := deps.Kube.Dynamic.Resource(kube.GVRCluster).List(ctx, metav1.ListOptions{}) + if err != nil { + return 0 + } + return len(list.Items) +} + +// hasSSOProvider reports whether the admin-managed "auth" config holds an +// enabled oidc, google or github provider. A missing, unreadable or +// malformed row is false. +func hasSSOProvider(ctx context.Context, store *db.Store) bool { + if store == nil { + return false + } + raw, ok, err := store.ConfigValue(ctx, "auth") + if err != nil || !ok { + return false + } + var cfg struct { + Providers []struct { + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + } `json:"providers"` + } + if json.Unmarshal([]byte(raw), &cfg) != nil { + return false + } + for _, p := range cfg.Providers { + if p.Enabled && (p.Kind == "oidc" || p.Kind == "google" || p.Kind == "github") { + return true + } + } + return false +} diff --git a/api/internal/telemetry/collect_test.go b/api/internal/telemetry/collect_test.go new file mode 100644 index 000000000..95afceeed --- /dev/null +++ b/api/internal/telemetry/collect_test.go @@ -0,0 +1,490 @@ +package telemetry + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/version" + fakediscovery "k8s.io/client-go/discovery/fake" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" + + "github.com/GameplanePanel/gameplane/api/internal/db" + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/telemetryschema" +) + +const testInstallID = "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10" + +// collectOpts describes the fake cluster collectKubeClients builds. +type collectOpts struct { + nodes []corev1.Node + serverInfo *version.Info + dynamic []runtime.Object + // nodesFail, versionFail and dynamicFail make the named calls error: + // dynamicFail keys are fake verbs ("list", "get"). + nodesFail bool + versionFail bool + dynamicFail map[string]bool +} + +// collectKubeClients builds a kube.Client over the typed and dynamic fakes. +func collectKubeClients(t *testing.T, o collectOpts) *kube.Client { + t.Helper() + cs := fake.NewSimpleClientset() + for i := range o.nodes { + if _, err := cs.CoreV1().Nodes().Create(context.Background(), &o.nodes[i], metav1.CreateOptions{}); err != nil { + t.Fatalf("create node: %v", err) + } + } + if o.serverInfo != nil { + cs.Discovery().(*fakediscovery.FakeDiscovery).FakedServerVersion = o.serverInfo + } + boom := func(clienttesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("boom") + } + if o.nodesFail { + cs.PrependReactor("list", "nodes", boom) + } + if o.versionFail { + cs.PrependReactor("get", "version", boom) + } + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), + map[schema.GroupVersionResource]string{ + kube.GVRs["servers"]: "GameServerList", + kube.GVRs["templates"]: "GameTemplateList", + kube.GVRs["schedules"]: "BackupScheduleList", + kube.GVRCluster: "ClusterList", + kube.GVRModuleSource: "ModuleSourceList", + }, o.dynamic...) + for verb := range o.dynamicFail { + dyn.PrependReactor(verb, "*", boom) + } + return &kube.Client{Typed: cs, Dynamic: dyn} +} + +// collectStore returns a migrated in-memory store. withID also stores the +// test install ID. +func collectStore(t *testing.T, withID bool) *db.Store { + t.Helper() + s, err := db.Open(t.Context(), "sqlite", ":memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + if err := s.Migrate(t.Context()); err != nil { + t.Fatalf("migrate: %v", err) + } + if withID { + if err := s.SetInstallID(t.Context(), testInstallID); err != nil { + t.Fatalf("set install id: %v", err) + } + } + return s +} + +func putConfig(t *testing.T, s *db.Store, key, value string) { + t.Helper() + if _, err := s.DB.ExecContext(t.Context(), + `INSERT INTO config(key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + key, value, "2026-01-01 00:00:00"); err != nil { + t.Fatalf("put config %q: %v", key, err) + } +} + +func gpObject(kind, name, ns string) *unstructured.Unstructured { + o := &unstructured.Unstructured{} + o.SetAPIVersion("gameplane.local/v1alpha1") + o.SetKind(kind) + o.SetName(name) + if ns != "" { + o.SetNamespace(ns) + } + return o +} + +// serverObj builds a GameServer using template tmpl. wake sets +// spec.idle.wakeOnConnect; tunnelProvider (when non-empty) sets spec.tunnel +// with the given enabled value. +func serverObj(name, tmpl string, wake bool, tunnelProvider string, tunnelEnabled bool) *unstructured.Unstructured { + o := gpObject("GameServer", name, "gameplane-games") + _ = unstructured.SetNestedField(o.Object, tmpl, "spec", "templateRef", "name") + if wake { + _ = unstructured.SetNestedField(o.Object, true, "spec", "idle", "wakeOnConnect") + } + if tunnelProvider != "" { + _ = unstructured.SetNestedField(o.Object, tunnelEnabled, "spec", "tunnel", "enabled") + _ = unstructured.SetNestedField(o.Object, tunnelProvider, "spec", "tunnel", "provider") + } + return o +} + +// templateObj builds a cluster-scoped GameTemplate with the given labels. +func templateObj(name string, labels map[string]string) *unstructured.Unstructured { + o := gpObject("GameTemplate", name, "") + if labels != nil { + o.SetLabels(labels) + } + return o +} + +// moduleTemplate builds a GameTemplate stamped the way the operator stamps a +// module-managed one. +func moduleTemplate(name, source, module string) *unstructured.Unstructured { + return templateObj(name, map[string]string{ + labelManagedBy: managedByModule, + labelModuleSource: source, + labelModuleName: module, + }) +} + +func moduleSourceObj(name string, ociModules, statusModules []string) *unstructured.Unstructured { + o := gpObject("ModuleSource", name, "") + names := func(in []string) []any { + out := make([]any, 0, len(in)) + for _, n := range in { + out = append(out, map[string]any{"name": n}) + } + return out + } + if ociModules != nil { + _ = unstructured.SetNestedSlice(o.Object, names(ociModules), "spec", "oci", "modules") + } + if statusModules != nil { + _ = unstructured.SetNestedSlice(o.Object, names(statusModules), "status", "modules") + } + return o +} + +func fixedNow() time.Time { return time.Date(2026, 10, 6, 9, 12, 44, 123456789, time.UTC) } + +func TestCollect_BasicCountsLocalClusterOnly(t *testing.T) { + k := collectKubeClients(t, collectOpts{dynamic: []runtime.Object{ + serverObj("a", "t1", false, "", false), + serverObj("b", "t1", false, "", false), + templateObj("t1", nil), + }}) + rep, err := Collect(t.Context(), Deps{Kube: k, Store: collectStore(t, true), Version: "v1.2.3"}) + if err != nil { + t.Fatalf("collect: %v", err) + } + if rep.Version != "v1.2.3" || rep.Servers != 2 || rep.Templates != 1 { + t.Fatalf("basic report = %+v, want {v1.2.3 2 1}", rep) + } + if rep.Ext != nil { + t.Fatal("the extended part must be absent when Extended is false") + } +} + +func TestCollect_BasicListFailureIsZeroNotError(t *testing.T) { + k := collectKubeClients(t, collectOpts{ + dynamic: []runtime.Object{serverObj("a", "t1", false, "", false)}, + dynamicFail: map[string]bool{"list": true}, + }) + rep, err := Collect(t.Context(), Deps{Kube: k, Store: collectStore(t, false), Version: "v1"}) + if err != nil { + t.Fatalf("collect: %v", err) + } + if rep.Servers != 0 || rep.Templates != 0 { + t.Fatalf("failed lists must count as 0, got %+v", rep) + } +} + +func TestCollect_NilClients(t *testing.T) { + for name, k := range map[string]*kube.Client{"nil client": nil, "empty client": {}} { + rep, err := Collect(t.Context(), Deps{Kube: k, Store: collectStore(t, true), Version: "v1", Extended: true, Now: fixedNow}) + if err != nil { + t.Fatalf("%s: collect: %v", name, err) + } + if rep.Servers != 0 || rep.Templates != 0 || rep.Ext == nil { + t.Fatalf("%s: report = %+v", name, rep) + } + if rep.Ext.Env.K8s != "other" || rep.Ext.Env.Nodes != "other" || !reflect.DeepEqual(rep.Ext.Env.Arch, []string{"other"}) { + t.Errorf("%s: env = %+v, want other fallbacks", name, rep.Ext.Env) + } + } +} + +func TestCollect_ExtendedFullReport(t *testing.T) { + const official = "default" + nodes := []corev1.Node{ + testNode("n1", nil, "", "Ubuntu 24.04", "amd64"), + testNode("n2", nil, "", "Ubuntu 24.04", "arm64"), + } + k := collectKubeClients(t, collectOpts{ + nodes: nodes, + serverInfo: &version.Info{Major: "1", Minor: "31+", GitVersion: "v1.31.2+k3s1"}, + dynamic: []runtime.Object{ + moduleSourceObj(official, []string{"minecraft-java", "terraria", "minecraft-java-evil"}, nil), + moduleTemplate("mc", official, "minecraft-java"), + moduleTemplate("tr", official, "terraria"), + moduleTemplate("upload-mc", "uploads", "minecraft-java"), + moduleTemplate("lookalike", official, "minecraft-java-evil"), + templateObj("manual", nil), + serverObj("s1", "mc", true, "", false), + serverObj("s2", "mc", false, "", false), + serverObj("s3", "tr", false, "playit", true), + serverObj("s4", "manual", false, "", false), + serverObj("s5", "ghost", false, "", false), // template does not exist + serverObj("s6", "mc", false, "frp", false), // tunnel configured but off + serverObj("s7", "upload-mc", false, "", false), + serverObj("s8", "lookalike", false, "", false), + gpObject("BackupSchedule", "nightly", "gameplane-games"), + gpObject("Cluster", "remote-a", ""), + gpObject("Cluster", "remote-b", ""), + }, + }) + store := collectStore(t, true) + putConfig(t, store, "auth", `{"providers":[{"name":"local","kind":"local","enabled":true},{"name":"g","kind":"google","enabled":true}]}`) + secret, err := store.EnsureSigningSecret(t.Context()) + if err != nil { + t.Fatalf("secret: %v", err) + } + priv, err := telemetryschema.DeriveKey(secret, testInstallID) + if err != nil { + t.Fatalf("derive: %v", err) + } + + rep, err := Collect(t.Context(), Deps{ + Kube: k, Store: store, Version: "v0.3.0", Extended: true, Now: fixedNow, + Flags: Flags{ + CaptureEnabled: true, AuditS3: true, DBDriver: "sqlite", OfficialModuleSource: official, + }, + }) + if err != nil { + t.Fatalf("collect: %v", err) + } + if rep.Version != "v0.3.0" || rep.Servers != 8 || rep.Templates != 5 { + t.Fatalf("basic part = %+v, want {v0.3.0 8 5}", rep) + } + ext := rep.Ext + if ext == nil { + t.Fatal("missing extended part") + } + if ext.Schema != 1 || ext.InstallID != testInstallID { + t.Errorf("schema/id = %d/%q", ext.Schema, ext.InstallID) + } + wantEnv := telemetryschema.Env{K8s: "1.31", Distro: "k3s", Arch: []string{"amd64", "arm64"}, Nodes: telemetryschema.NodeBand(2)} + if !reflect.DeepEqual(ext.Env, wantEnv) { + t.Errorf("env = %+v, want %+v", ext.Env, wantEnv) + } + wantGames := telemetryschema.Games{Official: map[string]int{"minecraft-java": 3, "terraria": 1}, Custom: 4} + if !reflect.DeepEqual(ext.Games, wantGames) { + t.Errorf("games = %+v, want %+v", ext.Games, wantGames) + } + wantFeatures := telemetryschema.Features{ + WakeOnConnect: true, Tunnels: []string{"playit"}, Capture: true, Backups: true, SSO: true, + AuditForwarding: true, Clusters: telemetryschema.ClusterBand(3), DB: "sqlite", Language: "en", + } + if !reflect.DeepEqual(ext.Features, wantFeatures) { + t.Errorf("features = %+v, want %+v", ext.Features, wantFeatures) + } + if want := telemetryschema.PublicKeyString(priv); ext.Key != want { + t.Errorf("key = %q, want the key derived from the secret and install ID (%q)", ext.Key, want) + } + if want := time.Date(2026, 10, 6, 9, 12, 44, 0, time.UTC); !ext.SentAt.Equal(want) { + t.Errorf("sentAt = %v, want %v (UTC, whole seconds)", ext.SentAt, want) + } +} + +func TestCollect_ExtendedFieldsFallBackWhenSourcesFail(t *testing.T) { + k := collectKubeClients(t, collectOpts{ + nodesFail: true, + versionFail: true, + dynamicFail: map[string]bool{"list": true, "get": true}, + }) + rep, err := Collect(t.Context(), Deps{ + Kube: k, Store: collectStore(t, true), Version: "v1", Extended: true, Now: fixedNow, + Flags: Flags{DBDriver: "mysql", OfficialModuleSource: "default"}, + }) + if err != nil { + t.Fatalf("a failing source must not fail the report: %v", err) + } + ext := rep.Ext + if ext == nil { + t.Fatal("missing extended part") + } + wantEnv := telemetryschema.Env{K8s: "other", Distro: "other", Arch: []string{"other"}, Nodes: "other"} + if !reflect.DeepEqual(ext.Env, wantEnv) { + t.Errorf("env = %+v, want %+v", ext.Env, wantEnv) + } + if len(ext.Games.Official) != 0 || ext.Games.Custom != 0 { + t.Errorf("games = %+v, want empty", ext.Games) + } + f := ext.Features + if f.WakeOnConnect || f.Backups || f.SSO || f.Capture || f.AuditForwarding || len(f.Tunnels) != 0 { + t.Errorf("features = %+v, want everything off", f) + } + if f.Clusters != telemetryschema.ClusterBand(1) || f.DB != "other" || f.Language != "en" { + t.Errorf("features = %+v, want cluster band of 1, db other, language en", f) + } +} + +func TestCollect_ExtendedErrors(t *testing.T) { + ctx := t.Context() + deps := func(s *db.Store) Deps { + return Deps{Kube: collectKubeClients(t, collectOpts{}), Store: s, Version: "v1", Extended: true, Now: fixedNow} + } + + if _, err := Collect(ctx, deps(collectStore(t, false))); err == nil || !strings.Contains(err.Error(), "install id") { + t.Errorf("no install ID: got %v, want an install id error", err) + } + + missing := collectStore(t, true) + if _, err := missing.DB.ExecContext(ctx, `DELETE FROM telemetry_state`); err != nil { + t.Fatalf("delete state: %v", err) + } + if _, err := Collect(ctx, deps(missing)); err == nil { + t.Error("a missing telemetry_state row must be an error") + } + + badSecret := collectStore(t, true) + if _, err := badSecret.DB.ExecContext(ctx, `UPDATE telemetry_state SET signing_secret = '!!not base64!!'`); err != nil { + t.Fatalf("corrupt secret: %v", err) + } + if _, err := Collect(ctx, deps(badSecret)); err == nil { + t.Error("an unreadable signing secret must be an error") + } +} + +func TestCollect_DefaultClockIsNow(t *testing.T) { + rep, err := Collect(t.Context(), Deps{ + Kube: collectKubeClients(t, collectOpts{}), Store: collectStore(t, true), Version: "v1", Extended: true, + }) + if err != nil { + t.Fatalf("collect: %v", err) + } + if d := time.Since(rep.Ext.SentAt); d < -time.Minute || d > time.Minute { + t.Fatalf("sentAt %v is not the current time", rep.Ext.SentAt) + } +} + +func TestServerVersion_MinorForms(t *testing.T) { + cases := []struct { + major, minor, want string + }{ + {"1", "31", "1.31"}, + {"1", "31+", "1.31"}, + {"1", "9", "1.9"}, + {"1", "", "other"}, + {"1", "1234", "other"}, + {"2", "0", "other"}, + {"", "", "other"}, + } + for _, tc := range cases { + k := collectKubeClients(t, collectOpts{serverInfo: &version.Info{Major: tc.major, Minor: tc.minor, GitVersion: "v1.0.0"}}) + git, minor := serverVersion(Deps{Kube: k}) + if minor != tc.want || git != "v1.0.0" { + t.Errorf("major=%q minor=%q: got %q (git %q), want %q", tc.major, tc.minor, minor, git, tc.want) + } + } + + failing := collectKubeClients(t, collectOpts{versionFail: true}) + if git, minor := serverVersion(Deps{Kube: failing}); git != "" || minor != "other" { + t.Errorf("discovery error: got %q/%q, want empty/other", git, minor) + } +} + +func TestNodeArches(t *testing.T) { + node := func(arch string) corev1.Node { return testNode("n", nil, "", "", arch) } + cases := []struct { + name string + nodes []corev1.Node + want []string + }{ + {"sorted and unique", []corev1.Node{node("arm64"), node("amd64"), node("arm64")}, []string{"amd64", "arm64"}}, + {"unknown becomes other", []corev1.Node{node("s390x"), node("sparc")}, []string{"other", "s390x"}}, + {"empty architecture is skipped", []corev1.Node{node(""), node("amd64")}, []string{"amd64"}}, + {"no nodes", nil, []string{"other"}}, + {"only blank architectures", []corev1.Node{node("")}, []string{"other"}}, + } + for _, tc := range cases { + if got := nodeArches(tc.nodes); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestCollectFeatures_TunnelsAreSanitisedAndSorted(t *testing.T) { + servers := []unstructured.Unstructured{ + *serverObj("a", "t", false, "tailscale", true), + *serverObj("b", "t", false, "frp", true), + *serverObj("c", "t", false, "tailscale", true), + *serverObj("d", "t", false, "my-private-relay", true), + *serverObj("e", "t", false, "playit", false), + } + f := collectFeatures(t.Context(), Deps{Store: collectStore(t, false), Flags: Flags{DBDriver: "postgres"}}, servers) + if want := []string{"frp", "other", "tailscale"}; !reflect.DeepEqual(f.Tunnels, want) { + t.Errorf("tunnels = %v, want %v", f.Tunnels, want) + } + if f.DB != "postgres" { + t.Errorf("db = %q, want postgres", f.DB) + } +} + +func TestCollectFeatures_SSOAndAuditSources(t *testing.T) { + ctx := t.Context() + store := collectStore(t, false) + base := func(f Flags) telemetryschema.Features { return collectFeatures(ctx, Deps{Store: store, Flags: f}, nil) } + + if f := base(Flags{}); f.SSO || f.AuditForwarding { + t.Errorf("nothing configured: got %+v", f) + } + if f := base(Flags{OIDCConfigured: true}); !f.SSO { + t.Error("the Helm OIDC flag must count as SSO") + } + if f := base(Flags{AuditWebhook: true}); !f.AuditForwarding { + t.Error("the audit webhook must count as audit forwarding") + } + if f := base(Flags{AuditS3: true}); !f.AuditForwarding { + t.Error("the S3 audit sink must count as audit forwarding") + } +} + +func TestHasSSOProvider(t *testing.T) { + ctx := t.Context() + if hasSSOProvider(ctx, nil) { + t.Error("a nil store has no providers") + } + cases := []struct { + name string + raw string // empty = no row + want bool + }{ + {"no row", "", false}, + {"malformed", `{not json`, false}, + {"no providers", `{"providers":[]}`, false}, + {"local only", `{"providers":[{"name":"local","kind":"local","enabled":true}]}`, false}, + {"oidc disabled", `{"providers":[{"name":"o","kind":"oidc","enabled":false}]}`, false}, + {"oidc enabled", `{"providers":[{"name":"o","kind":"oidc","enabled":true}]}`, true}, + {"google enabled", `{"providers":[{"name":"g","kind":"google","enabled":true}]}`, true}, + {"github enabled", `{"providers":[{"name":"h","kind":"github","enabled":true}]}`, true}, + {"unknown kind", `{"providers":[{"name":"x","kind":"saml","enabled":true}]}`, false}, + } + for _, tc := range cases { + store := collectStore(t, false) + if tc.raw != "" { + putConfig(t, store, "auth", tc.raw) + } + if got := hasSSOProvider(ctx, store); got != tc.want { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } + + closed := collectStore(t, false) + _ = closed.Close() + if hasSSOProvider(ctx, closed) { + t.Error("a store read error must count as no provider") + } +} diff --git a/api/internal/telemetry/consent.go b/api/internal/telemetry/consent.go new file mode 100644 index 000000000..a027c7b1e --- /dev/null +++ b/api/internal/telemetry/consent.go @@ -0,0 +1,223 @@ +package telemetry + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/db" +) + +// ErrOperatorDisabled is returned by the consent writers when the operator +// disabled telemetry at install time (destination kind "disabled"): the +// admin toggles are refused, whatever they say (FR-005). +var ErrOperatorDisabled = errors.New("telemetry is disabled by the operator") + +// firstReportWindow caps the random delay before the first report once the +// gate opens (research R12). +const firstReportWindow = 15 * time.Minute + +// configKey is the config row that holds the admin's choice. +const configKey = "telemetry" + +// Consent is the admin's stored choice, config key "telemetry". +type Consent struct { + // Basic is sendMetrics. + Basic bool + // Extended is true only while Basic is true. + Extended bool +} + +// storedConsent is Consent as stored in the config row. +type storedConsent struct { + SendMetrics bool `json:"sendMetrics"` + Extended bool `json:"extended"` +} + +// parseConsent decodes the raw config value. An absent, empty or malformed +// value means both tiers off, and Extended is forced off while Basic is. +func parseConsent(raw string, present bool) Consent { + if !present { + return Consent{} + } + var c storedConsent + if json.Unmarshal([]byte(raw), &c) != nil { + return Consent{} + } + return Consent{Basic: c.SendMetrics, Extended: c.SendMetrics && c.Extended} +} + +// ReadConsent reads the stored choice through the store. +func ReadConsent(ctx context.Context, store *db.Store) (Consent, error) { + raw, ok, err := store.ConfigValue(ctx, configKey) + if err != nil { + return Consent{}, fmt.Errorf("read telemetry consent: %w", err) + } + return parseConsent(raw, ok), nil +} + +// ReadConsentTx reads the stored choice inside tx. +func ReadConsentTx(ctx context.Context, tx *sql.Tx) (Consent, error) { + raw, ok, err := db.ConfigValueTx(ctx, tx, configKey) + if err != nil { + return Consent{}, fmt.Errorf("read telemetry consent: %w", err) + } + return parseConsent(raw, ok), nil +} + +// destActive reports whether the destination can receive reports at all. +func destActive(d Destination) bool { + return d.Kind != KindDisabled && d.Kind != KindNone && d.URL != "" +} + +// gateOpen is the report gate (data-model "Gate open"): the basic tier is +// effectively on, and either an admin made a choice or the notice was shown. +func gateOpen(basicOn bool, st db.TelemetryState) bool { + return basicOn && (st.ConsentSource != db.TelemetryConsentDefault || st.NoticeShownAt != "") +} + +// stamp formats t as the RFC 3339 UTC text telemetry_state stores. +func stamp(t time.Time) string { + return t.UTC().Format(time.RFC3339) +} + +// randN returns a uniform value in [0, n); n <= 0 gives 0. It reads +// crypto/rand, which keeps the jitter unpredictable across replicas. +func randN(n int64) int64 { + if n <= 0 { + return 0 + } + v, err := rand.Int(rand.Reader, big.NewInt(n)) + if err != nil { + return 0 + } + return v.Int64() +} + +// firstDue is when the first report is due once the gate opens: +// now + U(0, min(15m, interval/4)). +func firstDue(now time.Time, interval time.Duration, rnd func(int64) int64) time.Time { + window := min(firstReportWindow, interval/4) + return now.Add(time.Duration(rnd(int64(window)))) +} + +// NewInstallID returns a random lowercase UUIDv4. It is not derived from any +// cluster, host, network or user attribute (FR-012). +func NewInstallID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generate install id: %w", err) + } + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + h := hex.EncodeToString(b[:]) + return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:], nil +} + +// ApplyConsent records an admin choice inside tx. It is shared by the config +// hook (source "admin") and the notice actions. In one transaction it: +// - writes config key telemetry (extended is forced off while basic is off) +// - sets consent_source to source +// - creates the install ID (and the signing secret) when extended is on and +// there is none, or clears the ID when extended is off +// - opens the schedule when the gate is open (keeping an existing slot), or +// closes it +// +// It returns ErrOperatorDisabled, writing nothing, when dest is disabled. A +// non-positive interval means 24h. +func ApplyConsent(ctx context.Context, tx *sql.Tx, dest Destination, interval time.Duration, basic, extended bool, source string) error { + if dest.Kind == KindDisabled { + return ErrOperatorDisabled + } + if interval <= 0 { + interval = 24 * time.Hour + } + extended = extended && basic + raw, err := json.Marshal(storedConsent{SendMetrics: basic, Extended: extended}) + if err != nil { + return fmt.Errorf("apply consent: %w", err) + } + if err := db.UpsertConfigTx(ctx, tx, configKey, string(raw)); err != nil { + return fmt.Errorf("apply consent: %w", err) + } + st, err := db.GetTelemetryStateTx(ctx, tx) + if err != nil { + return fmt.Errorf("apply consent: %w", err) + } + installID := st.InstallID + if extended { + if installID == "" { + if installID, err = NewInstallID(); err != nil { + return fmt.Errorf("apply consent: %w", err) + } + } + if err := db.EnsureSigningSecretTx(ctx, tx); err != nil { + return fmt.Errorf("apply consent: %w", err) + } + } else { + installID = "" + } + next := "" + if gateOpen(destActive(dest) && basic, db.TelemetryState{ConsentSource: source, NoticeShownAt: st.NoticeShownAt}) { + next = st.NextDueAt + if next == "" { + next = stamp(firstDue(time.Now(), interval, randN)) + } + } + if err := db.UpdateTelemetryConsentTx(ctx, tx, source, installID, next); err != nil { + return fmt.Errorf("apply consent: %w", err) + } + return nil +} + +// MarkNoticeSeen records, inside tx, that the notice was rendered: it sets +// notice_shown_at once, creates the install ID (and secret) when the stored +// choice has extended on and there is none, and opens the schedule when the +// gate is open and no slot is set. consent_source is unchanged. A non-positive +// interval means 24h. +func MarkNoticeSeen(ctx context.Context, tx *sql.Tx, dest Destination, interval time.Duration) error { + if interval <= 0 { + interval = 24 * time.Hour + } + st, err := db.GetTelemetryStateTx(ctx, tx) + if err != nil { + return fmt.Errorf("mark notice seen: %w", err) + } + now := time.Now() + if st.NoticeShownAt == "" { + st.NoticeShownAt = stamp(now) + if err := db.MarkNoticeShownTx(ctx, tx, st.NoticeShownAt); err != nil { + return fmt.Errorf("mark notice seen: %w", err) + } + } + c, err := ReadConsentTx(ctx, tx) + if err != nil { + return fmt.Errorf("mark notice seen: %w", err) + } + installID := st.InstallID + if c.Extended && installID == "" { + if installID, err = NewInstallID(); err != nil { + return fmt.Errorf("mark notice seen: %w", err) + } + if err := db.EnsureSigningSecretTx(ctx, tx); err != nil { + return fmt.Errorf("mark notice seen: %w", err) + } + } + next := st.NextDueAt + if next == "" && gateOpen(destActive(dest) && c.Basic, st) { + next = stamp(firstDue(now, interval, randN)) + } + if next == st.NextDueAt && installID == st.InstallID { + return nil + } + if err := db.UpdateTelemetryConsentTx(ctx, tx, st.ConsentSource, installID, next); err != nil { + return fmt.Errorf("mark notice seen: %w", err) + } + return nil +} diff --git a/api/internal/telemetry/consent_test.go b/api/internal/telemetry/consent_test.go new file mode 100644 index 000000000..62757a173 --- /dev/null +++ b/api/internal/telemetry/consent_test.go @@ -0,0 +1,297 @@ +package telemetry + +import ( + "context" + "database/sql" + "errors" + "regexp" + "testing" + "time" + + "github.com/GameplanePanel/gameplane/api/internal/db" +) + +var uuidV4RE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + +var bundledDest = Destination{Kind: KindBundled, URL: "http://receiver.test/ingest", Host: "receiver.test"} + +// inTx runs fn in a transaction on store and commits when fn returns nil. +func inTx(t *testing.T, store *db.Store, fn func(ctx context.Context, tx *sql.Tx) error) error { + t.Helper() + ctx := t.Context() + tx, err := store.DB.BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer func() { _ = tx.Rollback() }() + if err := fn(ctx, tx); err != nil { + return err + } + return tx.Commit() +} + +func apply(t *testing.T, store *db.Store, dest Destination, basic, extended bool, source string) error { + t.Helper() + return inTx(t, store, func(ctx context.Context, tx *sql.Tx) error { + return ApplyConsent(ctx, tx, dest, 24*time.Hour, basic, extended, source) + }) +} + +func storedConfig(t *testing.T, store *db.Store) string { + t.Helper() + v, ok, err := store.ConfigValue(t.Context(), "telemetry") + if err != nil || !ok { + t.Fatalf("read config: %q, %v, %v", v, ok, err) + } + return v +} + +func TestApplyConsent_OperatorDisabledWritesNothing(t *testing.T) { + store := freshStore(t) + before := storedConfig(t, store) + err := apply(t, store, Destination{Kind: KindDisabled}, false, false, db.TelemetryConsentAdmin) + if !errors.Is(err, ErrOperatorDisabled) { + t.Fatalf("err = %v, want ErrOperatorDisabled", err) + } + if storedConfig(t, store) != before || mustState(t, store).ConsentSource != db.TelemetryConsentDefault { + t.Fatal("a refused save must change nothing") + } +} + +func TestApplyConsent_ExtendedOnCreatesIDAndOpensSchedule(t *testing.T) { + store := freshStore(t) + if err := apply(t, store, bundledDest, true, true, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("apply: %v", err) + } + st := mustState(t, store) + if st.ConsentSource != db.TelemetryConsentAdmin || !uuidV4RE.MatchString(st.InstallID) { + t.Fatalf("state = %+v, want source admin and a UUIDv4 install id", st) + } + if st.NextDueAt == "" { + t.Fatal("an admin choice opens the schedule") + } + if secret, err := store.EnsureSigningSecret(t.Context()); err != nil || len(secret) != 32 { + t.Fatalf("signing secret = %d bytes, %v; want it created with the ID", len(secret), err) + } + if got := storedConfig(t, store); got != `{"sendMetrics":true,"extended":true}` { + t.Fatalf("config = %s", got) + } +} + +func TestApplyConsent_BasicOffForcesExtendedOffAndClosesSchedule(t *testing.T) { + store := freshStore(t) + if err := apply(t, store, bundledDest, true, true, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("apply on: %v", err) + } + if err := apply(t, store, bundledDest, false, true, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("apply off: %v", err) + } + st := mustState(t, store) + if st.InstallID != "" || st.NextDueAt != "" { + t.Fatalf("state = %+v, want the ID deleted and the schedule closed", st) + } + if got := storedConfig(t, store); got != `{"sendMetrics":false,"extended":false}` { + t.Fatalf("config = %s, want extended stored off with basic", got) + } +} + +func TestApplyConsent_ExtendedOffThenOnMakesANewID(t *testing.T) { + store := freshStore(t) + if err := apply(t, store, bundledDest, true, true, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("apply: %v", err) + } + first := mustState(t, store).InstallID + secretBefore, err := store.EnsureSigningSecret(t.Context()) + if err != nil { + t.Fatalf("secret: %v", err) + } + if err := apply(t, store, bundledDest, true, false, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("extended off: %v", err) + } + if st := mustState(t, store); st.InstallID != "" || st.NextDueAt == "" { + t.Fatalf("state = %+v, want no ID but the basic schedule kept", st) + } + if err := apply(t, store, bundledDest, true, true, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("extended on again: %v", err) + } + second := mustState(t, store).InstallID + if second == "" || second == first { + t.Fatalf("install id after off/on = %q (was %q), want a new one", second, first) + } + secretAfter, err := store.EnsureSigningSecret(t.Context()) + if err != nil || string(secretAfter) != string(secretBefore) { + t.Fatalf("the signing secret must survive an off/on cycle (err %v)", err) + } +} + +func TestApplyConsent_DefaultSourceKeepsGateClosedUntilNoticeShown(t *testing.T) { + store := freshStore(t) + if err := apply(t, store, bundledDest, true, true, db.TelemetryConsentDefault); err != nil { + t.Fatalf("apply: %v", err) + } + if st := mustState(t, store); st.NextDueAt != "" { + t.Fatalf("slot = %q, want none while source is default and the notice is unseen", st.NextDueAt) + } + if err := store.MarkNoticeShown(t.Context(), "2026-10-06T09:00:00Z"); err != nil { + t.Fatalf("mark shown: %v", err) + } + if err := apply(t, store, bundledDest, true, true, db.TelemetryConsentDefault); err != nil { + t.Fatalf("apply after notice: %v", err) + } + if st := mustState(t, store); st.NextDueAt == "" { + t.Fatal("once the notice was shown the schedule opens") + } +} + +func TestApplyConsent_KeepsAnExistingSlotAndSkipsNoDestination(t *testing.T) { + store := freshStore(t) + if err := apply(t, store, bundledDest, true, false, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("apply: %v", err) + } + slot := mustState(t, store).NextDueAt + if err := apply(t, store, bundledDest, true, true, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("apply again: %v", err) + } + if got := mustState(t, store).NextDueAt; got != slot { + t.Fatalf("slot = %q, want the existing %q kept", got, slot) + } + + none := freshStore(t) + if err := apply(t, none, Destination{Kind: KindNone}, true, true, db.TelemetryConsentAdmin); err != nil { + t.Fatalf("apply with no destination: %v", err) + } + if st := mustState(t, none); st.NextDueAt != "" { + t.Fatalf("slot = %q, want none without a destination", st.NextDueAt) + } +} + +func TestApplyConsent_ZeroIntervalMeansDaily(t *testing.T) { + store := freshStore(t) + err := inTx(t, store, func(ctx context.Context, tx *sql.Tx) error { + return ApplyConsent(ctx, tx, bundledDest, 0, true, false, db.TelemetryConsentAdmin) + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + due := mustParse(t, mustState(t, store).NextDueAt) + if wait := time.Until(due); wait > 15*time.Minute { + t.Fatalf("first slot is %v away, want within the 15m window of a 24h interval", wait) + } +} + +func TestApplyConsent_MissingStateRowIsAnError(t *testing.T) { + store := freshStore(t) + if _, err := store.DB.ExecContext(t.Context(), `DELETE FROM telemetry_state`); err != nil { + t.Fatalf("delete row: %v", err) + } + if err := apply(t, store, bundledDest, true, true, db.TelemetryConsentAdmin); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("err = %v, want sql.ErrNoRows", err) + } +} + +func TestMarkNoticeSeen_SetsOnceCreatesIDAndOpensSchedule(t *testing.T) { + store := freshStore(t) // fresh install: default source, extended on, no ID yet + see := func() error { + return inTx(t, store, func(ctx context.Context, tx *sql.Tx) error { + return MarkNoticeSeen(ctx, tx, bundledDest, 24*time.Hour) + }) + } + if err := see(); err != nil { + t.Fatalf("seen: %v", err) + } + st := mustState(t, store) + if st.NoticeShownAt == "" || st.NextDueAt == "" || !uuidV4RE.MatchString(st.InstallID) { + t.Fatalf("state = %+v, want the notice stamped, a slot and an install id", st) + } + if st.ConsentSource != db.TelemetryConsentDefault { + t.Fatalf("consent source = %q, seen must not change it", st.ConsentSource) + } + if wait := mustParse(t, st.NextDueAt).Sub(mustParse(t, st.NoticeShownAt)); wait < 0 || wait > 15*time.Minute { + t.Fatalf("first slot is %v after the notice, want within 15m", wait) + } + if err := see(); err != nil { + t.Fatalf("seen again: %v", err) + } + if again := mustState(t, store); again != st { + t.Fatalf("a second seen changed the state: %+v then %+v", st, again) + } +} + +func TestMarkNoticeSeen_BasicOffOrNoDestinationOpensNothing(t *testing.T) { + off := freshStore(t) + if _, err := off.DB.ExecContext(t.Context(), + `UPDATE config SET value = '{"sendMetrics":false,"extended":false}' WHERE key = 'telemetry'`); err != nil { + t.Fatalf("turn off: %v", err) + } + none := freshStore(t) + for name, tc := range map[string]struct { + store *db.Store + dest Destination + }{"basic off": {off, bundledDest}, "no destination": {none, Destination{Kind: KindNone}}} { + err := inTx(t, tc.store, func(ctx context.Context, tx *sql.Tx) error { + return MarkNoticeSeen(ctx, tx, tc.dest, 0) + }) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + st := mustState(t, tc.store) + if st.NoticeShownAt == "" || st.NextDueAt != "" { + t.Fatalf("%s: state = %+v, want the notice stamped and no slot", name, st) + } + } +} + +func TestMarkNoticeSeen_MissingStateRowIsAnError(t *testing.T) { + store := freshStore(t) + if _, err := store.DB.ExecContext(t.Context(), `DELETE FROM telemetry_state`); err != nil { + t.Fatalf("delete row: %v", err) + } + err := inTx(t, store, func(ctx context.Context, tx *sql.Tx) error { + return MarkNoticeSeen(ctx, tx, bundledDest, time.Hour) + }) + if !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("err = %v, want sql.ErrNoRows", err) + } +} + +func TestNewInstallID_IsRandomUUIDv4(t *testing.T) { + seen := map[string]bool{} + for range 20 { + id, err := NewInstallID() + if err != nil || !uuidV4RE.MatchString(id) { + t.Fatalf("NewInstallID = %q, %v; want a lowercase UUIDv4", id, err) + } + if seen[id] { + t.Fatalf("NewInstallID repeated %q", id) + } + seen[id] = true + } +} + +func TestFirstDueAndRandN(t *testing.T) { + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) + if got := firstDue(now, 24*time.Hour, func(n int64) int64 { return n - 1 }); got.Sub(now) >= 15*time.Minute { + t.Errorf("a 24h interval allows up to 15m, got %v", got.Sub(now)) + } + if got := firstDue(now, time.Minute, func(n int64) int64 { return n - 1 }); got.Sub(now) >= 15*time.Second { + t.Errorf("a 1m interval allows up to interval/4 = 15s, got %v", got.Sub(now)) + } + if got := randN(0); got != 0 { + t.Errorf("randN(0) = %d, want 0", got) + } + for range 50 { + if v := randN(10); v < 0 || v >= 10 { + t.Fatalf("randN(10) = %d, out of [0,10)", v) + } + } +} + +func TestReadConsent_ClosedStoreIsAnError(t *testing.T) { + store := freshStore(t) + if err := store.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if _, err := ReadConsent(t.Context(), store); err == nil { + t.Fatal("want an error from a closed database") + } +} diff --git a/api/internal/telemetry/destination.go b/api/internal/telemetry/destination.go new file mode 100644 index 000000000..ed3e184ce --- /dev/null +++ b/api/internal/telemetry/destination.go @@ -0,0 +1,80 @@ +package telemetry + +import ( + "fmt" + "net/url" + "strings" +) + +// DefaultEndpoint is the project default telemetry destination, ruled in +// specs/022-default-telemetry-dashboard/OPEN-DECISIONS.md OD-1. It must stay +// an https URL (hack/check-telemetry-default.sh enforces this in CI). +const DefaultEndpoint = "https://telemetry.gameplane.net/ingest" + +// Destination kinds, in the order ResolveDestination considers them. +const ( + // KindDisabled is a hard off set by the operator; nothing is ever sent. + KindDisabled = "disabled" + // KindBundled is the chart's in-cluster receiver. + KindBundled = "bundled" + // KindCustom is an operator-supplied endpoint. + KindCustom = "custom" + // KindDefault is the project default, DefaultEndpoint. + KindDefault = "default" + // KindNone means no destination is in effect, so nothing is sent. + KindNone = "none" +) + +// Destination is where reports go. URL is empty for the disabled and none +// kinds. Host is the URL's host (with any port), safe to show to admins. +type Destination struct { + Kind string + URL string + Host string +} + +// ResolveDestination picks the one destination for this process from the +// operator's settings, first match wins: disabled, then bundled (an endpoint +// plus the chart's bundled marker), then custom (any endpoint), then the +// project default when DefaultEndpoint is set, otherwise none. The default +// must use https; any other scheme is an error so a typo can't send reports +// in clear text. +func ResolveDestination(disabled bool, endpoint string, bundled bool) (Destination, error) { + return resolveDestination(disabled, endpoint, bundled, DefaultEndpoint) +} + +// resolveDestination is ResolveDestination with the project default passed +// in, so the default branches can be exercised without editing the constant. +func resolveDestination(disabled bool, endpoint string, bundled bool, defaultEndpoint string) (Destination, error) { + endpoint = strings.TrimSpace(endpoint) + switch { + case disabled: + return Destination{Kind: KindDisabled}, nil + case endpoint != "" && bundled: + return Destination{Kind: KindBundled, URL: endpoint, Host: hostOf(endpoint)}, nil + case endpoint != "": + return Destination{Kind: KindCustom, URL: endpoint, Host: hostOf(endpoint)}, nil + case defaultEndpoint != "": + u, err := url.Parse(defaultEndpoint) + if err != nil { + return Destination{}, fmt.Errorf("default telemetry endpoint: %w", err) + } + if u.Scheme != "https" || u.Host == "" { + return Destination{}, fmt.Errorf("default telemetry endpoint %q must be an https URL", defaultEndpoint) + } + return Destination{Kind: KindDefault, URL: defaultEndpoint, Host: u.Host}, nil + default: + return Destination{Kind: KindNone}, nil + } +} + +// hostOf returns the host of rawURL, or "" when it doesn't parse. Custom and +// bundled endpoints keep today's behaviour of accepting any value, so a bad +// one is not an error here. +func hostOf(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return u.Host +} diff --git a/api/internal/telemetry/destination_test.go b/api/internal/telemetry/destination_test.go new file mode 100644 index 000000000..9f16b9104 --- /dev/null +++ b/api/internal/telemetry/destination_test.go @@ -0,0 +1,74 @@ +package telemetry + +import "testing" + +func TestResolveDestination_Table(t *testing.T) { + const ( + bundledURL = "http://gameplane-telemetry-receiver.gameplane-system.svc:8080/ingest" + defaultURL = "https://telemetry.example.org/ingest" + ) + cases := []struct { + name string + disabled bool + endpoint string + bundled bool + defaultURL string + wantKind string + wantURL string + wantHost string + wantErr bool + }{ + {name: "disabled wins over endpoint and default", disabled: true, endpoint: "https://x.example/i", defaultURL: defaultURL, wantKind: KindDisabled}, + {name: "disabled wins over bundled", disabled: true, endpoint: bundledURL, bundled: true, wantKind: KindDisabled}, + {name: "bundled needs an endpoint and the marker", endpoint: bundledURL, bundled: true, defaultURL: defaultURL, + wantKind: KindBundled, wantURL: bundledURL, wantHost: "gameplane-telemetry-receiver.gameplane-system.svc:8080"}, + {name: "custom keeps any scheme", endpoint: "http://collector.lan:9000/in", defaultURL: defaultURL, + wantKind: KindCustom, wantURL: "http://collector.lan:9000/in", wantHost: "collector.lan:9000"}, + {name: "custom endpoint is trimmed", endpoint: " https://c.example/i \n", wantKind: KindCustom, + wantURL: "https://c.example/i", wantHost: "c.example"}, + {name: "custom with an unparsable URL is not an error", endpoint: "://bad", wantKind: KindCustom, wantURL: "://bad"}, + {name: "default when nothing else is set", defaultURL: defaultURL, + wantKind: KindDefault, wantURL: defaultURL, wantHost: "telemetry.example.org"}, + {name: "bundled marker alone does not use the default", bundled: true, defaultURL: defaultURL, + wantKind: KindDefault, wantURL: defaultURL, wantHost: "telemetry.example.org"}, + {name: "none when there is no endpoint and no default", wantKind: KindNone}, + {name: "none when only the bundled marker is set", bundled: true, wantKind: KindNone}, + {name: "default over http is rejected", defaultURL: "http://telemetry.example.org/ingest", wantErr: true}, + {name: "default without a host is rejected", defaultURL: "https://", wantErr: true}, + {name: "default that does not parse is rejected", defaultURL: "%%", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveDestination(tc.disabled, tc.endpoint, tc.bundled, tc.defaultURL) + if tc.wantErr { + if err == nil { + t.Fatalf("want an error, got %+v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := Destination{Kind: tc.wantKind, URL: tc.wantURL, Host: tc.wantHost} + if got != want { + t.Fatalf("got %+v, want %+v", got, want) + } + }) + } +} + +func TestResolveDestination_UsesProjectConstant(t *testing.T) { + got, err := ResolveDestination(true, "", false) + if err != nil || got.Kind != KindDisabled || got.URL != "" { + t.Fatalf("disabled: got %+v, %v", got, err) + } + got, err = ResolveDestination(false, "http://collector.lan/in", false) + if err != nil || got.Kind != KindCustom { + t.Fatalf("custom: got %+v, %v", got, err) + } + // Whatever DefaultEndpoint holds must itself resolve: empty means none, + // anything else must be a valid https URL. + if _, err := ResolveDestination(false, "", false); err != nil { + t.Fatalf("DefaultEndpoint %q does not resolve: %v", DefaultEndpoint, err) + } +} diff --git a/api/internal/telemetry/distro.go b/api/internal/telemetry/distro.go new file mode 100644 index 000000000..f1edc5378 --- /dev/null +++ b/api/internal/telemetry/distro.go @@ -0,0 +1,54 @@ +package telemetry + +import ( + "strings" + + corev1 "k8s.io/api/core/v1" +) + +// detectDistro names the Kubernetes distribution from signals the API can +// already read, without any new RBAC. The first matching rule wins, in the +// order of research R16: the server's gitVersion suffix, then well-known node +// labels, then the node providerID prefix, then the node OS image. Anything +// else, including plain kubeadm, is "other". +func detectDistro(gitVersion string, nodes []corev1.Node) string { + for _, r := range []struct{ name, marker string }{ + {"k3s", "+k3s"}, + {"rke2", "+rke2"}, + {"k0s", "+k0s"}, + {"eks", "-eks-"}, + {"gke", "-gke."}, + } { + if strings.Contains(gitVersion, r.marker) { + return r.name + } + } + for _, r := range []struct{ name, label string }{ + {"aks", "kubernetes.azure.com/cluster"}, + {"openshift", "node.openshift.io/os_id"}, + {"microk8s", "microk8s.io/cluster"}, + {"minikube", "minikube.k8s.io/name"}, + } { + for i := range nodes { + if _, ok := nodes[i].Labels[r.label]; ok { + return r.name + } + } + } + for _, r := range []struct{ name, prefix string }{ + {"kind", "kind://"}, + {"doks", "digitalocean://"}, + } { + for i := range nodes { + if strings.HasPrefix(nodes[i].Spec.ProviderID, r.prefix) { + return r.name + } + } + } + for i := range nodes { + if strings.HasPrefix(nodes[i].Status.NodeInfo.OSImage, "Talos") { + return "talos" + } + } + return "other" +} diff --git a/api/internal/telemetry/distro_test.go b/api/internal/telemetry/distro_test.go new file mode 100644 index 000000000..ae7690227 --- /dev/null +++ b/api/internal/telemetry/distro_test.go @@ -0,0 +1,71 @@ +package telemetry + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// testNode builds a Node with just the fields detectDistro and nodeArches read. +func testNode(name string, labels map[string]string, providerID, osImage, arch string) corev1.Node { + return corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}, + Spec: corev1.NodeSpec{ProviderID: providerID}, + Status: corev1.NodeStatus{NodeInfo: corev1.NodeSystemInfo{ + OSImage: osImage, + Architecture: arch, + }}, + } +} + +func TestDetectDistro_EveryRow(t *testing.T) { + plain := testNode("n", nil, "", "Ubuntu 24.04", "amd64") + cases := []struct { + name string + gitVersion string + nodes []corev1.Node + want string + }{ + {"k3s", "v1.31.2+k3s1", []corev1.Node{plain}, "k3s"}, + {"rke2", "v1.30.4+rke2r1", []corev1.Node{plain}, "rke2"}, + {"k0s", "v1.31.1+k0s", []corev1.Node{plain}, "k0s"}, + {"eks", "v1.29.4-eks-036c24b", []corev1.Node{plain}, "eks"}, + {"gke", "v1.29.4-gke.1043002", []corev1.Node{plain}, "gke"}, + {"aks label", "v1.30.0", []corev1.Node{testNode("n", map[string]string{"kubernetes.azure.com/cluster": "MC_x"}, "", "", "amd64")}, "aks"}, + {"openshift label", "v1.30.0", []corev1.Node{testNode("n", map[string]string{"node.openshift.io/os_id": "rhcos"}, "", "", "amd64")}, "openshift"}, + {"microk8s label", "v1.30.0", []corev1.Node{testNode("n", map[string]string{"microk8s.io/cluster": "true"}, "", "", "amd64")}, "microk8s"}, + {"minikube label", "v1.30.0", []corev1.Node{testNode("n", map[string]string{"minikube.k8s.io/name": "minikube"}, "", "", "amd64")}, "minikube"}, + {"kind providerID", "v1.31.0", []corev1.Node{testNode("n", nil, "kind://docker/kind/kind-control-plane", "", "amd64")}, "kind"}, + {"doks providerID", "v1.30.2", []corev1.Node{testNode("n", nil, "digitalocean://123456", "", "amd64")}, "doks"}, + {"talos osImage", "v1.31.0", []corev1.Node{testNode("n", nil, "", "Talos (v1.8.0)", "amd64")}, "talos"}, + {"plain kubeadm is other", "v1.31.0", []corev1.Node{plain}, "other"}, + {"no nodes and no marker is other", "", nil, "other"}, + {"match on a later node", "v1.30.0", []corev1.Node{plain, testNode("w", nil, "kind://docker/kind/w", "", "arm64")}, "kind"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := detectDistro(tc.gitVersion, tc.nodes); got != tc.want { + t.Fatalf("detectDistro(%q) = %q, want %q", tc.gitVersion, got, tc.want) + } + }) + } +} + +// The first matching row wins, in the order of research R16. +func TestDetectDistro_Precedence(t *testing.T) { + kindNode := testNode("n", map[string]string{"minikube.k8s.io/name": "m"}, "kind://docker/x", "Talos", "amd64") + if got := detectDistro("v1.31.0+k3s1", []corev1.Node{kindNode}); got != "k3s" { + t.Errorf("gitVersion marker must beat labels and providerID, got %q", got) + } + if got := detectDistro("v1.31.0", []corev1.Node{kindNode}); got != "minikube" { + t.Errorf("a node label must beat providerID and osImage, got %q", got) + } + noLabel := testNode("n", nil, "kind://docker/x", "Talos", "amd64") + if got := detectDistro("v1.31.0", []corev1.Node{noLabel}); got != "kind" { + t.Errorf("providerID must beat osImage, got %q", got) + } + if got := detectDistro("v1.31.0+k3s1-eks-1", nil); got != "k3s" { + t.Errorf("k3s must be checked before eks, got %q", got) + } +} diff --git a/api/internal/telemetry/official.go b/api/internal/telemetry/official.go new file mode 100644 index 000000000..4bfdc3990 --- /dev/null +++ b/api/internal/telemetry/official.go @@ -0,0 +1,75 @@ +package telemetry + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/telemetryschema" +) + +// Labels the operator stamps on a module-managed GameTemplate +// (operator/api/v1alpha1/module_types.go). The API module doesn't import the +// operator module, so the values are repeated here. +const ( + labelManagedBy = "gameplane.local/managed-by" + labelModuleName = "gameplane.local/module-name" + labelModuleSource = "gameplane.local/module-source" + managedByModule = "Module" +) + +// listedModules returns the module names the ModuleSource called source +// serves: spec.oci.modules[].name plus the names in status.modules (the +// source's indexed catalog, which is where a git source's names appear). A +// missing source, an empty source name or any read error yields an empty set, +// so every server then counts as custom. +func listedModules(ctx context.Context, dyn dynamic.Interface, source string) map[string]bool { + out := map[string]bool{} + if source == "" || dyn == nil { + return out + } + ms, err := dyn.Resource(kube.GVRModuleSource).Get(ctx, source, metav1.GetOptions{}) + if err != nil { + return out + } + if mods, found, _ := unstructured.NestedSlice(ms.Object, "spec", "oci", "modules"); found { + addModuleNames(out, mods) + } + if mods, found, _ := unstructured.NestedSlice(ms.Object, "status", "modules"); found { + addModuleNames(out, mods) + } + return out +} + +// addModuleNames adds the "name" of every map entry in mods to set. +func addModuleNames(set map[string]bool, mods []any) { + for _, m := range mods { + entry, ok := m.(map[string]any) + if !ok { + continue + } + if name, ok := entry["name"].(string); ok && name != "" { + set[name] = true + } + } +} + +// officialModule returns the official module name a GameTemplate with these +// labels provides, or "" when it isn't one. It is official only when it is +// managed by a Module, comes from the official ModuleSource (source, from +// --official-module-source), names a module that source lists, and that name +// is in the embedded catalog. Manual templates, uploads, other sources and +// look-alike names all return "" and are counted as custom. +func officialModule(labels map[string]string, source string, listed map[string]bool) string { + if source == "" || labels[labelManagedBy] != managedByModule || labels[labelModuleSource] != source { + return "" + } + name := labels[labelModuleName] + if name == "" || !listed[name] || !telemetryschema.IsOfficial(name) { + return "" + } + return name +} diff --git a/api/internal/telemetry/official_test.go b/api/internal/telemetry/official_test.go new file mode 100644 index 000000000..5441edfaf --- /dev/null +++ b/api/internal/telemetry/official_test.go @@ -0,0 +1,113 @@ +package telemetry + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/runtime" +) + +func TestOfficialModule_Table(t *testing.T) { + listed := map[string]bool{ + "minecraft-java": true, + "terraria": true, + "minecraft-java-evil": true, // listed by the source, but not in the embedded catalog + "private-game-by-user": true, + } + good := map[string]string{ + labelManagedBy: managedByModule, + labelModuleSource: "default", + labelModuleName: "minecraft-java", + } + with := func(key, val string) map[string]string { + out := map[string]string{} + for k, v := range good { + out[k] = v + } + if val == "" { + delete(out, key) + } else { + out[key] = val + } + return out + } + cases := []struct { + name string + labels map[string]string + source string + want string + }{ + {"official module", good, "default", "minecraft-java"}, + {"another official module", with(labelModuleName, "terraria"), "default", "terraria"}, + {"nil labels (manual template)", nil, "default", ""}, + {"not managed by a module", with(labelManagedBy, ""), "default", ""}, + {"managed by something else", with(labelManagedBy, "Helm"), "default", ""}, + {"upload source", with(labelModuleSource, "uploads"), "default", ""}, + {"source label missing", with(labelModuleSource, ""), "default", ""}, + {"no official source configured", good, "", ""}, + {"module name missing", with(labelModuleName, ""), "default", ""}, + {"module not listed by the source", with(labelModuleName, "valheim"), "default", ""}, + {"look-alike name outside the catalog", with(labelModuleName, "minecraft-java-evil"), "default", ""}, + {"listed but not in the catalog", with(labelModuleName, "private-game-by-user"), "default", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := officialModule(tc.labels, tc.source, listed); got != tc.want { + t.Fatalf("officialModule = %q, want %q", got, tc.want) + } + }) + } +} + +func TestListedModules_UnionOfSpecAndStatus(t *testing.T) { + src := moduleSourceObj("default", []string{"minecraft-java", "terraria"}, []string{"terraria", "valheim"}) + k := collectKubeClients(t, collectOpts{dynamic: []runtime.Object{src}}) + got := listedModules(context.Background(), k.Dynamic, "default") + for _, name := range []string{"minecraft-java", "terraria", "valheim"} { + if !got[name] { + t.Errorf("%s missing from %v", name, got) + } + } + if len(got) != 3 { + t.Errorf("got %v, want exactly three names", got) + } +} + +func TestListedModules_EmptyOnProblems(t *testing.T) { + ctx := context.Background() + src := moduleSourceObj("default", []string{"minecraft-java"}, nil) + k := collectKubeClients(t, collectOpts{dynamic: []runtime.Object{src}}) + + if got := listedModules(ctx, k.Dynamic, ""); len(got) != 0 { + t.Errorf("empty source name: got %v", got) + } + if got := listedModules(ctx, nil, "default"); len(got) != 0 { + t.Errorf("nil client: got %v", got) + } + if got := listedModules(ctx, k.Dynamic, "absent"); len(got) != 0 { + t.Errorf("unknown source: got %v", got) + } + + failing := collectKubeClients(t, collectOpts{ + dynamic: []runtime.Object{src}, + dynamicFail: map[string]bool{"get": true}, + }) + if got := listedModules(ctx, failing.Dynamic, "default"); len(got) != 0 { + t.Errorf("a failing Get must yield an empty set, got %v", got) + } +} + +func TestAddModuleNames_SkipsMalformedEntries(t *testing.T) { + set := map[string]bool{} + addModuleNames(set, []any{ + map[string]any{"name": "terraria"}, + map[string]any{"name": ""}, + map[string]any{"name": 7}, + map[string]any{"other": "x"}, + "not-a-map", + nil, + }) + if len(set) != 1 || !set["terraria"] { + t.Fatalf("got %v, want only terraria", set) + } +} diff --git a/api/internal/telemetry/telemetry.go b/api/internal/telemetry/telemetry.go index 046535e81..f040b6951 100644 --- a/api/internal/telemetry/telemetry.go +++ b/api/internal/telemetry/telemetry.go @@ -1,148 +1,470 @@ -// Package telemetry implements an opt-in, off-by-default reporter that -// periodically POSTs anonymous, aggregate usage counts to a configurable -// endpoint. It exists so the admin "Send anonymous metrics" toggle does -// something — previously it persisted a value nothing read. +// Package telemetry sends the install's usage report to its resolved +// destination (specs/022-default-telemetry-dashboard). // -// Privacy: the payload carries only the control-plane version and total -// counts. No names, namespaces, hostnames, IPs, or identifiers. Reporting -// happens only when BOTH an endpoint is configured (operator-set) AND the -// sendMetrics config toggle is on (admin-set) — opt-in on two axes. +// Privacy: the basic report carries only the control-plane version and +// total counts. The extended report adds an install ID and environment +// categories, only while the admin's extended switch is on, and is signed. +// Nothing is sent while the operator disabled telemetry, no destination is in +// effect, the admin turned basic off, or (on a fresh install) before the +// notice was shown to an admin (FR-004). +// +// The schedule lives in the database (telemetry_state.next_due_at), so a +// restart never sends early and replicas sharing one database claim each +// slot with a conditional UPDATE (research R12). package telemetry import ( "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "log/slog" "net/http" + "net/url" "time" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/GameplanePanel/gameplane/api/internal/db" - "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/telemetryschema" +) + +const ( + // maxPoll caps how often the reporter looks at the schedule. + maxPoll = 5 * time.Minute + // baseBackoff is the wait after the first failure; it doubles per + // further failure up to the report interval. + baseBackoff = time.Hour + // extFallbackFor is how long the extended part is withheld from an + // endpoint that answered 400 to it (research R2). + extFallbackFor = 7 * 24 * time.Hour + // outcomeOK and outcomeFailed are the telemetry_state.last_outcome values. + outcomeOK = "ok" + outcomeFailed = "failed" + // challengeTimeout bounds the request for a proof-of-work challenge + // (research R21). + challengeTimeout = 10 * time.Second + // solveMargin is how long before a challenge expires the solver must + // give up. + solveMargin = 30 * time.Second + // challengeTTL is how long a challenge lives when the provider's + // expiresAt is missing or unreadable (the receiver's fixed lifetime). + challengeTTL = 15 * time.Minute + // maxChallengeBody bounds how much of a challenge response is read. + maxChallengeBody = 4096 ) -// Reporter periodically sends anonymous usage telemetry to a configured -// endpoint when enabled via the admin toggle. +// errPoWDifficulty is wrapped by the failure for a challenge whose difficulty +// is outside 0 to telemetryschema.MaxPoWBits (FR-040). +var errPoWDifficulty = errors.New("challenge difficulty out of range") + +// Config configures a Reporter. +type Config struct { + // Dest is the resolved destination. Kinds disabled and none never send. + Dest Destination + // Interval is the spacing between reports; non-positive means 24h. + Interval time.Duration + // Auth, when non-empty, is sent verbatim as the Authorization header. + Auth string + // Deps is what Collect reads from. Deps.Store is required. Extended and + // Now are set per report by the reporter. + Deps Deps + // Client is the HTTP client; nil means one with a 15s timeout. + Client *http.Client + // Now returns the current time; nil means time.Now. Tests set it. + Now func() time.Time + // Rand returns a uniform value in [0, n); nil means math/rand/v2. + Rand func(n int64) int64 +} + +// Reporter sends telemetry reports on the persisted schedule. type Reporter struct { - store *db.Store - k *kube.Client - endpoint string - auth string - version string + dest Destination interval time.Duration + auth string + deps Deps + store *db.Store client *http.Client + now func() time.Time + rnd func(int64) int64 } -// New builds a Reporter. auth, when non-empty, is sent verbatim as the -// Authorization header on every report — the telemetry-receiver checks -// it when deployed with an ingest token. -func New(store *db.Store, k *kube.Client, endpoint, auth, version string, interval time.Duration) *Reporter { - if interval <= 0 { - interval = 24 * time.Hour +// New builds a Reporter from cfg. +func New(cfg Config) *Reporter { + if cfg.Interval <= 0 { + cfg.Interval = 24 * time.Hour + } + if cfg.Client == nil { + cfg.Client = &http.Client{Timeout: 15 * time.Second} + } + if cfg.Now == nil { + cfg.Now = time.Now + } + if cfg.Rand == nil { + cfg.Rand = randN } return &Reporter{ - store: store, - k: k, - endpoint: endpoint, - auth: auth, - version: version, - interval: interval, - client: &http.Client{Timeout: 15 * time.Second}, + dest: cfg.Dest, + interval: cfg.Interval, + auth: cfg.Auth, + deps: cfg.Deps, + store: cfg.Deps.Store, + client: cfg.Client, + now: cfg.Now, + rnd: cfg.Rand, } } -// payload is the anonymous, aggregate telemetry. Deliberately carries no -// names, namespaces, hosts, or identifiers. -type payload struct { - Version string `json:"version"` - Servers int `json:"servers"` - Templates int `json:"templates"` +// pollEvery is how often Run evaluates the schedule: min(5m, interval/4). +func (r *Reporter) pollEvery() time.Duration { + return max(time.Second, min(maxPoll, r.interval/4)) } -// Run reports on an interval until ctx is cancelled. It returns -// immediately when no endpoint is configured (telemetry off). +// Run evaluates the schedule every pollEvery until ctx is cancelled. It +// returns at once, making no connection, when the destination is disabled or +// none. func (r *Reporter) Run(ctx context.Context) { - if r.endpoint == "" { - slog.Info("telemetry disabled: no endpoint configured") + if !destActive(r.dest) { + slog.Info("telemetry off: no destination in effect", "kind", r.dest.Kind) return } - t := time.NewTicker(r.interval) + t := time.NewTicker(r.pollEvery()) defer t.Stop() for { + if err := r.Tick(ctx); err != nil { + slog.Warn("telemetry report", "err", err) + } select { case <-ctx.Done(): return case <-t.C: - if err := r.reportOnce(ctx); err != nil { - slog.Warn("telemetry report", "err", err) - } } } } -// enabled reads the admin sendMetrics toggle on each tick so flipping it -// in the UI takes effect without a restart. -func (r *Reporter) enabled(ctx context.Context) bool { - raw, ok, err := r.store.ConfigValue(ctx, "telemetry") - if err != nil || !ok { - return false +// Tick evaluates the schedule once. It opens or closes the gate's slot, +// and, when a slot is due and this process wins the claim, sends one report. +// It returns an error when the send failed (the failure is also recorded). +func (r *Reporter) Tick(ctx context.Context) error { + now := r.now() + st, err := r.store.GetTelemetryState(ctx) + if err != nil { + return fmt.Errorf("telemetry tick: %w", err) + } + c, err := ReadConsent(ctx, r.store) + if err != nil { + return fmt.Errorf("telemetry tick: %w", err) } - var c struct { - SendMetrics bool `json:"sendMetrics"` + if !gateOpen(destActive(r.dest) && c.Basic, st) { + if st.NextDueAt == "" { + return nil + } + if err := r.store.CloseTelemetrySchedule(ctx); err != nil { + return fmt.Errorf("telemetry tick: %w", err) + } + return nil + } + if st.NextDueAt == "" { + if _, err := r.store.OpenTelemetrySchedule(ctx, stamp(firstDue(now, r.interval, r.rnd))); err != nil { + return fmt.Errorf("telemetry tick: %w", err) + } + return nil } - if json.Unmarshal([]byte(raw), &c) != nil { + due, perr := time.Parse(time.RFC3339, st.NextDueAt) + if perr != nil { + due = now // a corrupt slot counts as due, and the claim replaces it + } + if now.Before(due) { + return nil + } + return r.attempt(ctx, st, c, now) +} + +// jitter returns U(0, d). +func (r *Reporter) jitter(d time.Duration) time.Duration { + return time.Duration(r.rnd(int64(d))) +} + +// extWithheld reports whether the extended part is withheld because this +// endpoint answered 400 to it within the last 7 days. +func (r *Reporter) extWithheld(st db.TelemetryState, now time.Time) bool { + return ExtWithheld(st, r.dest.URL, now) +} + +// ExtWithheld reports whether the extended part is withheld from endpoint +// because it answered 400 to it within the last 7 days. The reporter and the +// admin preview both use it, so the preview shows what would be sent. +func ExtWithheld(st db.TelemetryState, endpoint string, now time.Time) bool { + if st.ExtUnsupportedEndpoint != endpoint { return false } - return c.SendMetrics + until, err := time.Parse(time.RFC3339, st.ExtUnsupportedUntil) + return err == nil && now.Before(until) } -// reportOnce collects and POSTs one telemetry sample, or does nothing -// when the admin toggle is off. -func (r *Reporter) reportOnce(ctx context.Context) error { - if !r.enabled(ctx) { - return nil +// attempt claims the due slot and, if it won, builds, signs and sends one +// report, then records the outcome. +func (r *Reporter) attempt(ctx context.Context, st db.TelemetryState, c Consent, now time.Time) error { + claimed := stamp(now.Add(r.interval + r.jitter(r.interval/48))) + won, err := r.store.ClaimTelemetryDue(ctx, st.NextDueAt, claimed, stamp(now)) + if err != nil { + return fmt.Errorf("telemetry attempt: %w", err) + } + if !won { + return nil // another replica sent this slot + } + rep, err := r.build(ctx, st, c.Extended && !r.extWithheld(st, now), now) + if err != nil { + return r.finish(ctx, st, claimed, err) + } + status, err := r.send(ctx, rep) + if err == nil && status == http.StatusBadRequest && rep.Ext != nil { + // The endpoint doesn't understand the extended part: re-send the + // basic report once and stop sending ext to it for a week. + basic := rep + basic.Ext = nil + status, err = r.send(ctx, basic) + if mErr := r.store.SetExtUnsupported(ctx, stamp(now.Add(extFallbackFor)), r.dest.URL); mErr != nil { + slog.Warn("telemetry fallback marker", "err", mErr) + } + } else if err == nil && status == http.StatusConflict && rep.Ext != nil { + // The receiver says another key claimed this install ID (FR-037): + // replace the ID, keep the signing secret, and re-send once. A second + // 409 falls through to the failure below. A 403 never gets here. + status, err = r.rotateAndResend(ctx, now) + } + if err == nil && (status < 200 || status > 299) { + err = fmt.Errorf("telemetry endpoint returned %d", status) + } + return r.finish(ctx, st, claimed, err) +} + +// finish records the outcome of a claimed attempt: cause is nil on success. +// On success the next slot is interval plus jitter after completion, so +// reports are at least one interval apart. On failure it backs off +// min(1h*2^n, interval). cause is returned. +func (r *Reporter) finish(ctx context.Context, st db.TelemetryState, claimed string, cause error) error { + done := r.now() + res := db.TelemetryResult{ExpectedNext: claimed} + if cause == nil { + res.Outcome = outcomeOK + res.SuccessAt = stamp(done) + res.NextDue = stamp(done.Add(r.interval + r.jitter(r.interval/48))) + } else { + res.Outcome = outcomeFailed + res.Failures = st.ConsecutiveFailures + 1 + res.NextDue = stamp(done.Add(r.backoff(st.ConsecutiveFailures))) + } + if err := r.store.RecordTelemetryResult(ctx, res); err != nil { + return errors.Join(cause, fmt.Errorf("telemetry attempt: %w", err)) + } + return cause +} + +// backoff is min(1h*2^failures, interval). +func (r *Reporter) backoff(failures int) time.Duration { + d := baseBackoff + for i := 0; i < failures && d < r.interval; i++ { + d *= 2 } - p := payload{Version: r.version} - if n, err := r.count(ctx, "servers"); err == nil { - p.Servers = n + return min(d, r.interval) +} + +// build collects the report. extended is the gate's extendedOn minus the +// fallback marker; a fresh install is seeded with extended on and no install +// ID yet, so the ID is created here when missing. +func (r *Reporter) build(ctx context.Context, st db.TelemetryState, extended bool, now time.Time) (telemetryschema.Report, error) { + if extended && st.InstallID == "" { + id, err := NewInstallID() + if err != nil { + return telemetryschema.Report{}, fmt.Errorf("telemetry build: %w", err) + } + if err := r.store.SetInstallID(ctx, id); err != nil { + return telemetryschema.Report{}, fmt.Errorf("telemetry build: %w", err) + } } - if n, err := r.count(ctx, "templates"); err == nil { - p.Templates = n + rep, err := BuildReport(ctx, r.deps, extended, now) + if err != nil { + return telemetryschema.Report{}, fmt.Errorf("telemetry build: %w", err) } - body, err := json.Marshal(p) + return rep, nil +} + +// BuildReport collects the report for deps at now, with the extended part +// when extended is true. The reporter and the admin preview both call it, so +// the preview is the report that would be sent (SC-006). +func BuildReport(ctx context.Context, deps Deps, extended bool, now time.Time) (telemetryschema.Report, error) { + deps.Extended = extended + deps.Now = func() time.Time { return now } + return Collect(ctx, deps) +} + +// rotateAndResend handles a 409 id_claimed: it replaces the install ID (the +// signing secret is kept, so the new ID gets a new, unlinkable key), records +// last_id_rotation_at, rebuilds and re-signs the extended report, and POSTs it +// once. It returns that POST's status. +func (r *Reporter) rotateAndResend(ctx context.Context, now time.Time) (int, error) { + id, err := NewInstallID() if err != nil { - return err + return 0, fmt.Errorf("telemetry rotate: %w", err) + } + if err := r.store.RotateInstallID(ctx, id, stamp(now)); err != nil { + return 0, fmt.Errorf("telemetry rotate: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpoint, bytes.NewReader(body)) + rep, err := BuildReport(ctx, r.deps, true, now) if err != nil { - return err + return 0, fmt.Errorf("telemetry rotate: %w", err) + } + return r.send(ctx, rep) +} + +// send encodes rep and POSTs it, signing the exact body bytes when rep +// carries the extended part (the signature header is absent otherwise, +// FR-038). It returns the HTTP status. +func (r *Reporter) send(ctx context.Context, rep telemetryschema.Report) (int, error) { + body, err := telemetryschema.Encode(rep) + if err != nil { + return 0, fmt.Errorf("telemetry send: %w", err) + } + sig := "" + if rep.Ext != nil { + secret, err := r.store.EnsureSigningSecret(ctx) + if err != nil { + return 0, fmt.Errorf("telemetry send: %w", err) + } + priv, err := telemetryschema.DeriveKey(secret, rep.Ext.InstallID) + if err != nil { + return 0, fmt.Errorf("telemetry send: %w", err) + } + sig = telemetryschema.Sign(priv, body) + } + return r.post(ctx, body, sig) +} + +// post sends one report and returns the status code. When the destination +// offers proof-of-work challenges (research R21) each request carries a solved +// one: the challenge is fetched fresh for every request, because challenges +// are single use. A 428 gets one new challenge and one resend; a second 428 is +// returned to the caller, which records it as a failed attempt. An error +// means no request got an answer, or the challenge could not be solved. +func (r *Reporter) post(ctx context.Context, body []byte, sig string) (int, error) { + status := 0 + for range 2 { + header, err := r.solveChallenge(ctx) + if err != nil { + return 0, err + } + status, err = r.postOnce(ctx, body, sig, header) + if err != nil || status != http.StatusPreconditionRequired { + return status, err + } + } + return status, nil +} + +// postOnce sends one request, with the proof-of-work header when pow is not +// empty, and returns the status code. +func (r *Reporter) postOnce(ctx context.Context, body []byte, sig, pow string) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.dest.URL, bytes.NewReader(body)) + if err != nil { + return 0, fmt.Errorf("telemetry post: %w", err) } req.Header.Set("Content-Type", "application/json") if r.auth != "" { req.Header.Set("Authorization", r.auth) } + if sig != "" { + req.Header.Set(telemetryschema.SignatureHeader, sig) + } + if pow != "" { + req.Header.Set(telemetryschema.PoWHeader, pow) + } resp, err := r.client.Do(req) if err != nil { - return err + return 0, fmt.Errorf("telemetry post: %w", err) } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode >= 300 { - return fmt.Errorf("telemetry endpoint returned %d", resp.StatusCode) + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + return resp.StatusCode, nil +} + +// challengeReply is the body of the provider's GET /v1/challenge. +type challengeReply struct { + Challenge string `json:"challenge"` + Bits int `json:"bits"` + ExpiresAt string `json:"expiresAt"` +} + +// challengeURL is the endpoint with its last path segment replaced by +// v1/challenge: https://host/prefix/ingest becomes +// https://host/prefix/v1/challenge. A query or fragment is dropped. +func challengeURL(endpoint string) (string, error) { + base, err := url.Parse(endpoint) + if err != nil { + return "", fmt.Errorf("telemetry challenge url: %w", err) } - return nil + return base.ResolveReference(&url.URL{Path: "v1/challenge"}).String(), nil } -func (r *Reporter) count(ctx context.Context, kind string) (int, error) { - gvr, ok := kube.GVRs[kind] +// solveChallenge returns the proof-of-work header value for the next +// request, or "" when the destination does not offer challenges (FR-040). +// It fails, without any POST, when the challenge is harder than +// telemetryschema.MaxPoWBits or cannot be solved before it expires. Solving +// runs on the calling goroutine, which is the reporter's own. +func (r *Reporter) solveChallenge(ctx context.Context) (string, error) { + ch, ok := r.fetchChallenge(ctx) if !ok { - return 0, nil + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("telemetry challenge: %w", err) + } + return "", nil + } + if ch.Bits < 0 || ch.Bits > telemetryschema.MaxPoWBits { + return "", fmt.Errorf("telemetry pow: %w: %d bits, the cap is %d", errPoWDifficulty, ch.Bits, telemetryschema.MaxPoWBits) + } + left := challengeTTL + if exp, perr := time.Parse(time.RFC3339, ch.ExpiresAt); perr == nil { + left = exp.Sub(r.now()) + } + budget := left - solveMargin + if budget <= 0 { + return "", errors.New("telemetry pow: challenge expires too soon to solve") } - list, err := r.k.Dynamic.Resource(gvr).List(ctx, metav1.ListOptions{}) + solveCtx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + nonce, err := telemetryschema.SolvePoW(solveCtx, ch.Challenge, ch.Bits) if err != nil { - return 0, err + return "", fmt.Errorf("telemetry pow: %w", err) + } + return telemetryschema.FormatPoW(ch.Challenge, nonce), nil +} + +// fetchChallenge asks the destination for a challenge. ok is false when it +// offers none: any answer other than a readable 200 means the provider does +// not require proof-of-work, and so does a request that fails (the POST that +// follows meets the same failure and reports it). The request is a plain GET +// without the Authorization header, with a 10-second timeout. +func (r *Reporter) fetchChallenge(ctx context.Context) (ch challengeReply, ok bool) { + target, err := challengeURL(r.dest.URL) + if err != nil { + return ch, false + } + cctx, cancel := context.WithTimeout(ctx, challengeTimeout) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodGet, target, nil) + if err != nil { + return ch, false + } + resp, err := r.client.Do(req) + if err != nil { + return ch, false + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return ch, false + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxChallengeBody)).Decode(&ch); err != nil || ch.Challenge == "" { + return challengeReply{}, false } - return len(list.Items), nil + return ch, true } diff --git a/api/internal/telemetry/telemetry_branches_test.go b/api/internal/telemetry/telemetry_branches_test.go index 8647bf5a8..534065538 100644 --- a/api/internal/telemetry/telemetry_branches_test.go +++ b/api/internal/telemetry/telemetry_branches_test.go @@ -3,7 +3,6 @@ package telemetry import ( "context" "net/http" - "net/http/httptest" "path/filepath" "testing" "time" @@ -11,8 +10,9 @@ import ( "github.com/GameplanePanel/gameplane/api/internal/db" ) -// bareStore is a migrated store with no telemetry config row (telStore -// seeds one; some branches need its absence). +// bareStore is a migrated store with no telemetry config row. Migrate seeds +// the fresh-install default; this helper removes it so some branches behave as +// before. func bareStore(ctx context.Context, t *testing.T) *db.Store { t.Helper() store, err := db.Open(ctx, "sqlite", "file:"+filepath.Join(t.TempDir(), "t.db")) @@ -23,23 +23,47 @@ func bareStore(ctx context.Context, t *testing.T) *db.Store { if err := store.Migrate(ctx); err != nil { t.Fatalf("migrate: %v", err) } + if _, err := store.DB.ExecContext(ctx, `DELETE FROM config WHERE key = 'telemetry'`); err != nil { + t.Fatalf("delete telemetry: %v", err) + } return store } func TestNew_DefaultsInterval(t *testing.T) { - r := New(bareStore(context.Background(), t), telKube(), "http://x", "", "v1", 0) + r := New(Config{Deps: Deps{Store: bareStore(context.Background(), t), Kube: telKube()}, Interval: 0}) if r.interval != 24*time.Hour { t.Fatalf("interval = %v, want the 24h default for a non-positive interval", r.interval) } + if r.client == nil || r.now == nil || r.rnd == nil { + t.Fatal("New must fill the client, clock and random source defaults") + } +} + +func TestPollEvery(t *testing.T) { + for _, tc := range []struct { + interval time.Duration + want time.Duration + }{ + {24 * time.Hour, 5 * time.Minute}, + {time.Hour, 5 * time.Minute}, + {20 * time.Minute, 5 * time.Minute}, + {time.Minute, 15 * time.Second}, + {time.Second, time.Second}, + } { + r := New(Config{Interval: tc.interval, Deps: Deps{Store: bareStore(context.Background(), t)}}) + if got := r.pollEvery(); got != tc.want { + t.Errorf("pollEvery(interval %v) = %v, want %v", tc.interval, got, tc.want) + } + } } func TestEnabled_Branches(t *testing.T) { ctx := context.Background() t.Run("absent config is disabled", func(t *testing.T) { - r := New(bareStore(ctx, t), telKube(), "http://x", "", "v1", time.Hour) - if r.enabled(ctx) { - t.Fatal("want disabled when there is no telemetry config row") + c, err := ReadConsent(ctx, bareStore(ctx, t)) + if err != nil || c != (Consent{}) { + t.Fatalf("consent = %+v, %v; want both tiers off for a missing telemetry config row", c, err) } }) @@ -50,28 +74,92 @@ func TestEnabled_Branches(t *testing.T) { "2026-01-01T00:00:00Z"); err != nil { t.Fatalf("seed: %v", err) } - r := New(store, telKube(), "http://x", "", "v1", time.Hour) - if r.enabled(ctx) { - t.Fatal("want disabled for a malformed telemetry config") + c, err := ReadConsent(ctx, store) + if err != nil || c != (Consent{}) { + t.Fatalf("consent = %+v, %v; want both tiers off for a malformed telemetry config", c, err) + } + }) + + t.Run("extended is off while basic is off", func(t *testing.T) { + if c := parseConsent(`{"sendMetrics":false,"extended":true}`, true); c != (Consent{}) { + t.Fatalf("consent = %+v, want both off", c) + } + }) + + t.Run("both tiers on", func(t *testing.T) { + if c := parseConsent(`{"sendMetrics":true,"extended":true}`, true); c != (Consent{Basic: true, Extended: true}) { + t.Fatalf("consent = %+v, want both on", c) + } + }) + + t.Run("a legacy value without extended is basic only", func(t *testing.T) { + if c := parseConsent(`{"sendMetrics":true}`, true); c != (Consent{Basic: true}) { + t.Fatalf("consent = %+v, want basic only", c) } }) } func TestCount_UnknownKind(t *testing.T) { - r := New(bareStore(context.Background(), t), telKube(), "http://x", "", "v1", time.Hour) - n, err := r.count(context.Background(), "bogus") - if err != nil || n != 0 { - t.Fatalf("count(bogus) = %d, %v; want 0, nil", n, err) + deps := Deps{Store: bareStore(context.Background(), t), Kube: telKube()} + if items := listItems(context.Background(), deps, "bogus"); len(items) != 0 { + t.Fatalf("listItems(bogus) = %d items, want 0", len(items)) } } func TestReportOnce_EndpointErrorPropagates(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - r := New(telStore(t, true), telKube(), srv.URL, "", "v1", time.Hour) - if err := r.reportOnce(context.Background()); err == nil { + srv, got := newSink(t) + got.setRespond(func(sentReport) int { return http.StatusInternalServerError }) + r := testReporter(telStore(t, true), telKube(), srv.URL, newFakeClock()) + if err := sendNow(t, r); err == nil { t.Fatal("want an error when the telemetry endpoint returns 500") } + if st := mustState(t, r.store); st.LastOutcome != "failed" || st.ConsecutiveFailures != 1 { + t.Fatalf("state = %+v, want one recorded failure", st) + } +} + +func TestBackoff_DoublesAndCapsAtInterval(t *testing.T) { + r := New(Config{Interval: 24 * time.Hour, Deps: Deps{Store: bareStore(context.Background(), t)}}) + for failures, want := range map[int]time.Duration{ + 0: time.Hour, 1: 2 * time.Hour, 2: 4 * time.Hour, 3: 8 * time.Hour, + 4: 16 * time.Hour, 5: 24 * time.Hour, 40: 24 * time.Hour, + } { + if got := r.backoff(failures); got != want { + t.Errorf("backoff(%d) = %v, want %v", failures, got, want) + } + } + short := New(Config{Interval: time.Minute, Deps: Deps{Store: bareStore(context.Background(), t)}}) + if got := short.backoff(0); got != time.Minute { + t.Errorf("backoff(0) with a 1m interval = %v, want the interval cap", got) + } +} + +func TestExtWithheld_Branches(t *testing.T) { + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) + r := New(Config{ + Dest: Destination{Kind: KindCustom, URL: "https://t.example/ingest"}, + Deps: Deps{Store: bareStore(context.Background(), t)}, + }) + active := db.TelemetryState{ + ExtUnsupportedEndpoint: "https://t.example/ingest", + ExtUnsupportedUntil: now.Add(time.Hour).Format(time.RFC3339), + } + if !r.extWithheld(active, now) { + t.Error("an unexpired marker for this endpoint must withhold ext") + } + expired := active + expired.ExtUnsupportedUntil = now.Add(-time.Second).Format(time.RFC3339) + if r.extWithheld(expired, now) { + t.Error("an expired marker must not withhold ext") + } + other := active + other.ExtUnsupportedEndpoint = "https://other.example/ingest" + if r.extWithheld(other, now) { + t.Error("a marker for another endpoint must not withhold ext") + } + garbled := active + garbled.ExtUnsupportedUntil = "not a time" + if r.extWithheld(garbled, now) { + t.Error("an unparsable marker must not withhold ext") + } } diff --git a/api/internal/telemetry/telemetry_pow_test.go b/api/internal/telemetry/telemetry_pow_test.go new file mode 100644 index 000000000..b881521b8 --- /dev/null +++ b/api/internal/telemetry/telemetry_pow_test.go @@ -0,0 +1,440 @@ +package telemetry + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + + "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/telemetryschema" +) + +// telKubeExt returns a fake Kubernetes client with all list kinds registered, +// needed for tests that use extended telemetry. +func telKubeExt(objs ...runtime.Object) *kube.Client { + gvkr := map[schema.GroupVersionResource]string{ + kube.GVRs["servers"]: "GameServerList", + kube.GVRs["templates"]: "GameTemplateList", + kube.GVRs["schedules"]: "BackupScheduleList", + kube.GVRCluster: "ClusterList", + kube.GVRModuleSource: "ModuleSourceList", + } + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), gvkr, objs...) + return &kube.Client{Dynamic: dyn} +} + +// Spec 022 US7 (T105, T106): before each POST the reporter asks the provider +// for a proof-of-work challenge, solves it and sends the solution; a provider +// that offers none gets reports without the header (research R21, FR-040). + +// powPosted is one POST the proof-of-work provider received. +type powPosted struct { + path string + header string // the proof-of-work header, "" when absent + signature string + body []byte + // token is the challenge the header named, and solved is whether the + // header carried a valid, unused solution of an issued challenge. + token string + solved bool +} + +// powProvider is a test telemetry provider that offers challenges. +type powProvider struct { + mu sync.Mutex + // bits is the difficulty of every challenge it issues. + bits int + // getStatus, when not 0, is the status of GET /v1/challenge with an empty + // body (404 means the provider offers no challenges). getBody, when not + // empty, is sent as the body of a 200 instead of a real challenge. + getStatus int + getBody string + // expiresIn is how long challenges live, counted from clock; expiresRaw, + // when not empty, replaces the expiresAt value. + clock *fakeClock + expiresIn time.Duration + expiresRaw string + // status picks the status of the n-th POST (1-based); nil means 204. + status func(n int, p powPosted) int + + issued map[string]bool // token to used + getPaths []string + getAuth []string + posts []powPosted +} + +// newPoWProvider starts a provider with the given difficulty on a fake clock. +func newPoWProvider(t *testing.T, clk *fakeClock, bits int) (*httptest.Server, *powProvider) { + t.Helper() + p := &powProvider{bits: bits, clock: clk, expiresIn: 15 * time.Minute, issued: map[string]bool{}} + srv := httptest.NewServer(http.HandlerFunc(p.serve)) + t.Cleanup(srv.Close) + return srv, p +} + +func (p *powProvider) serve(w http.ResponseWriter, r *http.Request) { + p.mu.Lock() + defer p.mu.Unlock() + if r.Method == http.MethodGet { + p.getPaths = append(p.getPaths, r.URL.Path) + p.getAuth = append(p.getAuth, r.Header.Get("Authorization")) + switch { + case p.getStatus != 0: + w.WriteHeader(p.getStatus) + case p.getBody != "": + _, _ = io.WriteString(w, p.getBody) + default: + token := "challenge-" + strconv.Itoa(len(p.issued)+1) + p.issued[token] = false + expires := p.clock.Now().Add(p.expiresIn).UTC().Format(time.RFC3339) + if p.expiresRaw != "" { + expires = p.expiresRaw + } + _ = json.NewEncoder(w).Encode(map[string]any{"challenge": token, "bits": p.bits, "expiresAt": expires}) + } + return + } + body, _ := io.ReadAll(r.Body) + rec := powPosted{ + path: r.URL.Path, header: r.Header.Get(telemetryschema.PoWHeader), + signature: r.Header.Get(telemetryschema.SignatureHeader), body: body, + } + if token, nonce, err := telemetryschema.ParsePoW(rec.header); err == nil { + rec.token = token + used, issued := p.issued[token] + rec.solved = issued && !used && telemetryschema.PoWOK(token, nonce, p.bits) + if issued { + p.issued[token] = true + } + } + p.posts = append(p.posts, rec) + status := http.StatusNoContent + if p.status != nil { + status = p.status(len(p.posts), rec) + } + w.WriteHeader(status) +} + +func (p *powProvider) postCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.posts) +} + +func (p *powProvider) post(i int) powPosted { + p.mu.Lock() + defer p.mu.Unlock() + return p.posts[i] +} + +func (p *powProvider) getCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.getPaths) +} + +func TestChallengeURL(t *testing.T) { + for endpoint, want := range map[string]string{ + "https://telemetry.example.com/ingest": "https://telemetry.example.com/v1/challenge", + "https://telemetry.example.com/prefix/ingest": "https://telemetry.example.com/prefix/v1/challenge", + "https://telemetry.example.com/a/b/c/ingest": "https://telemetry.example.com/a/b/c/v1/challenge", + "https://telemetry.example.com": "https://telemetry.example.com/v1/challenge", + "https://telemetry.example.com/": "https://telemetry.example.com/v1/challenge", + "https://telemetry.example.com/prefix/": "https://telemetry.example.com/prefix/v1/challenge", + "http://gameplane-telemetry-receiver:8080/ingest": "http://gameplane-telemetry-receiver:8080/v1/challenge", + "https://telemetry.example.com/ingest?x=1#frag": "https://telemetry.example.com/v1/challenge", + } { + got, err := challengeURL(endpoint) + if err != nil || got != want { + t.Errorf("challengeURL(%q) = %q, %v, want %q", endpoint, got, err, want) + } + } + if _, err := challengeURL("http://[::1"); err == nil { + t.Error("challengeURL of an unparsable endpoint must fail") + } +} + +func TestReporter_ProviderWithoutChallengesGetsReportsWithoutTheHeader(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 10) + prov.getStatus = http.StatusNotFound + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, clk) + + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if prov.postCount() != 1 || prov.post(0).header != "" { + t.Fatalf("posts = %d header %q, want one POST with no proof-of-work header", prov.postCount(), prov.post(0).header) + } + if prov.getCount() != 1 { + t.Fatalf("challenge requests = %d, want 1", prov.getCount()) + } + if st := mustState(t, store); st.LastOutcome != outcomeOK || st.ConsecutiveFailures != 0 { + t.Fatalf("state = %+v, want ok", st) + } +} + +func TestReporter_AnyNon200ChallengeAnswerMeansNoProofOfWork(t *testing.T) { + for name, set := range map[string]func(*powProvider){ + "server error": func(p *powProvider) { p.getStatus = http.StatusInternalServerError }, + "rate limited": func(p *powProvider) { p.getStatus = http.StatusTooManyRequests }, + "not JSON": func(p *powProvider) { p.getBody = "hello" }, + "no challenge": func(p *powProvider) { p.getBody = `{"bits":3}` }, + "truncated JSON": func(p *powProvider) { p.getBody = `{"challenge":"x"` }, + } { + t.Run(name, func(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 10) + set(prov) + r := testReporter(telStore(t, true), telKube(), srv.URL, clk) + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if prov.postCount() != 1 || prov.post(0).header != "" { + t.Fatalf("posts = %d header %q, want one POST with no header", prov.postCount(), prov.post(0).header) + } + }) + } +} + +func TestReporter_UnreachableChallengeEndpointFallsBackToThePost(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 10) + r := testReporter(telStore(t, true), telKube(), srv.URL, clk) + r.client = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return nil, errors.New("connection refused") + } + return http.DefaultTransport.RoundTrip(req) + })} + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if prov.postCount() != 1 || prov.post(0).header != "" { + t.Fatalf("posts = %d header %q, want one POST with no header", prov.postCount(), prov.post(0).header) + } +} + +// roundTripFunc adapts a function to http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestReporter_SolvesTheChallengeAndSendsAHeaderThatVerifies(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 10) + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL+"/prefix/ingest", clk) + r.auth = "Bearer secret" + + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if prov.postCount() != 1 { + t.Fatalf("posts = %d, want 1", prov.postCount()) + } + p := prov.post(0) + token, nonce, err := telemetryschema.ParsePoW(p.header) + if err != nil { + t.Fatalf("header %q does not parse: %v", p.header, err) + } + if !telemetryschema.PoWOK(token, nonce, 10) || !p.solved { + t.Fatalf("header %q is not a valid solution of a 10-bit challenge", p.header) + } + if p.path != "/prefix/ingest" || prov.getPaths[0] != "/prefix/v1/challenge" { + t.Fatalf("paths: POST %q, challenge GET %q, want /prefix/ingest and /prefix/v1/challenge", p.path, prov.getPaths[0]) + } + if prov.getAuth[0] != "" { + t.Fatalf("the challenge request carried Authorization %q, want none", prov.getAuth[0]) + } + if st := mustState(t, store); st.LastOutcome != outcomeOK || st.ConsecutiveFailures != 0 { + t.Fatalf("state = %+v, want ok", st) + } +} + +func TestReporter_ZeroBitChallengeStillSendsTheHeader(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 0) + prov.expiresRaw = "not a time" // an unreadable expiry means the default lifetime + r := testReporter(telStore(t, true), telKube(), srv.URL, clk) + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if p := prov.post(0); !p.solved || p.header != p.token+":0" { + t.Fatalf("header %q solved %v, want %q accepted", p.header, p.solved, p.token+":0") + } +} + +func TestReporter_428GetsOneNewChallengeAndOneResend(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 8) + prov.status = func(n int, _ powPosted) int { + if n == 1 { + return http.StatusPreconditionRequired + } + return http.StatusNoContent + } + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, clk) + + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if prov.postCount() != 2 || prov.getCount() != 2 { + t.Fatalf("posts = %d, challenge requests = %d, want 2 and 2", prov.postCount(), prov.getCount()) + } + first, second := prov.post(0), prov.post(1) + if first.token == second.token || !first.solved || !second.solved { + t.Fatalf("tokens %q then %q (solved %v, %v), want two different, valid solutions", first.token, second.token, first.solved, second.solved) + } + if string(first.body) != string(second.body) { + t.Fatal("the resend must carry the same report") + } + if st := mustState(t, store); st.LastOutcome != outcomeOK || st.ConsecutiveFailures != 0 { + t.Fatalf("state = %+v, want ok", st) + } +} + +func TestReporter_SecondProofOfWorkRefusalIsFailed(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 8) + prov.status = func(int, powPosted) int { return http.StatusPreconditionRequired } + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, clk) + + if err := sendNow(t, r); err == nil { + t.Fatal("two 428s in a row must fail the attempt") + } + if prov.postCount() != 2 || prov.getCount() != 2 { + t.Fatalf("posts = %d, challenge requests = %d, want exactly 2 and 2", prov.postCount(), prov.getCount()) + } + if st := mustState(t, store); st.LastOutcome != outcomeFailed || st.ConsecutiveFailures != 1 { + t.Fatalf("state = %+v, want failed with normal backoff", st) + } +} + +func TestReporter_ChallengeAboveTheCapIsFailedWithoutAPost(t *testing.T) { + for _, bits := range []int{telemetryschema.MaxPoWBits + 1, 99, -1} { + t.Run(strconv.Itoa(bits), func(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, bits) + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, clk) + + err := sendNow(t, r) + if !errors.Is(err, errPoWDifficulty) { + t.Fatalf("attempt error = %v, want errPoWDifficulty", err) + } + if prov.postCount() != 0 { + t.Fatalf("posts = %d, want none: the install must not solve or send", prov.postCount()) + } + if st := mustState(t, store); st.LastOutcome != outcomeFailed || st.ConsecutiveFailures != 1 { + t.Fatalf("state = %+v, want failed with normal backoff", st) + } + }) + } +} + +func TestReporter_ChallengeThatExpiresTooSoonIsFailedWithoutAPost(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 8) + prov.expiresIn = solveMargin // no time left once the margin is kept + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, clk) + + if err := sendNow(t, r); err == nil { + t.Fatal("a challenge with no solving time left must fail the attempt") + } + if prov.postCount() != 0 { + t.Fatalf("posts = %d, want none", prov.postCount()) + } + if st := mustState(t, store); st.LastOutcome != outcomeFailed { + t.Fatalf("state = %+v, want failed", st) + } +} + +func TestReporter_SolvingStopsWhenTheContextIsDone(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 8) + r := testReporter(telStore(t, true), telKube(), srv.URL, clk) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := r.solveChallenge(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("solveChallenge on a cancelled context = %v, want context.Canceled", err) + } + if prov.postCount() != 0 { + t.Fatalf("posts = %d, want none", prov.postCount()) + } +} + +func TestReporter_409RotationResendCarriesAFreshChallenge(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 8) + prov.status = func(n int, _ powPosted) int { + if n == 1 { + return http.StatusConflict + } + return http.StatusNoContent + } + store := telStoreExt(t, true, true) + r := testReporter(store, telKubeExt(), srv.URL, clk) + + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if prov.postCount() != 2 || prov.getCount() != 2 { + t.Fatalf("posts = %d, challenge requests = %d, want the 409 and one re-POST, each with its own challenge", prov.postCount(), prov.getCount()) + } + first, second := prov.post(0), prov.post(1) + if first.token == "" || second.token == "" || first.token == second.token { + t.Fatalf("tokens %q then %q, want two different challenges (they are single use)", first.token, second.token) + } + if !first.solved || !second.solved { + t.Fatalf("solved = %v, %v, want both valid", first.solved, second.solved) + } + if first.signature == "" || second.signature == "" || first.signature == second.signature { + t.Fatal("both POSTs must be signed, with different signatures after the rotation") + } + if st := mustState(t, store); st.LastOutcome != outcomeOK { + t.Fatalf("state = %+v, want ok", st) + } +} + +func TestReporter_400FallbackResendCarriesAFreshChallenge(t *testing.T) { + clk := newFakeClock() + srv, prov := newPoWProvider(t, clk, 8) + prov.status = func(n int, _ powPosted) int { + if n == 1 { + return http.StatusBadRequest + } + return http.StatusNoContent + } + r := testReporter(telStoreExt(t, true, true), telKubeExt(), srv.URL, clk) + + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if prov.postCount() != 2 || prov.getCount() != 2 { + t.Fatalf("posts = %d, challenge requests = %d, want 2 and 2", prov.postCount(), prov.getCount()) + } + first, second := prov.post(0), prov.post(1) + if first.token == second.token || !second.solved { + t.Fatalf("tokens %q then %q solved %v, want a fresh, valid challenge for the basic re-send", first.token, second.token, second.solved) + } + if second.signature != "" || strings.Contains(string(second.body), `"ext"`) { + t.Fatal("the fallback re-send must be basic only") + } +} diff --git a/api/internal/telemetry/telemetry_rotate_test.go b/api/internal/telemetry/telemetry_rotate_test.go new file mode 100644 index 000000000..bf9fcfb63 --- /dev/null +++ b/api/internal/telemetry/telemetry_rotate_test.go @@ -0,0 +1,184 @@ +package telemetry + +import ( + "bytes" + "database/sql" + "errors" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/GameplanePanel/gameplane/telemetryschema" +) + +// Spec 022 US7 (T084, T087): a 409 id_claimed makes the reporter replace its +// install ID and re-send in the same attempt; a 403 never rotates. + +// decodeSent decodes the body of one recorded POST. +func decodeSent(t *testing.T, s sentReport) telemetryschema.Report { + t.Helper() + rep, _, err := telemetryschema.Decode(s.body) + if err != nil || rep.Ext == nil { + t.Fatalf("decode sent body %q: ext=%v err=%v", s.body, rep.Ext != nil, err) + } + return rep +} + +func TestReporter_409RotatesTheIDAndResendsInTheSameAttempt(t *testing.T) { + srv, got := newSink(t) + var calls atomic.Int32 + got.setRespond(func(sentReport) int { + if calls.Add(1) == 1 { + return http.StatusConflict + } + return http.StatusNoContent + }) + clk := newFakeClock() + store := telStoreExt(t, true, true) + secretBefore, err := store.EnsureSigningSecret(t.Context()) + if err != nil { + t.Fatalf("ensure secret: %v", err) + } + r := testReporter(store, telKubeExt(), srv.URL, clk) + + if err := sendNow(t, r); err != nil { + t.Fatalf("attempt: %v", err) + } + if got.count() != 2 { + t.Fatalf("posts = %d, want the 409 and one re-POST in the same attempt", got.count()) + } + first, second := decodeSent(t, got.at(0)), decodeSent(t, got.at(1)) + if first.Ext.InstallID != testInstallID { + t.Fatalf("first POST id = %q, want the original %q", first.Ext.InstallID, testInstallID) + } + if second.Ext.InstallID == first.Ext.InstallID || second.Ext.Key == first.Ext.Key { + t.Fatalf("re-POST reused the id or key: %q / %q", second.Ext.InstallID, second.Ext.Key) + } + if got.at(1).signature == "" || got.at(1).signature == got.at(0).signature { + t.Fatal("re-POST must carry a new signature") + } + if err := telemetryschema.Verify(got.at(1).signature, second.Ext.Key, got.at(1).body); err != nil { + t.Fatalf("re-POST signature does not verify under its own key: %v", err) + } + priv, err := telemetryschema.DeriveKey(secretBefore, second.Ext.InstallID) + if err != nil { + t.Fatalf("derive key: %v", err) + } + if telemetryschema.PublicKeyString(priv) != second.Ext.Key { + t.Fatal("re-POST key is not derived from the kept signing secret and the new id") + } + + st := mustState(t, store) + if st.InstallID != second.Ext.InstallID { + t.Fatalf("stored id = %q, want the id that was re-sent (%q)", st.InstallID, second.Ext.InstallID) + } + if want := stamp(clk.Now()); st.LastIDRotationAt != want { + t.Fatalf("last_id_rotation_at = %q, want %q", st.LastIDRotationAt, want) + } + if st.LastOutcome != outcomeOK || st.ConsecutiveFailures != 0 { + t.Fatalf("outcome = %q failures = %d, want ok and none (SC-015)", st.LastOutcome, st.ConsecutiveFailures) + } + secretAfter, err := store.EnsureSigningSecret(t.Context()) + if err != nil || !bytes.Equal(secretAfter, secretBefore) { + t.Fatalf("signing secret changed across the rotation: %v", err) + } +} + +func TestReporter_SecondConflictIsFailedAndRotatesOnlyOnce(t *testing.T) { + srv, got := newSink(t) + got.setRespond(func(sentReport) int { return http.StatusConflict }) + clk := newFakeClock() + store := telStoreExt(t, true, true) + r := testReporter(store, telKubeExt(), srv.URL, clk) + + if err := sendNow(t, r); err == nil { + t.Fatal("two 409s in a row must fail the attempt") + } + if got.count() != 2 { + t.Fatalf("posts = %d, want exactly the 409 and one re-POST", got.count()) + } + first, second := decodeSent(t, got.at(0)), decodeSent(t, got.at(1)) + st := mustState(t, store) + if first.Ext.InstallID != testInstallID || st.InstallID != second.Ext.InstallID || st.InstallID == testInstallID { + t.Fatalf("ids: first %q second %q stored %q, want one rotation only", first.Ext.InstallID, second.Ext.InstallID, st.InstallID) + } + if st.LastOutcome != outcomeFailed || st.ConsecutiveFailures != 1 || st.LastIDRotationAt == "" { + t.Fatalf("state = %+v, want failed, one failure and the rotation recorded", st) + } +} + +func TestReporter_403NeverRotatesTheID(t *testing.T) { + srv, got := newSink(t) + got.setRespond(func(sentReport) int { return http.StatusForbidden }) + store := telStoreExt(t, true, true) + r := testReporter(store, telKubeExt(), srv.URL, newFakeClock()) + + if err := sendNow(t, r); err == nil { + t.Fatal("a 403 must fail the attempt") + } + if got.count() != 1 { + t.Fatalf("posts = %d, want one (no re-POST and no basic fallback)", got.count()) + } + st := mustState(t, store) + if st.InstallID != testInstallID || st.LastIDRotationAt != "" { + t.Fatalf("state = %+v, a 403 must keep the id and set no rotation", st) + } + if st.LastOutcome != outcomeFailed || st.ConsecutiveFailures != 1 { + t.Fatalf("outcome = %q failures = %d, want failed with normal backoff", st.LastOutcome, st.ConsecutiveFailures) + } +} + +func TestReporter_RotationFailsWhenExtendedWentOffDuringTheSend(t *testing.T) { + srv, got := newSink(t) + store := telStoreExt(t, true, true) + got.setRespond(func(sentReport) int { + // The admin turns extended off while the report is in flight. + if err := store.ClearInstallID(t.Context()); err != nil { + t.Errorf("clear install id: %v", err) + } + return http.StatusConflict + }) + r := testReporter(store, telKubeExt(), srv.URL, newFakeClock()) + + if err := sendNow(t, r); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("attempt error = %v, want the failed rotation (no ID to replace)", err) + } + if got.count() != 1 { + t.Fatalf("posts = %d, want no re-POST after a failed rotation", got.count()) + } + st := mustState(t, store) + if st.InstallID != "" || st.LastIDRotationAt != "" || st.LastOutcome != outcomeFailed { + t.Fatalf("state = %+v, want no ID resurrected and the attempt failed", st) + } +} + +func TestBuildReport_ExtendedFlagControlsTheExtPart(t *testing.T) { + store := telStoreExt(t, true, true) + deps := Deps{Kube: telKubeExt(), Store: store, Version: "v1.2.3"} + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) + + basic, err := BuildReport(t.Context(), deps, false, now) + if err != nil || basic.Ext != nil || basic.Version != "v1.2.3" { + t.Fatalf("basic = %+v, %v, want a report without ext", basic, err) + } + full, err := BuildReport(t.Context(), deps, true, now) + if err != nil || full.Ext == nil || !full.Ext.SentAt.Equal(now) || full.Ext.InstallID != testInstallID { + t.Fatalf("extended = %+v, %v, want ext stamped with now and the stored id", full, err) + } +} + +func TestExtWithheld_ExportedMatchesTheMarker(t *testing.T) { + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) + store := telStoreExt(t, true, true) + if err := store.SetExtUnsupported(t.Context(), stamp(now.Add(time.Hour)), "http://old"); err != nil { + t.Fatalf("set marker: %v", err) + } + st := mustState(t, store) + if !ExtWithheld(st, "http://old", now) { + t.Fatal("the marked endpoint must be withheld until the marker expires") + } + if ExtWithheld(st, "http://new", now) || ExtWithheld(st, "http://old", now.Add(2*time.Hour)) { + t.Fatal("another endpoint, or an expired marker, must not be withheld") + } +} diff --git a/api/internal/telemetry/telemetry_test.go b/api/internal/telemetry/telemetry_test.go index 519a37c18..227e66cdb 100644 --- a/api/internal/telemetry/telemetry_test.go +++ b/api/internal/telemetry/telemetry_test.go @@ -3,9 +3,11 @@ package telemetry import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "path/filepath" + "sync" "testing" "time" @@ -16,9 +18,20 @@ import ( "github.com/GameplanePanel/gameplane/api/internal/db" "github.com/GameplanePanel/gameplane/api/internal/kube" + "github.com/GameplanePanel/gameplane/telemetryschema" ) +// telStore returns a migrated file-backed store whose admin made an explicit +// choice: basic is sendMetrics and extended is off. Because the source is +// "admin", the report gate is open without the first-login notice. It +// replaces the fresh-install default Migrate seeds. func telStore(t *testing.T, sendMetrics bool) *db.Store { + t.Helper() + return telStoreExt(t, sendMetrics, false) +} + +// telStoreExt is telStore with an explicit extended choice. +func telStoreExt(t *testing.T, sendMetrics, extended bool) *db.Store { t.Helper() store, err := db.Open(context.Background(), "sqlite", "file:"+filepath.Join(t.TempDir(), "t.db")) if err != nil { @@ -28,13 +41,37 @@ func telStore(t *testing.T, sendMetrics bool) *db.Store { if err := store.Migrate(context.Background()); err != nil { t.Fatalf("migrate: %v", err) } - val, _ := json.Marshal(map[string]bool{"sendMetrics": sendMetrics}) + val, _ := json.Marshal(map[string]bool{"sendMetrics": sendMetrics, "extended": extended}) if _, err := store.DB.ExecContext(context.Background(), - `INSERT INTO config(key, value, updated_at) VALUES ('telemetry', ?, ?)`, + `INSERT INTO config(key, value, updated_at) VALUES ('telemetry', ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, string(val), "2026-01-01T00:00:00Z", ); err != nil { t.Fatalf("seed config: %v", err) } + if _, err := store.DB.ExecContext(context.Background(), + `UPDATE telemetry_state SET consent_source = 'admin'`); err != nil { + t.Fatalf("seed consent source: %v", err) + } + if extended { + if err := store.SetInstallID(context.Background(), testInstallID); err != nil { + t.Fatalf("seed install id: %v", err) + } + } + return store +} + +// freshStore is a migrated file-backed store exactly as a fresh install +// leaves it: consent_source "default", basic and extended on, no notice yet. +func freshStore(t *testing.T) *db.Store { + t.Helper() + store, err := db.Open(context.Background(), "sqlite", "file:"+filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + if err := store.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } return store } @@ -58,58 +95,628 @@ func unstr(kind, name, ns string) *unstructured.Unstructured { return o } -func TestReportOnce_EnabledPostsAnonymousCounts(t *testing.T) { - var got payload +// fakeClock is a settable clock shared by a Reporter and its test. +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func newFakeClock() *fakeClock { + return &fakeClock{t: time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC)} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +// sentReport is one POST the test server received. +type sentReport struct { + body []byte + auth string + signature string + contentType string +} + +// hasExt reports whether the body carries the extended part. +func (s sentReport) hasExt() bool { + rep, _, err := telemetryschema.Decode(s.body) + return err == nil && rep.Ext != nil +} + +// sink is a test telemetry endpoint that records every POST. respond, when +// set, picks the status for a request (default 200). +type sink struct { + mu sync.Mutex + reqs []sentReport + respond func(sentReport) int +} + +func newSink(t *testing.T) (*httptest.Server, *sink) { + t.Helper() + s := &sink{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewDecoder(r.Body).Decode(&got) - w.WriteHeader(http.StatusOK) + if r.Method == http.MethodGet { + // The reporter asks for a proof-of-work challenge before each POST + // (spec 022 T105); this provider offers none. + http.NotFound(w, r) + return + } + body, _ := io.ReadAll(r.Body) + rec := sentReport{ + body: body, + auth: r.Header.Get("Authorization"), + signature: r.Header.Get(telemetryschema.SignatureHeader), + contentType: r.Header.Get("Content-Type"), + } + s.mu.Lock() + s.reqs = append(s.reqs, rec) + respond := s.respond + s.mu.Unlock() + status := http.StatusOK + if respond != nil { + status = respond(rec) + } + w.WriteHeader(status) })) - defer srv.Close() + t.Cleanup(srv.Close) + return srv, s +} + +func (s *sink) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.reqs) +} + +func (s *sink) at(i int) sentReport { + s.mu.Lock() + defer s.mu.Unlock() + return s.reqs[i] +} + +func (s *sink) setRespond(f func(sentReport) int) { + s.mu.Lock() + defer s.mu.Unlock() + s.respond = f +} + +// testReporter builds a Reporter on a custom destination at url with a 24h +// interval, the fake clock and zero jitter (every first slot is due at once). +func testReporter(store *db.Store, k *kube.Client, url string, clk *fakeClock) *Reporter { + return New(Config{ + Dest: Destination{Kind: KindCustom, URL: url, Host: "test"}, + Interval: 24 * time.Hour, + Deps: Deps{Kube: k, Store: store, Version: "v1.2.3"}, + Now: clk.Now, + Rand: func(int64) int64 { return 0 }, + }) +} + +// sendNow opens the schedule (the first Tick) and sends the report that is +// then due (the second Tick), returning the second Tick's error. +func sendNow(t *testing.T, r *Reporter) error { + t.Helper() + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("opening tick: %v", err) + } + return r.Tick(t.Context()) +} + +func mustState(t *testing.T, store *db.Store) db.TelemetryState { + t.Helper() + st, err := store.GetTelemetryState(t.Context()) + if err != nil { + t.Fatalf("get state: %v", err) + } + return st +} + +func mustParse(t *testing.T, ts string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, ts) + if err != nil { + t.Fatalf("parse %q: %v", ts, err) + } + return v +} +func TestReportOnce_EnabledPostsAnonymousCounts(t *testing.T) { + srv, got := newSink(t) k := telKube( unstr("GameServer", "a", "gameplane-games"), unstr("GameServer", "b", "gameplane-games"), unstr("GameTemplate", "minecraft", ""), ) - r := New(telStore(t, true), k, srv.URL, "", "v1.2.3", time.Hour) - if err := r.reportOnce(context.Background()); err != nil { - t.Fatalf("reportOnce: %v", err) + r := testReporter(telStore(t, true), k, srv.URL, newFakeClock()) + if err := sendNow(t, r); err != nil { + t.Fatalf("tick: %v", err) + } + if got.count() != 1 { + t.Fatalf("posts = %d, want 1", got.count()) + } + rec := got.at(0) + rep, _, err := telemetryschema.Decode(rec.body) + if err != nil { + t.Fatalf("decode body %s: %v", rec.body, err) + } + if rep.Version != "v1.2.3" || rep.Servers != 2 || rep.Templates != 1 || rep.Ext != nil { + t.Fatalf("report = %+v, want a basic {v1.2.3 2 1}", rep) + } + if rec.contentType != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", rec.contentType) } - if got.Version != "v1.2.3" || got.Servers != 2 || got.Templates != 1 { - t.Fatalf("payload = %+v, want {v1.2.3 2 1}", got) + if rec.signature != "" { + t.Fatalf("a basic report must not carry the signature header, got %q", rec.signature) + } + st := mustState(t, r.store) + if st.LastOutcome != "ok" || st.LastSuccessAt == "" || st.ConsecutiveFailures != 0 { + t.Fatalf("state after success = %+v", st) } } func TestReportOnce_SendsAuthHeader(t *testing.T) { - var gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - r := New(telStore(t, true), telKube(), srv.URL, "Bearer ingest-tok", "v1", time.Hour) - if err := r.reportOnce(context.Background()); err != nil { - t.Fatalf("reportOnce: %v", err) + srv, got := newSink(t) + r := testReporter(telStore(t, true), telKube(), srv.URL, newFakeClock()) + r.auth = "Bearer ingest-tok" + if err := sendNow(t, r); err != nil { + t.Fatalf("tick: %v", err) } - if gotAuth != "Bearer ingest-tok" { - t.Fatalf("Authorization = %q, want the configured token", gotAuth) + if got.count() != 1 || got.at(0).auth != "Bearer ingest-tok" { + t.Fatalf("posts = %d, Authorization = %q; want 1 post with the configured token", got.count(), got.at(0).auth) } } func TestReportOnce_DisabledSkipsPost(t *testing.T) { - hit := false - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hit = true - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - r := New(telStore(t, false), telKube(), srv.URL, "", "v1", time.Hour) - if err := r.reportOnce(context.Background()); err != nil { - t.Fatalf("reportOnce: %v", err) + srv, got := newSink(t) + clk := newFakeClock() + r := testReporter(telStore(t, false), telKube(), srv.URL, clk) + for range 3 { + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("tick: %v", err) + } + clk.Advance(48 * time.Hour) } - if hit { + if got.count() != 0 { t.Fatal("telemetry must not POST when sendMetrics is off") } + if st := mustState(t, r.store); st.NextDueAt != "" { + t.Fatalf("next_due_at = %q, want it to stay NULL while the gate is closed", st.NextDueAt) + } +} + +func TestReporter_NoPostWhileDefaultAndNoticeUnseen(t *testing.T) { + srv, got := newSink(t) + clk := newFakeClock() + store := freshStore(t) // consent_source default, sendMetrics and extended on + r := testReporter(store, telKube(), srv.URL, clk) + for range 5 { + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("tick: %v", err) + } + clk.Advance(30 * time.Hour) + } + if got.count() != 0 { + t.Fatalf("posts = %d, want 0 before any admin was shown the notice (FR-004)", got.count()) + } + st := mustState(t, store) + if st.NextDueAt != "" || st.InstallID != "" { + t.Fatalf("state = %+v, want no slot and no install id while the gate is closed", st) + } +} + +func TestReporter_FirstPostWithin15MinutesOfNoticeShown(t *testing.T) { + srv, got := newSink(t) + clk := newFakeClock() + store := freshStore(t) + k := collectKubeClients(t, collectOpts{}) + r := testReporter(store, k, srv.URL, clk) + r.rnd = func(n int64) int64 { return n - 1 } // the latest slot the window allows + + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("tick before the notice: %v", err) + } + shown := clk.Now() + if err := store.MarkNoticeShown(t.Context(), shown.Format(time.RFC3339)); err != nil { + t.Fatalf("mark notice shown: %v", err) + } + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("tick opening the gate: %v", err) + } + due := mustParse(t, mustState(t, store).NextDueAt) + if wait := due.Sub(shown); wait < 0 || wait > 15*time.Minute { + t.Fatalf("first slot is %v after the notice, want within 15m", wait) + } + clk.Advance(14 * time.Minute) + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("early tick: %v", err) + } + if got.count() != 0 { + t.Fatalf("posts = %d before the slot, want 0", got.count()) + } + clk.Advance(2 * time.Minute) + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("due tick: %v", err) + } + if got.count() != 1 { + t.Fatalf("posts = %d at the slot, want 1", got.count()) + } + + // A fresh install reports basic plus extended, signed with the key the + // report names, under the install ID the reporter created. + rec := got.at(0) + rep, _, err := telemetryschema.Decode(rec.body) + if err != nil || rep.Ext == nil { + t.Fatalf("decode %s: ext=%v err=%v, want an extended report", rec.body, rep.Ext, err) + } + if err := telemetryschema.Verify(rec.signature, rep.Ext.Key, rec.body); err != nil { + t.Fatalf("signature does not verify: %v", err) + } + if st := mustState(t, store); st.InstallID == "" || st.InstallID != rep.Ext.InstallID { + t.Fatalf("stored install id %q, report carries %q", st.InstallID, rep.Ext.InstallID) + } +} + +func TestReporter_ExtendedOffSendsBasicOnly(t *testing.T) { + srv, got := newSink(t) + r := testReporter(telStoreExt(t, true, false), collectKubeClients(t, collectOpts{}), srv.URL, newFakeClock()) + if err := sendNow(t, r); err != nil { + t.Fatalf("tick: %v", err) + } + if got.count() != 1 || got.at(0).hasExt() || got.at(0).signature != "" { + t.Fatalf("want one basic report without a signature, got %d posts", got.count()) + } +} + +func TestReporter_SpacingAtLeastInterval(t *testing.T) { + srv, got := newSink(t) + clk := newFakeClock() + r := testReporter(telStore(t, true), telKube(), srv.URL, clk) + if err := sendNow(t, r); err != nil { + t.Fatalf("first report: %v", err) + } + sentAt := clk.Now() + if due := mustParse(t, mustState(t, r.store).NextDueAt); due.Sub(sentAt) < 24*time.Hour { + t.Fatalf("next slot is %v after the send, want at least the 24h interval", due.Sub(sentAt)) + } + clk.Advance(24*time.Hour - time.Second) + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("tick: %v", err) + } + if got.count() != 1 { + t.Fatalf("posts = %d just under one interval later, want still 1", got.count()) + } + clk.Advance(time.Second) + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("tick: %v", err) + } + if got.count() != 2 { + t.Fatalf("posts = %d one interval later, want 2", got.count()) + } +} + +func TestReporter_RestartDoesNotSendEarly(t *testing.T) { + srv, got := newSink(t) + clk := newFakeClock() + store := telStore(t, true) + if err := sendNow(t, testReporter(store, telKube(), srv.URL, clk)); err != nil { + t.Fatalf("first report: %v", err) + } + for _, after := range []time.Duration{time.Minute, 6 * time.Hour} { + clk.Advance(after) + restarted := testReporter(store, telKube(), srv.URL, clk) // a new process, same database + if err := restarted.Tick(t.Context()); err != nil { + t.Fatalf("tick after restart: %v", err) + } + } + if got.count() != 1 { + t.Fatalf("posts = %d after restarts inside the interval, want 1", got.count()) + } + clk.Advance(24 * time.Hour) + if err := testReporter(store, telKube(), srv.URL, clk).Tick(t.Context()); err != nil { + t.Fatalf("tick after the interval: %v", err) + } + if got.count() != 2 { + t.Fatalf("posts = %d once the interval passed, want 2", got.count()) + } +} + +func TestReporter_FallsBackToBasicOn400ThenWaitsSevenDays(t *testing.T) { + srv, got := newSink(t) + got.setRespond(func(rec sentReport) int { + if rec.hasExt() { + return http.StatusBadRequest // an old receiver rejects the unknown key + } + return http.StatusOK + }) + clk := newFakeClock() + store := telStoreExt(t, true, true) + r := testReporter(store, collectKubeClients(t, collectOpts{}), srv.URL, clk) + start := clk.Now() + + // Day 0: the extended report is refused, the basic one accepted. + if err := sendNow(t, r); err != nil { + t.Fatalf("day 0: %v", err) + } + if got.count() != 2 || !got.at(0).hasExt() || got.at(0).signature == "" { + t.Fatalf("day 0: want an extended signed POST first, got %d posts", got.count()) + } + if got.at(1).hasExt() || got.at(1).signature != "" { + t.Fatal("day 0: the retry must be basic only and unsigned") + } + st := mustState(t, store) + if st.LastOutcome != "ok" { + t.Fatalf("outcome = %q, want ok (the basic report was accepted)", st.LastOutcome) + } + if want := start.Add(7 * 24 * time.Hour).Format(time.RFC3339); st.ExtUnsupportedUntil != want || st.ExtUnsupportedEndpoint != srv.URL { + t.Fatalf("fallback marker = %q for %q, want %q for %q", st.ExtUnsupportedUntil, st.ExtUnsupportedEndpoint, want, srv.URL) + } + + // Days 1 to 6: one basic POST per day, no extended attempt. + for day := 1; day <= 6; day++ { + clk.Advance(24 * time.Hour) + before := got.count() + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("day %d: %v", day, err) + } + if got.count() != before+1 || got.at(before).hasExt() { + t.Fatalf("day %d: want exactly one basic POST", day) + } + } + + // Day 7: the marker has expired, so the extended part is tried again. + clk.Advance(24 * time.Hour) + before := got.count() + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("day 7: %v", err) + } + if got.count() != before+2 || !got.at(before).hasExt() || got.at(before+1).hasExt() { + t.Fatalf("day 7: want an extended attempt then a basic retry, got %d new posts", got.count()-before) + } +} + +func TestReporter_FallbackMarkerIsPerEndpoint(t *testing.T) { + oldSrv, oldGot := newSink(t) + oldGot.setRespond(func(rec sentReport) int { + if rec.hasExt() { + return http.StatusBadRequest + } + return http.StatusOK + }) + newSrv, newGot := newSink(t) + clk := newFakeClock() + store := telStoreExt(t, true, true) + k := collectKubeClients(t, collectOpts{}) + if err := sendNow(t, testReporter(store, k, oldSrv.URL, clk)); err != nil { + t.Fatalf("old endpoint: %v", err) + } + + // The operator points the install at another endpoint: the marker for the + // old one doesn't apply, so the extended part goes out again. + clk.Advance(24 * time.Hour) + if err := testReporter(store, k, newSrv.URL, clk).Tick(t.Context()); err != nil { + t.Fatalf("new endpoint: %v", err) + } + if newGot.count() != 1 || !newGot.at(0).hasExt() { + t.Fatalf("new endpoint got %d posts, want one extended report", newGot.count()) + } +} + +func TestReporter_BackoffGrowsAndCaps(t *testing.T) { + srv, got := newSink(t) + got.setRespond(func(sentReport) int { return http.StatusInternalServerError }) + clk := newFakeClock() + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, clk) + if err := r.Tick(t.Context()); err != nil { // opens the first slot + t.Fatalf("opening tick: %v", err) + } + + want := []time.Duration{time.Hour, 2 * time.Hour, 4 * time.Hour, 8 * time.Hour, 16 * time.Hour, 24 * time.Hour, 24 * time.Hour} + for i, wait := range want { + if err := r.Tick(t.Context()); err == nil { + t.Fatalf("failure %d: want the send error from Tick", i+1) + } + st := mustState(t, store) + if st.LastOutcome != "failed" || st.ConsecutiveFailures != i+1 { + t.Fatalf("failure %d: state = %+v", i+1, st) + } + if backoff := mustParse(t, st.NextDueAt).Sub(clk.Now()); backoff != wait { + t.Fatalf("failure %d: backoff = %v, want %v", i+1, backoff, wait) + } + // Nothing is sent before the backed-off slot, and nothing is queued. + sent := got.count() + clk.Advance(wait - time.Second) + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("failure %d: early tick: %v", i+1, err) + } + if got.count() != sent { + t.Fatalf("failure %d: sent before the backed-off slot", i+1) + } + clk.Advance(time.Second) + } + + // Recovery resets the counter and schedules a full interval ahead. + got.setRespond(nil) + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("recovery tick: %v", err) + } + st := mustState(t, store) + if st.LastOutcome != "ok" || st.ConsecutiveFailures != 0 { + t.Fatalf("after recovery: %+v", st) + } + if next := mustParse(t, st.NextDueAt).Sub(clk.Now()); next < 24*time.Hour { + t.Fatalf("next slot after recovery is %v away, want at least 24h", next) + } +} + +func TestReporter_TwoReportersShareOneSlot(t *testing.T) { + srv, got := newSink(t) + clk := newFakeClock() + store := telStore(t, true) + a := testReporter(store, telKube(), srv.URL, clk) + b := testReporter(store, telKube(), srv.URL, clk) + if err := a.Tick(t.Context()); err != nil { // opens the slot, due now + t.Fatalf("opening tick: %v", err) + } + + var wg sync.WaitGroup + for _, r := range []*Reporter{a, b} { + wg.Add(1) + go func() { + defer wg.Done() + if err := r.Tick(t.Context()); err != nil { + t.Errorf("tick: %v", err) + } + }() + } + wg.Wait() + if got.count() != 1 { + t.Fatalf("posts = %d from two reporters on one database, want exactly 1", got.count()) + } +} + +func TestReporter_ClaimLostMeansNoSend(t *testing.T) { + srv, got := newSink(t) + clk := newFakeClock() + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, clk) + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("opening tick: %v", err) + } + st := mustState(t, store) + // Another replica claims the slot between this one's read and its claim. + if _, err := store.ClaimTelemetryDue(t.Context(), st.NextDueAt, "2026-10-07T09:00:00Z", "2026-10-06T09:00:00Z"); err != nil { + t.Fatalf("rival claim: %v", err) + } + if err := r.attempt(t.Context(), st, Consent{Basic: true}, clk.Now()); err != nil { + t.Fatalf("attempt: %v", err) + } + if got.count() != 0 { + t.Fatalf("posts = %d after losing the claim, want 0", got.count()) + } +} + +func TestReporter_GateClosingClearsTheSlot(t *testing.T) { + srv, got := newSink(t) + clk := newFakeClock() + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, clk) + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("opening tick: %v", err) + } + if mustState(t, store).NextDueAt == "" { + t.Fatal("the first tick should have opened the schedule") + } + if _, err := store.DB.ExecContext(t.Context(), + `UPDATE config SET value = '{"sendMetrics":false,"extended":false}' WHERE key = 'telemetry'`); err != nil { + t.Fatalf("turn off: %v", err) + } + if err := r.Tick(t.Context()); err != nil { + t.Fatalf("closing tick: %v", err) + } + if got.count() != 0 || mustState(t, store).NextDueAt != "" { + t.Fatalf("posts = %d, slot = %q; want none and a cleared slot", got.count(), mustState(t, store).NextDueAt) + } +} + +func TestReporter_NoDestinationNeverSends(t *testing.T) { + srv, got := newSink(t) + for _, kind := range []string{KindNone, KindDisabled} { + r := testReporter(telStore(t, true), telKube(), srv.URL, newFakeClock()) + r.dest = Destination{Kind: kind} + if err := sendNow(t, r); err != nil { + t.Fatalf("%s: %v", kind, err) + } + r.Run(t.Context()) // returns at once, without a ticker + } + if got.count() != 0 { + t.Fatalf("posts = %d with no destination, want 0", got.count()) + } +} + +func TestReporter_RunTicksThenStopsOnCancel(t *testing.T) { + srv, got := newSink(t) + store := telStore(t, true) + r := testReporter(store, telKube(), srv.URL, newFakeClock()) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan struct{}) + go func() { + r.Run(ctx) + close(done) + }() + + // Run's first tick opens the schedule without waiting for the poll timer. + deadline := time.Now().Add(5 * time.Second) + for mustState(t, store).NextDueAt == "" { + if time.Now().After(deadline) { + t.Fatal("Run did not evaluate the schedule on start") + } + time.Sleep(10 * time.Millisecond) + } + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Run did not return after its context was cancelled") + } + if got.count() != 0 { + t.Fatalf("posts = %d, want 0 (the opening tick only schedules)", got.count()) + } +} + +func TestReporter_SendFailuresAreRecorded(t *testing.T) { + cases := map[string]string{ + "unreachable": "http://127.0.0.1:1/ingest", + "bad url": "http://%zz", + } + for name, url := range cases { + t.Run(name, func(t *testing.T) { + store := telStore(t, true) + r := testReporter(store, telKube(), url, newFakeClock()) + if err := sendNow(t, r); err == nil { + t.Fatal("want an error") + } + if st := mustState(t, store); st.LastOutcome != "failed" || st.ConsecutiveFailures != 1 { + t.Fatalf("state = %+v, want a recorded failure", st) + } + }) + } +} + +func TestReporter_CollectFailureIsRecorded(t *testing.T) { + srv, got := newSink(t) + store := telStoreExt(t, true, true) + if _, err := store.DB.ExecContext(t.Context(), `UPDATE telemetry_state SET signing_secret = '!!not base64!!'`); err != nil { + t.Fatalf("corrupt secret: %v", err) + } + r := testReporter(store, collectKubeClients(t, collectOpts{}), srv.URL, newFakeClock()) + if err := sendNow(t, r); err == nil { + t.Fatal("want the collection error") + } + if got.count() != 0 { + t.Fatalf("posts = %d, want none when the report can't be built", got.count()) + } + if st := mustState(t, store); st.LastOutcome != "failed" { + t.Fatalf("outcome = %q, want failed", st.LastOutcome) + } +} + +func TestReporter_TickErrorsOnClosedStore(t *testing.T) { + store := telStore(t, true) + r := testReporter(store, telKube(), "http://127.0.0.1:1", newFakeClock()) + if err := store.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if err := r.Tick(t.Context()); err == nil { + t.Fatal("want an error from a closed database") + } } diff --git a/api/specs.md b/api/specs.md index 3603f55ce..b3ce7ca86 100644 --- a/api/specs.md +++ b/api/specs.md @@ -18,7 +18,7 @@ The api module is the REST + WebSocket gateway for Gameplane, serving the web da - **Module registries:** pluggable providers (CurseForge, Modrinth, Spigot, Hangar, Nexus, Steam, Thunderstore, Factorio, GitHub, UMod); live lookup + caching - **Multi-cluster dispatch:** `?cluster=` selector routes requests to remote clusters via registered kubeconfigs; home cluster is `local` - **Unified reads:** `/fleet/*` aggregates only the caller's authorized resources across registered clusters, with explicit target identities, target permissions, partial failures and bounded work; it does not schedule or mutate resources. -- **Telemetry:** opt-in anonymous daily usage metrics (version, server count, template count) POSTed to admin-configured endpoint +- **Telemetry:** daily usage reports POSTed to the resolved destination (operator hard-off, bundled receiver, custom endpoint, or the project's default receiver). Two admin-consented tiers: basic (`version`, `servers`, `templates`) and extended (random install ID, environment categories, game and feature counts, signing key, send time; reports are signed). New installs share both tiers by default once the first-login notice has been shown to an admin; nothing is sent before then. The schedule and consent source live in `telemetry_state` (migration 015), so restarts never send early and replicas share one schedule. When the destination offers proof-of-work challenges (`GET /v1/challenge`), the reporter solves one per request (a challenge above 26 bits is refused) ## Non-goals / boundaries @@ -58,7 +58,7 @@ api/ - **handlers:** 23+ route groups (Audit, AuthProviderSecrets, Capture, Cluster, ClusterActions, Clusters, Config, Destinations, Events, Lifecycle, ModIDs, ModSources, Modules, ModUpdates, Notifications, Ownership, PodEvents, Registry, RegistrySecrets, Resources, Roles, SystemLogs, Users, WebSocket Mount) - **auth:** SessionStore (CSRF + expiry), Local (argon2id password check), OIDC (provider registry + claim mapping), Registry (auth provider discovery per request) - **rbac:** Middleware (namespace/cluster-scoped permission check + owner/collaborator fallback), rule table (method/path -> permission), catalog (permission definitions) -- **db:** driver-selectable (modernc.org/sqlite or pgx/v5 via postgres build tag), migrations (001-012 per dialect in `migrations/sqlite/` and `migrations/postgres/`; 013+ shared and portable in `migrations/common/`), Store (query interface); queries use `?` placeholders, rebound to `$n` by the Postgres connection (`db.Rebind`) +- **db:** driver-selectable (modernc.org/sqlite or pgx/v5 via postgres build tag), migrations (001-012 per dialect in `migrations/sqlite/` and `migrations/postgres/`; 013+ shared and portable in `migrations/common/`), Store (query interface); queries use `?` placeholders, rebound to `$n` by the Postgres connection (`db.Rebind`); migration 015 (`migrations/common/015_telemetry_state.sql`) adds `telemetry_state` (singleton row: consent source, install ID, notice shown time, report schedule `next_due_at`/`last_attempt_at`/`last_success_at`, `last_outcome`, `consecutive_failures`, extended-fallback fields `ext_unsupported_until`/`ext_unsupported_endpoint`, `signing_secret`, `last_id_rotation_at`; seeded in Go by `Store.Migrate` from the existing `telemetry` config value) and `telemetry_notice_acks` (one row per admin who dismissed the notice: `user_id`, `acked_at`, `action`; rows are removed in Go when the user is deleted) - **kube:** Client (K8s API wrapper), Registry (per-cluster clients from Cluster CRDs), watch (cluster-config sync) - **audit:** Auditor (insert to DB + distribute to sinks), webhook sink (POST JSON to URL), S3 sink (object storage), hash-chain (detect tampering) - **notify:** Notifier (watch GameServer/Backup/Restore status, format + deliver to sinks), sinks (Discord, Slack, SMTP, webhook) @@ -66,7 +66,7 @@ api/ - **registry:** provider types (each implements Search, Details, Manifest, Download), Set (versioned provider pool with key fallback) - **scope:** ResolveNamespace (extract from path or default), ResolveCluster (validate `?cluster=` against registry) - **httperr:** classify error type to safe HTTP status + message; preserve full error server-side -- **telemetry:** daily metrics reporter (version, server/template counts) +- **telemetry:** destination resolution (`ResolveDestination`), consent, report collection and preview (`BuildReport`), the scheduled reporter (Ed25519 signing, proof-of-work solving, install-ID rotation on `409 id_claimed`) ## External interface / contracts @@ -78,8 +78,8 @@ Two subcommands: - **Core flags:** `--addr`, `--metrics-addr`, `--db-driver`, `--db-dsn`, `--log-level` - **OIDC flags** (install-time, Helm-seeded): `--oidc-issuer`, `--oidc-client-id`, `--oidc-client-secret`, `--oidc-redirect-url`, `--oidc-display-name` (login button label, no hostname — pre-auth surface), `--oidc-groups-claim` (configurable claim name, defaults to "groups"), `--oidc-default-role` (default for unmapped users: "", "viewer", "operator", "admin", or "deny"), `--oidc-role-mapping-admin` (comma-separated group names for admin role), `--oidc-role-mapping-operator`, `--oidc-role-mapping-viewer`. All have `GAMEPLANE_OIDC_*` env fallbacks (preferred over flags for credentials). - **Storage class (report-only):** `--game-data-storage-class` — echoed in `GET /admin/config`'s `installTimeSettings.gameDataStorageClass`, read-only, unaffected by overrides. - - **Other flags:** `--audit-*`, `--agent-*`, `--namespace`, `--cluster-ops`, `--cluster-external-address` (node-routable API server address used in the join command and downloaded kubeconfig instead of the in-cluster ClusterIP; empty = in-cluster address), `--update-channel`, `--curseforge-api-key`, `--telemetry-*`, `--capture-enabled`, `--capture-default-max-duration` (only these two are CLI flags; default/max retention and default max size are `GAMEPLANE_CAPTURE_*`-env-only, no flag) - - Env overrides via GAMEPLANE_* vars (credentials come from env only, never flags) + - **Other flags:** `--audit-*`, `--agent-*`, `--namespace`, `--cluster-ops`, `--cluster-external-address` (node-routable API server address used in the join command and downloaded kubeconfig instead of the in-cluster ClusterIP; empty = in-cluster address), `--update-channel`, `--curseforge-api-key`, `--telemetry-endpoint` (empty = the project's default receiver), `--telemetry-disabled` (hard off, whatever the admin toggles say), `--telemetry-interval` (default 24h, minimum 1m), `--official-module-source` (name of the official ModuleSource, so telemetry can tell official modules from custom ones; empty = every module counts as custom), `--capture-enabled`, `--capture-default-max-duration` (only these two are CLI flags; default/max retention and default max size are `GAMEPLANE_CAPTURE_*`-env-only, no flag) + - Env overrides via GAMEPLANE_* vars (credentials come from env only, never flags); telemetry also reads env-only `GAMEPLANE_TELEMETRY_AUTH` (ingest token sent verbatim as the Authorization header) and `GAMEPLANE_TELEMETRY_BUNDLED` (set only by the chart when it wires the bundled receiver) - Initialize: database + migrations, K8s client, auth (local + OIDC with Helm-seeded provider synthesis), audit (sinks), notifier, cluster watch, telemetry, session GC, capture config - Routes all mounted at startup; chi router with security middleware (secure headers, body limit, audit, session auth, RBAC, rate limiting); MountCapture wires capture endpoints @@ -171,6 +171,10 @@ The HTTP server listens on `:8000` (configurable) with these route groups. Prome - `/admin/audit/export` — GET: streams the full matching audit trail as a download, `?format=csv` (default) or `?format=json`; optional filters `since`/`until` (RFC3339 timestamps, inclusive), `actor` (case-insensitive substring), `method` (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`), and `status` (class `2xx`/`4xx`/`5xx`); an unrecognised `format`, `method`, or `status` value returns 400. CSV header, in column order: `id, ts, actor, method, path, target, status, ip, reason`. JSON is a top-level array of the same `audit.Event` objects `/admin/audit` returns. - `/admin/config` — GET/PATCH: global settings (OIDC, notifications, telemetry, module upload limits, etc.) - **instanceName field:** The `instanceName` field on `/admin/config` is optional; when omitted or empty, notifications fall back to "Gameplane" as the instance identifier. +- `/admin/telemetry` — GET (`config:read`): destination (`kind` default|custom|bundled|disabled|none, `host` only, null for disabled/none), `operatorDisabled`, stored `consent` (`basic`, `extended`, `source`), `installId` (null while extended is off), `preview` (the exact report the reporter would send now, built by `telemetry.BuildReport`; null when basic is off or nothing would be sent) and delivery `status` (`lastAttemptAt`, `lastSuccessAt`, `lastOutcome` never|ok|failed, `lastIdRotationAt`). The signing secret and failure details are never returned +- `/admin/telemetry/install-id` — POST (`config:manage`): replace the install ID, keep the signing secret and schedule; returns `{"installId"}`; 409 when extended is off or the operator disabled telemetry +- `/admin/telemetry/notice` — GET (`config:read`; returns `{"pending":false}` unless the caller also holds `config:manage`): whether the first-login notice is pending for the caller, with destination host and the exact basic/extended field lists; POST (`config:manage`) `{"action": "seen"|"keep"|"extended-off"|"all-off"}` records the caller's dismissal (`seen` only sets the notice-shown time and opens the report schedule, recording no ack), 204 on success, 409 when not pending (except `seen`) or the operator disabled telemetry. RBAC entries sit before the `/admin/*` fallback in `rbac.go` +- The telemetry consent itself is saved through `PUT /admin/config/telemetry` (`{"sendMetrics": bool, "extended": bool}`; `sendMetrics:false` forces `extended:false`; 409 when the operator disabled telemetry). The save runs in one transaction with the consent source, install ID and schedule updates - `/admin/notifications` — PATCH config + test-send to sinks - `/admin/auth` — PATCH identity-provider secrets - `/admin/registries/{provider}/secret` — PATCH mod-registry API keys diff --git a/charts/gameplane/templates/NOTES.txt b/charts/gameplane/templates/NOTES.txt new file mode 100644 index 000000000..05742eea5 --- /dev/null +++ b/charts/gameplane/templates/NOTES.txt @@ -0,0 +1,26 @@ +{{- /* + Spec 022 install notes. Both warnings read the receiver keys through + guards (F-214): after `helm upgrade --reuse-values` from beta.8 the + persistence and dashboard blocks are absent. +*/ -}} +{{- $r := .Values.api.telemetry.receiver }} +{{- $receiverDeployed := and (eq (include "gameplane.apiEnabled" .) "true") $r.enabled (not .Values.api.telemetry.endpoint) }} +{{- $persistence := $r.persistence | default dict }} +{{- $persistenceEnabled := ternary $persistence.enabled false (hasKey $persistence "enabled") }} +{{- $dashboard := $r.dashboard | default dict }} +{{- $dashboardToken := $dashboard.tokenSecretRef | default dict }} +{{- if and $receiverDeployed .Values.serviceMonitors.enabled (not $dashboardToken.name) }} + +WARNING: serviceMonitors.enabled and the bundled telemetry receiver are on, +but api.telemetry.receiver.dashboard.tokenSecretRef.name is empty. The +receiver serves /metrics only on its dashboard listener, behind a Bearer +token, so no receiver ServiceMonitor was rendered. Create a Secret holding +the token and set api.telemetry.receiver.dashboard.tokenSecretRef.name to +scrape the receiver. +{{- end }} +{{- if and $receiverDeployed (not $persistenceEnabled) }} + +WARNING: api.telemetry.receiver.persistence.enabled is false. The bundled +telemetry receiver stores its aggregates in an emptyDir, so all of them are +lost whenever its pod restarts. +{{- end }} diff --git a/charts/gameplane/templates/api.yaml b/charts/gameplane/templates/api.yaml index f7084421b..9d14271ba 100644 --- a/charts/gameplane/templates/api.yaml +++ b/charts/gameplane/templates/api.yaml @@ -328,6 +328,18 @@ spec: - --telemetry-endpoint={{ $telemetryURL }} {{- end }} {{- /* + Spec 022 (F-214): enabled and interval are absent after `helm + upgrade --reuse-values` from beta.8, so they are read through + hasKey. enabled defaults to true; false is the hard off switch. + */}} + {{- $telemetry := .Values.api.telemetry }} + {{- if not (ternary $telemetry.enabled true (hasKey $telemetry "enabled")) }} + - --telemetry-disabled + {{- end }} + {{- if .Values.defaultModuleSource.enabled }} + - --official-module-source={{ .Values.defaultModuleSource.name | default "default" }} + {{- end }} + {{- /* An explicit updates.channel always wins. Otherwise derive it so the dashboard doesn't lie: "edge" when the images actually deployed track the rolling :edge build (image.tag=edge, or — @@ -384,6 +396,14 @@ spec: name: {{ .Values.api.audit.s3.credentialsSecretRef.name }} key: {{ .Values.api.audit.s3.credentialsSecretRef.secretKeyKey }} {{- end }} + {{- if and (not .Values.api.telemetry.endpoint) .Values.api.telemetry.receiver.enabled }} + - name: GAMEPLANE_TELEMETRY_BUNDLED + value: "true" + {{- end }} + {{- with (.Values.api.telemetry.interval | default "") }} + - name: GAMEPLANE_TELEMETRY_INTERVAL + value: {{ . | quote }} + {{- end }} {{- if .Values.api.telemetry.authSecretRef.name }} - name: GAMEPLANE_TELEMETRY_AUTH valueFrom: diff --git a/charts/gameplane/templates/servicemonitors.yaml b/charts/gameplane/templates/servicemonitors.yaml index 5366842c9..03bde412e 100644 --- a/charts/gameplane/templates/servicemonitors.yaml +++ b/charts/gameplane/templates/servicemonitors.yaml @@ -46,16 +46,18 @@ spec: - { port: metrics, interval: 30s, path: /metrics } --- {{- /* - F-217: the telemetry-receiver renders no ServiceMonitor at all today - (its /ingest+/metrics listener is otherwise only reachable from the API - pod, see the NetworkPolicy in telemetry-receiver.yaml). Only render this - when the receiver itself is actually deployed (same condition as - telemetry-receiver.yaml) — a ServiceMonitor for a Service that doesn't - exist just leaves Prometheus with a permanently-down target. The + F-217: the telemetry-receiver serves /metrics only on its dashboard + listener (port 8081, Bearer auth with the dashboard token), so this + renders only when the receiver itself is deployed (same condition as + telemetry-receiver.yaml) AND dashboard.tokenSecretRef.name is set: without + a token Prometheus would hold a permanently-down target (NOTES.txt warns). + The key is read through guards (F-214). The trailing `---` stays inside this block (not after it) so skipping it doesn't leave a stray empty document ahead of the PodMonitor below. */}} -{{- if and .Values.api.telemetry.receiver.enabled (not .Values.api.telemetry.endpoint) }} +{{- $receiverDashboard := .Values.api.telemetry.receiver.dashboard | default dict }} +{{- $receiverToken := $receiverDashboard.tokenSecretRef | default dict }} +{{- if and .Values.api.telemetry.receiver.enabled (not .Values.api.telemetry.endpoint) $receiverToken.name }} apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor metadata: @@ -70,7 +72,12 @@ spec: selector: matchLabels: { app.kubernetes.io/name: gameplane-telemetry-receiver } endpoints: - - { port: http, interval: 30s, path: /metrics } + - port: dashboard + interval: 30s + path: /metrics + bearerTokenSecret: + name: {{ $receiverToken.name }} + key: {{ $receiverToken.key | default "token" }} --- {{- end }} {{- end }} diff --git a/charts/gameplane/templates/telemetry-receiver.yaml b/charts/gameplane/templates/telemetry-receiver.yaml index f5d93f96f..4e8255fbc 100644 --- a/charts/gameplane/templates/telemetry-receiver.yaml +++ b/charts/gameplane/templates/telemetry-receiver.yaml @@ -5,6 +5,65 @@ condition in api.yaml). */ -}} {{- if and (eq (include "gameplane.apiEnabled" .) "true") .Values.api.telemetry.receiver.enabled (not .Values.api.telemetry.endpoint) }} {{- $r := .Values.api.telemetry.receiver }} +{{- /* + Every key added after v0.2.0-beta.8 may be absent after `helm upgrade + --reuse-values` (F-214), so each is read through a guard: a missing block + becomes an empty dict, and hasKey (not `default`) keeps an explicit 0 or + false the operator set. Absent keys fall back to the values.yaml defaults. + In particular: an absent persistence block (an upgrade with --reuse-values + from beta.8) counts as disabled, so the upgrade adds no PVC. +*/}} +{{- $persistence := $r.persistence | default dict }} +{{- $persistenceEnabled := ternary $persistence.enabled false (hasKey $persistence "enabled") }} +{{- $dashboard := $r.dashboard | default dict }} +{{- $dashboardToken := $dashboard.tokenSecretRef | default dict }} +{{- $pepper := $r.pepperSecretRef | default dict }} +{{- $publicSummary := $r.publicSummary | default dict }} +{{- if and $persistenceEnabled (gt (int $r.replicas) 1) }} +{{- fail "api.telemetry.receiver.replicas must be 1 while api.telemetry.receiver.persistence.enabled is true: the SQLite store has a single writer" }} +{{- end }} +{{- if and (hasKey $r "retentionDays") (lt (int $r.retentionDays) 365) }} +{{- fail "api.telemetry.receiver.retentionDays must be at least 365 (spec 022 OD-3)" }} +{{- end }} +{{- if and (hasKey $r "activityExpiryDays") (lt (int $r.activityExpiryDays) 31) }} +{{- fail "api.telemetry.receiver.activityExpiryDays must be at least 31 (spec 022 OD-4)" }} +{{- end }} +{{- /* + Proof-of-work (spec 022 FR-039, R21, OD-5). The whole block may be absent + after --reuse-values from a release before it (F-214); absent keys fall + back to the values.yaml defaults, with proof-of-work off. +*/}} +{{- $pow := $r.ingestPow | default dict }} +{{- $powEnabled := ternary $pow.enabled false (hasKey $pow "enabled") }} +{{- $powTarget := ternary $pow.targetPerMinute 60 (hasKey $pow "targetPerMinute") }} +{{- $powMin := ternary $pow.minBits 0 (hasKey $pow "minBits") }} +{{- $powMax := ternary $pow.maxBits 22 (hasKey $pow "maxBits") }} +{{- if lt (int64 $powTarget) 1 }} +{{- fail "api.telemetry.receiver.ingestPow.targetPerMinute must be at least 1 (spec 022 OD-5)" }} +{{- end }} +{{- if gt (int64 $powMax) 26 }} +{{- fail "api.telemetry.receiver.ingestPow.maxBits must be at most 26, the hardest challenge an install solves (spec 022 OD-5)" }} +{{- end }} +{{- if lt (int64 $powMax) (int64 $powMin) }} +{{- fail "api.telemetry.receiver.ingestPow.maxBits must be at least minBits (spec 022 OD-5)" }} +{{- end }} +{{- if $persistenceEnabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: gameplane-telemetry-receiver-data + namespace: {{ .Release.Namespace }} + labels: {{- include "gameplane.labels" . | nindent 4 }} +spec: + accessModes: [ReadWriteOnce] + {{- with $persistence.storageClassName }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ $persistence.size | default "1Gi" | quote }} +--- +{{- end }} # Anonymous-usage-telemetry collector. The API POSTs its daily # {version, servers, templates} report here; this validates it and # exposes aggregate Prometheus metrics. It touches no Kubernetes API, so @@ -17,6 +76,10 @@ metadata: labels: {{- include "gameplane.labels" . | nindent 4 }} spec: replicas: {{ $r.replicas }} + # One writer owns the SQLite file, so a rollout must stop the old pod + # before the new one starts. + strategy: + type: Recreate selector: matchLabels: { app.kubernetes.io/name: gameplane-telemetry-receiver } template: @@ -28,6 +91,9 @@ spec: securityContext: runAsNonRoot: true runAsUser: 65532 + # The image is distroless nonroot (UID 65532); fsGroup makes the + # PVC (or emptyDir) mounted at /data writable by that user. + fsGroup: 65532 seccompProfile: { type: RuntimeDefault } containers: - name: receiver @@ -35,6 +101,42 @@ spec: imagePullPolicy: {{ .Values.image.pullPolicy }} env: - { name: LISTEN_ADDR, value: ":8080" } + - { name: DATA_DIR, value: "/data" } + - name: PUBLIC_SUMMARY + value: {{ ($publicSummary.enabled | default false) | quote }} + - name: RETENTION_DAYS + value: {{ (ternary $r.retentionDays 730 (hasKey $r "retentionDays")) | int64 | quote }} + - name: ACTIVITY_EXPIRY_DAYS + value: {{ (ternary $r.activityExpiryDays 90 (hasKey $r "activityExpiryDays")) | int64 | quote }} + - name: INGEST_SOURCE_DAILY_LIMIT + value: {{ (ternary $r.ingestSourceDailyLimit 20 (hasKey $r "ingestSourceDailyLimit")) | int64 | quote }} + {{- with $r.trustedProxyCIDRs }} + - name: TRUSTED_PROXY_CIDRS + value: {{ join "," . | quote }} + {{- end }} + - name: INGEST_POW + value: {{ $powEnabled | quote }} + - name: INGEST_POW_TARGET_PER_MIN + value: {{ $powTarget | int64 | quote }} + - name: INGEST_POW_MIN_BITS + value: {{ $powMin | int64 | quote }} + - name: INGEST_POW_MAX_BITS + value: {{ $powMax | int64 | quote }} + {{- if $dashboardToken.name }} + # The dashboard listener only starts when this is set. + - name: DASHBOARD_TOKEN + valueFrom: + secretKeyRef: + name: {{ $dashboardToken.name }} + key: {{ $dashboardToken.key | default "token" }} + {{- end }} + {{- if $pepper.name }} + - name: ID_PEPPER + valueFrom: + secretKeyRef: + name: {{ $pepper.name }} + key: {{ $pepper.key | default "pepper" }} + {{- end }} {{- if .Values.api.telemetry.authSecretRef.name }} # Require the same shared token the API sends as its # Authorization header, so the receiver rejects POSTs from @@ -48,6 +150,9 @@ spec: {{- end }} ports: - { name: http, containerPort: 8080 } + {{- if $dashboardToken.name }} + - { name: dashboard, containerPort: 8081 } + {{- end }} livenessProbe: { httpGet: { path: /healthz, port: http }, initialDelaySeconds: 5 } readinessProbe: { httpGet: { path: /healthz, port: http }, initialDelaySeconds: 2 } resources: {{ toYaml $r.resources | nindent 12 }} @@ -55,6 +160,17 @@ spec: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: { drop: [ALL] } + volumeMounts: + - { name: data, mountPath: /data } + volumes: + - name: data + {{- if $persistenceEnabled }} + persistentVolumeClaim: + claimName: gameplane-telemetry-receiver-data + {{- else }} + # Persistence is disabled: aggregates are lost when the pod restarts. + emptyDir: {} + {{- end }} {{- with .Values.image.pullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} @@ -71,6 +187,9 @@ spec: selector: { app.kubernetes.io/name: gameplane-telemetry-receiver } ports: - { name: http, port: 8080, targetPort: http } + {{- if $dashboardToken.name }} + - { name: dashboard, port: 8081, targetPort: dashboard } + {{- end }} {{- if .Values.networkPolicies.enabled }} --- apiVersion: networking.k8s.io/v1 @@ -93,17 +212,20 @@ spec: - protocol: TCP port: 8080 {{- /* - F-217: /metrics shares this listener/port with /ingest, and until now - the only ingress rule was "from the API pod", so no in-cluster - Prometheus could ever reach /metrics regardless of the ServiceMonitor - above. /ingest still requires AUTH_TOKEN (see the receiver's - Deployment env above), so widening this rule to the operator's - configured Prometheus namespace doesn't expose it to unauthenticated - writes — it only lets that namespace's pods reach the port at all. - Rendered only once an operator actually names their Prometheus - namespace via serviceMonitors.scrapeNamespaceSelector; left empty, - this stays exactly as restrictive as before. + Port 8080 (/ingest) admits only the API pod. /metrics lives on the + dashboard listener (8081) behind Bearer auth, so the dashboard port is + opened only when a dashboard token is named: first to the operator's + dashboard.ingressFrom peers, then (F-217) to the Prometheus namespace + named via serviceMonitors.scrapeNamespaceSelector. With no peers + configured, port-forward still works. */}} + {{- if $dashboardToken.name }} + {{- with $dashboard.ingressFrom }} + - from: {{- toYaml . | nindent 8 }} + ports: + - protocol: TCP + port: 8081 + {{- end }} {{- with .Values.serviceMonitors.scrapeNamespaceSelector }} {{- if $.Values.serviceMonitors.enabled }} - from: @@ -111,7 +233,8 @@ spec: {{- toYaml . | nindent 12 }} ports: - protocol: TCP - port: 8080 + port: 8081 + {{- end }} {{- end }} {{- end }} {{- end }} diff --git a/charts/gameplane/values.yaml b/charts/gameplane/values.yaml index b8a49bf3b..c3b85ff2f 100644 --- a/charts/gameplane/values.yaml +++ b/charts/gameplane/values.yaml @@ -234,15 +234,23 @@ api: accessKeyKey: access-key # key containing the access key ID secretKeyKey: secret-key # key containing the secret access key - # Anonymous usage telemetry ({version, servers, templates} once a day — - # see docs/install.md). Two independent gates: the admin toggle in the - # dashboard decides WHETHER anything is sent, this block decides WHERE. - # With endpoint empty and receiver.enabled false (the defaults), - # telemetry is off entirely. + # Anonymous usage telemetry (see docs/install.md). Two independent gates: + # the admin switches in the dashboard decide WHETHER anything is sent, this + # block decides WHETHER it may be sent at all (enabled) and WHERE it goes + # (endpoint, receiver). telemetry: - # External receiver URL, e.g. https://telemetry.example.com/ingest. - # Setting it disables the receiver auto-wiring below. + # false = hard off: the API never sends telemetry, whatever the admin + # switches say. This is the single setting for air-gapped and + # privacy-sensitive clusters. The bundled receiver, if enabled below, is + # still deployed. + enabled: true + # Where reports are sent. Empty means the project's default provider + # (https://telemetry.gameplane.net/ingest, compiled into the API); set a URL such as + # https://telemetry.example.com/ingest to send reports to your own + # receiver instead. Setting it disables the receiver auto-wiring below. endpoint: "" + # Testing only: the spacing between reports. Don't change in production. + interval: 24h # Optional shared ingest token, sourced from a Secret (never a flag). # The API sends it verbatim as the Authorization header and the # bundled receiver requires it — recommended when the receiver is @@ -256,8 +264,56 @@ api: # pointed at it automatically. receiver: enabled: false + # Must stay 1 while persistence is enabled: the SQLite file on the PVC + # has a single writer, so the template fails the render otherwise. replicas: 1 resources: {} + # Aggregate storage (SQLite on a PVC mounted at /data). With + # persistence disabled an emptyDir is used instead and every + # aggregate is lost when the pod restarts. + persistence: + enabled: true + size: 1Gi + storageClassName: "" + # Private dashboard (spec 022). The dashboard listener only starts when + # tokenSecretRef.name names a Secret holding the dashboard token. + dashboard: + tokenSecretRef: + name: "" + key: token + # Extra NetworkPolicy peers allowed to reach the dashboard port + # (port-forward works without any). Each item is a NetworkPolicyPeer, + # e.g. {namespaceSelector: {matchLabels: {team: ops}}}. + ingressFrom: [] + # Serve the public aggregate counters on GET /v1/summary. + publicSummary: + enabled: false + # Optional HMAC pepper for install IDs, sourced from a Secret. Empty + # name = the receiver generates one and keeps it in its database. + pepperSecretRef: + name: "" + key: pepper + # Daily aggregate retention in days. Minimum 365 (the receiver + # refuses to start below that). + retentionDays: 730 + # Per-install activity record expiry in days. Minimum 31. + activityExpiryDays: 90 + # Accepted reports per source address per UTC day; 0 = unlimited. + ingestSourceDailyLimit: 20 + # CIDRs whose X-Forwarded-For header is trusted for the source address. + trustedProxyCIDRs: [] + # Proof-of-work on report ingestion (spec 022 FR-039, OD-5). Off by + # default; a provider turns it on for a public endpoint. While on, + # every report must carry a solved challenge from GET /v1/challenge. + # The difficulty is 0 bits (or minBits) up to targetPerMinute + # challenges a minute, rises with the request rate up to maxBits, and + # falls one bit every 5 minutes. Installs refuse challenges above 26 + # bits, so maxBits may not exceed 26. + ingestPow: + enabled: false + targetPerMinute: 60 + minBits: 0 + maxBits: 22 oidc: enabled: false @@ -521,8 +577,11 @@ serviceMonitors: # expressions/labels) identifying the namespace your Prometheus runs in. # Only used, when non-empty, to open NetworkPolicy exceptions so that # Prometheus can reach the agent's metrics port 9090 (F-216; rationale in - # agent/specs.md) and the telemetry-receiver's /metrics on its normal - # port (F-217) when networkPolicies.enabled is also true. Required + # agent/specs.md) and the telemetry-receiver's /metrics on its + # dashboard port 8081 (F-217; /metrics is served there with Bearer auth, + # so the receiver ServiceMonitor also needs + # api.telemetry.receiver.dashboard.tokenSecretRef.name) when + # networkPolicies.enabled is also true. Required # alongside enabled: true whenever networkPolicies.enabled is also true; # left empty, both stay unreachable to Prometheus even though their # ServiceMonitor/PodMonitor objects are rendered. Example: diff --git a/deploy/kind/e2e.sh b/deploy/kind/e2e.sh index 3f9f85dfc..a16fa6ac7 100755 --- a/deploy/kind/e2e.sh +++ b/deploy/kind/e2e.sh @@ -8,7 +8,7 @@ # Diffs from dev-up (deploy/kind/up.sh): # - Single-node cluster (faster boot, sufficient for E2E coverage). # - Skips ingress-nginx (the dashboard isn't exercised here). -# - Loads pre-built gameplane-test/{operator,api,agent,sentinel,capture-sidecar,fakeoidc}: +# - Loads pre-built gameplane-test/{operator,api,agent,sentinel,capture-sidecar,telemetry-receiver,fakeoidc}: # images (and gameplane-test/gameprobe: when present). # - Helm install with --wait so pods are Ready before tests start. # @@ -163,8 +163,8 @@ EOF install_metallb apply_metallb_pools - echo "loading gameplane-test/{operator,api,agent,sentinel,capture-sidecar}:${TAG} images into kind" - for img in operator api agent sentinel capture-sidecar; do + echo "loading gameplane-test/{operator,api,agent,sentinel,capture-sidecar,telemetry-receiver}:${TAG} images into kind" + for img in operator api agent sentinel capture-sidecar telemetry-receiver; do if ! docker image inspect "gameplane-test/${img}:${TAG}" >/dev/null 2>&1; then echo " missing local image gameplane-test/${img}:${TAG} — building" docker build -t "gameplane-test/${img}:${TAG}" -f "${REPO}/${img}/Dockerfile" "${REPO}" @@ -298,6 +298,8 @@ EOF # Disable the web front end: the suite drives the API directly via # port-forward (never the browser), so building/loading the nginx image # would only add minutes, and `--wait` would block on an unloaded image. + # Deploy the bundled telemetry receiver and point the API at it: never let + # CI or dev installs reach the project provider (spec 022 R17). helm upgrade --install gameplane "${CHART_DIR}" \ --namespace gameplane-system --create-namespace \ --set "image.registry=gameplane-test" \ @@ -318,6 +320,7 @@ EOF --set "operator.addressManager=metallb" \ --set "operator.gameDataStorage.storageClassName=gameplane-e2e-install-default" \ --set "defaultModuleSource.enabled=false" \ + --set "api.telemetry.receiver.enabled=true" \ --wait --timeout 5m echo diff --git a/design-export/MANIFEST.md b/design-export/MANIFEST.md index 565631385..8de051893 100644 --- a/design-export/MANIFEST.md +++ b/design-export/MANIFEST.md @@ -2500,6 +2500,29 @@ Server Settings gets a "Game configuration" section (edit `spec.config` from the - **Screenshots:** `Export(ids, "png", …, {scale: 2})`. - **Re-exported for the sub-nav change:** PNGs of `swxkJ`, `E0ypH`, `J5pjJ3`, `ugDSa`, `i1bLR`, `KaRFX`, `Y5cmvI`, `VfB0Y`, `i8wib`, `xCJlu`, `RodrS`, `VctzT`, `iLm38`, `QpEvu`, `XR0f9`, `nGJE1`, `dhPtX`, `tSOeI`, `y3YcQ`, `EKJyM`, `IMSD5`, `gWqMD` and `EV8mp`; JSON of `Y5cmvI` and `J5pjJ3` (they were out of date with the canvas). +## Spec 022 telemetry notice and settings (2026-10-07) + +T041 and T052 of `specs/022-default-telemetry-dashboard/`. New frames sit in an empty region below the Admin Settings row (x 11480–21120, y 25404–28584); no existing frame changed. Destination hosts are samples while OD-1 is unruled: their text nodes are named `host (SAMPLE, OD-1 unruled)` (default) and `host (SAMPLE)` (bundled). + +| Object ID | Name | Change | +| --- | --- | --- | +| `lh8p8` | Gameplane/Telemetry Notice Banner | **New** reusable component, modelled on the Safe Mode Banner (`DAz77`) in the accent tone with a lucide `info` icon. Headline "Anonymous usage metrics are on for this install.", the destination host line, a basic field column and an extended field column that says a random install ID is included, the actions "Keep sharing", "Turn off extended" and "Turn off all", and the links "Open Telemetry settings" and "Data-handling statement" (`TELEMETRY_STATEMENT_URL`). No dismiss button. | +| `zj5a3` | Screen/Dashboard Home — Telemetry notice (default) | **New.** Copy of `j24cXg` (1380 tall) with the banner at the top of the body, default destination (sample host `telemetry.example.org`). | +| `GR1G9` | Screen/Dashboard Home — Telemetry notice (bundled) | **New.** As `zj5a3`, bundled in-cluster receiver (sample host `gameplane-telemetry-receiver.gameplane.svc`). | +| `l7TRz9` | Screen/Admin Settings — Telemetry (default) | **New.** Copy of `uoxQW`. Subtitle names the install ID. Destination line, basic and extended switches on, install-ID row with "Reset ID", read-only next-report preview JSON, status "ok". | +| `r0heh` | Screen/Admin Settings — Telemetry (custom) | **New.** Custom destination; status "failed" plus the line "Install ID replaced on Oct 4, 2026: the destination reported it was in use by another key." | +| `cDiNb` | Screen/Admin Settings — Telemetry (bundled) | **New.** Bundled destination; basic on, extended off, install-ID row dimmed ("No install ID while extended metrics are off."), Reset ID dimmed; status "never". | +| `DBQyF` | Screen/Admin Settings — Telemetry (disabled) | **New.** Both switches off and dimmed, warning message "Telemetry is disabled by the operator.", Save dimmed. | +| `K5XinZ` | Screen/Admin Settings — Telemetry (none) | **New.** No destination; basic switch off and enabled, extended dimmed; no preview or status. | +| `yrxK2` | Screen/Admin Settings — Telemetry (Reset ID confirm) | **New.** Copy of `l7TRz9` with a 40% scrim and an AlertDialog/Danger instance: "Reset install ID?", Cancel / Reset ID. | + +- **JSON:** made with `Print(JSON.stringify(Get(id, {depth: 30, includePathGeometry: true})))`, length-checked against the canvas and re-serialized with `jq .`. Zero `"..."` elisions, and `jq empty` passes. +- **Screenshots:** `Export(ids, "png", …, {scale: 2})`. +- **Text wrap fix (2026-10-07, after review by the maintainer):** in `U8Zugm` the title (`p6wdYs`), destination suffix (`w4oMww`) and both field descriptions (`js01h`, `WW2ac`) now use `textGrowth: fixed-width` with `width: fill_container`, so long text wraps inside the banner instead of running past it (most visible on `woRBK`, the bundled host). Re-exported: JSON of `U8Zugm`; PNGs of `U8Zugm`, `jmcmS` and `woRBK`. The screens' JSON is unchanged (they hold only instance overrides). +- **OD-1 ruled (2026-10-07):** telemetry.gameplane.net is the default endpoint per OPEN-DECISIONS OD-1. Updated text nodes: `jhnKn` in `U8Zugm`; `jhnKn` in the `t6Wzr` instance override in `jmcmS`; `i6gVPJ` in `TEBym`; `J98Oe` in `oqm73`. Names updated from `host (SAMPLE, OD-1 unruled)` to `host`. Re-exported: JSON and PNG of `U8Zugm`, `jmcmS`, `TEBym`, `oqm73`. +- **Reset ID button icon removed (2026-10-07):** removed the plus icon from the resetIdBtn component instances in the Admin Settings screens. The button component's default plus icon is now hidden with `descendants: {"X3iEI": {"enabled": false}}` on all instances (VATLE in DtjyL, P8V7X8 in uwPVC, qq0qa in oqm73, and AILYr in TEBym). Re-exported JSON and PNG for TEBym, DtjyL, uwPVC, oqm73. +- **Rebuilt in the master merge (2026-10-07):** master's `design.pen` (with `FBiMN` and the `qFLfB` rename from PR #584) was taken as the base and these frames were re-created on it from their exports, so their ids changed: `U8Zugm` → `lh8p8`, `jmcmS` → `zj5a3`, `woRBK` → `GR1G9`, `TEBym` → `l7TRz9`, `DtjyL` → `r0heh`, `uwPVC` → `cDiNb`, `Rvv6z` → `DBQyF`, `RT8lU` → `K5XinZ`, `oqm73` → `yrxK2`. The rebuilt JSON equals the previous export with ids remapped (length and FNV-1a hash checked against the canvas; the hidden rows of the bundled, disabled and none screens carry the x/y/width the canvas now computes for them). The PNGs were re-exported: the two Dashboard Home screens now show the banner's actions row below both metric columns, where the previous PNGs held a stale layout of the same JSON. Internal node ids named in the notes above refer to the pre-merge file. + ## Share Start recovery (PR #584, 2026-10-07) | Object ID | Name | Change | diff --git a/design-export/json/DBQyF.json b/design-export/json/DBQyF.json new file mode 100644 index 000000000..4d01b247f --- /dev/null +++ b/design-export/json/DBQyF.json @@ -0,0 +1,523 @@ +{ + "type": "frame", + "id": "DBQyF", + "x": 16400, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (disabled)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 641, + "fill": "$background/background", + "children": [ + { + "id": "jb2it", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "CZLXz", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "L5CeQ", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "ZHjUq", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "WaQzQ", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "gnNvq", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "wQExX", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "jigMQ", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "uhp7C", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "T4JamN", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "EGOdH", + "name": "destinationRow", + "width": "fill_container", + "fill": "$warning/soft", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "ZXcWt", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "shield-off", + "library": "Material Symbols Rounded", + "fill": "$warning/soft-foreground" + }, + { + "type": "frame", + "id": "ax0Ht", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "MC4kp", + "name": "destTitle", + "fill": "$foreground/foreground", + "content": "Telemetry is disabled by the operator.", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ipare", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No data of either kind is sent and no telemetry destination is contacted, whatever the switches below say. Only an operator can change this, at install time.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "Wcah8", + "type": "ref", + "ref": "eg3vM", + "name": "destChip", + "descendants": { + "S2JwD": { + "content": "Disabled" + } + } + } + ] + }, + { + "type": "frame", + "id": "s8V4RQ", + "name": "telemetryRow", + "opacity": 0.5, + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "aD3WD", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "e8KSRm", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "UqkyC", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "pv6u1", + "type": "ref", + "ref": "rh2QH", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "d6UNY", + "name": "extendedRow", + "opacity": 0.5, + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "klsL5", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "PksiU", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "eF5fz", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "mFzAM", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "lKdlx", + "type": "ref", + "ref": "rh2QH", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Q5cSk8", + "x": 0, + "y": 218, + "name": "installIdRow", + "enabled": false, + "width": "fill_container(856)", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "QUqUt", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "c62rOD", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Cec0C", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wxRQV", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "BnNus", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "wyQ2s", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "dDFBA", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "d791Wi", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "thVid", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "c1wLHW", + "name": "previewJson", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Nothing to preview: telemetry is disabled by the operator.", + "lineHeight": 1.5, + "fontFamily": "Geist", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "h4t9WP", + "x": 0, + "y": 395, + "name": "statusRow", + "enabled": false, + "width": "fill_container(856)", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "xpROA", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "circle-check", + "library": "Material Symbols Rounded", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "U0Eb0P", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last report sent successfully on Oct 6, 2026, 09:12 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qHqUB", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "Eq31M", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "opacity": 0.5, + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/design-export/json/GR1G9.json b/design-export/json/GR1G9.json new file mode 100644 index 000000000..ae7ee2b70 --- /dev/null +++ b/design-export/json/GR1G9.json @@ -0,0 +1,1349 @@ +{ + "type": "frame", + "id": "GR1G9", + "x": 14760, + "y": 27204, + "name": "Screen/Dashboard Home — Telemetry notice (bundled)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1380, + "fill": "$background/background", + "children": [ + { + "id": "O7GOL", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container" + }, + { + "type": "frame", + "id": "q8Uej", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "F9uobE", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gb27k": { + "fontFamily": "$typography/font-sans" + } + } + }, + { + "type": "frame", + "id": "RrWXd", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "id": "rj03O", + "type": "ref", + "ref": "lh8p8", + "name": "Telemetry Notice", + "width": "fill_container", + "descendants": { + "dzZwq": { + "content": "gameplane-telemetry-receiver.gameplane.svc", + "name": "host (SAMPLE)" + }, + "jCJTc": { + "content": ", the receiver bundled with this install. Reports stay inside your cluster." + } + } + }, + { + "id": "VyEYQ", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "VbHwU": { + "content": "Your game servers and resources across authorized locations." + }, + "TgdLz": { + "children": [ + { + "id": "bI3je", + "type": "ref", + "ref": "tpKRk", + "name": "Create Server Button", + "descendants": { + "q58In/Xpn7K": { + "library": "lucide", + "icon": "plus" + }, + "q58In/U6q13B": { + "content": "Create server" + } + } + } + ] + } + } + }, + { + "id": "Dc5TC", + "type": "ref", + "ref": "AT7ya", + "name": "Dashboard location filter", + "width": 240, + "context": "Optional location filter; All locations combines authorized inventory.", + "descendants": { + "cFsFk": { + "content": "All locations", + "fill": "$foreground/foreground" + } + } + }, + { + "id": "A5HcX", + "type": "ref", + "ref": "YF85x", + "name": "Stats Row", + "width": "fill_container", + "descendants": { + "m20CHt/QdcEm": { + "fill": "#000000" + }, + "pkvT2/QdcEm": { + "fill": "$chart/stat" + }, + "FlXsH/QdcEm": { + "fill": "$chart/stat" + } + } + }, + { + "type": "frame", + "id": "Z6jyj", + "name": "Row2", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "ENX9Y", + "name": "Fleet Status Card", + "width": "fill_container", + "height": 372, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "e6D5J", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dX1T5", + "name": "title", + "fill": "$foreground/foreground", + "content": "Fleet status", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "u0G1MU", + "name": "link", + "fill": "$muted", + "content": "1 servers", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZcBsE", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "ugMqf", + "name": "Health Bar", + "clip": true, + "width": "fill_container", + "height": 8, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "id": "sPxJn", + "name": "Running Segment", + "fill": "$success/success", + "width": "fill_container", + "height": 8 + } + ] + }, + { + "type": "frame", + "id": "D6Jupi", + "name": "Legend", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "wqlmt", + "name": "legend-Running 1", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "b7HqI", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "GHX30", + "name": "label", + "fill": "$muted", + "content": "Running 1", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eUDfB", + "name": "legend-Stopped 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "HI5Td", + "name": "dot", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "Ga3Nd", + "name": "label", + "fill": "$muted", + "content": "Stopped 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PlhV4", + "name": "legend-Failed 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "Q5FRz0", + "name": "dot", + "fill": "$danger/danger", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "kmn6P", + "name": "label", + "fill": "$muted", + "content": "Failed 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dgLC2", + "name": "Attention Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "B1HSUR", + "name": "attnLabel", + "fill": "$muted", + "content": "NEEDS ATTENTION", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "XrCOg", + "name": "healthyRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Uc7Gc", + "name": "check", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "JFJZx", + "name": "healthyLabel", + "fill": "$muted", + "content": "Everything looks healthy.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wQFe0", + "name": "Cluster Resources Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "RBzCd", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RQqqG", + "name": "title", + "fill": "$foreground/foreground", + "content": "Cluster resources", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "X4V95B", + "name": "link", + "fill": "$accent/accent", + "content": "View cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kjAUx", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "NvvbZ", + "name": "Meters", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "GnpUm", + "name": "CPU Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "eONo0", + "name": "CPU Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "JxbI7", + "name": "CPU Label", + "fill": "$muted", + "content": "CPU", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WYkDf", + "name": "CPU Pct", + "fill": "$foreground/foreground", + "content": "0%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "C6OJo", + "name": "CPU Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "yG4gE", + "name": "CPU Fill", + "fill": "$accent/accent", + "width": 0, + "height": 6 + } + ] + }, + { + "type": "text", + "id": "l009rk", + "name": "CPU Sub", + "fill": "$muted", + "content": "0 / 12 cores", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rTJqh", + "name": "Memory Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "Hu1G6", + "name": "Memory Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "YZJLv", + "name": "Memory Label", + "fill": "$muted", + "content": "Memory", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "MP6GS", + "name": "Memory Pct", + "fill": "$foreground/foreground", + "content": "0%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "VPvVF", + "name": "Memory Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "MukLA", + "name": "Memory Fill", + "fill": "$accent/accent", + "width": 0, + "height": 6 + } + ] + }, + { + "type": "text", + "id": "l1DSuW", + "name": "Memory Sub", + "fill": "$muted", + "content": "0 B / 41 GB", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "UxbGM", + "name": "Storage Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "H1AfzY", + "name": "Storage Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "nOUxN", + "name": "Storage Label", + "fill": "$muted", + "content": "Storage", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "G3Lpz", + "name": "Storage Pct", + "fill": "$foreground/foreground", + "content": "25%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IDDuf", + "name": "Storage Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "STiGN", + "name": "Storage Fill", + "fill": "$success/success", + "width": 110, + "height": 6 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "H0Io3J", + "name": "Nodes Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "OEaZr", + "name": "nodesLabel", + "fill": "$muted", + "content": "NODES", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "lwjSY", + "name": "Node kubelab-control", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "Dwcel", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "OUMiG", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-control", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "UP0qr", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZcZng", + "name": "Node kubelab-worker-1", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "KhH6P", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "n8EFt", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-worker-1", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "LOgVF", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Zf5lJ", + "name": "Node kubelab-worker-2", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "qZljR", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "YW9QB", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-worker-2", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "OP98d", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "sW8cJ", + "name": "Row3", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "lQ9GL", + "name": "Central audit activity", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "NAugs", + "name": "Central audit scope", + "fill": "$muted", + "content": "Central Gameplane audit activity", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "l5q0j", + "name": "Recent Activity Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "qLKyP", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qjUgW", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent activity", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "D4451", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HzLFF", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "WJ0bg", + "name": "anonymous created-4h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "EMapf", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "d4jMS", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "MuQeY", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "yps5n", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "NEnAQ", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "S8oW3", + "name": "time", + "fill": "$muted", + "content": "4h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JsY8j", + "name": "admin created-18h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "TurTP", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "u8Qks", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "m50ubv", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "EkV3J", + "name": "title", + "fill": "$foreground/foreground", + "content": "admin created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "zSLBZ", + "name": "path", + "fill": "$muted", + "content": "POST /servers", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "rTfEb", + "name": "time", + "fill": "$muted", + "content": "18h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Mxfl0", + "name": "anonymous created-22h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "S9q3k", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "TYuis", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "e1lo31", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "ucppN", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "QddRu", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "t0Y6V", + "name": "time", + "fill": "$muted", + "content": "22h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hpmri", + "name": "anonymous created-1d ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "mXJFx", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "KnnKk", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "lSme8", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "SBjrl", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ZkXuD", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "btIk1", + "name": "time", + "fill": "$muted", + "content": "1d ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "l6D3NG", + "name": "anonymous created-1d ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gEcki", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "TJUXX", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "wyy4o", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "H18z3", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "N4s2yN", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "T8r0yY", + "name": "time", + "fill": "$muted", + "content": "1d ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ugPLs", + "name": "Recent Backups Card", + "width": "fill_container", + "height": 353, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "hYoyr", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "i9jhMf", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent backups", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "uRvCC", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "v0DIq", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 40, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "C17hb", + "name": "empty", + "fill": "$muted", + "content": "No backups yet.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/design-export/json/K5XinZ.json b/design-export/json/K5XinZ.json new file mode 100644 index 000000000..b4f96b5b2 --- /dev/null +++ b/design-export/json/K5XinZ.json @@ -0,0 +1,521 @@ +{ + "type": "frame", + "id": "K5XinZ", + "x": 18040, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (none)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 605, + "fill": "$background/background", + "children": [ + { + "id": "vrJqn", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "ZDPAQ", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "aaM6h", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "MSfvx", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "K6vVa", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "M4Uamg", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "KnZmD", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "jXdYm", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "ZjXwf", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "usPX3", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "FR1sB", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Lf5Va", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "circle-slash", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "Irbib", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "t67L2", + "name": "destTitle", + "fill": "$foreground/foreground", + "content": "No telemetry destination configured.", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "NHeZ1", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Nothing is sent. An operator can set a custom or bundled destination at install time.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "TGqWC", + "type": "ref", + "ref": "KmZnF", + "name": "destChip", + "descendants": { + "xZ9ah": { + "content": "No destination" + } + } + } + ] + }, + { + "type": "frame", + "id": "u5uN6", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qKiFo", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "E3CsS", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "w7LUN", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "L9Gg2H", + "type": "ref", + "ref": "rh2QH", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "YZ8M5", + "name": "extendedRow", + "opacity": 0.5, + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "MEKOs", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "G8uxgg", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "yop7K", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "DT5P1", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Turn on basic usage metrics to enable extended usage metrics.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "dy9DZ", + "type": "ref", + "ref": "rh2QH", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "j18F4n", + "x": 0, + "y": 182, + "name": "installIdRow", + "enabled": false, + "width": "fill_container(856)", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Y1pNB", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "VY8NU", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "a9Y6S", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KNGZo", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "Xe5vb", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "a13SoT", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "acf1z", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "kjTTh", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "o5GCW", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "ZZcKL", + "name": "previewJson", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Nothing to preview: no destination is configured.", + "lineHeight": 1.5, + "fontFamily": "Geist", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Q3IStt", + "x": 0, + "y": 359, + "name": "statusRow", + "enabled": false, + "width": "fill_container(856)", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "U6DNh", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "circle-check", + "library": "Material Symbols Rounded", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "Vlhfy", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last report sent successfully on Oct 6, 2026, 09:12 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "MxFTb", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "oHhoP", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/design-export/json/cDiNb.json b/design-export/json/cDiNb.json new file mode 100644 index 000000000..1e3029806 --- /dev/null +++ b/design-export/json/cDiNb.json @@ -0,0 +1,531 @@ +{ + "type": "frame", + "id": "cDiNb", + "x": 14760, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (bundled)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 777, + "fill": "$background/background", + "children": [ + { + "id": "SZqT4", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "BPHgi", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "g5b2At", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "mvBQN", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "BxiQJ", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "qxsMk", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "x3FpWK", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "j3yT8", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "MecVG", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "qm0J5", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "HSFqE", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "taRYg", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "house", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "LQx4P", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "W1Opd", + "name": "host (SAMPLE)", + "fill": "$foreground/foreground", + "content": "Sending to gameplane-telemetry-receiver.gameplane.svc", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "gGGWe", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The receiver bundled with this install. Reports stay inside your cluster.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "cMhI7", + "type": "ref", + "ref": "UOoQW", + "name": "destChip", + "descendants": { + "YXoPG": { + "content": "Bundled" + } + } + }, + { + "type": "text", + "id": "ICsn1", + "name": "destStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "uLcI7", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BtYr7", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "p9Uxb", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KQPv9", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "p25s6", + "type": "ref", + "ref": "hl7R3", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "g9uOK", + "name": "extendedRow", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "ErWqF", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CWscr", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "LwD9N", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "nVxtQ", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "cwXJ3", + "type": "ref", + "ref": "rh2QH", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "QRgzv", + "name": "installIdRow", + "width": "fill_container", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "bbsik", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "JV3Lq", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "qHRdo", + "name": "installIdValue", + "fill": "$muted", + "content": "No install ID while extended metrics are off.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "QUQwC", + "x": 0, + "y": 37, + "name": "installIdHelp", + "enabled": false, + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container(740)", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "PD8Zb", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "opacity": 0.5, + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "Xah7Q", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "x4Za32", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "s1Z4Ct", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "TIblz", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "X6n3I", + "name": "previewJson", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"version\": \"0.3.0\",\n \"servers\": 5,\n \"templates\": 7\n}", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "NZYIA", + "name": "statusRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "bR4pr", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "clock", + "library": "Material Symbols Rounded", + "fill": "$muted" + }, + { + "type": "text", + "id": "SKyZx", + "name": "statusText", + "fill": "$muted", + "content": "No report sent yet.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "R77io", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "V7JPh", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/design-export/json/l7TRz9.json b/design-export/json/l7TRz9.json new file mode 100644 index 000000000..50ade602e --- /dev/null +++ b/design-export/json/l7TRz9.json @@ -0,0 +1,527 @@ +{ + "type": "frame", + "id": "l7TRz9", + "x": 11480, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (default)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1031, + "fill": "$background/background", + "children": [ + { + "id": "VJan2", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "f0b9q", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "YCCTY", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "KDu8M", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "ckrv3", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "aRwmV", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "bBTbp", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "k6Yt5", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "eJnTo", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "akS4L", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "viQWf", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "H0uGxR", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "globe", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "jXaCa", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "FkDph", + "name": "host", + "fill": "$foreground/foreground", + "content": "Sending to telemetry.gameplane.net", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "VcHN2", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The Gameplane project's telemetry service.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "GXzqz", + "type": "ref", + "ref": "kGH2O", + "name": "destChip", + "descendants": { + "O1PqF5": { + "content": "Project default" + } + } + }, + { + "type": "text", + "id": "kz2Hz", + "name": "destStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "oiqgN", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "a5Gz9E", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "w6elN", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "oOnYs", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "mt1Jr", + "type": "ref", + "ref": "hl7R3", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "iDkU7", + "name": "extendedRow", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "G3kwa", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "eqcqd", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "l5jHVa", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "mCOpV", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "p9TqED", + "type": "ref", + "ref": "hl7R3", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "G6UKye", + "name": "installIdRow", + "width": "fill_container", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "N8PiP7", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "nQFKU", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "G1enxe", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iOxRj", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "RYoue", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "cvxBf", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "fl698", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "MUfXb", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "tRekr", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "Q5r0x", + "name": "previewJson", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"version\": \"0.3.0\",\n \"servers\": 3,\n \"templates\": 7,\n \"ext\": {\n \"schema\": 1,\n \"installId\": \"3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10\",\n \"env\": { \"k8s\": \"1.31\", \"distro\": \"k3s\", \"arch\": [\"amd64\"], \"nodes\": \"1\" },\n \"games\": { \"official\": { \"minecraft-java\": 2, \"terraria\": 1 }, \"custom\": 0 },\n \"features\": {\n \"wakeOnConnect\": true, \"tunnels\": [\"playit\"], \"capture\": false,\n \"backups\": true, \"sso\": false, \"auditForwarding\": false,\n \"clusters\": \"1\", \"db\": \"sqlite\", \"language\": \"en\"\n },\n \"key\": \"pV0mY3Qn8wK2b7cXlR4sTt9uJ1eHaZ6dFgQmNoP5rSk\",\n \"sentAt\": \"2026-10-06T09:12:44Z\"\n }\n}", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "xUV9o", + "name": "statusRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "r8eVe", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "circle-check", + "library": "Material Symbols Rounded", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "v07BWT", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last report sent successfully on Oct 6, 2026, 09:12 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vvERC", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "XzN1k", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/design-export/json/lh8p8.json b/design-export/json/lh8p8.json new file mode 100644 index 000000000..4bcfb7289 --- /dev/null +++ b/design-export/json/lh8p8.json @@ -0,0 +1,268 @@ +{ + "type": "frame", + "id": "lh8p8", + "x": 11480, + "y": 27204, + "name": "Gameplane/Telemetry Notice Banner", + "reusable": true, + "width": 1100, + "fill": "$accent/soft", + "cornerRadius": 8, + "stroke": "$accent/soft-foreground", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "icon", + "id": "jnKcH", + "name": "tnIcon", + "width": 20, + "height": 20, + "icon": "info", + "library": "lucide", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "TOsXx", + "name": "tnContents", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "xfEiQ", + "name": "tnTitle", + "fill": "$accent/soft-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Anonymous usage metrics are on for this install.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "WIzDP", + "name": "tnDestRow", + "width": "fill_container", + "gap": 4, + "children": [ + { + "type": "text", + "id": "f6sfW", + "name": "destLabel", + "fill": "$foreground/foreground", + "content": "Reports are sent to", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "dzZwq", + "name": "host", + "fill": "$accent/soft-foreground", + "content": "telemetry.gameplane.net", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "jCJTc", + "name": "tnDestSuffix", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": ", the Gameplane project's telemetry service.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JJ906", + "name": "tnFieldCols", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "kLCfk", + "name": "tnBasic", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "yC32k", + "name": "basicLabel", + "fill": "$foreground/foreground", + "content": "Basic metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "D0wKt", + "name": "basicDesc", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Gameplane version, number of servers, number of templates.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "nB5zt", + "name": "tnExtended", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "d6ULPb", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Extended metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Aj65h", + "name": "extDesc", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Kubernetes version, distribution, CPU architecture and node-count band; servers per official game module (others counted as custom); which optional features are in use; and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Qs5j2", + "name": "tnActions", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "lFiXj", + "type": "ref", + "ref": "z9ShNE", + "name": "tnKeep", + "descendants": { + "nWTJb": { + "enabled": false + }, + "IVgQ8/U1xvSo": { + "content": "Keep sharing" + } + } + }, + { + "id": "tgQa9", + "type": "ref", + "ref": "d5N3W3", + "name": "tnExtOff", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Turn off extended" + } + } + }, + { + "id": "nF9qA", + "type": "ref", + "ref": "d5N3W3", + "name": "tnAllOff", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Turn off all" + } + } + }, + { + "type": "frame", + "id": "aXQdE", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "Sstlp", + "name": "tnLinks", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "EimG7", + "name": "tnOpenSettings", + "fill": "$foreground/link", + "content": "Open Telemetry settings", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ayd49", + "name": "sep1", + "fill": "$muted", + "content": "·", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "BaWOS", + "name": "tnStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] +} diff --git a/design-export/json/r0heh.json b/design-export/json/r0heh.json new file mode 100644 index 000000000..41cf95266 --- /dev/null +++ b/design-export/json/r0heh.json @@ -0,0 +1,557 @@ +{ + "type": "frame", + "id": "r0heh", + "x": 13120, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (custom)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1032, + "fill": "$background/background", + "children": [ + { + "id": "K4Pd5", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "p1Tm5", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "wJcHv", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "WeNcI", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "T3U3l2", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "wcqSm", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "VjSvS", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "zMflH", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "D0Ayp4", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "gfaY0", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "O4y4o", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "dNVYp", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "server", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "dT5O6", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "G6EK4E", + "name": "destTitle", + "fill": "$foreground/foreground", + "content": "Sending to telemetry.corp.example.net", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ODx5q", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Custom destination set by your operator. Nothing is sent to the Gameplane project.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "o0rbrv", + "type": "ref", + "ref": "KmZnF", + "name": "destChip", + "descendants": { + "xZ9ah": { + "content": "Custom" + } + } + }, + { + "type": "text", + "id": "gByiC", + "name": "destStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "v1QNq", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "z0pC9U", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "B0NSMJ", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "EWyuE", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "Wq49L", + "type": "ref", + "ref": "hl7R3", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "VHN8K", + "name": "extendedRow", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "vhhM8", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "yYPjK", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "v1im4", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "VwFtD", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "z1Fib", + "type": "ref", + "ref": "hl7R3", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "u3paX", + "name": "installIdRow", + "width": "fill_container", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "KXAIF", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "XucpG", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "e7wvLd", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "b7e04d12-5a3c-4f98-8d6e-21c9a0f4e7b3", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "QBqy8", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "JR2Iq", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "nKoLh", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "svgri", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "tMdnR", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "l5laXN", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "A8lYG", + "name": "previewJson", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"version\": \"0.3.0\",\n \"servers\": 11,\n \"templates\": 9,\n \"ext\": {\n \"schema\": 1,\n \"installId\": \"b7e04d12-5a3c-4f98-8d6e-21c9a0f4e7b3\",\n \"env\": { \"k8s\": \"1.30\", \"distro\": \"rke2\", \"arch\": [\"amd64\", \"arm64\"], \"nodes\": \"4-10\" },\n \"games\": { \"official\": { \"minecraft-java\": 6, \"valheim\": 4 }, \"custom\": 1 },\n \"features\": {\n \"wakeOnConnect\": true, \"tunnels\": [\"tailscale\"], \"capture\": false,\n \"backups\": true, \"sso\": true, \"auditForwarding\": true,\n \"clusters\": \"2-3\", \"db\": \"postgres\", \"language\": \"en\"\n },\n \"key\": \"Zk4t8Wq1nB6xYv3LmP0cRs7dEhJaUo2FgTiNyQ5bXwA\",\n \"sentAt\": \"2026-10-06T09:12:44Z\"\n }\n}", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "gs2kZ", + "name": "statusRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "uqAgT", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "triangle-alert", + "library": "Material Symbols Rounded", + "fill": "$warning/soft-foreground" + }, + { + "type": "text", + "id": "S1oY8", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last attempt failed, will retry. Last successful report: Oct 5, 2026, 09:10 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "HA1Kb", + "name": "idReplacedRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "U0DP6", + "name": "idReplacedIcon", + "width": 14, + "height": 14, + "icon": "info", + "library": "lucide", + "fill": "$accent/soft-foreground" + }, + { + "type": "text", + "id": "VLMbI", + "name": "idReplacedText", + "fill": "$foreground/foreground", + "content": "Install ID replaced on Oct 4, 2026: the destination reported it was in use by another key.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "i4F3Q", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "KLwdf", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/design-export/json/yrxK2.json b/design-export/json/yrxK2.json new file mode 100644 index 000000000..a35ed7306 --- /dev/null +++ b/design-export/json/yrxK2.json @@ -0,0 +1,563 @@ +{ + "type": "frame", + "id": "yrxK2", + "x": 19680, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (Reset ID confirm)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1031, + "fill": "$background/background", + "children": [ + { + "id": "SyZg9", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "qv0OX", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "h84EC", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "GcIdA", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "b1lOS", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "Ibs1J", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "MNnEE", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "vGL2p", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "Mtz0y", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work." + } + } + }, + { + "type": "frame", + "id": "VTiD2", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "ORKLU", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "HbRz7", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "globe", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "wHkVS", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zBl0v", + "name": "destTitle", + "fill": "$foreground/foreground", + "content": "Sending to telemetry.gameplane.net", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "e4DXS", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The Gameplane project's telemetry service.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "nJfig", + "type": "ref", + "ref": "kGH2O", + "name": "destChip", + "descendants": { + "O1PqF5": { + "content": "Project default" + } + } + }, + { + "type": "text", + "id": "dpfwv", + "name": "destStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "s3NIhS", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "go2el", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "whKB6", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "EGc8u", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "ZbFLM", + "type": "ref", + "ref": "hl7R3", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "PBYzs", + "name": "extendedRow", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "sCZf3", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Mfuhy", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "aWWNb", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "UMdYk", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "tOUx0", + "type": "ref", + "ref": "hl7R3", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "CVSFy", + "name": "installIdRow", + "width": "fill_container", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "H3mIM", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zYBBg", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "T8oP2F", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "bjBd7", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "iAK13", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "qfypH", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "c4Gkg", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "UWltB", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "DW45f", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "OxLcj", + "name": "previewJson", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"version\": \"0.3.0\",\n \"servers\": 3,\n \"templates\": 7,\n \"ext\": {\n \"schema\": 1,\n \"installId\": \"3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10\",\n \"env\": { \"k8s\": \"1.31\", \"distro\": \"k3s\", \"arch\": [\"amd64\"], \"nodes\": \"1\" },\n \"games\": { \"official\": { \"minecraft-java\": 2, \"terraria\": 1 }, \"custom\": 0 },\n \"features\": {\n \"wakeOnConnect\": true, \"tunnels\": [\"playit\"], \"capture\": false,\n \"backups\": true, \"sso\": false, \"auditForwarding\": false,\n \"clusters\": \"1\", \"db\": \"sqlite\", \"language\": \"en\"\n },\n \"key\": \"pV0mY3Qn8wK2b7cXlR4sTt9uJ1eHaZ6dFgQmNoP5rSk\",\n \"sentAt\": \"2026-10-06T09:12:44Z\"\n }\n}", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "FpkUc", + "name": "statusRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Hb6X1", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "circle-check", + "library": "Material Symbols Rounded", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "oIV6b", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last report sent successfully on Oct 6, 2026, 09:12 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qIBwu", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "K2cB7L", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "PWcdt", + "layoutPosition": "absolute", + "x": 0, + "y": 0, + "name": "resetScrim", + "width": 1440, + "height": 1031, + "fill": "#00000066", + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "id": "g69jZL", + "type": "ref", + "ref": "TzRet", + "name": "Reset ID confirm", + "descendants": { + "ocSbw": { + "content": "Reset install ID?" + }, + "rs23G": { + "content": "This replaces your random install ID 3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10 with a new one. The old ID is never sent again, and the destination cannot link the old and new IDs. The next report is sent under the new ID." + }, + "RAlJp": { + "content": "Cancel" + }, + "i56eAy": { + "content": "Reset ID" + } + } + } + ] + } + ] +} diff --git a/design-export/json/zj5a3.json b/design-export/json/zj5a3.json new file mode 100644 index 000000000..710238b1e --- /dev/null +++ b/design-export/json/zj5a3.json @@ -0,0 +1,1349 @@ +{ + "type": "frame", + "id": "zj5a3", + "x": 13120, + "y": 27204, + "name": "Screen/Dashboard Home — Telemetry notice (default)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1380, + "fill": "$background/background", + "children": [ + { + "id": "PxCc3", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container" + }, + { + "type": "frame", + "id": "bp3OL", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "vSp7k", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gb27k": { + "fontFamily": "$typography/font-sans" + } + } + }, + { + "type": "frame", + "id": "fz58Y", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "id": "hCjBQ", + "type": "ref", + "ref": "lh8p8", + "name": "Telemetry Notice", + "width": "fill_container", + "descendants": { + "dzZwq": { + "content": "telemetry.gameplane.net", + "name": "host" + }, + "jCJTc": { + "content": ", the Gameplane project's telemetry service." + } + } + }, + { + "id": "xh7ww", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "VbHwU": { + "content": "Your game servers and resources across authorized locations." + }, + "TgdLz": { + "children": [ + { + "id": "mTzRU", + "type": "ref", + "ref": "tpKRk", + "name": "Create Server Button", + "descendants": { + "q58In/Xpn7K": { + "library": "lucide", + "icon": "plus" + }, + "q58In/U6q13B": { + "content": "Create server" + } + } + } + ] + } + } + }, + { + "id": "K3F7v", + "type": "ref", + "ref": "AT7ya", + "name": "Dashboard location filter", + "width": 240, + "context": "Optional location filter; All locations combines authorized inventory.", + "descendants": { + "cFsFk": { + "content": "All locations", + "fill": "$foreground/foreground" + } + } + }, + { + "id": "gz2vP", + "type": "ref", + "ref": "YF85x", + "name": "Stats Row", + "width": "fill_container", + "descendants": { + "m20CHt/QdcEm": { + "fill": "#000000" + }, + "pkvT2/QdcEm": { + "fill": "$chart/stat" + }, + "FlXsH/QdcEm": { + "fill": "$chart/stat" + } + } + }, + { + "type": "frame", + "id": "cqhug", + "name": "Row2", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "NRr9X", + "name": "Fleet Status Card", + "width": "fill_container", + "height": 372, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "dg2VY", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HMyb7", + "name": "title", + "fill": "$foreground/foreground", + "content": "Fleet status", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "XXrLg", + "name": "link", + "fill": "$muted", + "content": "1 servers", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oqSbh", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "D3Yex", + "name": "Health Bar", + "clip": true, + "width": "fill_container", + "height": 8, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "id": "TO30o", + "name": "Running Segment", + "fill": "$success/success", + "width": "fill_container", + "height": 8 + } + ] + }, + { + "type": "frame", + "id": "kYaLY", + "name": "Legend", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "LvQn3", + "name": "legend-Running 1", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "jrucE", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "p1H79e", + "name": "label", + "fill": "$muted", + "content": "Running 1", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IH9RZ", + "name": "legend-Stopped 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "r0nSx", + "name": "dot", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "DDmOy", + "name": "label", + "fill": "$muted", + "content": "Stopped 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "I6mrG", + "name": "legend-Failed 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "iLeE6", + "name": "dot", + "fill": "$danger/danger", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "cfRcE", + "name": "label", + "fill": "$muted", + "content": "Failed 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "DDXsp", + "name": "Attention Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "OMtsl", + "name": "attnLabel", + "fill": "$muted", + "content": "NEEDS ATTENTION", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "LXG8k", + "name": "healthyRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "jPq2V", + "name": "check", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "WDpub", + "name": "healthyLabel", + "fill": "$muted", + "content": "Everything looks healthy.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pLUNS", + "name": "Cluster Resources Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "yOlKQ", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Qk2q8", + "name": "title", + "fill": "$foreground/foreground", + "content": "Cluster resources", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "EPIHR", + "name": "link", + "fill": "$accent/accent", + "content": "View cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GEYNd", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "vF4ot", + "name": "Meters", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Ggykh", + "name": "CPU Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "Kr88b", + "name": "CPU Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "h9MjN9", + "name": "CPU Label", + "fill": "$muted", + "content": "CPU", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "I6RpH", + "name": "CPU Pct", + "fill": "$foreground/foreground", + "content": "0%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "E6WE6m", + "name": "CPU Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "e1rjs", + "name": "CPU Fill", + "fill": "$accent/accent", + "width": 0, + "height": 6 + } + ] + }, + { + "type": "text", + "id": "DwEjp", + "name": "CPU Sub", + "fill": "$muted", + "content": "0 / 12 cores", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "pecFX", + "name": "Memory Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "m6U5Mq", + "name": "Memory Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "GDVyA", + "name": "Memory Label", + "fill": "$muted", + "content": "Memory", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KonJs", + "name": "Memory Pct", + "fill": "$foreground/foreground", + "content": "0%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "v3nSG5", + "name": "Memory Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "fOM5A", + "name": "Memory Fill", + "fill": "$accent/accent", + "width": 0, + "height": 6 + } + ] + }, + { + "type": "text", + "id": "njEGI", + "name": "Memory Sub", + "fill": "$muted", + "content": "0 B / 41 GB", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PiQH5", + "name": "Storage Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "VFy66", + "name": "Storage Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "raekr", + "name": "Storage Label", + "fill": "$muted", + "content": "Storage", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ZeuDV", + "name": "Storage Pct", + "fill": "$foreground/foreground", + "content": "25%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "omSap", + "name": "Storage Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "UedLK", + "name": "Storage Fill", + "fill": "$success/success", + "width": 110, + "height": 6 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "IWgvk", + "name": "Nodes Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "Ys5im", + "name": "nodesLabel", + "fill": "$muted", + "content": "NODES", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "t55MW", + "name": "Node kubelab-control", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "S4OL1", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "x3scf", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-control", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vsngF", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "n723ny", + "name": "Node kubelab-worker-1", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "NKzVP", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "VQLrM", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-worker-1", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ESfx6", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "g2cJ4", + "name": "Node kubelab-worker-2", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "v1aW9", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "NxE0t", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-worker-2", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WCx9m", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "J7ZYVN", + "name": "Row3", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "K8p3TH", + "name": "Central audit activity", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "YS2uu", + "name": "Central audit scope", + "fill": "$muted", + "content": "Central Gameplane audit activity", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "IhEu2", + "name": "Recent Activity Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "bUuCK", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "V6LuQD", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent activity", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "a5VbzV", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "W0p8pu", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "eAC5T", + "name": "anonymous created-4h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ViwcI", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "lciyY", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "hh61z", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "CT4pX", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "xsdZ4", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "r75RcD", + "name": "time", + "fill": "$muted", + "content": "4h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bul6Z", + "name": "admin created-18h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "dUviB", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "hVsy7", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "EOHQr", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "HMjal", + "name": "title", + "fill": "$foreground/foreground", + "content": "admin created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Y9R0c2", + "name": "path", + "fill": "$muted", + "content": "POST /servers", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "i60vAz", + "name": "time", + "fill": "$muted", + "content": "18h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "nxuKK", + "name": "anonymous created-22h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zNIzy", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "qypFe", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "B4jH3m", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "QZFNp", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "otBBJ", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "M48Ze", + "name": "time", + "fill": "$muted", + "content": "22h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "W6hX1O", + "name": "anonymous created-1d ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "YBw2I", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "jYZhF", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "kXmT3", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "z09x3a", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "B01Xa", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "jMU20", + "name": "time", + "fill": "$muted", + "content": "1d ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "g9CMr", + "name": "anonymous created-1d ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RoY79", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "z1ImCY", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "tOx2y", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "ldBF7", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "H2Fxj6", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "EyWlk", + "name": "time", + "fill": "$muted", + "content": "1d ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "L6QZba", + "name": "Recent Backups Card", + "width": "fill_container", + "height": 353, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "uUt7L", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "xNVj0", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent backups", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "AhXv3", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "n4Rzpy", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 40, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qfhEB", + "name": "empty", + "fill": "$muted", + "content": "No backups yet.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/design-export/screenshots/DBQyF.png b/design-export/screenshots/DBQyF.png new file mode 100644 index 000000000..e4e2816cb Binary files /dev/null and b/design-export/screenshots/DBQyF.png differ diff --git a/design-export/screenshots/GR1G9.png b/design-export/screenshots/GR1G9.png new file mode 100644 index 000000000..e0d7b72f5 Binary files /dev/null and b/design-export/screenshots/GR1G9.png differ diff --git a/design-export/screenshots/K5XinZ.png b/design-export/screenshots/K5XinZ.png new file mode 100644 index 000000000..b7aa1729e Binary files /dev/null and b/design-export/screenshots/K5XinZ.png differ diff --git a/design-export/screenshots/cDiNb.png b/design-export/screenshots/cDiNb.png new file mode 100644 index 000000000..3e78cc12e Binary files /dev/null and b/design-export/screenshots/cDiNb.png differ diff --git a/design-export/screenshots/l7TRz9.png b/design-export/screenshots/l7TRz9.png new file mode 100644 index 000000000..5c533c30c Binary files /dev/null and b/design-export/screenshots/l7TRz9.png differ diff --git a/design-export/screenshots/lh8p8.png b/design-export/screenshots/lh8p8.png new file mode 100644 index 000000000..ccbffb81b Binary files /dev/null and b/design-export/screenshots/lh8p8.png differ diff --git a/design-export/screenshots/r0heh.png b/design-export/screenshots/r0heh.png new file mode 100644 index 000000000..e374a7ee0 Binary files /dev/null and b/design-export/screenshots/r0heh.png differ diff --git a/design-export/screenshots/yrxK2.png b/design-export/screenshots/yrxK2.png new file mode 100644 index 000000000..e783f8a45 Binary files /dev/null and b/design-export/screenshots/yrxK2.png differ diff --git a/design-export/screenshots/zj5a3.png b/design-export/screenshots/zj5a3.png new file mode 100644 index 000000000..f85e382e1 Binary files /dev/null and b/design-export/screenshots/zj5a3.png differ diff --git a/design.pen b/design.pen index 24c5c6cab..7adf0f426 100644 --- a/design.pen +++ b/design.pen @@ -122945,6 +122945,6194 @@ } } ] + }, + { + "type": "frame", + "id": "lh8p8", + "x": 11480, + "y": 27204, + "name": "Gameplane/Telemetry Notice Banner", + "reusable": true, + "width": 1100, + "fill": "$accent/soft", + "cornerRadius": 8, + "stroke": "$accent/soft-foreground", + "strokeWidth": 1, + "strokeAlignment": "inner", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "icon", + "id": "jnKcH", + "name": "tnIcon", + "width": 20, + "height": 20, + "icon": "info", + "library": "lucide", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "TOsXx", + "name": "tnContents", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "text", + "id": "xfEiQ", + "name": "tnTitle", + "fill": "$accent/soft-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Anonymous usage metrics are on for this install.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "WIzDP", + "name": "tnDestRow", + "width": "fill_container", + "gap": 4, + "children": [ + { + "type": "text", + "id": "f6sfW", + "name": "destLabel", + "fill": "$foreground/foreground", + "content": "Reports are sent to", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "dzZwq", + "name": "host", + "fill": "$accent/soft-foreground", + "content": "telemetry.gameplane.net", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "jCJTc", + "name": "tnDestSuffix", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": ", the Gameplane project's telemetry service.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JJ906", + "name": "tnFieldCols", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "kLCfk", + "name": "tnBasic", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "yC32k", + "name": "basicLabel", + "fill": "$foreground/foreground", + "content": "Basic metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "D0wKt", + "name": "basicDesc", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Gameplane version, number of servers, number of templates.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "nB5zt", + "name": "tnExtended", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "d6ULPb", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Extended metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 13, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Aj65h", + "name": "extDesc", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Kubernetes version, distribution, CPU architecture and node-count band; servers per official game module (others counted as custom); which optional features are in use; and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Qs5j2", + "name": "tnActions", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "id": "lFiXj", + "type": "ref", + "ref": "z9ShNE", + "name": "tnKeep", + "descendants": { + "nWTJb": { + "enabled": false + }, + "IVgQ8/U1xvSo": { + "content": "Keep sharing" + } + } + }, + { + "id": "tgQa9", + "type": "ref", + "ref": "d5N3W3", + "name": "tnExtOff", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Turn off extended" + } + } + }, + { + "id": "nF9qA", + "type": "ref", + "ref": "d5N3W3", + "name": "tnAllOff", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Turn off all" + } + } + }, + { + "type": "frame", + "id": "aXQdE", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "Sstlp", + "name": "tnLinks", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "EimG7", + "name": "tnOpenSettings", + "fill": "$foreground/link", + "content": "Open Telemetry settings", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ayd49", + "name": "sep1", + "fill": "$muted", + "content": "·", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "BaWOS", + "name": "tnStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zj5a3", + "x": 13120, + "y": 27204, + "name": "Screen/Dashboard Home — Telemetry notice (default)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1380, + "fill": "$background/background", + "children": [ + { + "id": "PxCc3", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container" + }, + { + "type": "frame", + "id": "bp3OL", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "vSp7k", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gb27k": { + "fontFamily": "$typography/font-sans" + } + } + }, + { + "type": "frame", + "id": "fz58Y", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "id": "hCjBQ", + "type": "ref", + "ref": "lh8p8", + "name": "Telemetry Notice", + "width": "fill_container", + "descendants": { + "dzZwq": { + "content": "telemetry.gameplane.net", + "name": "host" + }, + "jCJTc": { + "content": ", the Gameplane project's telemetry service." + } + } + }, + { + "id": "xh7ww", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "VbHwU": { + "content": "Your game servers and resources across authorized locations." + }, + "TgdLz": { + "children": [ + { + "id": "mTzRU", + "type": "ref", + "ref": "tpKRk", + "name": "Create Server Button", + "descendants": { + "q58In/Xpn7K": { + "library": "lucide", + "icon": "plus" + }, + "q58In/U6q13B": { + "content": "Create server" + } + } + } + ] + } + } + }, + { + "id": "K3F7v", + "type": "ref", + "ref": "AT7ya", + "name": "Dashboard location filter", + "width": 240, + "context": "Optional location filter; All locations combines authorized inventory.", + "descendants": { + "cFsFk": { + "content": "All locations", + "fill": "$foreground/foreground" + } + } + }, + { + "id": "gz2vP", + "type": "ref", + "ref": "YF85x", + "name": "Stats Row", + "width": "fill_container", + "descendants": { + "m20CHt/QdcEm": { + "fill": "#000000" + }, + "pkvT2/QdcEm": { + "fill": "$chart/stat" + }, + "FlXsH/QdcEm": { + "fill": "$chart/stat" + } + } + }, + { + "type": "frame", + "id": "cqhug", + "name": "Row2", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "NRr9X", + "name": "Fleet Status Card", + "width": "fill_container", + "height": 372, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "dg2VY", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HMyb7", + "name": "title", + "fill": "$foreground/foreground", + "content": "Fleet status", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "XXrLg", + "name": "link", + "fill": "$muted", + "content": "1 servers", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "oqSbh", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "D3Yex", + "name": "Health Bar", + "clip": true, + "width": "fill_container", + "height": 8, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "id": "TO30o", + "name": "Running Segment", + "fill": "$success/success", + "width": "fill_container", + "height": 8 + } + ] + }, + { + "type": "frame", + "id": "kYaLY", + "name": "Legend", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "LvQn3", + "name": "legend-Running 1", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "jrucE", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "p1H79e", + "name": "label", + "fill": "$muted", + "content": "Running 1", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IH9RZ", + "name": "legend-Stopped 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "r0nSx", + "name": "dot", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "DDmOy", + "name": "label", + "fill": "$muted", + "content": "Stopped 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "I6mrG", + "name": "legend-Failed 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "iLeE6", + "name": "dot", + "fill": "$danger/danger", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "cfRcE", + "name": "label", + "fill": "$muted", + "content": "Failed 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "DDXsp", + "name": "Attention Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "OMtsl", + "name": "attnLabel", + "fill": "$muted", + "content": "NEEDS ATTENTION", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "LXG8k", + "name": "healthyRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "jPq2V", + "name": "check", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "WDpub", + "name": "healthyLabel", + "fill": "$muted", + "content": "Everything looks healthy.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pLUNS", + "name": "Cluster Resources Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "yOlKQ", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Qk2q8", + "name": "title", + "fill": "$foreground/foreground", + "content": "Cluster resources", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "EPIHR", + "name": "link", + "fill": "$accent/accent", + "content": "View cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GEYNd", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "vF4ot", + "name": "Meters", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "Ggykh", + "name": "CPU Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "Kr88b", + "name": "CPU Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "h9MjN9", + "name": "CPU Label", + "fill": "$muted", + "content": "CPU", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "I6RpH", + "name": "CPU Pct", + "fill": "$foreground/foreground", + "content": "0%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "E6WE6m", + "name": "CPU Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "e1rjs", + "name": "CPU Fill", + "fill": "$accent/accent", + "width": 0, + "height": 6 + } + ] + }, + { + "type": "text", + "id": "DwEjp", + "name": "CPU Sub", + "fill": "$muted", + "content": "0 / 12 cores", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "pecFX", + "name": "Memory Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "m6U5Mq", + "name": "Memory Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "GDVyA", + "name": "Memory Label", + "fill": "$muted", + "content": "Memory", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KonJs", + "name": "Memory Pct", + "fill": "$foreground/foreground", + "content": "0%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "v3nSG5", + "name": "Memory Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "fOM5A", + "name": "Memory Fill", + "fill": "$accent/accent", + "width": 0, + "height": 6 + } + ] + }, + { + "type": "text", + "id": "njEGI", + "name": "Memory Sub", + "fill": "$muted", + "content": "0 B / 41 GB", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PiQH5", + "name": "Storage Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "VFy66", + "name": "Storage Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "raekr", + "name": "Storage Label", + "fill": "$muted", + "content": "Storage", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ZeuDV", + "name": "Storage Pct", + "fill": "$foreground/foreground", + "content": "25%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "omSap", + "name": "Storage Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "UedLK", + "name": "Storage Fill", + "fill": "$success/success", + "width": 110, + "height": 6 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "IWgvk", + "name": "Nodes Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "Ys5im", + "name": "nodesLabel", + "fill": "$muted", + "content": "NODES", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "t55MW", + "name": "Node kubelab-control", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "S4OL1", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "x3scf", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-control", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vsngF", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "n723ny", + "name": "Node kubelab-worker-1", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "NKzVP", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "VQLrM", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-worker-1", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ESfx6", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "g2cJ4", + "name": "Node kubelab-worker-2", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "v1aW9", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "NxE0t", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-worker-2", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WCx9m", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "J7ZYVN", + "name": "Row3", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "K8p3TH", + "name": "Central audit activity", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "YS2uu", + "name": "Central audit scope", + "fill": "$muted", + "content": "Central Gameplane audit activity", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "IhEu2", + "name": "Recent Activity Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "bUuCK", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "V6LuQD", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent activity", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "a5VbzV", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "W0p8pu", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "eAC5T", + "name": "anonymous created-4h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "ViwcI", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "lciyY", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "hh61z", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "CT4pX", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "xsdZ4", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "r75RcD", + "name": "time", + "fill": "$muted", + "content": "4h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bul6Z", + "name": "admin created-18h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "dUviB", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "hVsy7", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "EOHQr", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "HMjal", + "name": "title", + "fill": "$foreground/foreground", + "content": "admin created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Y9R0c2", + "name": "path", + "fill": "$muted", + "content": "POST /servers", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "i60vAz", + "name": "time", + "fill": "$muted", + "content": "18h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "nxuKK", + "name": "anonymous created-22h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zNIzy", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "qypFe", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "B4jH3m", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "QZFNp", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "otBBJ", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "M48Ze", + "name": "time", + "fill": "$muted", + "content": "22h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "W6hX1O", + "name": "anonymous created-1d ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "YBw2I", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "jYZhF", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "kXmT3", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "z09x3a", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "B01Xa", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "jMU20", + "name": "time", + "fill": "$muted", + "content": "1d ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "g9CMr", + "name": "anonymous created-1d ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RoY79", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "z1ImCY", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "tOx2y", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "ldBF7", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "H2Fxj6", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "EyWlk", + "name": "time", + "fill": "$muted", + "content": "1d ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "L6QZba", + "name": "Recent Backups Card", + "width": "fill_container", + "height": 353, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "uUt7L", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "xNVj0", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent backups", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "AhXv3", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "n4Rzpy", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 40, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qfhEB", + "name": "empty", + "fill": "$muted", + "content": "No backups yet.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "l7TRz9", + "x": 11480, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (default)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1031, + "fill": "$background/background", + "children": [ + { + "id": "VJan2", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "f0b9q", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "YCCTY", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "KDu8M", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "ckrv3", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "aRwmV", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "bBTbp", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "k6Yt5", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "eJnTo", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "akS4L", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "viQWf", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "H0uGxR", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "globe", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "jXaCa", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "FkDph", + "name": "host", + "fill": "$foreground/foreground", + "content": "Sending to telemetry.gameplane.net", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "VcHN2", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The Gameplane project's telemetry service.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "GXzqz", + "type": "ref", + "ref": "kGH2O", + "name": "destChip", + "descendants": { + "O1PqF5": { + "content": "Project default" + } + } + }, + { + "type": "text", + "id": "kz2Hz", + "name": "destStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "oiqgN", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "a5Gz9E", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "w6elN", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "oOnYs", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "mt1Jr", + "type": "ref", + "ref": "hl7R3", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "iDkU7", + "name": "extendedRow", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "G3kwa", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "eqcqd", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "l5jHVa", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "mCOpV", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "p9TqED", + "type": "ref", + "ref": "hl7R3", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "G6UKye", + "name": "installIdRow", + "width": "fill_container", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "N8PiP7", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "nQFKU", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "G1enxe", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "iOxRj", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "RYoue", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "cvxBf", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "fl698", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "MUfXb", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "tRekr", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "Q5r0x", + "name": "previewJson", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"version\": \"0.3.0\",\n \"servers\": 3,\n \"templates\": 7,\n \"ext\": {\n \"schema\": 1,\n \"installId\": \"3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10\",\n \"env\": { \"k8s\": \"1.31\", \"distro\": \"k3s\", \"arch\": [\"amd64\"], \"nodes\": \"1\" },\n \"games\": { \"official\": { \"minecraft-java\": 2, \"terraria\": 1 }, \"custom\": 0 },\n \"features\": {\n \"wakeOnConnect\": true, \"tunnels\": [\"playit\"], \"capture\": false,\n \"backups\": true, \"sso\": false, \"auditForwarding\": false,\n \"clusters\": \"1\", \"db\": \"sqlite\", \"language\": \"en\"\n },\n \"key\": \"pV0mY3Qn8wK2b7cXlR4sTt9uJ1eHaZ6dFgQmNoP5rSk\",\n \"sentAt\": \"2026-10-06T09:12:44Z\"\n }\n}", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "xUV9o", + "name": "statusRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "r8eVe", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "circle-check", + "library": "Material Symbols Rounded", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "v07BWT", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last report sent successfully on Oct 6, 2026, 09:12 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vvERC", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "XzN1k", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "GR1G9", + "x": 14760, + "y": 27204, + "name": "Screen/Dashboard Home — Telemetry notice (bundled)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1380, + "fill": "$background/background", + "children": [ + { + "id": "O7GOL", + "type": "ref", + "ref": "kKFX9", + "name": "App Sidebar", + "height": "fill_container" + }, + { + "type": "frame", + "id": "q8Uej", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "F9uobE", + "type": "ref", + "ref": "gu5WY", + "name": "Top Bar", + "width": "fill_container", + "descendants": { + "Gb27k": { + "fontFamily": "$typography/font-sans" + } + } + }, + { + "type": "frame", + "id": "RrWXd", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "gap": 24, + "padding": 32, + "children": [ + { + "id": "rj03O", + "type": "ref", + "ref": "lh8p8", + "name": "Telemetry Notice", + "width": "fill_container", + "descendants": { + "dzZwq": { + "content": "gameplane-telemetry-receiver.gameplane.svc", + "name": "host (SAMPLE)" + }, + "jCJTc": { + "content": ", the receiver bundled with this install. Reports stay inside your cluster." + } + } + }, + { + "id": "VyEYQ", + "type": "ref", + "ref": "xCDF7", + "name": "Page Header", + "width": "fill_container", + "descendants": { + "VbHwU": { + "content": "Your game servers and resources across authorized locations." + }, + "TgdLz": { + "children": [ + { + "id": "bI3je", + "type": "ref", + "ref": "tpKRk", + "name": "Create Server Button", + "descendants": { + "q58In/Xpn7K": { + "library": "lucide", + "icon": "plus" + }, + "q58In/U6q13B": { + "content": "Create server" + } + } + } + ] + } + } + }, + { + "id": "Dc5TC", + "type": "ref", + "ref": "AT7ya", + "name": "Dashboard location filter", + "width": 240, + "context": "Optional location filter; All locations combines authorized inventory.", + "descendants": { + "cFsFk": { + "content": "All locations", + "fill": "$foreground/foreground" + } + } + }, + { + "id": "A5HcX", + "type": "ref", + "ref": "YF85x", + "name": "Stats Row", + "width": "fill_container", + "descendants": { + "m20CHt/QdcEm": { + "fill": "#000000" + }, + "pkvT2/QdcEm": { + "fill": "$chart/stat" + }, + "FlXsH/QdcEm": { + "fill": "$chart/stat" + } + } + }, + { + "type": "frame", + "id": "Z6jyj", + "name": "Row2", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "ENX9Y", + "name": "Fleet Status Card", + "width": "fill_container", + "height": 372, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "e6D5J", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dX1T5", + "name": "title", + "fill": "$foreground/foreground", + "content": "Fleet status", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "u0G1MU", + "name": "link", + "fill": "$muted", + "content": "1 servers", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZcBsE", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "ugMqf", + "name": "Health Bar", + "clip": true, + "width": "fill_container", + "height": 8, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "id": "sPxJn", + "name": "Running Segment", + "fill": "$success/success", + "width": "fill_container", + "height": 8 + } + ] + }, + { + "type": "frame", + "id": "D6Jupi", + "name": "Legend", + "gap": 20, + "children": [ + { + "type": "frame", + "id": "wqlmt", + "name": "legend-Running 1", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "b7HqI", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "GHX30", + "name": "label", + "fill": "$muted", + "content": "Running 1", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eUDfB", + "name": "legend-Stopped 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "HI5Td", + "name": "dot", + "fill": "$muted", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "Ga3Nd", + "name": "label", + "fill": "$muted", + "content": "Stopped 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PlhV4", + "name": "legend-Failed 0", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "Q5FRz0", + "name": "dot", + "fill": "$danger/danger", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "kmn6P", + "name": "label", + "fill": "$muted", + "content": "Failed 0", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "dgLC2", + "name": "Attention Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "B1HSUR", + "name": "attnLabel", + "fill": "$muted", + "content": "NEEDS ATTENTION", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "XrCOg", + "name": "healthyRow", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Uc7Gc", + "name": "check", + "width": 16, + "height": 16, + "icon": "circle-check", + "library": "lucide", + "fill": "$success/success" + }, + { + "type": "text", + "id": "JFJZx", + "name": "healthyLabel", + "fill": "$muted", + "content": "Everything looks healthy.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wQFe0", + "name": "Cluster Resources Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "RBzCd", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RQqqG", + "name": "title", + "fill": "$foreground/foreground", + "content": "Cluster resources", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "X4V95B", + "name": "link", + "fill": "$accent/accent", + "content": "View cluster", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kjAUx", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "NvvbZ", + "name": "Meters", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "GnpUm", + "name": "CPU Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "eONo0", + "name": "CPU Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "JxbI7", + "name": "CPU Label", + "fill": "$muted", + "content": "CPU", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WYkDf", + "name": "CPU Pct", + "fill": "$foreground/foreground", + "content": "0%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "C6OJo", + "name": "CPU Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "yG4gE", + "name": "CPU Fill", + "fill": "$accent/accent", + "width": 0, + "height": 6 + } + ] + }, + { + "type": "text", + "id": "l009rk", + "name": "CPU Sub", + "fill": "$muted", + "content": "0 / 12 cores", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "rTJqh", + "name": "Memory Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "Hu1G6", + "name": "Memory Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "YZJLv", + "name": "Memory Label", + "fill": "$muted", + "content": "Memory", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "MP6GS", + "name": "Memory Pct", + "fill": "$foreground/foreground", + "content": "0%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "VPvVF", + "name": "Memory Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "MukLA", + "name": "Memory Fill", + "fill": "$accent/accent", + "width": 0, + "height": 6 + } + ] + }, + { + "type": "text", + "id": "l1DSuW", + "name": "Memory Sub", + "fill": "$muted", + "content": "0 B / 41 GB", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "UxbGM", + "name": "Storage Meter", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "H1AfzY", + "name": "Storage Row", + "width": "fill_container", + "justifyContent": "space_between", + "children": [ + { + "type": "text", + "id": "nOUxN", + "name": "Storage Label", + "fill": "$muted", + "content": "Storage", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "G3Lpz", + "name": "Storage Pct", + "fill": "$foreground/foreground", + "content": "25%", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IDDuf", + "name": "Storage Bar", + "clip": true, + "width": "fill_container", + "height": 6, + "fill": "$default/default", + "cornerRadius": 999, + "children": [ + { + "type": "rectangle", + "cornerRadius": 999, + "id": "STiGN", + "name": "Storage Fill", + "fill": "$success/success", + "width": 110, + "height": 6 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "H0Io3J", + "name": "Nodes Section", + "width": "fill_container", + "stroke": "$border/border", + "strokeWidth": { + "top": 1 + }, + "strokeAlignment": "inner", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 0, + 0, + 0 + ], + "children": [ + { + "type": "text", + "id": "OEaZr", + "name": "nodesLabel", + "fill": "$muted", + "content": "NODES", + "fontFamily": "$typography/font-sans", + "fontSize": 10, + "fontWeight": "500", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "lwjSY", + "name": "Node kubelab-control", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "Dwcel", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "OUMiG", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-control", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "UP0qr", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZcZng", + "name": "Node kubelab-worker-1", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "KhH6P", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "n8EFt", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-worker-1", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "LOgVF", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Zf5lJ", + "name": "Node kubelab-worker-2", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "qZljR", + "name": "dot", + "fill": "$success/success", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "YW9QB", + "name": "name", + "fill": "$foreground/foreground", + "content": "kubelab-worker-2", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "OP98d", + "name": "meta", + "fill": "$muted", + "content": "0 pods · cpu 0%", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "sW8cJ", + "name": "Row3", + "width": "fill_container", + "gap": 24, + "children": [ + { + "type": "frame", + "id": "lQ9GL", + "name": "Central audit activity", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "NAugs", + "name": "Central audit scope", + "fill": "$muted", + "content": "Central Gameplane audit activity", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "l5q0j", + "name": "Recent Activity Card", + "width": "fill_container", + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "qLKyP", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qjUgW", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent activity", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "D4451", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "HzLFF", + "name": "Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "WJ0bg", + "name": "anonymous created-4h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "EMapf", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "d4jMS", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "MuQeY", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "yps5n", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "NEnAQ", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "S8oW3", + "name": "time", + "fill": "$muted", + "content": "4h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JsY8j", + "name": "admin created-18h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "TurTP", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "u8Qks", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "m50ubv", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "EkV3J", + "name": "title", + "fill": "$foreground/foreground", + "content": "admin created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "zSLBZ", + "name": "path", + "fill": "$muted", + "content": "POST /servers", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "rTfEb", + "name": "time", + "fill": "$muted", + "content": "18h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Mxfl0", + "name": "anonymous created-22h ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "S9q3k", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "TYuis", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "e1lo31", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "ucppN", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "QddRu", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "t0Y6V", + "name": "time", + "fill": "$muted", + "content": "22h ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hpmri", + "name": "anonymous created-1d ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "mXJFx", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "KnnKk", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "lSme8", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "SBjrl", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ZkXuD", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "btIk1", + "name": "time", + "fill": "$muted", + "content": "1d ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "l6D3NG", + "name": "anonymous created-1d ago", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "gEcki", + "name": "iconBox", + "width": 28, + "height": 28, + "fill": "$default/default", + "cornerRadius": 6, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "TJUXX", + "name": "plus", + "width": 14, + "height": 14, + "icon": "plus", + "library": "lucide", + "fill": "$success/success" + } + ] + }, + { + "type": "frame", + "id": "wyy4o", + "name": "textCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "H18z3", + "name": "title", + "fill": "$foreground/foreground", + "content": "anonymous created", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "N4s2yN", + "name": "path", + "fill": "$muted", + "content": "POST /auth/login", + "fontFamily": "$typography/font-sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "T8r0yY", + "name": "time", + "fill": "$muted", + "content": "1d ago", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "ugPLs", + "name": "Recent Backups Card", + "width": "fill_container", + "height": 353, + "fill": "$surface/surface", + "cornerRadius": "$radius/3xl", + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 4 + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "hYoyr", + "name": "Header", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "i9jhMf", + "name": "title", + "fill": "$foreground/foreground", + "content": "Recent backups", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "uRvCC", + "name": "link", + "fill": "$accent/accent", + "content": "View all", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "v0DIq", + "name": "Content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "padding": [ + 40, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "C17hb", + "name": "empty", + "fill": "$muted", + "content": "No backups yet.", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "r0heh", + "x": 13120, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (custom)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1032, + "fill": "$background/background", + "children": [ + { + "id": "K4Pd5", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "p1Tm5", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "wJcHv", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "WeNcI", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "T3U3l2", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "wcqSm", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "VjSvS", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "zMflH", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "D0Ayp4", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "gfaY0", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "O4y4o", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "dNVYp", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "server", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "dT5O6", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "G6EK4E", + "name": "destTitle", + "fill": "$foreground/foreground", + "content": "Sending to telemetry.corp.example.net", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "ODx5q", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Custom destination set by your operator. Nothing is sent to the Gameplane project.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "o0rbrv", + "type": "ref", + "ref": "KmZnF", + "name": "destChip", + "descendants": { + "xZ9ah": { + "content": "Custom" + } + } + }, + { + "type": "text", + "id": "gByiC", + "name": "destStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "v1QNq", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "z0pC9U", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "B0NSMJ", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "EWyuE", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "Wq49L", + "type": "ref", + "ref": "hl7R3", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "VHN8K", + "name": "extendedRow", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "vhhM8", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "yYPjK", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "v1im4", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "VwFtD", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "z1Fib", + "type": "ref", + "ref": "hl7R3", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "u3paX", + "name": "installIdRow", + "width": "fill_container", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "KXAIF", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "XucpG", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "e7wvLd", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "b7e04d12-5a3c-4f98-8d6e-21c9a0f4e7b3", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "QBqy8", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "JR2Iq", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "nKoLh", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "svgri", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "tMdnR", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "l5laXN", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "A8lYG", + "name": "previewJson", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"version\": \"0.3.0\",\n \"servers\": 11,\n \"templates\": 9,\n \"ext\": {\n \"schema\": 1,\n \"installId\": \"b7e04d12-5a3c-4f98-8d6e-21c9a0f4e7b3\",\n \"env\": { \"k8s\": \"1.30\", \"distro\": \"rke2\", \"arch\": [\"amd64\", \"arm64\"], \"nodes\": \"4-10\" },\n \"games\": { \"official\": { \"minecraft-java\": 6, \"valheim\": 4 }, \"custom\": 1 },\n \"features\": {\n \"wakeOnConnect\": true, \"tunnels\": [\"tailscale\"], \"capture\": false,\n \"backups\": true, \"sso\": true, \"auditForwarding\": true,\n \"clusters\": \"2-3\", \"db\": \"postgres\", \"language\": \"en\"\n },\n \"key\": \"Zk4t8Wq1nB6xYv3LmP0cRs7dEhJaUo2FgTiNyQ5bXwA\",\n \"sentAt\": \"2026-10-06T09:12:44Z\"\n }\n}", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "gs2kZ", + "name": "statusRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "uqAgT", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "triangle-alert", + "library": "Material Symbols Rounded", + "fill": "$warning/soft-foreground" + }, + { + "type": "text", + "id": "S1oY8", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last attempt failed, will retry. Last successful report: Oct 5, 2026, 09:10 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "HA1Kb", + "name": "idReplacedRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "U0DP6", + "name": "idReplacedIcon", + "width": 14, + "height": 14, + "icon": "info", + "library": "lucide", + "fill": "$accent/soft-foreground" + }, + { + "type": "text", + "id": "VLMbI", + "name": "idReplacedText", + "fill": "$foreground/foreground", + "content": "Install ID replaced on Oct 4, 2026: the destination reported it was in use by another key.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "i4F3Q", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "KLwdf", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "cDiNb", + "x": 14760, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (bundled)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 777, + "fill": "$background/background", + "children": [ + { + "id": "SZqT4", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "BPHgi", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "g5b2At", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "mvBQN", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "BxiQJ", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "qxsMk", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "x3FpWK", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "j3yT8", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "MecVG", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "qm0J5", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "HSFqE", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "taRYg", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "house", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "LQx4P", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "W1Opd", + "name": "host (SAMPLE)", + "fill": "$foreground/foreground", + "content": "Sending to gameplane-telemetry-receiver.gameplane.svc", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "gGGWe", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The receiver bundled with this install. Reports stay inside your cluster.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "cMhI7", + "type": "ref", + "ref": "UOoQW", + "name": "destChip", + "descendants": { + "YXoPG": { + "content": "Bundled" + } + } + }, + { + "type": "text", + "id": "ICsn1", + "name": "destStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "uLcI7", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "BtYr7", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "p9Uxb", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KQPv9", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "p25s6", + "type": "ref", + "ref": "hl7R3", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "g9uOK", + "name": "extendedRow", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "ErWqF", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "CWscr", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "LwD9N", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "nVxtQ", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "cwXJ3", + "type": "ref", + "ref": "rh2QH", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "QRgzv", + "name": "installIdRow", + "width": "fill_container", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "bbsik", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "JV3Lq", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "qHRdo", + "name": "installIdValue", + "fill": "$muted", + "content": "No install ID while extended metrics are off.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "QUQwC", + "x": 0, + "y": 37, + "name": "installIdHelp", + "enabled": false, + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container(740)", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "PD8Zb", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "opacity": 0.5, + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "Xah7Q", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "x4Za32", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "s1Z4Ct", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "TIblz", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "X6n3I", + "name": "previewJson", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"version\": \"0.3.0\",\n \"servers\": 5,\n \"templates\": 7\n}", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "NZYIA", + "name": "statusRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "bR4pr", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "clock", + "library": "Material Symbols Rounded", + "fill": "$muted" + }, + { + "type": "text", + "id": "SKyZx", + "name": "statusText", + "fill": "$muted", + "content": "No report sent yet.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "R77io", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "V7JPh", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "DBQyF", + "x": 16400, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (disabled)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 641, + "fill": "$background/background", + "children": [ + { + "id": "jb2it", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "CZLXz", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "L5CeQ", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "ZHjUq", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "WaQzQ", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "gnNvq", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "wQExX", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "jigMQ", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "uhp7C", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "T4JamN", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "EGOdH", + "name": "destinationRow", + "width": "fill_container", + "fill": "$warning/soft", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "ZXcWt", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "shield-off", + "library": "Material Symbols Rounded", + "fill": "$warning/soft-foreground" + }, + { + "type": "frame", + "id": "ax0Ht", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "MC4kp", + "name": "destTitle", + "fill": "$foreground/foreground", + "content": "Telemetry is disabled by the operator.", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "Ipare", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "No data of either kind is sent and no telemetry destination is contacted, whatever the switches below say. Only an operator can change this, at install time.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "Wcah8", + "type": "ref", + "ref": "eg3vM", + "name": "destChip", + "descendants": { + "S2JwD": { + "content": "Disabled" + } + } + } + ] + }, + { + "type": "frame", + "id": "s8V4RQ", + "name": "telemetryRow", + "opacity": 0.5, + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "aD3WD", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "e8KSRm", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "UqkyC", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "pv6u1", + "type": "ref", + "ref": "rh2QH", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "d6UNY", + "name": "extendedRow", + "opacity": 0.5, + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "klsL5", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "PksiU", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "eF5fz", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "mFzAM", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "lKdlx", + "type": "ref", + "ref": "rh2QH", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Q5cSk8", + "x": 0, + "y": 218, + "name": "installIdRow", + "enabled": false, + "width": "fill_container(856)", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "QUqUt", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "c62rOD", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Cec0C", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "wxRQV", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "BnNus", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "wyQ2s", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "dDFBA", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "d791Wi", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "thVid", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "c1wLHW", + "name": "previewJson", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Nothing to preview: telemetry is disabled by the operator.", + "lineHeight": 1.5, + "fontFamily": "Geist", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "h4t9WP", + "x": 0, + "y": 395, + "name": "statusRow", + "enabled": false, + "width": "fill_container(856)", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "xpROA", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "circle-check", + "library": "Material Symbols Rounded", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "U0Eb0P", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last report sent successfully on Oct 6, 2026, 09:12 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qHqUB", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "Eq31M", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "opacity": 0.5, + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "K5XinZ", + "x": 18040, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (none)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 605, + "fill": "$background/background", + "children": [ + { + "id": "vrJqn", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "ZDPAQ", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "aaM6h", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "MSfvx", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "K6vVa", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "M4Uamg", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "KnZmD", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "jXdYm", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "ZjXwf", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work. Extended metrics include a random install ID." + } + } + }, + { + "type": "frame", + "id": "usPX3", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "FR1sB", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Lf5Va", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "circle-slash", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "Irbib", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "t67L2", + "name": "destTitle", + "fill": "$foreground/foreground", + "content": "No telemetry destination configured.", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "NHeZ1", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Nothing is sent. An operator can set a custom or bundled destination at install time.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "TGqWC", + "type": "ref", + "ref": "KmZnF", + "name": "destChip", + "descendants": { + "xZ9ah": { + "content": "No destination" + } + } + } + ] + }, + { + "type": "frame", + "id": "u5uN6", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qKiFo", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "E3CsS", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "w7LUN", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "L9Gg2H", + "type": "ref", + "ref": "rh2QH", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "YZ8M5", + "name": "extendedRow", + "opacity": 0.5, + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "MEKOs", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "G8uxgg", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "yop7K", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "DT5P1", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Turn on basic usage metrics to enable extended usage metrics.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "dy9DZ", + "type": "ref", + "ref": "rh2QH", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "j18F4n", + "x": 0, + "y": 182, + "name": "installIdRow", + "enabled": false, + "width": "fill_container(856)", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Y1pNB", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "VY8NU", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "a9Y6S", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KNGZo", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "Xe5vb", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "a13SoT", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "acf1z", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "kjTTh", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "o5GCW", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "ZZcKL", + "name": "previewJson", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Nothing to preview: no destination is configured.", + "lineHeight": 1.5, + "fontFamily": "Geist", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Q3IStt", + "x": 0, + "y": 359, + "name": "statusRow", + "enabled": false, + "width": "fill_container(856)", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "U6DNh", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "circle-check", + "library": "Material Symbols Rounded", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "Vlhfy", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last report sent successfully on Oct 6, 2026, 09:12 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "MxFTb", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "oHhoP", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "yrxK2", + "x": 19680, + "y": 25404, + "name": "Screen/Admin Settings — Telemetry (Reset ID confirm)", + "theme": { + "semantic": "dark" + }, + "clip": true, + "width": 1440, + "height": 1031, + "fill": "$background/background", + "children": [ + { + "id": "SyZg9", + "type": "ref", + "ref": "kKFX9", + "height": "fill_container", + "descendants": { + "iTbd8": { + "fill": "#00000000" + }, + "iTbd8/ghUsw": { + "fill": "$foreground/foreground" + }, + "iTbd8/bpSQ7": { + "fill": "$foreground/foreground" + }, + "ya5KB": { + "fill": "$accent/soft" + }, + "ya5KB/Sr2Q5": { + "fill": "$accent/soft-foreground" + }, + "ya5KB/Ra9NK": { + "fill": "$accent/soft-foreground" + } + } + }, + { + "type": "frame", + "id": "qv0OX", + "name": "Main", + "width": "fill_container", + "height": "fill_container", + "fill": "$background/background", + "layout": "vertical", + "children": [ + { + "id": "h84EC", + "type": "ref", + "ref": "gu5WY", + "width": "fill_container", + "descendants": { + "Gl7AY": { + "content": "Settings" + } + } + }, + { + "type": "frame", + "id": "GcIdA", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "id": "b1lOS", + "type": "ref", + "ref": "xCDF7", + "width": "fill_container", + "descendants": { + "spCmk": { + "content": "Admin settings" + }, + "VbHwU": { + "content": "Platform-wide configuration for this Gameplane instance." + }, + "TgdLz": { + "enabled": false + } + } + }, + { + "type": "frame", + "id": "Ibs1J", + "name": "Settings Layout", + "width": "fill_container", + "height": "fill_container", + "gap": 24, + "children": [ + { + "id": "MNnEE", + "type": "ref", + "ref": "Mp7Ep", + "name": "Settings Nav", + "width": 220, + "descendants": { + "eIjoV": { + "fill": "#00000000" + }, + "ZPLSA": { + "fill": "$surface/secondary" + } + } + }, + { + "id": "vGL2p", + "type": "ref", + "ref": "XDZ0E", + "name": "Settings Panel", + "width": "fill_container", + "children": [ + { + "id": "Mtz0y", + "type": "ref", + "ref": "FRpsu", + "name": "Card Header", + "descendants": { + "fuZHB": { + "content": "Telemetry" + }, + "BZG9o": { + "content": "Anonymous usage metrics help us prioritize work." + } + } + }, + { + "type": "frame", + "id": "VTiD2", + "name": "Card Content", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "ORKLU", + "name": "destinationRow", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "gap": 12, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "HbRz7", + "name": "destIcon", + "width": 20, + "height": 20, + "icon": "globe", + "library": "Material Symbols Rounded", + "fill": "$accent/soft-foreground" + }, + { + "type": "frame", + "id": "wHkVS", + "name": "destText", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zBl0v", + "name": "destTitle", + "fill": "$foreground/foreground", + "content": "Sending to telemetry.gameplane.net", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "e4DXS", + "name": "destDetail", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "The Gameplane project's telemetry service.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "nJfig", + "type": "ref", + "ref": "kGH2O", + "name": "destChip", + "descendants": { + "O1PqF5": { + "content": "Project default" + } + } + }, + { + "type": "text", + "id": "dpfwv", + "name": "destStatement", + "fill": "$foreground/link", + "content": "Data-handling statement", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "s3NIhS", + "name": "telemetryRow", + "width": "fill_container", + "gap": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "go2el", + "name": "labelCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "whKB6", + "name": "Label", + "fill": "$foreground/foreground", + "content": "Send basic usage metrics", + "fontFamily": "$typography/font-sans", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "EGc8u", + "name": "Description", + "fill": "$muted", + "content": "Gameplane version, number of servers and number of templates.", + "fontFamily": "$typography/font-sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "ZbFLM", + "type": "ref", + "ref": "hl7R3", + "name": "basicSwitch" + } + ] + }, + { + "type": "frame", + "id": "PBYzs", + "name": "extendedRow", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "children": [ + { + "type": "frame", + "id": "sCZf3", + "name": "extRowInner", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Mfuhy", + "name": "extLabelCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "aWWNb", + "name": "extLabel", + "fill": "$foreground/foreground", + "content": "Send extended usage metrics", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "UMdYk", + "name": "extDescription", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Adds Kubernetes version, distribution, CPU architecture, node-count band, servers per official game module, which optional features are in use, and a random install ID.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "tOUx0", + "type": "ref", + "ref": "hl7R3", + "name": "extendedSwitch" + } + ] + } + ] + }, + { + "type": "frame", + "id": "CVSFy", + "name": "installIdRow", + "width": "fill_container", + "gap": 16, + "padding": [ + 0, + 0, + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "H3mIM", + "name": "installIdCol", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "zYBBg", + "name": "installIdLabel", + "fill": "$foreground/foreground", + "content": "Install ID", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "T8oP2F", + "name": "installIdValue", + "fill": "$foreground/foreground", + "content": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "bjBd7", + "name": "installIdHelp", + "fill": "$muted", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Random and anonymous. Reset it to start reporting under a new ID; the old ID is never sent again.", + "lineHeight": 1.5, + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "id": "iAK13", + "type": "ref", + "ref": "d5N3W3", + "name": "resetIdBtn", + "descendants": { + "X3iEI": { + "enabled": false + }, + "i3zB62/zMNkR": { + "content": "Reset ID" + } + } + } + ] + }, + { + "type": "frame", + "id": "qfypH", + "name": "previewBlock", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "children": [ + { + "type": "text", + "id": "c4Gkg", + "name": "previewLabel", + "fill": "$foreground/foreground", + "content": "Next report preview", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "UWltB", + "name": "previewHelp", + "fill": "$muted", + "content": "Exactly what the next report contains. Read-only.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "DW45f", + "name": "previewCode", + "width": "fill_container", + "fill": "$surface/secondary", + "cornerRadius": 8, + "stroke": "$border/border", + "strokeWidth": 1, + "strokeAlignment": "inner", + "layout": "vertical", + "padding": [ + 12, + 16 + ], + "children": [ + { + "type": "text", + "id": "OxLcj", + "name": "previewJson", + "fill": "$foreground/foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "{\n \"version\": \"0.3.0\",\n \"servers\": 3,\n \"templates\": 7,\n \"ext\": {\n \"schema\": 1,\n \"installId\": \"3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10\",\n \"env\": { \"k8s\": \"1.31\", \"distro\": \"k3s\", \"arch\": [\"amd64\"], \"nodes\": \"1\" },\n \"games\": { \"official\": { \"minecraft-java\": 2, \"terraria\": 1 }, \"custom\": 0 },\n \"features\": {\n \"wakeOnConnect\": true, \"tunnels\": [\"playit\"], \"capture\": false,\n \"backups\": true, \"sso\": false, \"auditForwarding\": false,\n \"clusters\": \"1\", \"db\": \"sqlite\", \"language\": \"en\"\n },\n \"key\": \"pV0mY3Qn8wK2b7cXlR4sTt9uJ1eHaZ6dFgQmNoP5rSk\",\n \"sentAt\": \"2026-10-06T09:12:44Z\"\n }\n}", + "lineHeight": 1.5, + "fontFamily": "$typography/font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "FpkUc", + "name": "statusRow", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon", + "id": "Hb6X1", + "name": "statusIcon", + "width": 14, + "height": 14, + "icon": "circle-check", + "library": "Material Symbols Rounded", + "fill": "$success/soft-foreground" + }, + { + "type": "text", + "id": "oIV6b", + "name": "statusText", + "fill": "$foreground/foreground", + "content": "Last report sent successfully on Oct 6, 2026, 09:12 UTC.", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "qIBwu", + "name": "Card Actions", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "id": "K2cB7L", + "type": "ref", + "ref": "tpKRk", + "name": "Btn Save Changes", + "descendants": { + "q58In/Xpn7K": { + "enabled": false + }, + "q58In/U6q13B": { + "content": "Save changes" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "PWcdt", + "layoutPosition": "absolute", + "x": 0, + "y": 0, + "name": "resetScrim", + "width": 1440, + "height": 1031, + "fill": "#00000066", + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "id": "g69jZL", + "type": "ref", + "ref": "TzRet", + "name": "Reset ID confirm", + "descendants": { + "ocSbw": { + "content": "Reset install ID?" + }, + "rs23G": { + "content": "This replaces your random install ID 3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10 with a new one. The old ID is never sent again, and the destination cannot link the old and new IDs. The next report is sent under the new ID." + }, + "RAlJp": { + "content": "Cancel" + }, + "i56eAy": { + "content": "Reset ID" + } + } + } + ] + } + ] } ], "themes": { diff --git a/docker-bake.hcl b/docker-bake.hcl index d68662619..0efeedfdd 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -1,10 +1,10 @@ # Bake definition for the e2e images (used by .github/actions/e2e-images). -# `docker buildx bake --load e2e` builds all six concurrently — the +# `docker buildx bake --load e2e` builds all seven concurrently — # compile steps are independent, so this cuts image-build wall time versus # sequential `docker build`s. Tags match what `make e2e-images` produces # and what deploy/kind/e2e.sh loads. group "e2e" { - targets = ["e2e-operator", "e2e-api", "e2e-agent", "e2e-sentinel", "e2e-capture-sidecar", "e2e-fakeoidc"] + targets = ["e2e-operator", "e2e-api", "e2e-agent", "e2e-sentinel", "e2e-capture-sidecar", "e2e-telemetry-receiver", "e2e-fakeoidc"] } target "e2e-operator" { @@ -37,6 +37,14 @@ target "e2e-capture-sidecar" { tags = ["gameplane-test/capture-sidecar:e2e"] } +# The bundled telemetry receiver every e2e cluster deploys (deploy/kind/e2e.sh +# sets api.telemetry.receiver.enabled=true, spec 022 R17). +target "e2e-telemetry-receiver" { + context = "." + dockerfile = "telemetry-receiver/Dockerfile" + tags = ["gameplane-test/telemetry-receiver:e2e"] +} + # The fake OIDC issuer every e2e bucket's cluster bring-up points the # api Deployment's --oidc-issuer at (deploy/kind/e2e.sh), so it lives in # the base "e2e" group rather than being an opt-in target like gameprobe. diff --git a/docs/agent-architecture.md b/docs/agent-architecture.md index 7bc68b795..30f794204 100644 --- a/docs/agent-architecture.md +++ b/docs/agent-architecture.md @@ -27,6 +27,7 @@ This document is the **first stop** for AI agents modifying or extending Gamepla | **Tunnel** | `tunnel/specs.md` | Relay client supervisor for integrating with external tunnels (frp, Tailscale, playit). | | **Audit-Syslog-Bridge** | `audit-syslog-bridge/specs.md` | Relay forwarding HTTP JSON audit events to external syslog servers. | | **Telemetry-Receiver** | `telemetry-receiver/specs.md` | Ingests and processes anonymous usage telemetry from the API. | +| **Telemetryschema** | `telemetryschema/specs.md` | Shared telemetry report contract (types, enumerations, strict decode, official module catalog, report signing) used by the API and the telemetry-receiver. | | **MCP-Server** | `mcp-server/specs.md` | Read-only Model Context Protocol server exposing cluster state to AI tools. | | **Svcutil** | `svcutil/specs.md` | Shared stdlib-only env and graceful-shutdown helpers used by backend components. | diff --git a/docs/architecture.md b/docs/architecture.md index ee221103a..cc9ccb2fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -405,9 +405,18 @@ for the registration flow. sentinel uses a generic packets-in-window heuristic instead. See `gameproto/`. - **API → audit-syslog-bridge**: HTTP-JSON webhook; the bridge forwards plaintext or TLS syslog to the collector for the audit trail, enabled via `api.audit.webhook.syslogBridge.enabled`. -- **API → telemetry-receiver**: the anonymous daily usage report - (admin-toggle gated), auto-wired via `api.telemetry.receiver.enabled` or - aimed at an external URL via `api.telemetry.endpoint`. +- **API → telemetry-receiver**: the daily usage report, in a basic tier + (version, server and template counts) and an extended tier (adds a random + install ID, environment, game and feature categories, and an Ed25519 + signature). New installs send both by default after a first-login notice; + each tier has its own admin toggle, and `api.telemetry.enabled=false` + disables sending entirely. An empty `api.telemetry.endpoint` means the + project's default receiver (`telemetry.gameplane.net`); the bundled receiver + (`api.telemetry.receiver.enabled`) and any other URL replace it. The + receiver keeps daily aggregates in SQLite, serves a token-protected + dashboard on a separate port, and can expose a public five-count summary. + See [install.md](install.md#telemetry) and + [telemetry-provider.md](telemetry-provider.md). - **Operator → capture-sidecar [optional]**: network packet capture sidecar injected into game pods as an ephemeral container; captures AF_PACKET frames matching a BPF filter to PCAPNG output, exposed via mTLS on port `:9091`. diff --git a/docs/dependencies.md b/docs/dependencies.md index b6db502b1..791b5e12d 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -1,9 +1,9 @@ # Dependency report This is a component-by-component inventory of Gameplane's third-party -dependencies and why each one is there. It covers all 15 Go modules that -share `go.work` (`netguard`, `gameaction`, `gameproto`, `svcutil`, -`operator`, `api`, `agent`, `audit-syslog-bridge` [optional], +dependencies and why each one is there. It covers all 16 Go modules that +share `go.work` (`netguard`, `gameaction`, `gameproto`, `telemetryschema`, +`svcutil`, `operator`, `api`, `agent`, `audit-syslog-bridge` [optional], `telemetry-receiver` [optional], `sentinel` [optional], `capture-sidecar` [optional], `tunnel` [optional], `gp-module`, `mcp-server` [optional], `test/e2e`), the @@ -39,6 +39,7 @@ were added and verified on **2026-09-26**. | `netguard/` | Go | 0 (stdlib only) | Shared SSRF dial-guard used by the operator and agent | | `gameaction/` | Go | 0 (stdlib only) | Shared console-injection guard + command-template renderer used by the agent and API | | `gameproto/` | Go | 0 (stdlib only) | Shared Minecraft/Terraria handshake classifier used by sentinel | +| `telemetryschema/` | Go | 0 (stdlib only) | Shared telemetry report contract (types, strict decoder, official module catalog, report signing) used by the API reporter and the telemetry receiver | | `svcutil/` | Go | 0 (stdlib only) | Shared env-parsing + graceful-shutdown helpers (no active importers yet) | | `operator/` | Go | 19 | controller-runtime reconciler for the 8 CRDs; module OCI pull/verify, git/OCI fetch, restic-backup scheduling | | `api/` | Go | 17 | chi REST + WebSocket gateway: auth (local + OIDC), RBAC, K8s client, SQLite/Postgres persistence, notifications | @@ -103,6 +104,22 @@ Imported by: Also a local `replace` module (`replace github.com/GameplanePanel/gameplane/gameproto => ../gameproto` in `sentinel/go.mod`); same Dockerfile `COPY` requirement as netguard. +### telemetryschema + +`telemetryschema/go.mod` has **no `require` block at all** — it imports only the +Go standard library (`bytes`, `crypto/ed25519`, `crypto/hkdf`, `crypto/rand`, +`crypto/sha256`, `embed`, `encoding/base64`, `encoding/hex`, `encoding/json`, +`errors`, `fmt`, `io`, `math`, `regexp`, `slices`, `sort`, `strings`, `time`). It +is the shared telemetry report contract: the basic and extended report types, +the fixed category enumerations and band functions, the strict decoder, the +embedded official-module catalog, and Ed25519 report signing. + +Imported by (spec 022, in progress): +- `telemetry-receiver/` — to decode and validate reports (`Decode`) and to verify signatures (`Verify`). +- `api/` — to build, encode and sign the reporter's payload and the admin preview. + +Both importers use a local `replace` module (`replace github.com/GameplanePanel/gameplane/telemetryschema => ../telemetryschema`); the same Dockerfile `COPY` requirement as netguard applies. + ### svcutil `svcutil/go.mod` has **no `require` block at all** — stdlib only diff --git a/docs/install.md b/docs/install.md index b3383a910..92e313d18 100644 --- a/docs/install.md +++ b/docs/install.md @@ -267,7 +267,8 @@ on `:8090`. Three **off-by-default** chart toggles wire these into a Prometheus-Operator stack (e.g. kube-prometheus-stack): - `serviceMonitors.enabled` — `ServiceMonitor`s so Prometheus scrapes the - operator, API, and telemetry-receiver (when deployed), plus a `PodMonitor` + operator, API, and telemetry-receiver (when deployed with a dashboard + token; its `/metrics` needs that token), plus a `PodMonitor` that scrapes per-GameServer agent metrics from game pods in `gamesNamespace` on their plain, named `metrics` containerPort (`9090`, declared by the operator's `buildAgentContainer`; no TLS, no client cert — @@ -277,8 +278,10 @@ Prometheus-Operator stack (e.g. kube-prometheus-stack): whenever `networkPolicies.enabled` is also `true`. Without it, both the games-namespace default-deny policy (agent metrics port `9090` is not admitted from any namespace by default) and the telemetry-receiver's - `NetworkPolicy` (admitted only from the API pod) leave the `PodMonitor` - and `ServiceMonitor` targets unreachable even though they render. + `NetworkPolicy` (its metrics port `8081` admits only + `api.telemetry.receiver.dashboard.ingressFrom` peers) leave the + `PodMonitor` and `ServiceMonitor` targets unreachable even though they + render. - `prometheusRules.enabled` — a `PrometheusRule` of operator alerts. - `grafanaDashboards.enabled` — a Grafana dashboard `ConfigMap` the Grafana sidecar auto-imports (relabel via `grafanaDashboards.labels` if your sidecar @@ -422,21 +425,116 @@ a slow or down sink never blocks or fails a request. ### Telemetry -Gameplane can report anonymous usage once a day: `{version, servers, -templates}` — no names, namespaces, hostnames, or identifiers. Two -independent gates must both open before anything is sent: the admin -toggle (**Admin Settings → Telemetry → Send anonymous usage metrics**, -off by default) decides *whether*, and the chart decides *where*. With no -destination configured (the default), the reporter never runs. +Gameplane can send two tiers of usage reports, about once a day. Both are +described below, so you can decide before you install. + +**What is sent** + +- **Basic** — exactly three fields: `version` (the Gameplane version), + `servers` (how many GameServers exist) and `templates` (how many + GameTemplates exist). No names, namespaces, hostnames, or addresses. +- **Extended** — the basic fields plus the following, and nothing else: + - a random **install ID** (a UUID generated on your install; it is not + derived from your cluster, host, network, or users) + - environment: Kubernetes minor version (for example `1.31`), a + distribution category (`k3s`, `rke2`, `k0s`, `eks`, `gke`, `aks`, + `openshift`, `microk8s`, `minikube`, `kind`, `doks`, `talos` or + `other`), node CPU architectures, and a node-count band (`1`, `2-3`, + `4-10`, `11-50`, `51+`) + - game usage: a server count for each module from the official catalog, + plus one combined `custom` count for every other module (custom + module names are never sent) + - feature adoption: wake-on-connect (yes/no), relay tunnel types in use + (`frp`, `tailscale`, `playit`), packet capture enabled (yes/no), + backups configured on any server (yes/no), single sign-on (yes/no), + audit forwarding (yes/no), a registered-cluster band (`1`, `2-3`, + `4-10`, `11+`), the database kind (`sqlite` or `postgres`) and the UI + language + - the install's public signing key and the time the report was sent + +Every extended value comes from a fixed set of categories or bands, or is +a number, the install ID, the public key or the send time. A value that +doesn't fit is sent as `other`. Extended reports are signed with a key +derived from a random secret that never leaves your install combined with +the install ID, so nobody can report under your ID with a different key. +Signing proves which key sent a report; it does not prove the install is +real. The install ID is pseudonymous: it lets the receiver tell that two +reports came from the same install, and it is deleted when you turn +extended off. See [Security](security.md#telemetry) for the threat model. + +**Defaults** + +| Install | Basic | Extended | +|---|---|---| +| New install with a destination in effect | on | on | +| Existing install that saved a basic choice | keeps that choice | off | +| Existing install that never saved a choice | off | off | + +On a new install, the first admin to sign in sees a notice that lists both +tiers and names the destination. No report is sent until an admin has seen +the notice. After that, **Admin Settings → Telemetry** shows the +destination, a preview of the exact next report (including the install +ID), the last attempt, separate basic and extended toggles, and an +install-ID reset. Turning basic off turns extended off with it. The +operator's install-time `api.telemetry.enabled=false` always wins over the +toggles. + +The project's data-handling statement, including how long the project +keeps data (daily aggregates for 24 months; per-install activity records +expire after 90 days without a report) is at +. + +> **Upgrading?** Installs that already had **Send anonymous usage +> metrics** switched on, while no destination was configured, saved that +> choice but it had no effect. After upgrading to the release that adds +> the project's default receiver (`telemetry.gameplane.net`), those installs +> start sending **basic** reports to it. Extended stays off until an admin turns it on. If you do +> not want that, set `api.telemetry.enabled=false` before you upgrade, or +> turn the toggle off in Admin Settings. Installs that already pointed at +> a custom `api.telemetry.endpoint` or the bundled receiver keep sending +> only there, never to the project's default receiver. Installs that +> never saved a choice stay off. + +**Where reports go, and how to change it** + +- `api.telemetry.enabled` — set to `false` for a hard off: the API never + sends telemetry, whatever the admin toggles say, and no first-login + notice is shown. Use this for air-gapped and privacy-sensitive + clusters. + ```sh + helm upgrade ... --set api.telemetry.enabled=false + ``` + +- `api.telemetry.endpoint` — when empty (the default), reports go to the + project's default receiver, `https://telemetry.gameplane.net/ingest`. Set a + URL to send them to your own receiver instead (e.g. + `https://telemetry.example.com/ingest`); it replaces the default and never + also sends to it. An install whose endpoint was already + set keeps working unchanged. - `api.telemetry.receiver.enabled` — deploy the bundled - [telemetry-receiver](../telemetry-receiver/README.md) [optional] next to the API - and point the API at it automatically. It logs each report and exposes - aggregate Prometheus metrics (`gameplane_telemetry_reports_total` by - version, fleet-size histograms) on its `/metrics`. -- `api.telemetry.endpoint` — send reports to an external receiver URL - instead (e.g. `https://telemetry.example.com/ingest`); setting it - overrides the receiver auto-wiring. + [telemetry-receiver](../telemetry-receiver/README.md) [optional] next to + the API and point the API at it automatically (when `endpoint` is + empty). The bundled receiver stores daily aggregates in a PVC + (`api.telemetry.receiver.persistence.enabled`, default `true`, 1Gi; the + chart runs a single replica while persistence is on). It serves + aggregate figures on a private dashboard and, optionally, a public + summary; see the next two options. To run a receiver for other + installs, see the [provider runbook](telemetry-provider.md). +- `api.telemetry.receiver.dashboard.tokenSecretRef` — name a Secret (key + `token` by default) holding the dashboard token. With a token set, the + receiver starts a dashboard listener on port `8081` that you reach by + `kubectl port-forward` (or from peers listed in + `api.telemetry.receiver.dashboard.ingressFrom`). Without a token there + is no dashboard. The same token (as a Bearer token) protects the + receiver's `/metrics`, which moved to this port. +- `api.telemetry.receiver.publicSummary.enabled` — set `true` to enable + `GET /v1/summary`, a public, unauthenticated endpoint that returns only + five headline counts. Off by default. +- `api.telemetry.receiver.retentionDays` (default `730`, minimum `365`) + and `api.telemetry.receiver.activityExpiryDays` (default `90`, minimum + `31`) — how long the receiver keeps daily aggregates and per-install + activity records. Lower values fail rendering. - `api.telemetry.authSecretRef` — optional shared ingest token, sourced from a Secret. The API sends it verbatim as the `Authorization` header and the bundled receiver requires it — recommended when the receiver is @@ -445,11 +543,19 @@ destination configured (the default), the reporter never runs. ```sh kubectl -n gameplane-system create secret generic telemetry-ingest \ --from-literal=token='Bearer some-long-random-string' + kubectl -n gameplane-system create secret generic telemetry-dashboard \ + --from-literal=token="$(openssl rand -base64 32)" helm upgrade ... \ --set api.telemetry.receiver.enabled=true \ - --set api.telemetry.authSecretRef.name=telemetry-ingest + --set api.telemetry.authSecretRef.name=telemetry-ingest \ + --set api.telemetry.receiver.dashboard.tokenSecretRef.name=telemetry-dashboard + kubectl -n gameplane-system port-forward svc/gameplane-telemetry-receiver 8081:8081 ``` + Then open `http://localhost:8081` and sign in with the dashboard token. + The receiver's session cookie is `Secure`, so use a browser that treats + `localhost` as a secure origin or put TLS in front. + ## Installing a module The chart ships two `ModuleSource`s: `default` (pulls pre-built bundles from the diff --git a/docs/security.md b/docs/security.md index fd9b8cecc..ad6c20002 100644 --- a/docs/security.md +++ b/docs/security.md @@ -9,7 +9,8 @@ point. Assume: - cluster-internal attackers may land a pod in `gameplane-games` via a compromised game image (Minecraft plugins, Valheim mods, etc.), - the game pods themselves should be treated as low-trust, -- a dashboard user with only `servers:read` must not be able to read game passwords (RCON, admin or server passwords) out of a GameServer. +- a dashboard user with only `servers:read` must not be able to read game passwords (RCON, admin or server passwords) out of a GameServer, +- a usage-telemetry receiver is reachable by anyone on the internet, so it must stay safe when fed hostile or invented reports, and must never reveal figures to an unauthenticated viewer (see [Telemetry](#telemetry)). ## Authentication @@ -728,6 +729,162 @@ a self-hosted API-key reviewer: The review is advisory — a human reviewer must still validate changes before merge — and the tool can be disabled at any time via GitHub's app management UI. +## Telemetry + +Usage telemetry has two ends, each with its own trust boundary. For what +is sent and how to turn it off, see [install.md](install.md#telemetry). +For running a receiver, see [telemetry-provider.md](telemetry-provider.md). +The project's data-handling statement is at +. + +### Public unauthenticated ingest and summary + +A receiver's `/ingest` is open to the internet by design: installs have no +project-issued credential, so it cannot require one. (A self-hosted +receiver can require `AUTH_TOKEN`; the project's default receiver cannot.) +Anyone can therefore post reports. Defences: + +- strict decoding: one JSON object, no duplicate or unknown keys, a 16 KiB + body cap, and every string matched against a pattern or a fixed list, so + hostile text can't create unbounded categories or reach the dashboard +- a per-source daily limit on accepted reports (`INGEST_SOURCE_DAILY_LIMIT`, + default `20`, tolerant of shared NAT) and a rate limit on the public + summary, both held in memory only +- optional proof-of-work (`INGEST_POW`, off by default; see below), which + raises the cost of bulk reports in proportion to the request rate +- only daily aggregates and expiring activity records are stored; raw + reports and source addresses are never written to disk + +`GET /v1/summary` is off unless `PUBLIC_SUMMARY=true`. It returns five +fixed counts and nothing else: no breakdown, no per-day series, no install +ID. It is cached and rate-limited so public polling can't degrade ingest. + +What this does not stop: someone can invent installs and send fabricated +basic reports, within the per-source limits. Figures are approximate and +self-reported, and the dashboard labels them that way. + +### Proof-of-work on ingest + +When a receiver sets `INGEST_POW=true`, `/ingest` requires a solved +challenge from `GET /v1/challenge` in the `Gameplane-Telemetry-PoW` header. +Challenges are HMAC-signed with a key held only in the receiver's memory, +expire after 15 minutes and work once. The difficulty is `INGEST_POW_MIN_BITS` (default zero) while at most +than `INGEST_POW_TARGET_PER_MIN` challenges a minute are issued, then rises +with the rate (up to `INGEST_POW_MAX_BITS`, at most 26) and falls one bit per +five minutes. The check runs before the body is read, so a refused report +costs the receiver one HMAC and one hash, and it answers `428` +(`pow_required` or `pow_invalid`) or, when its used-challenge set (1,000,000 +entries) is full, `503 pow_busy`. Challenge requests have their own +per-source limit. + +What it stops: cheap bulk fabrication. The work a flood must do grows +faster than its rate, while an install sending one report a day pays +nothing under normal load and a few seconds during a flood. It also +needs no per-install secret, so it works against an attacker who holds many +source addresses, which the per-source limits do not. + +What it does not stop: +- a determined or well-resourced sender: SHA-256 is cheap on GPUs and + dedicated hardware, so it deters volume, not a patient attacker +- fabricated installs: solving a challenge proves work was done, not that an + install exists, so invented installs remain possible and figures stay + approximate +- many sources raising the difficulty for everyone: the difficulty is + global and capped by `INGEST_POW_MAX_BITS`; the per-source challenge limit + only stops a single source doing it alone +- anything on the dashboard login: it has no proof-of-work (the dashboard + uses no JavaScript); its defence is the credential minimum below + +Proof-of-work does not replace signing or the per-source daily limit; all +three apply. A restart drops the challenge key and the used set, so +outstanding challenges fail once and installs retry with a new one. + +### The pseudonymous install ID + +The extended tier carries a random UUID. It is pseudonymous, not anonymous: +it links one install's reports over time. It is never derived from the +cluster, host, network or users; it is kept only on the install; an admin +can reset it at any time (the install then looks new to the receiver, and +the old ID lapses); and it is deleted when extended is turned off. The +receiver stores only `HMAC-SHA256(pepper, installID)` and never stores an +extended attribute (environment, games, features) next to it; those exist +only as daily aggregates. + +### Signing: what it does and doesn't stop + +Every extended report is signed (Ed25519) with a key derived from a random +secret kept in the install's database combined with the install ID. The +secret and private key never leave the install. The receiver binds an +install ID to the first valid key that uses it (the claim) and then +refuses, changing no stored data: + +- a bad or missing signature (`403 bad_signature`) +- a send time outside `[now - 36h, now + 1h]` (`403 stale`) +- a different key for a claimed ID (`409 id_claimed`) +- a send time not later than the last accepted one (`403 replay`) + +Signing stops impersonation of an existing install and replayed reports. +It does not stop fabricated installs: anyone can generate a new ID and key +pair, so a flood of invented installs is limited only by the per-source +limits. A claim lasts as long as the activity record (90 days without a +report by default), then the ID can be claimed again. If the receiver says +an ID is claimed by another key, the install replaces its ID and resends +once; any other refusal never rotates the ID. + +### Dashboard authentication and the refusal invariant + +The receiver's dashboard runs on its own listener (`:8081`), started only +when `DASHBOARD_TOKEN` is set; with no token there is no dashboard. Keep +that port off the public internet (the chart's NetworkPolicy admits only +configured peers). It uses one operator token from a Secret, which must be +at least 32 characters (generate it from 32 random bytes); the receiver +refuses to start with a shorter one. Per-source login limits can be +outrun by anyone holding many IPv6 `/64`s, so the token's length, not the +limiter, is what makes guessing infeasible: + +- browser login compares the token in constant time and sets an + `HttpOnly; Secure; SameSite=Strict` session cookie valid 12 hours; login + and logout also require a same-origin `Origin`; logins are limited to 5 + per minute per source +- scripts can send `Authorization: Bearer `; `/metrics` accepts only + the Bearer token +- the cookie key is derived from the token, so replacing the token + invalidates every session and loses no data +- every dashboard response carries a strict `Content-Security-Policy`, with + no inline script or style, and `Cache-Control: no-store` + +**Refusal invariant:** an unauthenticated response never contains a figure, +a date other than page chrome, a version, category or module name, or +anything that varies with whether data exists. A wrong token gets the same +"Invalid credentials" login page whether or not the receiver is empty, and +an unauthenticated JSON request gets `401 {"error":"unauthorized"}`. CI +compares the unauthenticated response against the login page of an empty +receiver. + +### Source-address handling + +The receiver uses the TCP peer address as the source for limits. It trusts +`X-Forwarded-For` only when the peer is inside `TRUSTED_PROXY_CIDRS` +(empty by default), so a client can't pick its own source by setting the +header. IPv4 addresses and IPv6 `/64` prefixes are treated as one source +each. Sources live in memory only (capped at 100,000 per limiter, +least-recently-used evicted), are never logged to the database, and +counters reset at UTC midnight. If you run behind a proxy and leave +`TRUSTED_PROXY_CIDRS` empty, every client shares the proxy's address and +the per-source limits throttle everyone together. + +### The pepper + +`ID_PEPPER` keys the HMAC that turns an install ID into the stored +activity-record key, so a stored value can't be matched to an ID taken +from an install without the pepper. If unset, the receiver generates one +on first start and keeps it in its database, so it is as exposed as the +database file. Set it from a Secret to keep it out of backups of the +database. Changing the pepper makes every existing record unmatchable: all +installs look new for one day and their activity history is lost; no +install is locked out. See +[telemetry-provider.md](telemetry-provider.md#rotating-secrets). + ## API cluster-wide pod list The API's `-api-read` ClusterRole grants `list` on Pods in **every** @@ -784,6 +941,11 @@ Secrets Gameplane reads or creates, by convention: `gameplane.local/notification-sink=true` in the control-plane namespace (user-supplied; referenced by name from Admin Settings → Notifications, read by the API at delivery time — see [notifications](notifications.md)). +- telemetry receiver credentials — `api.telemetry.receiver.dashboard.tokenSecretRef` + (dashboard and `/metrics` token, at least 32 characters) and `api.telemetry.receiver.pepperSecretRef` + (install-ID pepper), both user-supplied and injected as env vars + (`DASHBOARD_TOKEN`, `ID_PEPPER`), never flags. See + [telemetry-provider.md](telemetry-provider.md#rotating-secrets). Rotation: deleting the `-rcon` secret triggers a reconciliation and generates a fresh password on the next pod restart. diff --git a/docs/telemetry-provider.md b/docs/telemetry-provider.md new file mode 100644 index 000000000..ebec85dd4 --- /dev/null +++ b/docs/telemetry-provider.md @@ -0,0 +1,702 @@ +# Running a telemetry provider + +This is the runbook for running a Gameplane telemetry receiver for other +installs: the project maintainers' own default receiver +(`telemetry.gameplane.net`), or one you host yourself. It covers deployment, TLS, secrets, retention, metrics and +backups. It is a runbook only; the repository ships no infrastructure +code for the project's receiver. + +For what installs send, see [install.md](install.md#telemetry). For the +threat model, see [security.md](security.md#telemetry). For the receiver's +behaviour and metrics, see +[`telemetry-receiver/README.md`](../telemetry-receiver/README.md). The +project's data-handling statement is at +. + +## Listeners at a glance + +| Listener | Variable (default) | Serves | Exposure | +|---|---|---|---| +| Public | `LISTEN_ADDR` (`:8080`) | `POST /ingest`, `GET /v1/summary` (only when `PUBLIC_SUMMARY=true`), `GET /v1/challenge` (only when `INGEST_POW=true`), `GET /healthz` | Public internet, behind TLS | +| Dashboard | `DASHBOARD_LISTEN_ADDR` (`:8081`) | login page, dashboard, `/api/v1/views`, `/metrics`, static assets | Private network only | + +The dashboard listener starts only when `DASHBOARD_TOKEN` is set. The +public listener answers `404` for `/metrics`. + +## Deploy the image + +The receiver is a single static binary in a distroless, non-root image: + +```sh +docker run -d --name telemetry-receiver \ + -p 127.0.0.1:8080:8080 -p 127.0.0.1:8081:8081 \ + -v telemetry-data:/data \ + -e DATA_DIR=/data \ + -e DASHBOARD_TOKEN="$(cat dashboard-token)" \ + -e ID_PEPPER="$(cat id-pepper)" \ + -e PUBLIC_SUMMARY=true \ + ghcr.io/gameplanepanel/gameplane/telemetry-receiver:edge +``` + +Notes: + +- Use a release tag instead of `edge` in production. +- The image runs as UID/GID `65532` with a read-only-friendly layout. The + `/data` volume must be writable by that user. +- Without `DATA_DIR` the receiver keeps everything in memory, logs a + warning, and loses it all on restart. Always set it for a real provider. +- Run **one replica** only. The database is a single SQLite file; two + processes on it will conflict. Use a recreate rollout, not a rolling one. +- Kubernetes: see [Deploy on Kubernetes](#deploy-on-kubernetes) for complete + manifests for a standalone provider. The chart's receiver + (`api.telemetry.receiver.enabled=true`) renders the same Deployment shape, + with a PVC at `/data`, a `Recreate` strategy and NetworkPolicies, and is + the right choice for a receiver bundled next to one Gameplane install. + +### Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `LISTEN_ADDR` | `:8080` | Public listener. | +| `DASHBOARD_LISTEN_ADDR` | `:8081` | Dashboard listener. | +| `DASHBOARD_TOKEN` | *(empty)* | Dashboard credential, from a Secret. Empty means no dashboard listener. When set it must be at least 32 characters, or the receiver exits at startup. See [Secrets](#secrets). | +| `AUTH_TOKEN` | *(empty)* | Ingest token. Leave empty on a public provider: installs have no credential to present. | +| `DATA_DIR` | *(empty)* | SQLite directory. Empty means in-memory. | +| `PUBLIC_SUMMARY` | `false` | Enables `GET /v1/summary`. | +| `TRUSTED_PROXY_CIDRS` | *(empty)* | Comma-separated CIDRs whose `X-Forwarded-For` is trusted. | +| `INGEST_SOURCE_DAILY_LIMIT` | `20` | Accepted reports per source per UTC day. `0` means unlimited. | +| `RETENTION_DAYS` | `730` | Daily aggregate retention. Minimum `365`. | +| `ACTIVITY_EXPIRY_DAYS` | `90` | Activity record expiry. Minimum `31`. | +| `ID_PEPPER` | *(empty)* | HMAC pepper for install IDs, from a Secret. Empty means one is generated and kept in the database. | +| `INGEST_POW` | `false` | Requires proof-of-work on `/ingest` and serves `/v1/challenge`. See [Proof-of-work on ingest](#proof-of-work-on-ingest). | +| `INGEST_POW_TARGET_PER_MIN` | `60` | Normal challenge rate: no work is required at or below it. Must be at least `1`. | +| `INGEST_POW_MIN_BITS` | `0` | Lowest difficulty issued. Must not be negative. | +| `INGEST_POW_MAX_BITS` | `22` | Highest difficulty issued. At least `INGEST_POW_MIN_BITS` and at most `26`. | + +A value below a minimum, a malformed `TRUSTED_PROXY_CIDRS` entry, an +unparsable number, a `DASHBOARD_TOKEN` shorter than 32 characters or an +`INGEST_POW_MAX_BITS` outside its range stops the receiver at start with an +error. + +## Deploy on Kubernetes + +This section sets up a standalone provider in its own namespace, `gameplane-telemetry`. It follows the shape of the project's provider at `telemetry.gameplane.net`: Cloudflare's proxy in front, Traefik on k3s as the ingress, a certificate from cert-manager through a Cloudflare DNS-01 challenge, a private dashboard and proof-of-work. + +Other ingress controllers and CDNs work the same way. The parts to adapt are the Ingress annotations, the NetworkPolicy peer and `TRUSTED_PROXY_CIDRS`. The repository ships no manifests for this, so copy the ones below. For a receiver that serves a single Gameplane install, use the chart instead (`api.telemetry.receiver.enabled=true`). + +### Prerequisites + +- k3s (or any cluster) with Traefik as the ingress controller in `kube-system`, configured to trust Cloudflare's forwarded headers. `forwardedHeaders.trustedIPs` on the `web` and `websecure` entry points must list Cloudflare's ranges, and Traefik must see Cloudflare's address as the peer (for example `service.spec.externalTrafficPolicy: Local` with ServiceLB). The [client address check](#check-the-client-address) below confirms both. +- cert-manager. +- The zone (`gameplane.net` here) on Cloudflare, and a Cloudflare API token with Zone:DNS:Edit and Zone:Zone:Read on it. +- A CNI that enforces NetworkPolicies (the kube-router that ships with k3s does). + +### 1. Namespace, secrets and trusted proxies + +```sh +kubectl create namespace gameplane-telemetry + +# Dashboard token: 32 random bytes = 44 base64 characters (the receiver refuses fewer than 32). +kubectl -n gameplane-telemetry create secret generic telemetry-dashboard \ + --from-literal=token="$(openssl rand -base64 32)" + +# ID pepper: set it before the first report and never lose it (see Rotating secrets). +kubectl -n gameplane-telemetry create secret generic telemetry-pepper \ + --from-literal=pepper="$(openssl rand -base64 32)" + +# Trusted proxies: the cluster's pod CIDR (where Traefik runs) plus Cloudflare's published ranges. +# CHANGE ME if your cluster is not on the k3s default. +# Check with: kubectl get nodes -o jsonpath='{.items[*].spec.podCIDR}' +POD_CIDR=10.42.0.0/16 +CF_RANGES=$( { curl -fsS https://www.cloudflare.com/ips-v4; echo; curl -fsS https://www.cloudflare.com/ips-v6; } | grep . | paste -sd, - ) +kubectl -n gameplane-telemetry create configmap telemetry-trusted-proxies \ + --from-literal=cidrs="${POD_CIDR},${CF_RANGES}" \ + --dry-run=client -o yaml | kubectl apply -f - + +# Check the result: the pod CIDR first, then Cloudflare's IPv4 and IPv6 ranges +# (for example 10.42.0.0/16,173.245.48.0/20,...,2400:cb00::/32,...). +kubectl -n gameplane-telemetry get configmap telemetry-trusted-proxies -o jsonpath='{.data.cidrs}'; echo +``` + +The list is a static copy. Cloudflare changes its ranges rarely; when it does, rerun the last two commands and restart the receiver with `kubectl -n gameplane-telemetry rollout restart deploy/telemetry-receiver`, because the variable is read at start. Update Traefik's `forwardedHeaders.trustedIPs` the same way. If you prefer, set `TRUSTED_PROXY_CIDRS` as a literal `value:` in the Deployment instead of the ConfigMap reference; the receiver reads only the environment variable. [How the client address is resolved](#5-how-the-client-address-is-resolved) explains why the list matters. For the token and pepper, see [Secrets](#secrets) and [Rotating secrets](#rotating-secrets). + +### 2. Certificate issuer (cert-manager DNS-01 through Cloudflare) + +Skip this step if you already have a ClusterIssuer for the zone. Otherwise store the Cloudflare API token and create the issuer: + +```sh +# CHANGE ME: your Cloudflare API token. +kubectl -n cert-manager create secret generic cloudflare-api-token \ + --from-literal=api-token='' +``` + +```yaml +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-cloudflare +spec: + acme: + server: https://acme-v02.api.letsencrypt.org/directory + email: you@example.com # CHANGE ME + privateKeySecretRef: + name: letsencrypt-cloudflare-account + solvers: + - dns01: + cloudflare: + apiTokenSecretRef: + name: cloudflare-api-token + key: api-token + selector: + dnsZones: [gameplane.net] # CHANGE ME: your zone +``` + +DNS-01 works with the Cloudflare proxy on, because validation never goes through the proxied hostname. + +### 3. Receiver, Service, Ingress and NetworkPolicy + +Save the following as `telemetry-provider.yaml` and apply it with `kubectl apply -f telemetry-provider.yaml`. It holds five documents: the volume, the receiver, its Service, the Ingress and the NetworkPolicy. + +```yaml +# 1. Storage: the SQLite database lives here (/data/telemetry.db). +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: telemetry-receiver-data + namespace: gameplane-telemetry +spec: + accessModes: [ReadWriteOnce] + # storageClassName: fast-ssd # CHANGE ME, or omit to use the cluster default + resources: + requests: + storage: 1Gi +--- +# 2. The receiver. One replica and Recreate: one process owns the SQLite file. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: telemetry-receiver + namespace: gameplane-telemetry + labels: + app.kubernetes.io/name: telemetry-receiver +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: telemetry-receiver + template: + metadata: + labels: + app.kubernetes.io/name: telemetry-receiver + spec: + securityContext: + runAsNonRoot: true + runAsUser: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: receiver + image: ghcr.io/gameplanepanel/gameplane/telemetry-receiver:edge # CHANGE ME: pin a release tag + imagePullPolicy: Always # edge moves; use IfNotPresent once you pin a release tag + env: + - { name: LISTEN_ADDR, value: ":8080" } + - { name: DATA_DIR, value: "/data" } + - name: DASHBOARD_TOKEN + valueFrom: + secretKeyRef: { name: telemetry-dashboard, key: token } + - name: ID_PEPPER + valueFrom: + secretKeyRef: { name: telemetry-pepper, key: pepper } + - name: TRUSTED_PROXY_CIDRS + valueFrom: + configMapKeyRef: { name: telemetry-trusted-proxies, key: cidrs } + - { name: PUBLIC_SUMMARY, value: "true" } + - { name: RETENTION_DAYS, value: "730" } + - { name: ACTIVITY_EXPIRY_DAYS, value: "90" } + - { name: INGEST_SOURCE_DAILY_LIMIT, value: "20" } + - { name: INGEST_POW, value: "true" } + - { name: INGEST_POW_TARGET_PER_MIN, value: "60" } + - { name: INGEST_POW_MIN_BITS, value: "0" } + - { name: INGEST_POW_MAX_BITS, value: "22" } + ports: + - { name: http, containerPort: 8080 } + - { name: dashboard, containerPort: 8081 } + livenessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 5 + readinessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 2 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: + - { name: data, mountPath: /data } + volumes: + - name: data + persistentVolumeClaim: + claimName: telemetry-receiver-data +--- +# 3. Service: ingest is public (through the Ingress), the dashboard stays cluster-internal. +apiVersion: v1 +kind: Service +metadata: + name: telemetry-receiver + namespace: gameplane-telemetry + labels: + app.kubernetes.io/name: telemetry-receiver +spec: + selector: + app.kubernetes.io/name: telemetry-receiver + ports: + - { name: ingest, port: 8080, targetPort: http } + - { name: dashboard, port: 8081, targetPort: dashboard } +--- +# 4. Ingress for Traefik: publishes only the public listener's routes, never :8081. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: telemetry-receiver + namespace: gameplane-telemetry + annotations: + cert-manager.io/cluster-issuer: letsencrypt-cloudflare + traefik.ingress.kubernetes.io/router.entrypoints: websecure +spec: + ingressClassName: traefik + tls: + - hosts: [telemetry.gameplane.net] # CHANGE ME + secretName: telemetry-gameplane-net-tls + rules: + - host: telemetry.gameplane.net # CHANGE ME + http: + paths: + - { path: /ingest, pathType: Exact, backend: { service: { name: telemetry-receiver, port: { name: ingest } } } } + - { path: /v1/challenge, pathType: Exact, backend: { service: { name: telemetry-receiver, port: { name: ingest } } } } + - { path: /v1/summary, pathType: Exact, backend: { service: { name: telemetry-receiver, port: { name: ingest } } } } + - { path: /healthz, pathType: Exact, backend: { service: { name: telemetry-receiver, port: { name: ingest } } } } +--- +# 5. NetworkPolicy: 8080 only from the Traefik pods, 8081 only from the +# monitoring namespace (kubectl port-forward is not affected), no egress. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: telemetry-receiver + namespace: gameplane-telemetry +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: telemetry-receiver + policyTypes: [Ingress, Egress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - { protocol: TCP, port: 8080 } + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring # CHANGE ME, or remove this rule + ports: + - { protocol: TCP, port: 8081 } + # The receiver makes no outbound connections: no DNS, no Kubernetes API, no other service. + egress: [] +``` + +Key points: + +- Values to change are marked `# CHANGE ME`: the image tag, the storage class, the hostname (in the Ingress `tls` and `rules`), the ClusterIssuer email and zone, the Cloudflare API token, the monitoring namespace, and `POD_CIDR` if your cluster is not on the k3s default. Also replace `telemetry.gameplane.net` in the Cloudflare rule and in the Verify commands. +- The provider features (dashboard, signed extended reports, proof-of-work) ship in the first release after v0.3.0. Pin that release or a later one. A v0.3.0 or older receiver has no dashboard and no `/v1/challenge`, and answers 400 to extended reports, so installs fall back to basic-only reports for 7 days. +- `edge` follows master. If you run it, keep `imagePullPolicy: Always` so that a restart pulls the latest build; with `IfNotPresent` a node keeps the image it already has. +- One replica with `Recreate`, because SQLite has a single writer. `fsGroup: 65532` makes `/data` writable for the distroless nonroot user. +- `AUTH_TOKEN` is deliberately unset: a public provider accepts reports from any install. +- `ID_PEPPER` is recommended. Without it the receiver generates a pepper and stores it in the database, so it is lost with the volume. +- Proof-of-work is on with the defaults (target 60 challenges per minute, 0 to 22 bits). See [Proof-of-work on ingest](#proof-of-work-on-ingest) to tune it. +- `PUBLIC_SUMMARY` is on, so `/v1/summary` is routed. If you turn it off, remove that path from the Ingress. +- If the probes fail after the NetworkPolicy is applied (some CNIs block the node's own probe traffic), add an `ipBlock` for your node addresses to the 8080 rule. + +### 4. Cloudflare settings + +1. DNS: add an `A` record (and an `AAAA` record if you have IPv6) named `telemetry` that points at the cluster's public address, set to **Proxied** (orange cloud). +2. SSL/TLS encryption mode: **Full (strict)**. The origin presents the cert-manager certificate. +3. Bot protection: installs send plain HTTP POSTs with no browser, so a challenge page breaks them (they record `failed` and retry). Keep **Bot Fight Mode** off for the zone, because on the Free plan it cannot be skipped per hostname. Add a WAF custom rule `(http.host eq "telemetry.gameplane.net")` with the action **Skip** for Super Bot Fight Mode, Browser Integrity Check and Security Level (and managed rules, if they challenge it). +4. Caching: add no "Cache Everything" rule for this hostname. `/v1/challenge` sends `Cache-Control: no-store`, and `/ingest` is a POST. +5. Managed Transforms: keep **Remove visitor IP headers** off. It strips `X-Forwarded-For`, and every install would then share one source address. +6. Optional: add a Cloudflare rate-limiting rule on `/ingest` as a first line of defense in front of proof-of-work. +7. Optional: allow only Cloudflare's ranges to reach port 443 on the origin (host firewall, or Authenticated Origin Pulls), so that nobody can bypass the rules above. + +### 5. How the client address is resolved + +The receiver rate-limits per source address, so it needs the install's address, not the proxy's. An install connects to Cloudflare, which adds `X-Forwarded-For: `. Traefik trusts Cloudflare's ranges, keeps that header and appends the Cloudflare edge address, so the receiver sees `X-Forwarded-For: , `. The TCP peer of the receiver is the Traefik pod, inside the pod CIDR. + +When the peer is inside `TRUSTED_PROXY_CIDRS`, the receiver takes the right-most `X-Forwarded-For` entry that is not itself trusted. That is why the list needs both the pod CIDR (for Traefik) and Cloudflare's ranges (to skip the edge address). Without Cloudflare's ranges, every install is attributed to one of a handful of edge addresses, and the per-source limits (20 reports a day, and the `/v1/challenge` burst) throttle all installs together. + +A client outside Cloudflare's ranges cannot choose its source address. Anything it puts in `X-Forwarded-For` stays to the left of the real entry and is ignored, and proof-of-work still applies to every request. A request that bypasses Cloudflare reaches Traefik from an untrusted address, so Traefik drops its `X-Forwarded-For`. An unparsable header falls back to the peer address. The address is used only in memory for rate limiting and is never stored; see [Source addresses and `TRUSTED_PROXY_CIDRS`](#source-addresses-and-trusted_proxy_cidrs). + +For a provider without a CDN, trust only the ingress controller's pod CIDR. + +### 6. Verify + +```sh +kubectl -n gameplane-telemetry rollout status deploy/telemetry-receiver +# Expect READY True. +kubectl -n gameplane-telemetry get certificate telemetry-gameplane-net-tls +# Expect: ok +curl -fsS https://telemetry.gameplane.net/healthz +# The public counters. +curl -fsS https://telemetry.gameplane.net/v1/summary +# A challenge (proof-of-work is on). +curl -fsS https://telemetry.gameplane.net/v1/challenge +# Expect 404: the metrics port 8081 is not routed. +curl -sS -o /dev/null -w '%{http_code}\n' https://telemetry.gameplane.net/metrics +``` + +#### Check the client address + +The receiver never logs addresses, so check what Traefik forwards with a throwaway echo service on the same host, then delete it: + +```sh +kubectl -n gameplane-telemetry create deployment whoami --image=traefik/whoami +kubectl -n gameplane-telemetry expose deployment whoami --port 80 +kubectl -n gameplane-telemetry create ingress whoami --class=traefik \ + --rule="telemetry.gameplane.net/whoami-check=whoami:80,tls=telemetry-gameplane-net-tls" \ + --annotation traefik.ingress.kubernetes.io/router.entrypoints=websecure +kubectl -n gameplane-telemetry rollout status deploy/whoami +# If Traefik answers 404, it has not picked up the route yet: retry after a few seconds. +curl -fsS https://telemetry.gameplane.net/whoami-check | grep -i x-forwarded-for +# expect: X-Forwarded-For: , +kubectl -n gameplane-telemetry delete ingress,service,deployment whoami +``` + +The NetworkPolicy selects only the receiver pods, so the whoami pod is reachable. If the first entry is not your public address, or only one address appears, Traefik is not trusting Cloudflare or does not see Cloudflare's address as the peer. Fix that before going live. + +#### Open the dashboard + +The dashboard on `:8081` is never routed. Reach it with a port-forward and sign in with the dashboard token: + +```sh +kubectl -n gameplane-telemetry port-forward svc/telemetry-receiver 8081:8081 +# in another terminal, print the token: +kubectl -n gameplane-telemetry get secret telemetry-dashboard -o jsonpath='{.data.token}' | base64 -d +``` + +Open `http://localhost:8081`. The session cookie is `Secure`, and browsers treat `localhost` as a secure origin, so sign-in works over plain HTTP there. + +### 7. Scrape metrics (optional) + +This needs the Prometheus Operator. The ServiceMonitor reads the same token Secret, and the NetworkPolicy above admits the `monitoring` namespace on 8081 only. + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: telemetry-receiver + namespace: gameplane-telemetry + # labels: { release: my-prometheus } # CHANGE ME, if your Prometheus selects ServiceMonitors by label +spec: + selector: + matchLabels: + app.kubernetes.io/name: telemetry-receiver + endpoints: + - port: dashboard + interval: 30s + path: /metrics + bearerTokenSecret: + name: telemetry-dashboard + key: token +``` + +### Upgrade and back up + +**Upgrade:** change the image tag in the manifest and run `kubectl apply -f telemetry-provider.yaml`. On `edge` with `imagePullPolicy: Always`, run `kubectl -n gameplane-telemetry rollout restart deploy/telemetry-receiver` instead. The Recreate strategy stops the old pod before the new one starts, so ingest returns 503 for a few seconds. Installs retry the next day or on their own backoff. + +**Back up first:** take a snapshot of the PVC, or stop the receiver (`kubectl -n gameplane-telemetry scale deploy/telemetry-receiver --replicas=0`) and copy the `/data` folder. See [Back up and restore `telemetry.db`](#back-up-and-restore-telemetrydb) for details. + +**Secret rotation:** update the Secret, then restart with `kubectl -n gameplane-telemetry rollout restart deploy/telemetry-receiver`. See [Rotating secrets](#rotating-secrets) for the effects. + +## TLS in front of `:8080` + +The receiver speaks plain HTTP. Installs only send reports over `https`, +so terminate TLS in front of the public listener with a reverse proxy, +ingress controller or load balancer, and forward to `:8080`. + +- Forward only `/ingest`, `/v1/summary`, `/healthz` and, when proof-of-work + is on, `/v1/challenge`. Do not publish `:8081`. +- Keep the request body limit at or above 16 KiB (the receiver rejects + anything larger with `413`). +- Pass the client address in `X-Forwarded-For` and set + `TRUSTED_PROXY_CIDRS` (below). + +## Keep `:8081` private + +The dashboard shows every aggregate and `/metrics`. Reach it over a private +network, a VPN or `kubectl port-forward`, never a public route. In +Kubernetes, the chart's NetworkPolicy admits port 8081 only from peers in +`api.telemetry.receiver.dashboard.ingressFrom` (plus the Prometheus +namespace when `serviceMonitors.scrapeNamespaceSelector` is set); with no +peers, `kubectl port-forward` still works. The session cookie is `Secure`, +so a browser needs `https` (or `localhost`) to keep a login. + +## Secrets + +Create both from Secrets, not literals in a manifest. + +```sh +# 32 random bytes: 44 characters in base64 (64 with `openssl rand -hex 32`). +kubectl -n gameplane-system create secret generic telemetry-dashboard \ + --from-literal=token="$(openssl rand -base64 32)" +kubectl -n gameplane-system create secret generic telemetry-pepper \ + --from-literal=pepper="$(openssl rand -base64 32)" +``` + +With the chart: `api.telemetry.receiver.dashboard.tokenSecretRef.name` and +`api.telemetry.receiver.pepperSecretRef.name` (key `token` and `pepper` by +default). They reach the pod as `DASHBOARD_TOKEN` and `ID_PEPPER`. + +- `DASHBOARD_TOKEN` is the one credential for the dashboard, its JSON + views and `/metrics`. Generate it from 32 random bytes, as above. The + receiver refuses to start when the token is set but shorter than 32 + characters (it counts characters, not bytes), because the per-source + login limit can be outrun by someone holding many IPv6 `/64`s and the + token's length is what makes guessing infeasible. A rotated token must + meet the same minimum, or the restart fails. +- `ID_PEPPER` keys the HMAC that turns an install ID into the stored + record key. If you leave it unset, the receiver generates one on first + start and stores it in the database. Set it explicitly when you want the + pepper to survive a lost database or to stay out of database backups. + Set it **before** the first report arrives: changing it later has the + effect described below. + +### Rotating secrets + + + +| Secret | Rotate by | Effect | +|---|---|---| +| `DASHBOARD_TOKEN` | Update the Secret and restart the receiver. | Every browser session is invalidated (the cookie key derives from the token), scripts need the new token, and Prometheus needs the new Bearer token. No stored data is lost. | +| `ID_PEPPER` | Update the Secret and restart the receiver. | Stored activity records no longer match any install ID. For the next day, every install looks new (new-install counts spike and lapsed counts are wrong for the 30 days that follow), unique-install and by-install views lose their history, and ID claims are forgotten, so installs re-claim on their next report. No install is rejected and no daily aggregate changes. Rotate only after a compromise. | + +Installs themselves are never affected by either rotation: they keep their +install IDs and signing keys. Neither rotation touches `AUTH_TOKEN`; rotate +that separately on the receiver and in `GAMEPLANE_TELEMETRY_AUTH` on each +install (only relevant for a private provider). + +## Public summary + +Set `PUBLIC_SUMMARY=true` (chart: `api.telemetry.receiver.publicSummary.enabled`) +to enable `GET /v1/summary` on the public listener. It needs no credentials +and returns exactly five keys: + +```json +{ + "asOf": "2026-10-05", + "reportsLatestDay": 412, + "reports30d": 11873, + "reportsTotal": 98211, + "activeInstalls30d": 1290 +} +``` + +It is served from a snapshot refreshed at most every 5 minutes, with +`Cache-Control: public, max-age=3600`, an `ETag` and +`Access-Control-Allow-Origin: *`, and is rate-limited per source. The +project's default receiver (`https://telemetry.gameplane.net/v1/summary`) +enables it; it is off by default elsewhere. + +## Proof-of-work on ingest + + + +`/ingest` is open to the internet, so a flood of fabricated reports costs +the sender nothing but bandwidth. Proof-of-work makes the cost grow with the +request rate while normal traffic pays nothing. It is off by default. Turn it +on for a public provider; leave it off for a private or bundled receiver. + +### Enable it + +- Standalone: set `INGEST_POW=true`. The receiver then serves + `GET /v1/challenge` and answers `428` to any `/ingest` without a valid + solved challenge. +- Chart: `api.telemetry.receiver.ingestPow.enabled=true`, with + `targetPerMinute`, `minBits` and `maxBits` for the three tuning + variables below. The chart refuses to render when `targetPerMinute` is + below `1`, `maxBits` is above `26` or `maxBits` is below `minBits`. +- Route `/v1/challenge` through your TLS proxy next to `/ingest`. It has its + own per-source limit (10 per minute, burst 5). +- Installs that run an API without proof-of-work support cannot solve a + challenge and are refused with `428 pow_required`. Installs that find no + challenge endpoint (any answer other than `200`) send without it, which + is why it is safe to leave off. + +How it works: each install asks for a challenge, finds a number so that +`SHA-256(challenge ":" number)` starts with the requested count of zero +bits, and sends the answer in the `Gameplane-Telemetry-PoW` header. A +challenge is valid for 15 minutes and for one report. Verification happens +before the body is read, so a refused report costs the receiver one HMAC and +one hash. The challenge key and the set of used challenges are in memory: a +restart invalidates outstanding challenges, and installs recover with one +automatic retry. + +### Tune it from `/metrics` + +Difficulty depends on `r`, the challenges issued in the last 60 seconds, and +`INGEST_POW_TARGET_PER_MIN` (`T`). At or below `T` the difficulty is +`INGEST_POW_MIN_BITS` (default `0`: no work). Above it, it is +`ceil(2 * log2(r / T))`, capped at `INGEST_POW_MAX_BITS`; it rises at once and +falls one bit every five minutes. An install's expected work is about +`2^bits` hashes, and installs refuse anything above 26 bits. + +Each daily report asks for one challenge, so challenges per minute are +about reports per minute (a retry after `428` asks for another). To choose +`INGEST_POW_TARGET_PER_MIN`: + +1. Run for a few days with `INGEST_POW=true` and defaults. +2. Read the busiest normal minute from + `gameplane_telemetry_pow_challenges_total`, for example + `max_over_time(rate(gameplane_telemetry_pow_challenges_total[1m])[7d:]) * 60`. +3. Set the target a little above it, so ordinary peaks cost nothing and + only a real surge raises the difficulty. + +Read the result with the gauge `gameplane_telemetry_pow_bits`: it is the +difficulty a challenge issued now would carry. A gauge that sits at your +`INGEST_POW_MIN_BITS` means traffic is normal. A gauge that stays raised +means traffic is above the target (the gauge counts the next challenge too, +so it can read `1` when the last minute was exactly at the target). Watch +alongside it: + +| Series | What it tells you | +|---|---| +| `gameplane_telemetry_pow_challenges_total` | Challenges issued; its rate is the `r` that drives difficulty. | +| `gameplane_telemetry_refused_total{reason="pow_required"}` | Reports with no solution: older installs, scripts, or a flood that skips the challenge. | +| `gameplane_telemetry_refused_total{reason="pow_invalid"}` | Bad, expired, reused or under-difficulty solutions. A burst right after a restart is normal. | +| `gameplane_telemetry_refused_total{reason="pow_busy"}` | The used-challenge set (1,000,000 entries) is full; reports get `503` with `Retry-After: 60`. Not seen in normal use. | +| `gameplane_telemetry_rate_limited_total{route="challenge"}` | One source requested challenges faster than 10 a minute. | + +Under attack, respond from the provider side with no install upgrade: lower +`INGEST_POW_TARGET_PER_MIN` or raise `INGEST_POW_MIN_BITS` and restart the +receiver. Many sources can still push the difficulty to +`INGEST_POW_MAX_BITS`; that costs a legitimate install at most a few +seconds of work per daily report, and it decays when the flood stops. + +## Source addresses and `TRUSTED_PROXY_CIDRS` + +Per-source limits use the TCP peer address. Behind a reverse proxy, every +request would otherwise come from the proxy, and the limits +(`INGEST_SOURCE_DAILY_LIMIT`, default 20 accepted reports per source per +UTC day) would throttle all installs together. Set `TRUSTED_PROXY_CIDRS` +to the proxy's address range (comma-separated, for example +`10.0.0.0/8,192.168.0.0/16`, using your real range). The receiver then +reads `X-Forwarded-For`, but only when the peer is inside those ranges, so a +client can't forge its source. Leave it empty if the receiver is directly +exposed. Addresses are held in memory only, and never stored. Behind a CDN such as Cloudflare, add the CDN's published ranges too (see [How the client address is resolved](#5-how-the-client-address-is-resolved)). + +## Retention + +| Setting | Default | Minimum | Covers | +|---|---|---|---| +| `RETENTION_DAYS` | `730` (24 months) | `365` | Daily aggregates. Older days are deleted by an hourly sweep. | +| `ACTIVITY_EXPIRY_DAYS` | `90` | `31` | Per-install activity records (transformed ID, key fingerprint, first and last seen, last send time, last version). An ID's claim expires with its record. | + +The activity expiry must stay above 30 days because the dashboard's +"lapsed" figure looks back 30 days. The summary's "total reports" counter +is kept separately and survives retention sweeps. If you publish a +data-handling statement, state the values you configured. + +## Scrape `/metrics` with the token + +Metrics are on the dashboard listener only and need the dashboard token as +a Bearer token. Existing series keep their names; new ones: +`gameplane_telemetry_duplicates_total`, +`gameplane_telemetry_rate_limited_total{route}` (`ingest`, `login`, +`summary`, `challenge`), +`gameplane_telemetry_extended_reports_total`, +`gameplane_telemetry_refused_total{reason}` (`bad_signature`, `stale`, +`id_claimed`, `replay`, `pow_required`, `pow_invalid`, `pow_busy`), +`gameplane_telemetry_pow_challenges_total` and the gauge +`gameplane_telemetry_pow_bits`. The two proof-of-work series are always +exported; `pow_bits` is `0` while `INGEST_POW` is off. + +Prometheus scrape config: + +```yaml +scrape_configs: + - job_name: telemetry-receiver + metrics_path: /metrics + authorization: + type: Bearer + credentials_file: /etc/prometheus/secrets/telemetry-dashboard-token + static_configs: + - targets: ["telemetry-receiver.internal:8081"] +``` + +With the chart and Prometheus Operator, `serviceMonitors.enabled=true` +renders a `ServiceMonitor` for the `dashboard` port with the token Secret +as `bearerTokenSecret`, but only when +`api.telemetry.receiver.dashboard.tokenSecretRef.name` is set. Without a +token, the chart renders no `ServiceMonitor` and prints a warning in the +install notes. Check manually with: + +```sh +curl -H "Authorization: Bearer $DASHBOARD_TOKEN" http://localhost:8081/metrics +``` + +A request without the header gets `401 {"error":"unauthorized"}`. + +## Back up and restore `telemetry.db` + +All durable state is one SQLite database, `$DATA_DIR/telemetry.db`, in WAL +mode (with `telemetry.db-wal` and `telemetry.db-shm` beside it while the +receiver runs). It holds the daily aggregates, the activity records and, +unless you set `ID_PEPPER`, the pepper. + +**Back up** + +- Prefer a consistent snapshot: snapshot the volume, or stop the receiver + (scale to 0) and copy the whole `DATA_DIR`, including the `-wal` and + `-shm` files if present. +- Or, with the `sqlite3` CLI on a copy host and the receiver running: + `sqlite3 /data/telemetry.db ".backup '/backup/telemetry-$(date +%F).db'"`. + Do not `cp` the main file alone while the receiver is writing. +- Treat backups as sensitive: they contain the activity records and + possibly the pepper. Encrypt them and keep them as short-lived as your + retention allows. + +**Restore** + +1. Stop the receiver (scale to 0). +2. Replace `telemetry.db` in `DATA_DIR` with the backup, and delete any + stale `telemetry.db-wal` and `telemetry.db-shm`. +3. Make sure `ID_PEPPER` is the same value as when the backup was taken (or + unset, if the pepper is stored in the database). A different pepper + behaves like a rotation: see [Rotating secrets](#rotating-secrets). +4. Start the receiver and check `/healthz` and the dashboard. + +Reports received between the backup and the restore are lost; installs +send again within a day, and per-day deduplication keeps the counts right. +Losing the database with no backup restarts all history; installs just +re-claim their IDs on their next report. + +## Checklist + +- [ ] TLS terminates in front of `:8080`; only `/ingest`, `/v1/summary`, + `/healthz` and (with proof-of-work on) `/v1/challenge` are routed. +- [ ] `:8081` is reachable only from a private network. +- [ ] `DATA_DIR` is a persistent volume; one replica. +- [ ] `DASHBOARD_TOKEN` (at least 32 characters, from 32 random bytes) and + `ID_PEPPER` come from Secrets. +- [ ] A public provider has `INGEST_POW=true`, and `INGEST_POW_TARGET_PER_MIN` + sits just above your busiest normal minute. +- [ ] `TRUSTED_PROXY_CIDRS` matches your proxy. +- [ ] Prometheus scrapes `:8081/metrics` with the Bearer token. +- [ ] Backups of `telemetry.db` are scheduled and a restore has been + rehearsed. +- [ ] The data-handling statement states your retention values. diff --git a/go.work b/go.work index 96d05a6d2..69a166196 100644 --- a/go.work +++ b/go.work @@ -13,6 +13,7 @@ use ( ./operator ./sentinel ./svcutil + ./telemetryschema ./telemetry-receiver ./test/e2e ./tunnel diff --git a/hack/auto_label.py b/hack/auto_label.py index ca68ad8d2..6aff61c12 100755 --- a/hack/auto_label.py +++ b/hack/auto_label.py @@ -62,6 +62,7 @@ "tunnel": "area: optional-components", "audit-syslog-bridge": "area: optional-components", "telemetry-receiver": "area: optional-components", + "telemetryschema": "area: shared", } AREA_BY_FILE = { diff --git a/hack/check-telemetry-catalog.sh b/hack/check-telemetry-catalog.sh new file mode 100755 index 000000000..ca4f275b1 --- /dev/null +++ b/hack/check-telemetry-catalog.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# check-telemetry-catalog.sh: Verify that telemetryschema/catalog.txt lists +# exactly the official modules in charts/gameplane/values.yaml +# (defaultModuleSource.oci.modules), in the same order (spec 022, research R15). +# +# Purpose: +# The telemetry receiver folds any game name that is not in the embedded +# catalog into "custom". If the chart's official module list and the catalog +# drift apart, a new official module would be counted as custom. This check +# fails make lint and CI on any difference. +# +# To fix a failure, make catalog.txt match the chart list (one name per line, +# same order, no blank lines) and keep telemetryschema tests in step. +# +# Test overrides: +# VALUES_FILE: values.yaml to read (default: charts/gameplane/values.yaml) +# CATALOG_FILE: catalog to compare (default: telemetryschema/catalog.txt) +# +# Exit codes: +# 0 = catalog.txt matches values.yaml +# 1 = mismatch (the diff is printed), or a file is missing or yields no modules + +set -eu + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +VALUES_FILE=${VALUES_FILE:-$ROOT/charts/gameplane/values.yaml} +CATALOG_FILE=${CATALOG_FILE:-$ROOT/telemetryschema/catalog.txt} + +# extract_modules prints the names under defaultModuleSource.oci.modules, one +# per line. It is a line-based scan keyed on the chart's fixed indentation: +# a top-level key at column 0, "oci:" at 2 spaces, "modules:" at 4 spaces and +# list items "- name" at 6 spaces. +extract_modules() { + awk ' + /^defaultModuleSource:/ { top = 1; next } + top && /^[^ #]/ { exit } + top && /^ [A-Za-z]/ { oci = ($0 ~ /^ oci:/); mods = 0; next } + top && oci && /^ modules:/ { mods = 1; next } + top && oci && mods && /^ - / { + name = $0 + sub(/^ - /, "", name) + sub(/[ \t]*#.*$/, "", name) + sub(/[ \t]+$/, "", name) + print name + next + } + top && oci && mods && /^ [A-Za-z]/ { mods = 0 } + ' "$1" +} + +for f in "$VALUES_FILE" "$CATALOG_FILE"; do + if [ ! -f "$f" ]; then + echo "check-telemetry-catalog: $f not found" >&2 + exit 1 + fi +done + +expected=$(mktemp) +trap 'rm -f "$expected"' EXIT HUP INT TERM +extract_modules "$VALUES_FILE" >"$expected" + +if [ ! -s "$expected" ]; then + echo "check-telemetry-catalog: no modules found under defaultModuleSource.oci.modules in $VALUES_FILE" >&2 + exit 1 +fi + +if diff -u "$expected" "$CATALOG_FILE" >&2; then + echo "check-telemetry-catalog: catalog.txt matches values.yaml ($(wc -l <"$expected" | tr -d ' ') modules)" + exit 0 +fi +echo "check-telemetry-catalog: telemetryschema/catalog.txt differs from defaultModuleSource.oci.modules in values.yaml (- values.yaml, + catalog.txt)" >&2 +exit 1 diff --git a/hack/check-telemetry-default.sh b/hack/check-telemetry-default.sh new file mode 100755 index 000000000..7bc27c0af --- /dev/null +++ b/hack/check-telemetry-default.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# check-telemetry-default.sh: fail unless the project default telemetry +# destination is set to an https URL (spec 022, research R13). +# +# api/internal/telemetry/destination.go holds DefaultEndpoint, the one place +# the project URL lives. OPEN-DECISIONS OD-1 is ruled (2026-10-07), so this +# check passes; it keeps enforcing that the default stays an https URL (FR-002, +# FR-009). It runs as its own CI job (telemetry-default-gate); it is +# deliberately not part of `make lint`. +# +# Exit codes: +# 0 = DefaultEndpoint is non-empty and starts with https:// +# 1 = it is empty, not https, or the constant could not be found + +set -eu + +cd "$(dirname "$0")/.." +file="${TELEMETRY_DESTINATION_FILE:-api/internal/telemetry/destination.go}" + +if [ ! -r "$file" ]; then + echo "check-telemetry-default: cannot read $file" >&2 + exit 1 +fi + +line=$(grep -E '^const DefaultEndpoint = "' "$file" || true) +if [ -z "$line" ]; then + echo "check-telemetry-default: no 'const DefaultEndpoint = \"...\"' line in $file" >&2 + exit 1 +fi + +value=$(printf '%s\n' "$line" | sed -e 's/^const DefaultEndpoint = "//' -e 's/".*$//') +case "$value" in +https://?*) + echo "check-telemetry-default: DefaultEndpoint is set ($value)" + ;; +"") + echo "check-telemetry-default: DefaultEndpoint is empty; it must be the project's https ingest URL (OPEN-DECISIONS OD-1)" >&2 + exit 1 + ;; +*) + echo "check-telemetry-default: DefaultEndpoint must start with https:// (got: $value)" >&2 + exit 1 + ;; +esac diff --git a/specs/022-default-telemetry-dashboard/OPEN-DECISIONS.md b/specs/022-default-telemetry-dashboard/OPEN-DECISIONS.md new file mode 100644 index 000000000..49381ba3a --- /dev/null +++ b/specs/022-default-telemetry-dashboard/OPEN-DECISIONS.md @@ -0,0 +1,104 @@ +# Open Decisions + +**Status**: 0 open; 5 ruled (OD-2, OD-3 and OD-4 on 2026-10-06, OD-1 and OD-5 on 2026-10-07). + +Per CLAUDE.md rule 10, an open value MUST NOT be committed as a settled contract in code, chart defaults or docs until it is ruled here. + +--- + +### OD-1: Default telemetry domain + +**Status**: RULED (2026-10-07, user) + +**Question**: Which domain (and ingest path) is the project's default telemetry destination? + +**Why it matters**: FR-001 needs a concrete default. FR-002 forbids shipping a placeholder. Until this is ruled, the default stays unset, stock installs keep today's behavior, and the opt-out default (FR-003) has no effect. + +**Context**: the project website has no custom domain yet. It is served from GitHub Pages at `valgulnecron.github.io/gameplane-website` (`website/src/config.ts`: `CUSTOM_DOMAIN` is undefined). GitHub Pages is static hosting, so it can't receive reports. + +**Options**: +1. Register one project domain and use `telemetry.`. The website can move to the same domain later. +2. A dedicated domain used only for telemetry. + +**Recommended default**: (1). A host users already know from the docs looks more trustworthy in the first-login notice and the Admin Settings destination line (FR-004, FR-017), and there is only one domain to register and renew. + +**Ruling (2026-10-07, user)**: the project domain is `gameplane.net`. The default telemetry destination host is `telemetry.gameplane.net`, so `telemetry.DefaultEndpoint = "https://telemetry.gameplane.net/ingest"` (the receiver serves `POST /ingest`, `GET /v1/challenge` and `GET /v1/summary`). The website moves to `gameplane.net`, so the data-handling statement URL (OD-2) is `https://gameplane.net/telemetry/`. The website-side changes (`CUSTOM_DOMAIN`, the statement page) belong to T096 in the `website` submodule. + +**Consequences**: +- `DefaultEndpoint` is set, so `telemetry-default-gate` passes and the feature PR can leave draft once the other gates are green. +- The destination copy in the designs and the docs name `telemetry.gameplane.net`; the bundled and custom sample hosts stay samples. + +**Superseded interim ruling (2026-10-06, user)**: The domain is not decided yet. +- Implementation proceeds with `telemetry.DefaultEndpoint` left **blank**, as FR-002 allows. +- The feature's PR **MUST NOT merge until a domain is chosen**, recorded here as RULED, and set in `DefaultEndpoint`. + +**How the block is enforced** (plan.md, Merge gate): +- The PR stays a **draft** until this decision is ruled. GitHub does not allow merging drafts, and the master ruleset has no required status checks that could block the merge instead. +- A dedicated CI job, `telemetry-default-gate`, fails while `DefaultEndpoint` is empty or isn't `https`. + +**Blocks**: +- merge of the feature PR +- FR-001's default value +- the FR-008 upgrade notes +- the destination copy in the first-login notice and Admin Settings designs + +--- + +### OD-2: Provider hosting, ownership, and data-handling statement + +**Status**: RULED (2026-10-06, user) + +**Question**: Who runs the default provider, and where is its data-handling statement published? + +**Ruling**: The project maintainers run the default provider. The data-handling statement is a page on the project website, in the `website` submodule. It is linked from: +- the first-login notice (FR-004) +- Admin Settings → Telemetry (FR-017) +- the upgrade notes (FR-008) + +**Consequences**: +- The statement page is new website work. It goes through the submodule's own PR flow, and the website's design-first rule applies only if it needs a new screen layout. +- The dashboard links to the statement through one constant, because the website URL changes once `CUSTOM_DOMAIN` is set (see OD-1). +- The statement states the OD-3 and OD-4 periods. + +--- + +### OD-3: Aggregate retention ceiling + +**Status**: RULED (2026-10-06, user) + +**Question**: How long does the provider keep daily aggregates? FR-020 sets a floor of 12 months. + +**Ruling**: **24 months**. `RETENTION_DAYS` defaults to `730`, the Helm value `api.telemetry.receiver.retentionDays` defaults to `730`, and the enforced minimum is 365. Two years covers year-over-year comparison, and the stated limit appears in the OD-2 statement. + +--- + +### OD-4: Activity-record inactivity expiry + +**Status**: RULED (2026-10-06, user) + +**Question**: How long after an install's last report does the provider delete its activity record? An activity record holds the transformed install ID, the first-seen date, the last-seen date and the last version (FR-015). + +**Ruling**: **90 days**. `ACTIVITY_EXPIRY_DAYS` defaults to `90`, the Helm value `api.telemetry.receiver.activityExpiryDays` defaults to `90`, and the enforced minimum is 31, because the expiry must stay longer than the 30-day lapsed window (US5). This keeps new and lapsed counts accurate for installs that go quiet for a season, and the period appears in the OD-2 statement. + +--- + +### OD-5: Proof-of-work defaults + +**Status**: RULED (2026-10-07, user) + +**Question**: Which defaults does the receiver's proof-of-work use (FR-039, FR-040, research R21)? The user set its shape on 2026-10-07 (spec Q12): off by default and turned on by the runbook for the project's provider, with a difficulty of zero while traffic is normal or absent, rising fast with the request rate and falling slowly. + +**Ruling**: the "gentler ceiling" option. + +| Setting | Value | Effect | +|---|---|---| +| `INGEST_POW` | `false` | Off in the binary and in the bundled receiver; the runbook turns it on for the project's provider. | +| `INGEST_POW_TARGET_PER_MIN` | `60` | The normal challenge rate. No work is required at or below it. | +| `INGEST_POW_MIN_BITS` | `0` | No work under normal load. | +| `INGEST_POW_MAX_BITS` | `22` | The ceiling during a flood: about 4.2 million hashes, under a second on a desktop and roughly 5–8 seconds on a Raspberry Pi 4 (estimates). | +| `MaxPoWBits` (install cap) | `26` | The install refuses harder challenges (about 67 million hashes, a minute or two on a Raspberry Pi 4), so a misconfigured provider can't burn an install's CPU. | +| Curve | `ceil(2 · log2(r / T))` | 10× the normal rate gives 7 bits, 100× gives 14, 1,000× gives 20. | +| Decay | 1 bit per 5 minutes | From 22 bits back to 0 in under 2 hours. | +| Challenge lifetime | 15 minutes | Longer than a Raspberry Pi 4 needs at the 26-bit cap. | +| Challenge requests per source | 10 per minute, burst 5 | One source can't raise the difficulty for everyone. | +| Used-challenge memory | 1,000,000 entries | Filling it needs that many solved challenges within 15 minutes. | diff --git a/specs/022-default-telemetry-dashboard/checklists/requirements.md b/specs/022-default-telemetry-dashboard/checklists/requirements.md new file mode 100644 index 000000000..3d77f2416 --- /dev/null +++ b/specs/022-default-telemetry-dashboard/checklists/requirements.md @@ -0,0 +1,58 @@ +# Specification Quality Checklist: Default telemetry destination and telemetry dashboard + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-10-06 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Iteration 1 left two clarification markers. In iteration 2 (2026-10-06) the user answered them: + - Q1: opt-out for new installs, with a first-login notice. + - Q2: a private receiver-hosted dashboard plus a public summary API limited to headline counts. + + The answers are recorded in spec.md → Clarifications. All items pass. +- Iteration 3 (2026-10-06) added extended telemetry: + - Q3: all four categories (environment, game usage, feature adoption, install ID). + - Q4: two tiers, both on by default for new installs. + + FRs were renumbered to FR-001 through FR-034. OD-4 (activity-record expiry) was added. No new markers; all items still pass. +- Iteration 4 (2026-10-06) added report signing at the user's request: + - Q5: the key is derived from a random secret in the install's database plus the install ID. + - Q6: mismatches are refused, and the install rotates its ID on a claim conflict. + + It added US7, FR-035 to FR-038 (appended, so earlier numbers are unchanged), SC-014 and SC-015, and updated FR-011, FR-012, FR-013 and FR-015. All items still pass. +- Iteration 5 (2026-10-06), from the analysis remediation during implement: + - Q7: turning basic off also turns extended off, and the UI disables the extended switch (FR-003, FR-019). + - Q8: operational metrics move behind the dashboard credential (FR-030). + - Q9: a provider runbook is added (T093). + - Q10: the provider dashboard is designed in `telemetry-receiver/telemetry-dashboard.pen`. + + No new markers; all items still pass. +- The domain (OD-1) has been ruled (2026-10-07): telemetry.gameplane.net. The data-handling statement URL (OD-2 dependent, T096 on website side) is https://gameplane.net/telemetry/. +- The Assumptions section cites repo process (Pencil design-first, E2E coverage, `telemetry-receiver/specs.md` upkeep) as governance constraints. These describe process, not implementation, and are kept on purpose. +- Size bands in US4 scenario 3 restate the provider's existing published buckets, so they are a product contract rather than an implementation choice. diff --git a/specs/022-default-telemetry-dashboard/contracts/api-telemetry-http.md b/specs/022-default-telemetry-dashboard/contracts/api-telemetry-http.md new file mode 100644 index 000000000..7a9415394 --- /dev/null +++ b/specs/022-default-telemetry-dashboard/contracts/api-telemetry-http.md @@ -0,0 +1,94 @@ +# Contract: Gameplane API telemetry endpoints + +**Requirements**: FR-003–FR-005, FR-012, FR-017–FR-019, FR-035, FR-037. **Research**: R10–R13, R18, R20. + +All routes are behind session auth. RBAC entries are added to `api/internal/rbac/rbac.go` before the `{segment: "admin", perm: "*"}` fallback: + +```go +{method: "GET", segment: "admin", prefix: "/admin/telemetry", perm: "config:read"}, +{segment: "admin", prefix: "/admin/telemetry", perm: "config:manage"}, +``` + +## `GET /admin/telemetry` (`config:read`) + +```json +{ + "destination": { "kind": "default", "host": "telemetry.example.org" }, + "operatorDisabled": false, + "consent": { "basic": true, "extended": true, "source": "default" }, + "installId": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "preview": { "version": "0.3.0", "servers": 3, "templates": 7, "ext": { "...": "see report-schema.md" } }, + "status": { "lastAttemptAt": "2026-10-06T09:12:44Z", "lastSuccessAt": "2026-10-06T09:12:44Z", "lastOutcome": "ok", "lastIdRotationAt": null } +} +``` + +- **`destination.kind`** is one of `default`, `custom`, `bundled`, `disabled` or `none`. `destination.host` is only the URL host, never the path, query or credentials, and is `null` for `disabled` and `none` (FR-017). +- **`consent`** holds the stored choice. When `operatorDisabled` is true, the stored values are reported but have no effect. +- **`installId`** is `null` whenever extended is off (FR-012). +- **`preview`** is the exact report the reporter would send now, built by the same function (SC-006). It is `null` when basic is off or the destination is `disabled` or `none`. When collection fails partway, the fields that failed are reported as they would be sent: `0` for counts, `other` for categories. +- **`status.lastOutcome`** is `never`, `ok` or `failed`. A failure detail is never exposed; the UI renders a fixed phrase (US3 scenario 2). +- **`status.lastIdRotationAt`** is when the reporter last replaced the ID because the receiver said it was claimed by another key (FR-037), or `null`. The UI renders "Install ID replaced on : the destination reported it was in use by another key." +- **The preview's `ext`** includes `key` (the public key for the current ID) and `sentAt` (the preview time). The signature header isn't shown. +- **`signing_secret` is never returned** by this or any other endpoint (FR-012). + +## `PUT /admin/config/telemetry` (`config:manage`, existing route `PUT /admin/config/{section}`, `api/internal/handlers/config.go:56`) + +Body: `{"sendMetrics": bool, "extended": bool}`. + +| Case | Result | +|---|---| +| `sendMetrics: false` (whatever `extended` says) | Saved as `extended: false` as well, then continues as "Valid" below (FR-003). | +| Operator disabled telemetry (`kind = disabled`) | `409` "telemetry is disabled by the operator" | +| Valid | `200`. In the same transaction: the config row is saved, `consent_source` becomes `admin`, the install ID is created or deleted to match `extended`, and the schedule opens (`next_due_at` set as in R12) or closes (`NULL`). | + +The response body is unchanged from the current section save. + +## `POST /admin/telemetry/install-id` (`config:manage`) + +Rotates the install ID. + +- `200 {"installId": ""}` on success. +- `409` when extended is off (there is no ID to reset). + +The old ID is discarded and never sent again (US3 scenario 4). + +## `GET /admin/telemetry/notice` (`config:manage`; a user without it gets `{"pending": false}`) + +```json +{ + "pending": true, + "destination": { "kind": "default", "host": "telemetry.example.org" }, + "fields": { + "basic": ["version", "servers", "templates"], + "extended": ["installId", "env.k8s", "env.distro", "env.arch", "env.nodes", "games", "features"] + } +} +``` + +`pending` is computed as in data-model.md under "Notice pending". When it is `false`, the other keys are omitted. + +## `POST /admin/telemetry/notice` (`config:manage`) + +Body: `{"action": "seen" | "keep" | "extended-off" | "all-off"}`. + +| Action | Effect | +|---|---| +| `seen` | Sets `notice_shown_at` if it isn't set yet, and opens the schedule (`next_due_at = now + U(0, min(15m, interval/4))`, research R12). Records no ack. Idempotent. | +| `keep` | Ack (`action=keep`). Consent unchanged. | +| `extended-off` | Ack. `extended=false`, `consent_source=admin`, install ID deleted. | +| `all-off` | Ack. `sendMetrics=false`, `extended=false`, `consent_source=admin`, install ID deleted, schedule closed. | + +- Every action returns `204`. +- Every action returns `409` when the notice isn't pending for the caller, except `seen`, which is a no-op in that case. + +## Audit + +Each mutating request above is audited through the existing middleware, as all `/admin/*` writes are. Install IDs are never written to audit `target` or `reason`. + +## Web client (`web/src/lib/api.ts`) + +- `Telemetry.get()` +- `Telemetry.resetInstallId()` +- `Telemetry.notice()` +- `Telemetry.ack(action)` +- `TelemetryCfg` in `web/src/lib/config.ts` gains `extended: boolean`. diff --git a/specs/022-default-telemetry-dashboard/contracts/install-config.md b/specs/022-default-telemetry-dashboard/contracts/install-config.md new file mode 100644 index 000000000..dbd9f76a8 --- /dev/null +++ b/specs/022-default-telemetry-dashboard/contracts/install-config.md @@ -0,0 +1,90 @@ +# Contract: install-time configuration (Helm values, API flags and environment) + +**Requirements**: FR-001, FR-002, FR-005, FR-006, FR-009, FR-033; SC-004. **Research**: R3, R12, R13, R15, R17. + +## Helm values (`charts/gameplane/values.yaml`) + +```yaml +api: + telemetry: + # NEW. false = hard off: the API never sends telemetry, whatever the admin + # toggles say. This is the single setting for air-gapped and + # privacy-sensitive clusters (FR-005, SC-004). + enabled: true + # Empty = the project's default provider (https://telemetry.gameplane.net/ingest, + # compiled into the API; OD-1 ruled 2026-10-07). Set a URL to send reports to your + # own receiver instead (FR-005, FR-006). + endpoint: "" + authSecretRef: { name: "", key: token } # unchanged + # NEW, testing only. Spacing between reports. Don't change in production. + interval: 24h + receiver: + enabled: false # unchanged; auto-wires the API when endpoint is empty + replicas: 1 # must stay 1 while persistence is enabled (the template fails otherwise) + resources: {} + persistence: # NEW (FR-020) + enabled: true + size: 1Gi + storageClassName: "" + dashboard: # NEW (FR-022, FR-028) + tokenSecretRef: { name: "", key: token } # empty name = no dashboard listener + # Extra NetworkPolicy peers allowed to reach the dashboard port + # (port-forward works without any). + ingressFrom: [] + publicSummary: + enabled: false # NEW (FR-031) + pepperSecretRef: { name: "", key: pepper } # NEW (R5), optional + retentionDays: 730 # NEW (OD-3: 24 months) + activityExpiryDays: 90 # NEW (OD-4) + ingestSourceDailyLimit: 20 # NEW (R6) + trustedProxyCIDRs: [] # NEW (R6) + ingestPow: # NEW (FR-039, R21, OD-5) + enabled: false # the project's provider turns it on (runbook) + targetPerMinute: 60 + minBits: 0 + maxBits: 22 +``` + +All new keys are read with `hasKey` or `dig` guards (the F-214 precedent), so `helm upgrade --reuse-values` from beta.8 needs no new values and adds no storage: an absent `persistence` block counts as disabled (an `emptyDir`), and other absent keys fall back to the defaults above. `retentionDays` below 365 or `activityExpiryDays` below 31 fails rendering, so a bad value never reaches a crash-looping pod. Likewise, `ingestPow.targetPerMinute` below 1, or `ingestPow.maxBits` below `minBits` or above 26, fails rendering. + +## How values render + +| Values | API arguments and environment | Destination kind | +|---|---|---| +| `enabled: false` | `--telemetry-disabled` | `disabled` | +| `endpoint: ""`, `receiver.enabled: true` | `--telemetry-endpoint=http://gameplane-telemetry-receiver..svc:8080/ingest`, `GAMEPLANE_TELEMETRY_BUNDLED=true` | `bundled` | +| `endpoint: "https://…"` | `--telemetry-endpoint=https://…` | `custom` | +| `endpoint: ""`, `receiver.enabled: false` | *(no endpoint argument)* | `default` (OD-1 ruled 2026-10-07) | +| `defaultModuleSource.enabled: true` | `--official-module-source=` | — | +| `interval` set | `GAMEPLANE_TELEMETRY_INTERVAL=` | — | + +`enabled: false` takes precedence over both the endpoint and the receiver. The receiver itself is still deployed if `receiver.enabled` is true. + +## API flags and environment (`api/cmd/main.go`) + +| Flag | Env | Default | Change | +|---|---|---|---| +| `--telemetry-endpoint` | `GAMEPLANE_TELEMETRY_ENDPOINT` | `""` | **Semantics change**: empty now means "use `telemetry.DefaultEndpoint`" (https://telemetry.gameplane.net/ingest, OD-1 ruled 2026-10-07) rather than "off". | +| `--telemetry-disabled` | `GAMEPLANE_TELEMETRY_DISABLED` | `false` | New. | +| — | `GAMEPLANE_TELEMETRY_BUNDLED` | `false` | New. Set only by the chart. | +| `--telemetry-interval` | `GAMEPLANE_TELEMETRY_INTERVAL` | `24h` | New. Minimum `1m`; lower values are a startup error. | +| `--official-module-source` | `GAMEPLANE_OFFICIAL_MODULE_SOURCE` | `""` | New. Empty means every module counts as `custom`. | +| — | `GAMEPLANE_TELEMETRY_AUTH` | `""` | Unchanged. | + +`telemetry.DefaultEndpoint` (`api/internal/telemetry`) is the only place the project URL lives (FR-002). It must use `https`; a non-https value fails startup (FR-009). + +## Receiver chart rendering (`templates/telemetry-receiver.yaml`) + +- **Workload.** The Deployment keeps `replicas: {{ $r.replicas }}`, adds `strategy: Recreate`, and fails rendering when `replicas > 1 && persistence.enabled`. +- **Volumes.** When `persistence.enabled`, a PVC `gameplane-telemetry-receiver-data` is mounted at `/data`. Otherwise an `emptyDir` is mounted there and the template adds a NOTES warning. In both cases the root filesystem stays read-only. +- **Environment.** `DATA_DIR=/data`, `DASHBOARD_TOKEN` and `ID_PEPPER` from their `secretKeyRef`s when named, `PUBLIC_SUMMARY`, `RETENTION_DAYS`, `ACTIVITY_EXPIRY_DAYS`, `INGEST_SOURCE_DAILY_LIMIT`, `TRUSTED_PROXY_CIDRS`, and `INGEST_POW`, `INGEST_POW_TARGET_PER_MIN`, `INGEST_POW_MIN_BITS` and `INGEST_POW_MAX_BITS`. +- **Service.** Adds port `dashboard` (8081 → 8081) when the dashboard token is named. +- **NetworkPolicy.** Port 8080 admits only the API pod. Port 8081 admits `dashboard.ingressFrom` peers, plus the Prometheus namespace when `serviceMonitors.scrapeNamespaceSelector` is set; that rule moves from 8080 to 8081, because `/metrics` now lives there. With no peers configured, port-forward still works. +- **ServiceMonitor** (`templates/servicemonitors.yaml`). It renders only when `dashboard.tokenSecretRef.name` is set, and scrapes port `dashboard`, path `/metrics`, with `bearerTokenSecret` pointing at that Secret. When `serviceMonitors.enabled` and the receiver are on without a token, NOTES prints a warning and no ServiceMonitor is rendered. + +## Test and dev installs (research R17) + +`deploy/kind/e2e.sh` and `deploy/kind/up.sh` must pass `--set api.telemetry.receiver.enabled=true`, so that no test or dev install ever reaches the project provider. The CI `helm template` checks (`.github/workflows/ci.yaml:775-804`) gain assertions for: + +- the `disabled`, `bundled` and `custom` renderings above +- `--reuse-values` compatibility with beta.8 diff --git a/specs/022-default-telemetry-dashboard/contracts/receiver-http.md b/specs/022-default-telemetry-dashboard/contracts/receiver-http.md new file mode 100644 index 000000000..cfa48148b --- /dev/null +++ b/specs/022-default-telemetry-dashboard/contracts/receiver-http.md @@ -0,0 +1,149 @@ +# Contract: telemetry-receiver HTTP surface + +**Requirements**: FR-014, FR-016, FR-020–FR-033, FR-035–FR-039, FR-041. **Research**: R3, R5–R9, R20, R21. + +The receiver runs two listeners. The dashboard listener is never started unless `DASHBOARD_TOKEN` is set. + +## Public listener: `LISTEN_ADDR` (default `:8080`) + +| Route | Method | Auth | Success | Failures | +|---|---|---|---|---| +| `/ingest` | POST | `AUTH_TOKEN` when set (unchanged). When `INGEST_POW=true`, a valid `Gameplane-Telemetry-PoW` header, checked before the body is read (R21). Reports with `ext` also need a valid `Gameplane-Telemetry-Signature`. | `204`, including for same-day duplicates (counted only in `duplicates`) | `400` structural error ([report-schema.md](report-schema.md)); `401` bad or missing token; `403` `bad_signature`, `stale` or `replay`; `409` `id_claimed`; `413` body over 16 KiB; `428` `pow_required` or `pow_invalid`; `429` per-source daily limit reached; `503` `pow_busy` with `Retry-After: 60` | +| `/v1/summary` | GET | none | `200` JSON (below) | `404` when `PUBLIC_SUMMARY` is not `true`; `429` per-source rate limit | +| `/v1/challenge` | GET | none | `200` JSON (below) | `404` when `INGEST_POW` is not `true`; `429` per-source rate limit (10 per minute, burst 5) | +| `/healthz` | GET | none | `200 ok` | — | + +### `GET /v1/summary` response (FR-029, FR-030) + +```json +{ + "asOf": "2026-10-05", + "reportsLatestDay": 412, + "reports30d": 11873, + "reportsTotal": 98211, + "activeInstalls30d": 1290 +} +``` + +- The response has exactly these five keys and nothing else. +- `asOf` is the latest complete UTC day. +- Response headers: + - `Content-Type: application/json` + - `Cache-Control: public, max-age=3600` + - `ETag` + - `Access-Control-Allow-Origin: *` +- The response is served from a snapshot recomputed at most every 5 minutes. +- With no data at all, every count is `0` and `asOf` is the previous UTC day. The summary is a counter, not a chart, so zeros here are accurate. + +The public listener answers `404` for `/metrics`. Operational metrics live only on the dashboard listener, behind the token (FR-030). + +### `GET /v1/challenge` response (FR-039, R21) + +```json +{ + "challenge": "AQAAAABpA2yQEp3x0b1f7a9c2e4d6b8a0c1e3f5.Zk9xQ2...", + "bits": 7, + "expiresAt": "2026-10-07T12:15:00Z" +} +``` + +- `challenge` is opaque to the sender: `base64url(payload) "." base64url(mac)` (R21). +- `bits` is the difficulty, from `INGEST_POW_MIN_BITS` up to `INGEST_POW_MAX_BITS`. It is also inside the MAC'd payload, which is what `/ingest` checks. +- `expiresAt` is 15 minutes after issue. +- The response carries `Cache-Control: no-store`. +- The sender solves it and sends `Gameplane-Telemetry-PoW: :` on `/ingest`, where `nonce` is decimal and `SHA-256(challenge ":" nonce)` starts with at least `bits` zero bits. Each challenge is accepted once. + +### New `/metrics` series (operations only) + +- `gameplane_telemetry_duplicates_total` +- `gameplane_telemetry_rate_limited_total{route}` +- `gameplane_telemetry_extended_reports_total` +- `gameplane_telemetry_refused_total{reason="bad_signature"|"stale"|"id_claimed"|"replay"|"pow_required"|"pow_invalid"|"pow_busy"}` +- `gameplane_telemetry_pow_bits` (gauge: the difficulty a challenge issued now would carry) +- `gameplane_telemetry_pow_challenges_total` + +The existing series keep their names and in-memory semantics. + +## Dashboard listener: `DASHBOARD_LISTEN_ADDR` (default `:8081`, only when `DASHBOARD_TOKEN` is set) + +| Route | Method | Auth | Success | When unauthenticated | +|---|---|---|---|---| +| `/login` | GET | none | Login form, which contains no data | — | +| `/login` | POST | form field `token`, same-origin `Origin` required | `303` to `/`, sets the session cookie | Login form re-rendered with "Invalid credentials". `429` after 5 attempts per minute per source. | +| `/logout` | POST | session, same-origin | `303` to `/login`, clears the cookie | `303` to `/login` | +| `/` | GET | session | Overview HTML for `?range=7\|30\|90\|365` (default 30; invalid values become 30) | `303` to `/login` | +| `/api/v1/views` | GET | session cookie, or `Authorization: Bearer ` | `200` JSON with the same view model the HTML renders | `401 {"error":"unauthorized"}` | +| `/metrics` | GET | `Authorization: Bearer ` only | Prometheus text (existing series, plus the new counters below) | `401 {"error":"unauthorized"}` | +| `/static/*` | GET | none | Embedded CSS and icons only | — | + +**Session cookie**: `gp_telemetry_session`, attributes `HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=43200`. The value is `expiry || HMAC(K, expiry)`, where `K` is derived from `DASHBOARD_TOKEN` (research R8). Changing the token invalidates every session. + +**Headers on every dashboard response**: + +- `Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'` +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy: no-referrer` +- `Cache-Control: no-store` + +**Refusal invariant (FR-023, SC-011)**: an unauthenticated response never contains: + +- any figure +- any date other than the page chrome +- any version, category or module name +- anything that varies with whether data exists + +An E2E check (R17) compares the unauthenticated response against the login page from an empty receiver. + +### `GET /api/v1/views` response shape + +```json +{ + "range": 30, + "asOf": "2026-10-05", + "empty": false, + "basic": { + "reportsPerDay": [{"day": "2026-09-06", "reports": 401}], + "versions": [{"label": "0.3.0", "reports": 8123}, {"label": "Other", "reports": 52}, {"label": "Invalid", "reports": 3}], + "fleet": { + "servers": {"bands": [{"le": "0", "reports": 120}], "median": 2}, + "templates": {"bands": [{"le": "0", "reports": 15}], "median": 4} + }, + "latestDay": {"serversTotal": 1840, "templatesTotal": 2611} + }, + "extended": { + "coverage": 0.71, + "installs": {"active1d": 290, "active7d": 812, "active30d": 1290}, + "newPerDay": [{"day": "2026-09-06", "count": 12}], + "lapsedPerDay": [{"day": "2026-09-06", "count": 4}], + "versionsByInstall": {"windowDays": 30, "items": [{"label": "0.3.0", "installs": 1011}]}, + "env": {"k8s": [{"label": "1.31", "share": 0.42}], "distro": [], "arch": [], "nodes": []}, + "games": [{"module": "minecraft-java", "installsShare": 0.63, "serversPerDay": 2140.5}, {"module": "custom", "installsShare": 0.08, "serversPerDay": 77.1}], + "features": [{"feature": "wakeOnConnect", "share": 0.18}], + "tunnels": [{"label": "playit", "share": 0.05}], + "clusters": [], "db": [], "language": [] + } +} +``` + +- When the range has no data: `empty: true`, with `basic` and `extended` set to `null`. The HTML renders the empty state (FR-027). +- When there is basic data but no extended reports: `extended: null`, and the HTML explains that no reports in the range included extended data. + +## Configuration + +| Variable | Default | Meaning | +|---|---|---| +| `LISTEN_ADDR` | `:8080` | Public listener. | +| `DASHBOARD_LISTEN_ADDR` | `:8081` | Dashboard listener. | +| `DASHBOARD_TOKEN` | *(empty)* | Dashboard credential, from a Secret. Empty means no dashboard listener. When set it must be at least 32 characters, or the receiver exits at startup (FR-041). | +| `AUTH_TOKEN` | *(empty)* | Ingest token (unchanged). | +| `DATA_DIR` | *(empty)* | SQLite directory. Empty means in-memory, and a startup warning is logged. | +| `PUBLIC_SUMMARY` | `false` | Enables `/v1/summary`. | +| `TRUSTED_PROXY_CIDRS` | *(empty)* | Comma-separated CIDRs whose `X-Forwarded-For` is trusted. | +| `INGEST_SOURCE_DAILY_LIMIT` | `20` | Accepted reports per source per UTC day. `0` means unlimited. | +| `RETENTION_DAYS` | `730` (OD-3) | Daily aggregate retention. Minimum 365. | +| `ACTIVITY_EXPIRY_DAYS` | `90` (OD-4) | Activity record expiry. Minimum 31. | +| `ID_PEPPER` | *(empty)* | HMAC pepper for install IDs, from a Secret. Empty means one is generated and kept in `meta`. | +| `INGEST_POW` | `false` (OD-5) | Requires proof-of-work on `/ingest` and serves `/v1/challenge` (R21). | +| `INGEST_POW_TARGET_PER_MIN` | `60` (OD-5) | Normal challenge rate; no work is required at or below it. Must be at least 1. | +| `INGEST_POW_MIN_BITS` | `0` (OD-5) | Lowest difficulty issued. | +| `INGEST_POW_MAX_BITS` | `22` (OD-5) | Highest difficulty issued. At least `INGEST_POW_MIN_BITS` and at most `MaxPoWBits` (26); an out-of-range value stops startup. | diff --git a/specs/022-default-telemetry-dashboard/contracts/report-schema.md b/specs/022-default-telemetry-dashboard/contracts/report-schema.md new file mode 100644 index 000000000..6faf201b9 --- /dev/null +++ b/specs/022-default-telemetry-dashboard/contracts/report-schema.md @@ -0,0 +1,122 @@ +# Contract: Telemetry report schema + +**Owner**: `telemetryschema/` (research R1). The API builds reports with this package and the receiver decodes them with it. +**Requirements**: FR-010, FR-011, FR-013, FR-016, FR-035–FR-038. + +## Body + +The body is one JSON object. Keys are case-sensitive. Duplicate keys, unknown keys, `null` values and trailing content are all rejected. The body may be at most 16 KiB, which is unchanged. + +### Basic tier (unchanged) + +```json +{ "version": "0.3.0", "servers": 3, "templates": 7 } +``` + +### Basic and extended tiers + +```json +{ + "version": "0.3.0", + "servers": 3, + "templates": 7, + "ext": { + "schema": 1, + "installId": "3f1c2a9e-8b4d-4e57-9a61-0c2d7e5b9f10", + "env": { "k8s": "1.31", "distro": "k3s", "arch": ["amd64"], "nodes": "1" }, + "games": { "official": { "minecraft-java": 2, "terraria": 1 }, "custom": 0 }, + "features": { + "wakeOnConnect": true, "tunnels": ["playit"], "capture": false, + "backups": true, "sso": false, "auditForwarding": false, + "clusters": "1", "db": "sqlite", "language": "en" + }, + "key": "pV0mY3Qn8wK2b7cXlR4sTt9uJ1eHaZ6dFgQmNoP5rSk", + "sentAt": "2026-10-06T09:12:44Z" + } +} +``` + +Sent with the header: + +```text +Gameplane-Telemetry-Signature: ed25519= +``` + +## Field rules + +| Path | Type | Allowed values | When the value doesn't fit | +|---|---|---|---| +| `version` | string | `^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$` | The receiver buckets it as `invalid`. | +| `servers`, `templates` | integer | ≥ 0 | 400 | +| `ext` | object | optional | — | +| `ext.schema` | integer | `1` | A higher value gets 400, which triggers the API's basic-only fallback. | +| `ext.installId` | string | lowercase UUIDv4 | Once `ext` passes the structural checks, the receiver ignores it and counts the report as basic. A structurally broken `ext` is a 400 whatever the ID. | +| `ext.env.k8s` | string | `1.`, where minor is 1–3 digits | `other` | +| `ext.env.distro` | string | `k3s` `rke2` `k0s` `eks` `gke` `aks` `openshift` `microk8s` `minikube` `kind` `doks` `talos` `other` | `other` | +| `ext.env.arch` | string[] | non-empty, sorted, unique, drawn from `amd64` `arm64` `arm` `ppc64le` `s390x` `riscv64` `other` | Unknown members become `other`. An empty array gets 400. | +| `ext.env.nodes` | string | `1` `2-3` `4-10` `11-50` `51+` | `other` | +| `ext.games.official` | object | keys are module names from the embedded catalog, values are integers ≥ 1 | Unknown keys are added to `custom`. A non-positive value gets 400. | +| `ext.games.custom` | integer | ≥ 0 | 400 | +| `ext.features.wakeOnConnect`, `capture`, `backups`, `sso`, `auditForwarding` | boolean | — | 400 | +| `ext.features.tunnels` | string[] | sorted, unique, drawn from `frp` `tailscale` `playit` `other` (may be empty) | Unknown members become `other`. | +| `ext.features.clusters` | string | `1` `2-3` `4-10` `11+` | `other` | +| `ext.features.db` | string | `sqlite` `postgres` | `other` | +| `ext.features.language` | string | `en` (spec 019 extends this list) | `other` | +| `ext.key` | string | canonical base64url (unpadded, no line breaks) encoding of a 32-byte Ed25519 public key | 400 | +| `ext.sentAt` | string | RFC 3339 UTC | 400 if it doesn't parse. 403 `stale` if it is outside `[now − 36h, now + 1h]`. | + +## Signature and claim (FR-035–FR-038, research R20) + +**Key derivation** (install side, `telemetryschema.DeriveKey`): + +```text +seed = HKDF-SHA256(ikm = signing_secret, salt = installId, + info = "gameplane-telemetry-signing-v1", L = 32) +priv = ed25519.NewKeyFromSeed(seed) +``` + +- `signing_secret` is 32 random bytes created once on the install. It never leaves the install. +- The key is unique to the ID, so a new ID always means a new key. + +**Signing**: `Gameplane-Telemetry-Signature: ed25519=`. +- `body` is the exact bytes POSTed. +- The header is required whenever `ext` is present, and absent on basic-only reports. + +**Verification** (receiver, `telemetryschema.Verify`, then claim lookup). Checks run in this order, and the first failure wins: + +| Check | Failure | +|---|---| +| Header present and well-formed, and the signature verifies with `ext.key` over the body | 403 `{"error":"bad_signature"}` | +| `sentAt` within `[now − 36h, now + 1h]` | 403 `{"error":"stale"}` | +| The ID is unclaimed (it gets claimed: `key_fp = SHA-256(key)`), or it is claimed with the same `key_fp` | 409 `{"error":"id_claimed"}` | +| `sentAt` is later than the ID's last accepted `sentAt` | 403 `{"error":"replay"}` | + +A refused report changes no stored data. + +## Band functions (exported by `telemetryschema`) + +| Function | Input → band | +|---|---| +| `NodeBand(n)` | 1 → `1`; 2–3 → `2-3`; 4–10 → `4-10`; 11–50 → `11-50`; over 50 → `51+` | +| `ClusterBand(n)` | n counts the local cluster plus registered clusters. 1 → `1`; 2–3 → `2-3`; 4–10 → `4-10`; over 10 → `11+` | +| `FleetBands` | `0,1,2,5,10,25,50,100,250,+Inf`, the same buckets as today's Prometheus histograms | + +## Privacy guarantees enforced by the schema + +- No field can hold free text. Every string is matched against a pattern or an enumeration, or is the install ID, the public key, or the send time. +- Module names can come only from the embedded catalog. +- Player counts, names, namespaces, hostnames, addresses, and custom module names cannot be represented in the schema. + +## Catalog + +`telemetryschema/catalog.txt` lists one official module name per line, generated from `charts/gameplane/values.yaml` → `defaultModuleSource.oci.modules`. `hack/check-telemetry-catalog.sh` fails CI when the two differ (research R15). + +## Compatibility + +| Install → receiver | Result | +|---|---| +| Old install (basic only) → new receiver | Accepted, counted as basic. | +| New install with `ext` → a receiver released before this feature (≤ v0.3.0) | 400. The install re-POSTs basic only, which is accepted, and suppresses `ext` for that endpoint for 7 days. | +| New install with `ext.schema` N → receiver that supports a lower schema | 400. Same fallback as the row above. | +| Install whose ID is claimed by another key → new receiver | 409. The install replaces its ID, re-signs and re-POSTs once (FR-037). | +| Bad signature, stale send time or replay → new receiver | 403. Counts as a failed attempt with normal backoff. No fallback and no ID rotation. | diff --git a/specs/022-default-telemetry-dashboard/data-model.md b/specs/022-default-telemetry-dashboard/data-model.md new file mode 100644 index 000000000..2de557289 --- /dev/null +++ b/specs/022-default-telemetry-dashboard/data-model.md @@ -0,0 +1,242 @@ +# Data Model: Default telemetry destination, extended telemetry, and telemetry dashboard + +**Feature**: [spec.md](spec.md) | **Research**: [research.md](research.md) + +There are three places where data lives: + +- **Wire**: the report itself, shared by both sides. Field-level rules are in [contracts/report-schema.md](contracts/report-schema.md). +- **API**: the install's own database, SQLite or PostgreSQL, through portable migration `015`. +- **Receiver**: the provider's SQLite file. + +All timestamps are RFC 3339 UTC strings bound from Go. All days are UTC calendar days written `YYYY-MM-DD`. + +--- + +## Wire (package `telemetryschema`) + +### Report + +| Field | Type | Rule | +|---|---|---| +| `version` | string | Required. Sanitised on the receiver to `invalid` unless it matches `^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$`. Unchanged from today. | +| `servers` | int | Required, ≥ 0. Unchanged. | +| `templates` | int | Required, ≥ 0. Unchanged. | +| `ext` | ExtendedPart | Optional. Present only while the extended tier is on (FR-011). | + +### ExtendedPart + +| Field | Type | Rule | +|---|---|---| +| `schema` | int | Required, `1`. A receiver rejects values above the one it supports with 400. | +| `installId` | string | Required. Lowercase UUIDv4. A malformed ID makes the receiver drop `ext` and count the report as basic only. | +| `env` | Env | Required. | +| `games` | Games | Required. | +| `features` | Features | Required. | +| `key` | string | Required. Base64url (unpadded) 32-byte Ed25519 public key derived for this `installId` (R20). | +| `sentAt` | string | Required. RFC 3339 UTC send time. Accepted window `[now − 36h, now + 1h]`. Must be strictly later than the ID's last accepted `sentAt`. | + +**Signature**: the header `Gameplane-Telemetry-Signature: ed25519=` covers the exact body bytes. It is required whenever `ext` is present and must be absent on basic-only reports (FR-035, FR-038). + +- **Env**: + - `k8s` (enumerated pattern `1.N`, otherwise `other`) + - `distro` (enumeration) + - `arch` (non-empty sorted set from an enumeration) + - `nodes` (band) +- **Games**: + - `official`: a map from catalog module name to its server count (int ≥ 1; zero entries are omitted) + - `custom`: int ≥ 0 +- **Features**: + - booleans: `wakeOnConnect`, `capture`, `backups`, `sso`, `auditForwarding` + - `tunnels` (sorted set from an enumeration, may be empty) + - `clusters` (band) + - `db` (enumeration) + - `language` (enumeration) + +Validation and sanitisation are identical on both sides because both import the same package (research R1): + +- **Structural errors** reject the report with 400: + - wrong JSON types + - unknown keys + - duplicate keys + - negative counts + - more than one JSON value +- **Out-of-set values** are kept and become `other`. Unknown module keys are folded into `custom`. + +--- + +## API (install side) + +### `config` row, key `telemetry` (existing table, extended value) + +| Field | Type | Meaning | +|---|---|---| +| `sendMetrics` | bool | Basic tier consent. Existing field. | +| `extended` | bool | Extended tier consent. New. Saving `sendMetrics: false` always stores `extended: false` too (FR-003). | + +If the row is absent, both tiers are off. The row is written by `PUT /admin/config/telemetry`, by the notice actions, and by the one-time seeding step (R10). + +### `telemetry_state` (new table, exactly one row, `id = 'singleton'`) + +| Column | Type | Null | Meaning | +|---|---|---|---| +| `id` | TEXT PK | no | Always `'singleton'`. | +| `consent_source` | TEXT | no | `default` (seeded on a fresh install), `legacy` (seeded on upgrade), or `admin` (an admin made an explicit choice). | +| `install_id` | TEXT | yes | UUIDv4. Present only while `config.telemetry.extended` is true (FR-012). | +| `notice_shown_at` | TEXT | yes | When an admin's dashboard first rendered the notice. Set once. | +| `next_due_at` | TEXT | yes | When the next report is due. NULL while the gate is closed. | +| `last_attempt_at` | TEXT | yes | When the last POST was attempted. | +| `last_success_at` | TEXT | yes | When a report was last accepted (2xx). | +| `last_outcome` | TEXT | no | `never` (default), `ok`, or `failed`. | +| `consecutive_failures` | INTEGER | no | Default 0. Drives backoff (R12). | +| `ext_unsupported_until` | TEXT | yes | Fallback marker (R2). | +| `ext_unsupported_endpoint` | TEXT | yes | The endpoint the fallback marker applies to. The marker is cleared when the destination changes. | +| `signing_secret` | TEXT | yes | 32 random bytes, base64. Created with the first install ID and kept for the install's lifetime. Never returned by any endpoint, logged or audited (FR-012, FR-035). | +| `last_id_rotation_at` | TEXT | yes | When the reporter last replaced the ID after a 409 `id_claimed` (FR-037). | + +No HTTP endpoint writes this table directly. It is written by: + +- the seeding step +- the post-save hook for the `telemetry` config section +- the notice endpoint +- the install-ID reset endpoint +- the reporter + +### `telemetry_notice_acks` (new table) + +| Column | Type | Meaning | +|---|---|---| +| `user_id` | BIGINT PK | The admin who dismissed the notice (`users.id`). Deleted in Go when the user is removed (migrations README rule 3). | +| `acked_at` | TEXT | When the notice was dismissed. | +| `action` | TEXT | `keep`, `extended-off`, or `all-off`. | + +### Migration `015_telemetry_state.sql` (portable, `common/`) + +The migration creates the two tables above with `CREATE TABLE`. It contains: + +- no `AUTOINCREMENT` +- no `datetime(...)` +- `last_outcome` with `DEFAULT 'never'` +- `consecutive_failures` with `DEFAULT 0` + +Seeding is **not** in SQL. It is the Go post-migration step from R10, because it has to read the existing JSON config value. + +### Derived values (not stored) + +- **Destination**: `{kind: default|custom|bundled|disabled|none, host}`, resolved from flags at startup (R13). +- **Effective consent**: + - `basicOn = sendMetrics && kind ∉ {disabled, none}` + - `extendedOn = basicOn && extended` +- **Gate open**: `basicOn && (consent_source ≠ 'default' || notice_shown_at ≠ NULL)`. +- **Notice pending (per user)**: all of these hold: + - the user has `config:manage` + - `kind ∉ {disabled, none}` + - `consent_source = 'default'` + - the user has no ack row +- **Preview**: the exact `Report` the reporter would build now (SC-006). It is produced by the same `Collect` function. + +### State transitions: consent + +```text + fresh DB (R10) upgrade (R10) + │ │ + ▼ ▼ + ┌──────────────────────────┐ ┌──────────────────────────┐ + │ default │ │ legacy │ + │ basic=on ext=on │ │ basic=saved|off ext=off │ + │ reports gated on notice │ │ no notice │ + └──────────┬───────────────┘ └───────────┬──────────────┘ + notice "seen" opens the gate │ + (consent_source stays default) │ + │ any admin choice: settings save or notice action + └──────────────┬────────────────┘ + ▼ + ┌──────────────────────┐ + │ admin │ + │ basic / ext as saved │ + └──────────────────────┘ +``` + +- **Install ID**: + - `extended` changes from false to true → generate a new ID. + - `extended` changes from true to false → set the ID to NULL. + - Reset → replace the ID. The old ID is never sent again (US3 scenarios 4 and 5). + - A 409 `id_claimed` from the receiver → replace the ID automatically and set `last_id_rotation_at` (FR-037). + - The signing key is never stored. It is derived from `signing_secret` and the current ID on each send, so every change of ID changes the key (R20). +- **Operator disabled** (`kind = disabled`) overrides every state. Toggles are refused with 409, and nothing is sent (FR-005). + +### State transitions: schedule (R12) + +```text +gate closed ──gate opens──▶ due at now+U(0,15m) ──claim──▶ attempt + ▲ │ + │ gate closes (next_due_at = NULL) 2xx ◀──────────┤ + │ │ │ error / non-2xx + │ ▼ ▼ + └──────────────────────────────── due at now+interval+jitter + (or now+backoff after an error) +``` + +Responses inside one attempt: + +- **400 with `ext` present**: re-POST without `ext` once, and set `ext_unsupported_*` (R2). +- **409 `id_claimed`**: replace the install ID (keeping `signing_secret`), set `last_id_rotation_at`, re-sign and re-POST once. A second 409 counts as an error (R20). +- **403** (`bad_signature`, `stale` or `replay`): counts as an error, with normal backoff. The ID is never rotated. + +--- + +## Receiver (provider side, SQLite at `$DATA_DIR/telemetry.db`) + +| Table | Key | Columns | Written by | +|---|---|---|---| +| `meta` | `key` | `value` | Startup and ingest. Keys: `schema_version`, `pepper` (only when `ID_PEPPER` is unset), `collection_started`, `reports_total`, `rollover_through`. | +| `daily_basic` | `day` | `reports`, `duplicates`, `servers_sum`, `templates_sum` | Ingest (every accepted report; `duplicates` instead of `reports` for per-ID same-day repeats). | +| `daily_version` | `(day, version)` | `reports` | Ingest. `version` is the sanitised value or `invalid`. | +| `daily_fleet` | `(day, metric, value)` | `reports` | Ingest. `metric` is `servers` or `templates`. `value` is the exact count 0–1000, or `1001` for more than 1000. | +| `daily_ext` | `day` | `ext_reports`, `active_installs`, `new_installs`, `lapsed_installs` | Ingest for the first three. Rollover for `lapsed_installs`. | +| `daily_dim` | `(day, dim, value)` | `installs` | Ingest of a non-duplicate extended report. See the dimension list below. | +| `daily_game` | `(day, module)` | `installs`, `servers` | Ingest. `module` is a catalog name or `custom`. | +| `activity` | `id_hmac` | `key_fp` (SHA-256 of the claiming public key), `first_seen`, `last_seen`, `last_sent_at`, `last_version` | Ingest: inserting a row *is* the claim (R20). Deleted by expiry, and the claim expires with it (FR-038). | + +`daily_dim` dimensions: + +- `k8s`, `distro`, `arch` (one row per architecture present), `nodes` +- `tunnel` (one row per provider present) +- `clusters`, `db`, `language` +- `feature` with value `wakeOnConnect`, `capture`, `backups`, `sso` or `auditForwarding` (a row only when the value is true) + +Every `value` is a `telemetryschema` enumeration member or `other`, so the row count per day is bounded by the size of those sets. + +### Invariants + +- **Nothing raw is stored.** There are no raw reports, source addresses or untransformed install IDs (FR-014, SC-012). Source addresses exist only in the in-memory limiters (R6). +- **Proof-of-work state is memory only.** The challenge MAC key, the issue-rate window and the used-challenge set (R21) are never written to the database, and a restart discards them. +- **Extended attributes are never keyed by ID.** `activity` has no column for any of them (FR-014). +- **Refused reports leave no trace.** A report refused by the proof-of-work, signature, window, claim or replay checks changes no table (FR-036, FR-039). Only the in-memory limiter and the `rate_limited` and `refused` metrics see it. +- **No full public keys are stored.** `key_fp` is enough to check the claim, because the key arrives with every report. +- **One extended contribution per install per day.** At most one extended report per `id_hmac` per day changes any aggregate (R5). +- **The running total never decreases.** `reports_total` is only ever incremented, so retention sweeps don't change the public "total since collection began". + +### Lifecycle jobs (hourly, idempotent, resumable through `rollover_through`) + +1. **Finalise each completed day D** after `rollover_through`: set `daily_ext[D].lapsed_installs = count(activity where last_seen = D − 30 days)`. +2. **Expire activity records** where `last_seen < today − ACTIVITY_EXPIRY_DAYS`. This uses OD-4 (ruled: 90 days), with an enforced minimum of 31. +3. **Delete `daily_*` rows** where `day < today − RETENTION_DAYS`. This uses OD-3 (ruled: 730 days, i.e. 24 months), with an enforced minimum of 365 (FR-020). + +### Derived views (computed at read time, [contracts/receiver-http.md](contracts/receiver-http.md)) + +The range R is 7, 30, 90 or 365 days and ends on the latest complete day. + +| View | Computation | +|---|---| +| Reports per day | `daily_basic.reports` series over R | +| Version adoption (reports) | `daily_version` summed over R. Top 10 by count, then `Other` (the rest) and `Invalid`. | +| Fleet distribution and median | `daily_fleet` summed over R. Bands `0,1,2,5,10,25,50,100,250,250+` are applied at read. The exact median comes from the cumulative counts. | +| Latest-day totals | `daily_basic.servers_sum`, `templates_sum` for the latest complete day | +| Unique active installs | `count(activity where last_seen ≥ asOf − {0, 6, 29})` | +| New and lapsed installs | `daily_ext.new_installs` and `lapsed_installs` series over R | +| Version adoption (installs) | `activity.last_version` for records with `last_seen` inside `min(R, ACTIVITY_EXPIRY_DAYS)`. The window used is shown. | +| Environment, games, feature shares | Σ `installs` ÷ Σ `daily_ext.ext_reports` over R (install-day weighted, research R4) | +| Extended coverage (FR-026) | Σ `ext_reports` ÷ Σ `reports` over R | +| Public summary (FR-029) | `reports` for asOf; Σ `reports` over the 30 days ending asOf; `meta.reports_total`; `count(activity where last_seen ≥ asOf − 29)`; `asOf` | + +`asOf` is the latest complete UTC day, which is yesterday. When no `daily_basic` row exists in R, every view returns its empty state instead of zeros (FR-027). diff --git a/specs/022-default-telemetry-dashboard/plan.md b/specs/022-default-telemetry-dashboard/plan.md new file mode 100644 index 000000000..1d1984f45 --- /dev/null +++ b/specs/022-default-telemetry-dashboard/plan.md @@ -0,0 +1,241 @@ +# Implementation Plan: Default telemetry destination, extended telemetry, and telemetry dashboard + +**Branch**: `feat/default-telemetry-dashboard` (not yet created; the spec-kit script derived `022-default-telemetry-dashboard`) | **Date**: 2026-10-06 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/022-default-telemetry-dashboard/spec.md` + +## Summary + +Telemetry gets a compiled-in default destination and two consent tiers, and the receiver becomes a small provider with durable aggregates, a private dashboard and a public summary. + +**Destination.** The API gains one authoritative default destination, `telemetry.DefaultEndpoint`. It is set to https://telemetry.gameplane.net/ingest (OD-1, ruled 2026-10-07). An operator can still choose a custom destination, the bundled receiver, or a hard **disable** (`api.telemetry.enabled=false`). + +**Consent.** Consent splits into a **basic** tier and an **extended** tier: +- **Fresh installs** (detected inside `Store.Migrate`) seed both tiers on, but nothing is sent until an admin's dashboard has displayed the first-login notice. +- **Upgraded installs** keep their saved basic choice, and extended stays off. + +**Reporter.** The reporter switches to a persisted, claim-based schedule that survives restarts and sends at most one report per 24 h. It also falls back to basic-only for receivers older than this feature. + +**Extended report.** It adds: +- a random install ID +- environment categories +- per-official-module server counts, with everything else summed as `custom` +- feature-adoption flags + +All of these come from a new shared stdlib-only module, `telemetryschema/`, so the API and the receiver validate the same fixed categories. + +**Signed reports (US7).** Every extended report is signed with Ed25519. The key is derived (HKDF) from a random secret kept in the install's database, combined with the current install ID, so resets produce unlinkable keys. The receiver binds each ID to the first key that uses it, and refuses, without counting anything, reports that are unsigned, tampered, stale, replayed or signed by another key. When an install's ID is claimed by someone else, it replaces the ID automatically and resends (research R20). + +**Receiver.** `telemetry-receiver` stores only **daily aggregates** plus **expiring activity records** (an HMAC of the install ID, first and last seen, last version) in SQLite on a PVC. Per-ID, per-day deduplication and in-memory per-source limits keep the counts honest. It serves: +- a **private, token-protected, server-rendered dashboard** on its own port +- an **opt-in public summary** of five counts + +The Admin Settings Telemetry section and the new notice banner are designed in `design.pen` first. The receiver's pages are designed in their own Pencil file, `telemetry-receiver/telemetry-dashboard.pen`, which starts from a copy of the HeroUI design. Prometheus `/metrics` moves behind the dashboard token, and a runbook documents how to run the project's receiver. + +## Technical Context + +**Language/Version**: Go 1.26 for `api`, `telemetry-receiver` and the new `telemetryschema`. TypeScript (strict) and React 19 for `web`. Helm 3 templates. + +**Primary Dependencies**: existing ones only, apart from the receiver gaining `modernc.org/sqlite`, which the API already uses. +- API: chi, client-go (dynamic and typed), `modernc.org/sqlite` / `pgx`. +- Receiver: `prometheus/client_golang`, plus `modernc.org/sqlite` (new to the receiver), stdlib `html/template` and `embed`. +- Web: HeroUI v3, TanStack Query. No new web dependency. + +**Storage**: +- API: migration `015_telemetry_state.sql` in `common/` (portable to SQLite and PostgreSQL), plus the existing `config` key `telemetry`. +- Receiver: SQLite file at `$DATA_DIR/telemetry.db` on a RWO PVC, or in-memory when `DATA_DIR` is unset. + +**Testing** (CI only, per constitution VI). Signing adds tests for derivation vectors, verification failure cases, and claim, replay and window rules: +- `go test` with coverage gates: `telemetryschema` 90 (new), `telemetry-receiver` 70, `api` 80. +- API envtest handler suites. +- vitest for the web. +- `helm template` assertions in `ci.yaml`. +- Kind E2E: new `telemetry` bucket, plus an assertion in the `upgrade` bucket. +- New lint script `hack/check-telemetry-catalog.sh`. + +**Target Platform**: Kubernetes (k3s, kind, managed), with linux/amd64 and arm64 distroless images. The default provider is the same receiver image, run by the project maintainers (OD-2). + +**Project Type**: Multi-component: Go services, a React dashboard, a Helm chart, and a new shared Go library module. + +**Performance Goals**: +- Ingest: 10,000 reports per day (about 0.1 req/s average, bursty) at the default provider. +- Dashboard: the 365-day view within 3 s (SC-010), backed by pre-aggregated daily rows. +- Public summary: served from a 5-minute snapshot. +- Install side: one collection pass per report or preview, with a bounded list of local CRs and nodes. Signing costs one HKDF and one Ed25519 signature per send. + +**Constraints**: +- No raw reports, source IPs or untransformed IDs are ever persisted (FR-014, SC-012). +- Every stored category is bounded by an enumeration. +- At most one accepted report per 24 h (FR-007). +- Every extended report is signed, and the signing secret never leaves the install (FR-035). +- `https` with full verification for the default destination (FR-009). +- The receiver stays a single static binary with a read-only root filesystem. +- The dashboard has no JavaScript and a strict CSP. +- `--reuse-values` from beta.8 must render unchanged. + +**Scale/Scope**: +- About 10k reporting installs. +- 12–24 months of daily aggregates. +- About 10k activity records. +- 5 new API routes or route variants. +- 1 rewritten settings section, plus 1 new banner. +- About 5 new frames in `design.pen`, and about 4 in `telemetry-receiver/telemetry-dashboard.pen`. +- 1 new Go module. +- 1 new protocol element: signed extended reports with a claim on first use (R20). + +No NEEDS CLARIFICATION remains. Every technical unknown is resolved in [research.md](research.md) (R1–R20). OD-1 (the domain) is ruled (2026-10-07): telemetry.gameplane.net. OD-2, OD-3 and OD-4 are ruled (2026-10-06). + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.* + +| Principle | Gate | Pre-design | Post-design | +|---|---|---|---| +| **I. E2E-tested delivery** | Every user-facing path has a Kind E2E in a registered bucket. | Planned. | **PASS**: new `telemetry` bucket with `TestTelemetryLifecycle` (`t.Parallel()`, unique names, sequential subtests because they mutate cluster-wide Helm state); `upgrade` bucket assertion (R17). Registered in `test/e2e/buckets.sh` and the CI matrix. | +| **II. Design-first UI** | Dashboard UI is designed in `design.pen` via the Pencil MCP before React or HTML is written, and touched nodes are exported to `design-export/`. | Planned. | **PASS**: frames for the notice banner, the five Telemetry settings states, and the receiver login, overview, empty and refusal pages (R18). `.pen` files are touched only through Pencil MCP. The export lands in the same commit. The receiver pages use a third Pencil source, `telemetry-receiver/telemetry-dashboard.pen` (spec Q10), exported to `telemetry-receiver/design-export/`. Principle II names only `design.pen` and `website.pen`, so this file follows the same rules by analogy: Pencil MCP only, design before code, export in the same commit. | +| **III. Best practice / no suppression** | No `//nolint` or `eslint-disable`; `%w` wrapping; strict TS; no CRD edits without codegen. | — | **PASS**: no CRD type changes, so CLAUDE.md rule 7 doesn't apply. The new module gets the standard lint config, and nothing is excluded. | +| **IV. Spec-driven; per-module `specs.md`** | Behaviour changes update `specs.md` in the same change. | — | **PASS** (planned): `telemetryschema/specs.md` (new), `telemetry-receiver/specs.md` and README (invariants rewritten: "daily aggregates and expiring activity records, never raw reports", "basic or extended report"), `api/specs.md`, `web/specs.md`. | +| **V. Delegate via Workflow** | Implementation goes through Workflow scripts, haiku first, review one tier up, `model:` set on every `agent()`. | — | **PASS** (process): the tasks phase produces scout briefs; implementation waves are split by component (see Delivery slices). | +| **VI. CI does the heavy lifting** | Locally only `go build` and `tsc --noEmit`. | — | **PASS**: quickstart lists only compile checks locally; everything else is CI. | +| CLAUDE.md core rule 3: login privacy | Unauthenticated views reveal nothing. | — | **PASS**: the receiver's refusal invariant (contracts/receiver-http.md). The Gameplane `/login` is untouched, and the notice appears only after authentication. | +| CLAUDE.md core rule 9: K8s primitives first | — | — | **PASS**: PVC for receiver data; Secrets for the dashboard token and pepper; NetworkPolicy for the dashboard port. | +| CLAUDE.md core rule 10: operator authority | — | — | **PASS**: the reporter only reads CRs; nothing bypasses reconciliation. This is the reporter's existing placement in the API. | +| CLAUDE.md system override 10: unsettled values | — | — | **PASS**: the domain is ruled (OD-1). Retention (730 days) and activity expiry (90 days) are ruled values (OD-3, OD-4). | + +**Result**: no violations. The one structural addition, a 16th Go module, is recorded under Complexity Tracking as a deliberate cost rather than a violation. + +## Project Structure + +### Documentation (this feature) + +```text +specs/022-default-telemetry-dashboard/ +├── spec.md +├── OPEN-DECISIONS.md # OD-1..OD-5 ruled +├── plan.md # this file +├── research.md # R1–R20 +├── data-model.md +├── quickstart.md +├── contracts/ +│ ├── report-schema.md # wire format, enums, bands, compatibility +│ ├── receiver-http.md # ingest, summary, dashboard routes, auth, env +│ ├── api-telemetry-http.md# admin routes, config PUT semantics, notice +│ └── install-config.md # Helm values, API flags, rendering table +├── checklists/requirements.md +└── tasks.md # /speckit-tasks (not created here) +``` + +### Source Code (repository root) + +```text +telemetryschema/ NEW stdlib-only module (R1) +├── go.mod module github.com/GameplanePanel/gameplane/telemetryschema +├── report.go Report / ExtendedPart / Env / Games / Features, JSON encode +├── decode.go strict decoder (moved from telemetry-receiver/main.go decodePayload, extended) +├── enums.go distro, arch, tunnel, db, language sets; NodeBand / ClusterBand / FleetBands +├── catalog.go, catalog.txt embedded official module list (R15) +├── sign.go DeriveKey (HKDF → Ed25519), Sign, Verify, signature header codec (R20) +├── *_test.go, .testcoverage.yml (90) +└── specs.md + +telemetry-receiver/ +├── main.go config + two listeners (slimmed) +├── ingest.go /ingest: auth, size cap, decode, signature/window/claim/replay checks (R20), limiter, dedupe, aggregate write +├── store.go SQLite schema, migrations, writes, rollover/expiry/retention jobs +├── views.go view model for the dashboard + public summary +├── dashboard.go token login, signed cookie, CSP, handlers +├── summary.go /v1/summary snapshot cache, CORS, ETag +├── ratelimit.go per-source daily counter + token buckets, trusted proxies +├── web/ go:embed templates + CSS + inline-SVG chart partials (no JS) +├── go.mod + modernc.org/sqlite, + replace ../telemetryschema +├── Dockerfile + COPY telemetryschema/ +├── README.md, specs.md rewritten invariants/config +└── *_test.go incl. synthetic-population accuracy (SC-008), 365-day view timing (SC-010) + +api/ +├── cmd/main.go new flags (install-config.md), reporter wiring +├── Dockerfile + COPY telemetryschema/ +├── go.mod + require/replace telemetryschema +├── internal/telemetry/ +│ ├── telemetry.go Reporter: destination resolution, gate, schedule claim, sign + send, 400 fallback, 409 ID rotation +│ ├── collect.go builds the Report (also used by the preview) +│ ├── distro.go R16 heuristics +│ └── *_test.go +├── internal/db/ +│ ├── migrations/common/015_telemetry_state.sql +│ ├── telemetry.go state/ack queries, claim UPDATE +│ └── db.go Migrate: fresh detection + one-time seed (R10) +├── internal/handlers/ +│ ├── telemetry.go GET /admin/telemetry, POST install-id, notice GET/POST +│ ├── config.go telemetry validator (extended ⇒ sendMetrics) + post-save hook + 409 +│ └── telemetry_envtest_test.go +├── internal/rbac/rbac.go /admin/telemetry entries before the admin wildcard +└── specs.md + +web/src/ +├── lib/api.ts, lib/config.ts Telemetry client; TelemetryCfg.extended +├── components/ui/TelemetryNotice.tsx banner (+ .test.tsx) +├── components/AppLayout.tsx mounts the notice for config:manage holders +└── routes/AdminSettings.tsx TelemetrySection rewrite (+ tests) + +charts/gameplane/ +├── values.yaml install-config.md keys +├── templates/api.yaml --telemetry-disabled, BUNDLED, interval, official source +├── templates/telemetry-receiver.yaml PVC, Recreate, env, dashboard port, NetworkPolicy +└── testdata/ reuse-values fixture assertions + +test/e2e/telemetry_e2e_test.go TestTelemetryLifecycle (R17) +test/e2e/buckets.sh + telemetry bucket; upgrade bucket assertion +deploy/kind/{e2e.sh,up.sh} explicit bundled receiver +hack/check-telemetry-catalog.sh catalog drift check (wired into make lint) + +design.pen → design-export/{json,screenshots}/ notice, settings states +telemetry-receiver/telemetry-dashboard.pen → telemetry-receiver/design-export/{json,screenshots}/ receiver login, overview, empty +docs/{install.md,architecture.md,security.md,telemetry-provider.md}, CHANGELOG.md, docs/agent-architecture.md, CLAUDE.md (repo map + coverage list) +go.work, Makefile (GO_MODULES), .github/workflows/ci.yaml (matrices, path filters, e2e bucket), .github/dependabot.yml +website/ (submodule): platform-settings-telemetry.mdx, helm-values-reference.mdx, air-gapped-installation.mdx +``` + +**Structure Decision**: the work spreads across existing components, plus one new root-level library module, `telemetryschema/`, which follows the netguard and gameaction pattern. No other new top-level directories are added. + +## Delivery slices (input for `/speckit-tasks`) + +The slices are implementation waves on **one feature branch and one PR**, in this order. The PR stays a draft until OD-1 is ruled (Merge gate), so none of this reaches `master` before the domain exists. + +1. **Contract module.** Add `telemetryschema`, move the receiver's decoder into it with no behaviour change, and add `sign.go` (key derivation, signing and verification, R20). Wire the new module through CI, the Makefile, `go.work`, Dependabot and the docs maps. Add the catalog and its drift check. +2. **Receiver provider** (US4, US5, US6, and US7 on the provider side; the signature, window, claim and replay checks come before any write): + - SQLite store, extended decode, dedupe, limits, and rollover/retention + - the dashboard (after its frames in `telemetry-receiver/telemetry-dashboard.pen`), `/metrics` moved behind the token, and the public summary + - chart persistence, dashboard and NetworkPolicy changes + - receiver `specs.md` and README +3. **API reporter and consent** (US1, US2, US3, and US7 on the install side: `signing_secret`, signing, rotation on 409): + - migration 015, the seed inside Migrate, and destination resolution + - the scheduler, claim and fallback, and collection + - the admin routes, RBAC and config hook + - chart flags; explicit receiver settings in the e2e and dev scripts +4. **Web** (US1, US3 UI): the `design.pen` frames and export first, then the notice banner and the settings section. +5. **E2E and docs:** the `telemetry` bucket, the upgrade assertion, FR-008 docs and CHANGELOG, `docs/security.md` threat-model additions, and website pages (through the submodule's own PR flow). + +Slices 2 and 3 can run in parallel once slice 1 is committed. Slice 4 needs slice 3's routes. Slice 5 needs everything before it. + +## Cross-spec dependencies + +- **019 localization-language-packs** (Draft) owns adding languages to the `telemetryschema` `language` enumeration and wiring the configured default. Until then `language` is always `en` (R19). + +## Merge gate (OD-1, ruled 2026-10-07) + +OD-1 is ruled: the domain is telemetry.gameplane.net, and `telemetry.DefaultEndpoint = "https://telemetry.gameplane.net/ingest"`. The merge gate is satisfied. The CI job `telemetry-default-gate` keeps enforcing that the default stays an https URL (FR-002, FR-009). + +## Release checklist + +- **OD-2 (ruled).** The project maintainers run the provider. The data-handling statement page on the project website is published, states 24-month retention (OD-3) and 90-day activity expiry (OD-4), and is linked from the notice, the settings and the upgrade notes through one URL constant. +- **FR-008.** The upgrade notes include the warning for installs whose basic toggle was already on. + +## Complexity Tracking + +There are no constitution violations. One deliberate structural cost is recorded so reviewers can weigh it: + +| Addition | Why needed | Simpler alternative rejected because | +|---|---|---| +| 16th Go module `telemetryschema/` (CI matrices, coverage gate 90, Dependabot, `go.work`, docs maps, `specs.md`) | FR-013 requires one fixed, published category set that both sides apply identically, and SC-006 requires the preview to match the payload. | Duplicating the enumerations relies on cross-module fixture tests that drift silently. Importing a receiver sub-package from the API inverts the dependency and drags SQLite and Prometheus into the API's module graph (research R1). | +| Receiver persistence (SQLite plus PVC) and a web UI in a component that was previously stateless | FR-020 (durable history) and FR-022 (a dashboard served by the receiver, clarification Q2). | Prometheus can't hold per-install deduplication state or survive restarts without extra infrastructure. Reusing the React app couples the receiver's release to the dashboard (research R3, R7). | diff --git a/specs/022-default-telemetry-dashboard/quickstart.md b/specs/022-default-telemetry-dashboard/quickstart.md new file mode 100644 index 000000000..df1533062 --- /dev/null +++ b/specs/022-default-telemetry-dashboard/quickstart.md @@ -0,0 +1,153 @@ +# Quickstart: validating feature 022 + +**Feature**: [spec.md](spec.md) | **Contracts**: [contracts/](contracts/) + +CI is the verification authority (constitution VI, CLAUDE.md rule 8). Locally, run only compile checks. The scenarios below are what the `telemetry` and `upgrade` E2E buckets automate (research R17). They are written so that a reviewer can also walk through them by hand on a dev Kind cluster. + +## Local compile checks (the only local verification) + +```sh +cd telemetryschema && go build ./... && cd .. +cd telemetry-receiver && go build ./... && cd .. +cd api && go build ./... && cd .. +cd web && npx tsc --noEmit && cd .. +helm template gameplane charts/gameplane --set api.telemetry.enabled=false | grep -- --telemetry-disabled +``` + +## CI runs + +| What | Where | +|---|---| +| Unit and coverage for `telemetryschema` (gate 90), `telemetry-receiver` (gate 70) and `api` (gate 80) | `go test` matrix (`ci.yaml` module lists) | +| API handler and migration integration | `make test-integration` (envtest) | +| Web components | vitest (`web` job) | +| Chart renderings | `helm template` checks | +| End to end | `make test-e2e-bucket BUCKET=telemetry`, and `BUCKET=upgrade` | +| Catalog drift | `hack/check-telemetry-catalog.sh` in `make lint` | + +## Manual walk-through on a dev cluster + +**Prerequisites**: + +- `git submodule update --init` +- `make dev-up` (with `up.sh` deploying the bundled receiver, per [install-config.md](contracts/install-config.md)) +- a dashboard token Secret: + +```sh +kubectl -n gameplane-system create secret generic telemetry-dashboard \ + --from-literal=token="$(openssl rand -hex 24)" +helm upgrade gameplane charts/gameplane --reuse-values \ + --set api.telemetry.receiver.dashboard.tokenSecretRef.name=telemetry-dashboard \ + --set api.telemetry.receiver.publicSummary.enabled=true \ + --set api.telemetry.interval=2m +``` + +### S1: a fresh install defaults on and is gated by the notice (US1; FR-003, FR-004) + +1. Sign in to the dashboard as the bootstrap admin. + - **Expect** the telemetry banner, naming the in-cluster receiver host and listing the basic and extended fields. +2. Before you sign in, `GET /admin/telemetry` returns `consent.source = "default"` and `status.lastOutcome = "never"`. No report arrives while nobody has seen the notice. +3. Leave the banner showing for one interval. + - **Expect** `lastOutcome = "ok"` and the receiver's `/api/v1/views` showing `extended.installs.active1d = 1`. + +### S2: the tiers and the install ID (US3; FR-012, FR-018, FR-019) + +1. Admin Settings → Telemetry shows the destination kind `bundled`, both switches, the install ID, the preview and the status line. +2. Turn off the extended switch and save. + - **Expect** the install ID disappears from the preview, and the next report's `ext_reports` count doesn't increase. +3. Turn it back on. + - **Expect** a new ID. +4. Choose "Reset ID". + - **Expect** a new ID, and the next report counted as a new install. +5. Compare the preview JSON with what the receiver counted. + - **Expect** them to agree field for field (SC-006). + +### S3: at most one accepted report per interval across restarts (FR-007, SC-005) + +```sh +kubectl -n gameplane-system rollout restart deploy/gameplane-api +``` + +- **Expect**: over the next 3 intervals, `daily_basic.reports` increases by 3 ± 1, and never by more than 1 within a single interval. + +### S4: the private dashboard and the refusal (US4; FR-022, FR-023, FR-027) + +```sh +kubectl -n gameplane-system port-forward svc/gameplane-telemetry-receiver 8081:8081 +``` + +1. Open `http://localhost:8081/`. + - **Expect** a redirect to the login page, which contains no figures. +2. Sign in with the token. + - **Expect** the overview for 30 days. 7, 90 and 365 also work. +3. `curl -s localhost:8081/api/v1/views` + - **Expect** `401 {"error":"unauthorized"}`. +4. `curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" localhost:8081/metrics` + - **Expect** `200`, and `401` without the header (spec Q8). +5. Rotate the Secret and restart the receiver. + - **Expect** the old session is rejected and the data is intact (FR-028). +6. On a receiver with no data: + - **Expect** the empty state, with no zero-filled charts. + +### S5: the public summary (US6; FR-029–FR-032) + +```sh +kubectl -n gameplane-system port-forward svc/gameplane-telemetry-receiver 8080:8080 +curl -si localhost:8080/v1/summary +``` + +- **Expect** exactly the five keys from [receiver-http.md](contracts/receiver-http.md), plus `Cache-Control: public, max-age=3600` and `Access-Control-Allow-Origin: *`. +- With `publicSummary.enabled=false`: **expect** `404`. +- `curl -si localhost:8080/metrics`: **expect** `404`. Metrics live only on the dashboard port (FR-030). + +### S6: operator disable and redirect (US2; FR-005, SC-003, SC-004) + +1. `helm upgrade … --reuse-values --set api.telemetry.enabled=false` + - **Expect** the settings section shows "disabled by the operator", the switches are disabled, `PUT /admin/config/telemetry` returns `409`, and the receiver gets no reports over 3 intervals. +2. `--set api.telemetry.enabled=true --set api.telemetry.endpoint=http:///ingest` + - **Expect** reports arrive only at the other receiver, and the settings show the kind `custom`. + +### S7: an old receiver still gets basic reports (FR-016, SC-013) + +Point `api.telemetry.endpoint` at a Deployment running `ghcr.io/valgulnecron/gameplane/telemetry-receiver:v0.2.0-beta.8`. + +- **Expect** the API logs one 400 followed by an accepted basic-only POST, and later reports go basic-only without a 400 first. + +### S8: upgrades preserve the existing choice (US1 scenarios 5 and 6; FR-003) + +This is automated in the `upgrade` bucket. A beta.8 install with no saved choice, upgraded to this version: + +- **Expect** `consent.source = "legacy"`, both tiers off, and `GET /admin/telemetry/notice` returning `pending: false`. +- If `sendMetrics` was true before the upgrade: **expect** basic on and extended off. + +### S9: forged reports are refused, and a claimed ID heals itself (US7; FR-035–FR-038, SC-014, SC-015) + +This is automated in the `telemetry` bucket with a test-only signing client built on `telemetryschema`. + +1. Take the install's current ID from `GET /admin/telemetry` and POST a report with that ID, signed by a different key. + - **Expect** `409 {"error":"id_claimed"}`, and no change to `/api/v1/views`. +2. POST an extended report with no signature header, then a correctly signed report with one body byte changed, then a byte-for-byte resend of an accepted report. + - **Expect** `403` with `bad_signature`, `bad_signature` and `replay` respectively, and no change to any figure. +3. Reset the install ID, and immediately claim the new ID with the test client's own key before the install reports. + - **Expect** the install's next attempt to get a 409, replace its ID and have the resend accepted. `GET /admin/telemetry` then shows a new `installId` and a set `status.lastIdRotationAt`. + +### S10: proof-of-work follows the request rate (FR-039–FR-041, SC-016) + +This is automated in the `telemetry` bucket, against a custom receiver deployed with `INGEST_POW=true`, `INGEST_POW_MIN_BITS=8` (so solving really happens) and `INGEST_POW_TARGET_PER_MIN=60`. + +1. Point the API at that receiver and wait one interval. + - **Expect** `gameplane_telemetry_pow_challenges_total` to rise and the report to be accepted (`lastOutcome = "ok"`). +2. POST `/ingest` with no `Gameplane-Telemetry-PoW` header, then with a used or tampered one. + - **Expect** `428` with `pow_required`, then `pow_invalid`, and no figure changed. +3. Unit tests cover the curve: at or below the normal rate a challenge carries `INGEST_POW_MIN_BITS`; at 1,000 times the rate it carries at least 20 bits; the difficulty falls one bit per 5 minutes after the flood stops. +4. Start the receiver with a 31-character `DASHBOARD_TOKEN`. + - **Expect** it to exit at startup with an error naming the 32-character minimum. + +## Merge gate (OD-1, ruled 2026-10-07) + +OD-1 is ruled: the domain is telemetry.gameplane.net. The merge gate is satisfied. The CI job `telemetry-default-gate` keeps enforcing that the default stays an https URL. + +## Release checklist + +- **OD-2.** The data-handling statement page on the project website is published, states 24-month retention (OD-3) and 90-day activity expiry (OD-4), and is linked from the notice, the settings and the upgrade notes. +- **FR-008 upgrade notes.** They are written in `CHANGELOG.md`, `docs/install.md` and the website telemetry page. diff --git a/specs/022-default-telemetry-dashboard/research.md b/specs/022-default-telemetry-dashboard/research.md new file mode 100644 index 000000000..567bed29f --- /dev/null +++ b/specs/022-default-telemetry-dashboard/research.md @@ -0,0 +1,473 @@ +# Research: Default telemetry destination, extended telemetry, and telemetry dashboard + +**Feature**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md) | **Date**: 2026-10-06 + +Each entry records a decision, why it was made, and what else was considered. Facts about current code cite `file:line` as of `master` at `d2cc7956`. + +## Baseline facts + +- **Reporter.** `api/internal/telemetry/telemetry.go` is a ticker loop. Its first report goes out one full interval (24 h) after process start (`Run`, `time.NewTicker(r.interval)`). An API that restarts more often than daily therefore never reports, which violates FR-007. It reads the admin toggle from `config.telemetry.sendMetrics` (`enabled()`), and counts GameServers and GameTemplates on the local cluster only (`count()`). +- **Wiring.** `api/cmd/main.go:392` starts the reporter with a hard-coded `24*time.Hour`. `--telemetry-endpoint`/`GAMEPLANE_TELEMETRY_ENDPOINT` defaults to `""`, which means off (`api/cmd/main.go:541`). +- **Chart.** `charts/gameplane/templates/api.yaml:323-329` auto-wires `http://gameplane-telemetry-receiver..svc:8080/ingest` when `api.telemetry.receiver.enabled` is set and `api.telemetry.endpoint` is empty. The receiver is a `Deployment` with `replicas: {{ $r.replicas }}`, `readOnlyRootFilesystem`, and no volume. Its NetworkPolicy admits port 8080 only from the API pod, plus an optional Prometheus namespace (`charts/gameplane/templates/telemetry-receiver.yaml`). +- **Receiver.** `telemetry-receiver/main.go` uses only stdlib and `client_golang`. Its strict token-walking decoder (`decodePayload`) rejects any key other than `version`, `servers` and `templates`. Metrics are kept in memory only. +- **API storage.** SQLite is the default and PostgreSQL is optional. New migrations go in `api/internal/db/migrations/common/` as portable SQL; the latest is `014_sessions_digest_reset.sql`. `Store.Migrate` (`api/internal/db/db.go:99`) is called by both `serve` (`api/cmd/main.go:163`) and `bootstrap-admin` (`api/cmd/bootstrap.go:47`). +- **API RBAC.** The API's ClusterRole already grants `get/list/watch` on `nodes`, all Gameplane CRDs including `clusters`, `modulesources` and `backupschedules`, and discovery (`charts/gameplane/templates/api.yaml:15-65`). The handlers already list nodes (`api/internal/handlers/cluster.go:187`) and read `ServerVersion()` (`cluster.go:446`). +- **API replicas.** `charts/gameplane/values.yaml:120` says "Keep at 1", because SQLite is single-writer and some locks are per-process. +- **Admin permissions.** `/admin/config` splits `config:read` (GET) from `config:manage` (`api/internal/rbac/rbac.go:209-210`). Any other `/admin/*` route falls through to the admin wildcard (`rbac.go:217`). +- **Module provenance.** A module-managed GameTemplate carries `gameplane.local/module-name` and `gameplane.local/module-source` labels (`operator/api/v1alpha1/module_types.go:135-139`). The chart's default ModuleSource (`defaultModuleSource.name: default`, OCI `ghcr.io/gameplanepanel/gameplane-modules`) lists the official catalog in `defaultModuleSource.oci.modules` (`charts/gameplane/values.yaml:548-584`) and verifies bundles against the chart-shipped cosign key. +- **Web.** There is no i18n framework yet (spec 019 is still Draft), so UI strings are English literals. `AppLayout.tsx:213` already mounts a sticky global banner (`SafeModeBanner`), which is the placement precedent. `AdminSettings.tsx:1545-1576` holds the current single-switch `TelemetrySection`. +- **E2E.** No telemetry E2E exists today. The buckets are `operator api-auth api-roles api-rbac api-agent api-mods ratelimit bot-fast bot-heavy multicluster upgrade` (`test/e2e/buckets.sh:357`). Every bucket shares one Helm install in `deploy/kind/e2e.sh:301`, and `Env.PortForward` exists (`test/e2e/env.go:342`). +- **Egress.** The chart restricts egress only in the games namespace (`networkpolicies.yaml`). The API pod's egress is unrestricted, so the default destination needs no chart egress change. + +--- + +## R1. One source of truth for the report contract + +**Decision**: Add a new stdlib-only Go module, `telemetryschema/`. It holds the basic and extended report types, every enumeration and band, the band functions, the strict decoder (moved out of the receiver's `decodePayload` and extended), sanitisation, and the embedded official-module catalog. `api` and `telemetry-receiver` both import it through `replace ../telemetryschema`, following the netguard and gameaction precedent (`api/go.mod:5-16`, `api/Dockerfile:7-9`). + +**Rationale**: +- FR-013 requires a *fixed, published* set of categories. Having one package define the categories that the API sends and the receiver accepts means the two sides cannot drift. +- SC-006 requires the preview to match the payload, which is easier when both are built from the same types. +- The receiver stays a single static binary, so it can still be run standalone. + +**Alternatives considered**: +- *Duplicate the enumerations in both modules and test them against a shared fixture.* This is cheaper up front, but tests that read across modules are brittle and drift is only caught after the fact. +- *Have the API import a sub-package of the receiver module.* This inverts the dependency (client depends on server) and pulls the receiver's `sqlite` and `prometheus` dependencies into the API's module graph. + +**Cost**: a 16th Go module must be added to `go.work`, `Makefile` `GO_MODULES`, both module matrices in `.github/workflows/ci.yaml` (`:457` and `:549`), the CI path filters, Dependabot, the `CLAUDE.md` repo map and coverage list, and `docs/agent-architecture.md`. It also needs its own `specs.md` (constitution IV). The coverage gate is **90**, matching gameproto (90) and slightly below netguard and gameaction (91), which are the other pure-logic shared libraries. This cost is recorded in plan.md under Complexity Tracking. + +## R2. Wire format and backward compatibility + +**Decision**: The three basic fields stay at the top level, unchanged. The extended tier is one optional object, `ext`, with its own `schema` integer (starting at `1`). The full shape is in [contracts/report-schema.md](contracts/report-schema.md). The same `POST ` URL carries both tiers. + +Fallback for older receivers (FR-016, SC-013): +- If a report that carries `ext` gets HTTP **400**, the API immediately re-POSTs the same report without `ext`, once. +- The API then records `ext_unsupported_until = now + 7 days` for that endpoint, so it doesn't send two POSTs every day. +- A receiver that understands `ext` but not a newer `ext.schema` value also answers 400, so the same fallback applies. +- Identity refusals use different codes, so they never trigger this fallback: **403** for a bad signature, stale send time or replay, and **409** for an ID claimed by another key (R20). + +**Rationale**: +- Old receivers reject unknown keys (`decodePayload` default branch), so a 400 is a guaranteed and observable signal. +- Keeping one URL means operators who configured a full `/ingest` URL don't have to reconfigure anything. + +**Alternatives considered**: +- *A versioned path such as `/v2/ingest`.* Deriving it from an operator-supplied URL is fragile, and the old receiver's 404 would need the same fallback logic anyway. +- *Negotiating through a header.* Old receivers ignore headers, so they would still reject the body. + +## R3. Receiver persistence + +**Decision**: Store data in SQLite, using `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0`, and already vetted because the API uses it at `api/go.mod:41`). +- **Location.** The file is `$DATA_DIR/telemetry.db`. When `DATA_DIR` is unset, the receiver uses an in-memory database and logs a warning, so a standalone `docker run` with no volume keeps working, but nothing survives a restart. +- **Chart.** The chart always sets `DATA_DIR=/data` and mounts a PVC by default (`receiver.persistence.enabled: true`, 1 Gi). It uses `strategy: Recreate`, and the template fails if `replicas > 1` while persistence is enabled. +- **Writes.** All writes go through one serialized writer, with WAL mode on. + +**Rationale**: +- FR-020 and SC-009 require data to survive restarts. +- 12–24 months of daily aggregates plus about 10k activity records amounts to a few MB. +- SQLite fits a single-replica collector and the distroless image, and needs no new infrastructure (constitution: Kubernetes primitives, meaning a PVC, first). + +**Alternatives considered**: +- *PostgreSQL.* This would add an operational dependency for every self-hoster. +- *A JSON file rewritten atomically.* Rewriting it on every ingest doesn't scale to 10k activity records. +- *Prometheus remote storage.* That stores raw-ish time series and cannot do the per-install deduplication from R5. + +## R4. Aggregation model (FR-020) + +**Decision**: Pre-aggregate per UTC day at ingest time. The tables are listed in [data-model.md](data-model.md) §Receiver. +- **Fleet sizes.** These are stored as an **exact-value histogram** per day and metric, with values capped at 1000 (`1001` means "over 1000"). The FR-024 size bands and the exact median are both derived when the data is read. +- **Extended dimensions.** These go in one generic `daily_dim(day, dim, value, installs)` table, whose `dim` and `value` come only from `telemetryschema` enumerations. +- **Games.** These go in `daily_game(day, module, installs, servers)`. +- **Cumulative total.** A running total of reports is kept in `meta`, so the public summary's "total since collection began" (FR-029) is unaffected by retention sweeps. + +**Rationale**: +- Every dashboard view becomes a scan of at most 365 rows per dimension, which keeps SC-010 (under 3 s) comfortable. +- Bounded enumerations bound the row count, so hostile input can't grow tables without limit (Edge Cases: malicious values). +- An exact histogram gives the real median that US4 scenario 3 asks for; band-only storage would only give a median band. + +**Alternatives considered**: +- *Storing reports and aggregating at read time.* FR-014 forbids storing raw reports. +- *Storing bands only.* This loses the exact median. + +**Range semantics**: +- **Basic "per day" views** chart daily values over the range. +- **Extended install-share views** (environment, games, features) show the sum of daily install counts divided by the sum of daily extended reports over the range, which is a share weighted by install-days. Each view is labelled "share of reporting installs, averaged over the range". +- **By-install version adoption** (US5 scenario 6) uses activity records, so it covers at most the activity-expiry window (OD-4). The dashboard states that window. + +## R5. Unique installs without linking attributes (FR-014, FR-015) + +**Decision**: +- **Install ID.** The API generates a UUIDv4 from `crypto/rand`. +- **Transformed ID.** The receiver stores only `HMAC-SHA256(pepper, installID)`, hex-encoded. The pepper comes from `ID_PEPPER`, a Secret. If that is unset, the receiver generates 32 random bytes on first start and keeps them in `meta`. +- **Activity record.** Each record holds the transformed ID, `key_fp` (the claim, R20), `first_seen`, `last_seen`, `last_sent_at` (replay guard, R20) and `last_version`. Nothing else. +- **Per-day deduplication.** For an extended report that has passed the R20 signature and claim checks: + - If no record exists, the receiver inserts one, which also claims the ID, and counts the report as `new_installs++` and `active_installs++`. + - If `last_seen < today`, it updates the record and counts `active_installs++`. + - If `last_seen == today`, the report is a **duplicate**. It is answered with 204 but changes no aggregate, basic or extended; only `daily_basic.duplicates` is incremented. +- **Lapsed installs.** An hourly rollover finalizes each completed day D. It sets `lapsed_installs[D] = count(activity where last_seen = D − 30)` and then deletes records whose `last_seen` is older than the expiry (OD-4, ruled: 90 days; the enforced minimum is 31). + +**Rationale**: +- An HMAC with a secret pepper means the stored value can't be matched against an ID taken from an install without that pepper. +- No extended attribute is ever written next to the ID, as FR-014 requires. +- Per-day deduplication stops restarts, retries and replays from inflating counts (Edge Cases: frequent restarts, spoofing). +- SC-008 (within 1 %) holds because uniques are exact distinct counts, not estimates. + +**Alternatives considered**: +- *HyperLogLog sketches.* These are approximate (around 1–2 % error) and can't give exact new and lapsed counts. +- *Plain SHA-256 of the ID.* An attacker who knows the ID can link it. That is low risk, but the pepper costs nothing. + +## R6. Per-source limits and source addresses (FR-014, FR-021, FR-032) + +**Decision**: All limits are in memory only and keyed by source, which is never written to disk. A source is the IPv4 address (IPv4-mapped IPv6 included) or the IPv6 /64, so one host can't rotate through its prefix. +- **Ingest.** At most `INGEST_SOURCE_DAILY_LIMIT` *accepted* reports per source per UTC day, default **20**. Over the limit the receiver returns `429`. +- **Public summary.** A token bucket of 60 requests per minute per source, burst 10. +- **Dashboard login.** 5 attempts per minute per source. +- **Source IP.** This is the TCP peer address. `X-Forwarded-For` is trusted only when the peer falls inside `TRUSTED_PROXY_CIDRS` (default empty). +- **Cleanup.** Daily counters reset at UTC midnight. Token buckets are purged after 10 idle minutes (daily counters are not, or an idle source would regain its budget). +- **Capacity.** Each limiter tracks at most 100,000 sources and evicts the least-recently-used one to admit a new source. Two other designs were rejected: + - Letting sources past the cap through untracked was a bypass (security review of 4c742e7d). + - Pooling them into one overflow budget let an attacker who filled the table deny every new legitimate source (security review of de5d0974). + + With eviction, a source's budget can only be reset by an attacker who controls more distinct IPv4 addresses or IPv6 /64s than the cap. At that scale per-source limiting no longer applies. The dashboard token is at least 32 characters (R8), which keeps guessing it infeasible at any rate, and proof-of-work (R21) is the cost that does scale with volume. + +**Rationale**: +- A default of 20 tolerates CGNAT and shared egress (several homelabs behind one address) while capping how much one source can distort a day. +- Trusting proxy headers only from configured networks stops clients from spoofing their source by setting `X-Forwarded-For` themselves. + +**Alternatives considered**: +- *A limit of 1 per source per day.* This breaks every install behind a shared NAT. +- *Proof-of-work or registration.* This can't be done without per-install secrets (spec Assumptions). + +## R7. Private dashboard delivery (FR-022–FR-028) + +**Decision**: +- **Rendering.** The receiver renders the dashboard itself with `html/template`. CSS and templates are embedded with `go:embed`, and charts are **inline SVG** generated server-side. There is **no JavaScript**. +- **Range selection.** The range is chosen with plain links (`?range=7|30|90|365`, default 30). +- **Listener.** The dashboard has its own listener, `DASHBOARD_LISTEN_ADDR` (default `:8081`), separate from the public listener (`:8080`: ingest, healthz, public summary). Templates use no inline `style` attributes or `