Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci-simulators.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,8 @@ jobs:
- name: Audit Dependencies
run: uv audit

- name: Run Tests
run: uv run pytest

- name: Smoke test
run: uv run python smoke.py
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ just test all
just test <svc> # chat telemetry twin notification socket dashboard frontend
# agent ap-collector claims-gateway provisioner registry tenancy
# aq-simulator sensor-simulator (assert the ingest-batch fixture)
# ap-simulator (asserts the simulation-start fixture)
just test <svc>-integration # throwaway DB/broker, composed, then torn down
# Go (registry, tenancy): testcontainers behind `-tags=integration`
just test integration # full backend acceptance suite
Expand Down Expand Up @@ -155,8 +156,8 @@ real port/adapter split only on the outbound side.

**Routing** (`Caddyfile`, mirrored by `k8s/istio-*.yml`): `/gateway`, `/tenancy`, `/twin`,
`/telemetry`, `/notification`, `/chat` (SSE, needs `flush_interval -1`), `/dashboard`,
`/socket.io`, `/agent` (ungated), `/` → frontend. `/telemetry/ingest` is ungated at the edge
and HMAC-verified in-service — it is one exact path, so a sub-path would 401.
`/socket.io`, `/agent` (ungated), `/` → frontend. `/telemetry/ingest` and `/telemetry/collector`
are ungated at the edge and HMAC-verified in-service — exact paths, so a sub-path would 401.
`registry` and `provisioner` have no external route.

## Golden rules
Expand Down
6 changes: 6 additions & 0 deletions Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@
uri strip_prefix /telemetry
reverse_proxy telemetry:3000
}
# A collector reads its routers here with the same key, signing a timestamp instead of
# a body. Exact path, and listed in the same Istio no-principal rule, for the same reason.
handle /telemetry/collector {
uri strip_prefix /telemetry
reverse_proxy telemetry:3000
}
handle_path /telemetry/* {
import require_gateway_auth
reverse_proxy telemetry:3000
Expand Down
142 changes: 140 additions & 2 deletions api/telemetry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,26 @@ components:
in: header
name: x-signature
description: >
Lowercase-hex HMAC-SHA256 over the raw request body, keyed by
TELEMETRY_INGEST_SECRET. A sensor gateway has no user JWT, so this is
Lowercase-hex HMAC-SHA256 over the raw request body, keyed by the
device key of the body's buildingId (or, in dev only, the shared
TELEMETRY_INGEST_SECRET). A sensor gateway has no user JWT, so this is
how it authenticates itself. The signature covers the exact bytes on
the wire — sign the string you send, never a re-serialised copy.
collectorSignature:
type: apiKey
in: header
name: x-signature
description: >
Lowercase-hex HMAC-SHA256, keyed by the buildingId's device key, over
"GET /collector\n{buildingId}\n{x-timestamp}". A GET has no body, so
the signed building and timestamp are what prove the caller holds the
key; a timestamp more than 300 s from telemetry's clock is refused.
Without buildingId only the dev shared key signs.
collectorTimestamp:
type: apiKey
in: header
name: x-timestamp
description: Unix seconds, decimal — the value the x-signature covers.

parameters:
SensorType:
Expand Down Expand Up @@ -270,6 +286,90 @@ security:
- gatewayClaims: []

paths:
/device-keys/buildings/{buildingId}:
post:
summary: Issue a building's device key
description: >
Revokes the building's current device key and returns its new one. This
is the only time the key is shown: put it in the collector's `keys` and
store it nowhere else. Keys are derived from the service's master key,
never stored; other buildings' keys are untouched.
tags: [Ingestion]
security:
- gatewayClaims: []
parameters:
- name: buildingId
in: path
required: true
schema: { type: string }
responses:
"200":
description: The new key; the previous one no longer signs
content:
application/json:
schema:
type: object
required: [buildingId, key]
properties:
buildingId: { type: string, example: "bldg-3f2b4c5d" }
key: { type: string, description: 64 lowercase hex characters }
"403":
description: The caller may not edit this building
"404":
description: The building is not registered

/collector:
get:
summary: Routers an on-site collector polls
description: >
Every router sensor placed in a room, grouped by building: what an
ap-collector polls and which room each one counts into. Outdoor routers
and buildings with no router are left out.

Addresses only, never logins: `endpoint` is the router's ubus URL on the
site network, and the credentials to use it stay in the collector's own
configuration. Ungated at the edge like `/ingest`, and signed by the
caller instead.
tags: [Ingestion]
security:
- collectorSignature: []
collectorTimestamp: []
parameters:
- name: buildingId
in: query
required: false
description: The building whose routers to read; the signing key must be its device key.
schema: { type: string, example: "bldg-3f2b4c5d" }
responses:
"200":
description: The routers, by building
content:
application/json:
schema:
type: object
required: [buildings]
properties:
buildings:
type: array
items:
type: object
required: [buildingId, routers]
properties:
buildingId: { type: string, example: "bldg-3f2b4c5d" }
routers:
type: array
minItems: 1
items:
type: object
required: [sensorId, roomId]
properties:
sensorId: { type: string, format: uuid }
roomId: { type: string, example: "room-lab-2" }
driver: { type: string, example: "openwrt-hostapd" }
endpoint: { type: string, example: "http://10.0.4.12/ubus" }
"401":
description: Unsigned, mis-signed, or stamped outside the 300 s window

/ingest:
post:
summary: Ingest one building tick
Expand Down Expand Up @@ -590,6 +690,44 @@ paths:
"404":
description: Unknown sensor type, or the room has no readings

/connected-devices/buildings/{buildingId}:
get:
summary: Every device connected in a building
description: >
The sum of the building's rooms' totalDeviceCount at its newest report.
A router counts the devices it hears wherever they stand, so this
building figure is the one that means what it says. A room whose last
report is older than the newest is left out (its router was removed or
moved), and each room counts once, so a report delivered twice does not
double the total.
tags: [Reads]
security:
- gatewayClaims: []
parameters:
- name: buildingId
in: path
required: true
schema: { type: string }
responses:
"200":
description: The building's connected devices at its newest report
content:
application/json:
schema:
type: object
required: [buildingId, totalDeviceCount, timestamp]
properties:
buildingId: { type: string, example: "bldg-3f2b4c5d" }
totalDeviceCount: { type: integer, minimum: 0, example: 47 }
timestamp:
type: integer
description: Epoch milliseconds of the newest report summed
example: 1757251200000
"403":
description: The caller may not read this building
"404":
description: The building never reported a device count

/{sensorType}/entireBuilding:
get:
summary: Latest reading for every room in a building
Expand Down
1 change: 1 addition & 0 deletions backend/acceptance/docker-compose.integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ services:
- DASHBOARD_URL=http://dashboard:3000
- EDGE_URL=http://gateway
- TELEMETRY_INGEST_SECRET=${TELEMETRY_INGEST_SECRET}
- TELEMETRY_DEVICE_MASTER_KEY=${TELEMETRY_DEVICE_MASTER_KEY}
volumes:
# :z relabels the bind mount for SELinux-enforcing hosts (same reason
# the root compose file's gateway service does it for its own mounts)
Expand Down
17 changes: 17 additions & 0 deletions backend/acceptance/edge/test_edge_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,20 @@ def test_the_ingest_ungate_is_an_exact_path_not_a_prefix():

assert response.status_code == 401
assert EDGE_REJECTION_MARKER in response.text


def test_the_collector_read_crosses_the_edge_ungated():
"""A collector carries no user JWT either. /telemetry/collector is the second exact
path both edges let through; telemetry verifies its signed timestamp itself. Unsigned,
it must be refused by telemetry, not by the edge.
"""
with httpx.Client(timeout=10.0) as client:
signed = telemetry.read_collector(client, f"{TELEMETRY_VIA_EDGE}/collector")
unsigned = telemetry.read_collector(
client, f"{TELEMETRY_VIA_EDGE}/collector", signed=False
)

assert signed.status_code == 200
assert "buildings" in signed.json()
assert unsigned.status_code == 401
assert EDGE_REJECTION_MARKER not in unsigned.text
8 changes: 4 additions & 4 deletions backend/acceptance/run-integration-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ export COMPOSE_BAKE=true

cd "$(dirname "${BASH_SOURCE[0]}")/../.." # repo root

# telemetry refuses to boot without an ingest signing key, and both it
# and the test client read the same one out of the repo-root .env that compose
# auto-loads. Generating it here keeps the suite runnable without `just stack env`.
# telemetry refuses to boot without its device master key, and both it and the
# test client read the shared ingest key out of the repo-root .env that compose
# auto-loads. Generating them here keeps the suite runnable without `just stack env`.
mise exec -- sh scripts/env/ingest-key.sh || {
echo "::error::could not generate TELEMETRY_INGEST_SECRET" >&2
echo "::error::could not generate TELEMETRY_INGEST_SECRET / TELEMETRY_DEVICE_MASTER_KEY" >&2
exit 1
}

Expand Down
11 changes: 11 additions & 0 deletions backend/acceptance/support/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,14 @@ def latest(client: httpx.Client, metric: str, building_id: str, room_id: str) ->

def latest_temperature(client: httpx.Client, building_id: str, room_id: str) -> dict:
return latest(client, "temperature", building_id, room_id)


def read_collector(client: httpx.Client, url: str, *, signed: bool = True) -> httpx.Response:
"""GET the collector's router list, signed as ap-collector signs it."""
if not signed:
return client.get(url)
stamp = str(int(time.time()))
signature = hmac.new(
config.TELEMETRY_INGEST_SECRET.encode(), f"GET /collector\n\n{stamp}".encode(), hashlib.sha256
).hexdigest()
return client.get(url, headers={"X-Signature": signature, "X-Timestamp": stamp})
6 changes: 3 additions & 3 deletions backend/acceptance/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions backend/agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ dependencies = [
"psycopg[binary]>=3.2",
"sqlalchemy[asyncio]>=2.0.35",
"alembic>=1.14.0",
"pyjwt[crypto]>=2.9.0",
"pyjwt[crypto]>=2.15.0",
"structlog>=24.4.0",
"opentelemetry-api>=1.28.0",
"opentelemetry-sdk>=1.28.0",
Expand Down Expand Up @@ -45,7 +45,7 @@ constraint-dependencies = [
"mako>=1.3.12",
"python-multipart>=0.0.31",
"starlette>=1.3.1",
"urllib3>=2.7.0",
"urllib3>=2.8.0",
"aiohttp>=3.14.3",
"cryptography>=50.0.0",
"joserfc>=1.6.8",
Expand Down
16 changes: 8 additions & 8 deletions backend/agent/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 14 additions & 4 deletions backend/telemetry/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Rust / Axum / Postgres+Timescale / Kafka / Redis. Ingests sensor readings, owns thresholds,
sensors and device actions, fans out to dashboard and raises alerts. Routes `/telemetry/*`
gated at the edge; `/telemetry/ingest` ungated and HMAC-verified in-service.
gated at the edge; `/telemetry/ingest` and `/telemetry/collector` ungated and HMAC-verified in-service.
Docs: `documentation/architecture/telemetry-architecture.qd`,
`design/telemetry-storage.qd`.

Expand Down Expand Up @@ -85,10 +85,11 @@ unreachable.
**Simulators are told what to simulate at start, and only then.** `PUT /simulation/buildings/{id}`
reads the building's sensors and sends each simulator in `SIMULATORS` the ones whose kind it
claims (body: `schemas/fixtures/simulation-start.json`). Every simulator is told, even with an
empty list — empty means stop, so a removed sensor stops being simulated. Routers (no simulator
claims them) and outdoor sensors are never sent. Calls run concurrently with a 5 s timeout, all
empty list — empty means stop, so a removed sensor stops being simulated. Kinds no simulator
claims, and outdoor sensors, are never sent. Calls run concurrently with a 5 s timeout, all
are tried, then any failure is `502`. `Simulation::new` refuses a kind that is not a device, or
one claimed twice (doubled readings). No simulator configured → `404`.
one claimed twice (doubled readings). No simulator configured → `404`. The body never
carries coordinates: positions are digital-twin's, not telemetry's.

**Registration**: telemetry consumes `building-registration-requested` and answers
`building-registration-completed` (both from `twin_schema`). `maxTemperature` is read here
Expand All @@ -104,3 +105,12 @@ just test telemetry-integration # tests/*.rs against a throwaway TimescaleDB,

`tests/` covers `api`, `persistence`, `fanout`, `alerts`, `registration`, `architecture`.
Migrations live in `migrations/`.

**Device keys are derived, never stored.** A building's key is `HMAC(TELEMETRY_DEVICE_MASTER_KEY,
"{buildingId}:{epoch}")`; `buildings.device_key_epoch` is the only state, bumped by
`POST /device-keys/buildings/{id}` to revoke. Read uncached, so a rotation holds on every replica.
`TELEMETRY_INGEST_SECRET` is the optional shared key that signs for any building — dev only.

**A building's connected devices are summed here, never by a client.** `Readings::building_total`
sums a metric over the rooms at the building's newest report, each room once
(`GET /connected-devices/buildings/{id}`). Only a count adds up: never route an average through it.
Loading
Loading