diff --git a/.gitignore b/.gitignore index ba61a22..1265355 100644 --- a/.gitignore +++ b/.gitignore @@ -229,4 +229,5 @@ CODEX.md .cursor/ .aider* .copilot/ +graphify-out/ *.pt diff --git a/README.md b/README.md index ac4cc1b..b5beb73 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,17 @@ Server settings in `configs/server.yaml` or via `CLOCKD_` environment variables: | `max_upload_mb` | `200` | Max upload size | | `max_workers` | `2` | Async job thread pool size | | `cameras_dir` | `configs/cameras` | Camera configs directory | +| `max_cameras` | `50` | Max camera configs the API will create via `POST /cameras` | + +Environment variables take precedence over `server.yaml`. Nested settings use `__` as the delimiter, which lets you keep secrets (NVR passwords, InfluxDB tokens) out of the config file entirely and inject them at deploy time — e.g. from a Kubernetes Secret: + +``` +CLOCKD_METRICS__INFLUXDB_V2__TOKEN=... +CLOCKD_EVENT_SOURCES__HOME_NVR__UNIFI__USERNAME=clockd-user +CLOCKD_EVENT_SOURCES__HOME_NVR__UNIFI__PASSWORD=... +``` + +A complete hardened Kubernetes deployment (secrets via env vars, non-root, read-only root filesystem, dropped capabilities) is provided at [`deploy/k8s-example.yaml`](deploy/k8s-example.yaml). ### Detection Backends diff --git a/configs/server.yaml b/configs/server.yaml index b28fb66..389758d 100644 --- a/configs/server.yaml +++ b/configs/server.yaml @@ -1,3 +1,9 @@ +# Any setting here can also be set via a CLOCKD_* environment variable, which +# takes precedence over this file. Nested fields use "__" as the delimiter: +# CLOCKD_METRICS__INFLUXDB_V2__TOKEN +# CLOCKD_EVENT_SOURCES__HOME_NVR__UNIFI__PASSWORD +# Use env vars to keep secrets out of this file (see deploy/k8s-example.yaml). + host: "0.0.0.0" port: 8000 verbose: false @@ -9,6 +15,7 @@ max_upload_mb: 200 max_workers: 2 job_ttl_seconds: 3600 cameras_dir: "configs/cameras" +max_cameras: 50 upload_dir: "/tmp/clockd_uploads" codeproject_ai: diff --git a/deploy/k8s-example.yaml b/deploy/k8s-example.yaml new file mode 100644 index 0000000..920147b --- /dev/null +++ b/deploy/k8s-example.yaml @@ -0,0 +1,176 @@ +# Example Kubernetes deployment for Clockd. +# +# Highlights: +# - Secrets (NVR password, InfluxDB token) are injected as CLOCKD_* env vars +# from a Secret and never stored in the ConfigMap. Env vars override +# server.yaml; nested fields use "__" as the delimiter. +# - Hardened pod security context: non-root, read-only root filesystem, +# all capabilities dropped, default seccomp profile, no privilege +# escalation. Uploads and Ultralytics settings write to an emptyDir +# mounted at /tmp. +# +# Adjust the namespace, storage class, image, and camera map for your cluster. +apiVersion: v1 +kind: Namespace +metadata: + name: clockd +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: clockd-config + namespace: clockd +data: + server.yaml: | + host: "0.0.0.0" + port: 8000 + detection_backend: "local" + model: "yolo26n.pt" + confidence: 0.3 + default_unit: "mph" + max_upload_mb: 200 + max_workers: 2 + max_cameras: 50 + cameras_dir: "/app/configs/cameras" + upload_dir: "/tmp/clockd_uploads" + + metrics: + influxdb_v2: + enabled: false + url: "http://influxdb.monitoring:8086" + org: "home" + bucket: "clockd" + # token is injected via CLOCKD_METRICS__INFLUXDB_V2__TOKEN + + event_sources: + home_nvr: + enabled: false + camera_map: + "your-protect-camera-id": "your-clockd-camera-id" + unit: "mph" + unifi: + host: "192.168.1.1" + verify_ssl: false + poll_interval_s: 30 + event_end_timeout_s: 300 + # username/password are injected via + # CLOCKD_EVENT_SOURCES__HOME_NVR__UNIFI__USERNAME / __PASSWORD +--- +apiVersion: v1 +kind: Secret +metadata: + name: clockd-secrets + namespace: clockd +type: Opaque +stringData: + CLOCKD_EVENT_SOURCES__HOME_NVR__UNIFI__USERNAME: "clockd-user" + CLOCKD_EVENT_SOURCES__HOME_NVR__UNIFI__PASSWORD: "change-me" + CLOCKD_METRICS__INFLUXDB_V2__TOKEN: "change-me" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: clockd-cameras + namespace: clockd +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: clockd + namespace: clockd + labels: + app: clockd +spec: + replicas: 1 + selector: + matchLabels: + app: clockd + template: + metadata: + labels: + app: clockd + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + # If your storage provider needs it for the cameras PVC, set fsGroup + # to the clockd user's group id from the image. + containers: + - name: clockd + image: ghcr.io/your-registry/clockd:latest + ports: + - containerPort: 8000 + name: http + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + env: + # Ultralytics writes its settings file on import; point it at the + # writable tmpfs since the root filesystem is read-only. + - name: YOLO_CONFIG_DIR + value: /tmp/ultralytics + envFrom: + - secretRef: + name: clockd-secrets + volumeMounts: + - name: config + mountPath: /app/configs/server.yaml + subPath: server.yaml + readOnly: true + - name: cameras + mountPath: /app/configs/cameras + - name: tmp + mountPath: /tmp + resources: + requests: + cpu: "1" + memory: 1Gi + limits: + cpu: "4" + memory: 4Gi + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: + - name: config + configMap: + name: clockd-config + - name: cameras + persistentVolumeClaim: + claimName: clockd-cameras + - name: tmp + emptyDir: + sizeLimit: 4Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: clockd + namespace: clockd + labels: + app: clockd +spec: + selector: + app: clockd + ports: + - name: http + port: 8000 + targetPort: http diff --git a/grafana/clockd-dashboard.json b/grafana/clockd-dashboard.json index b4b8ec2..c772f8d 100644 --- a/grafana/clockd-dashboard.json +++ b/grafana/clockd-dashboard.json @@ -33,6 +33,12 @@ "id": "histogram", "name": "Histogram", "version": "" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" } ], "title": "Clockd - Vehicle Speed Monitoring", @@ -49,23 +55,231 @@ "to": "now" }, "panels": [ + { + "title": "Total Detections", + "type": "stat", + "gridPos": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + }, + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "fixed", + "fixedColor": "purple" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + } + } + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"processing_summary\")\n |> filter(fn: (r) => r._field == \"vehicle_count\")\n |> group()\n |> count()\n |> set(key: \"_field\", value: \"total\")", + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + } + } + ] + }, + { + "title": "Vehicles >25mph", + "type": "stat", + "gridPos": { + "x": 6, + "y": 0, + "w": 6, + "h": 4 + }, + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "fixed", + "fixedColor": "red" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + } + ] + } + } + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> filter(fn: (r) => r._value > 25.0)\n |> group()\n |> count()\n |> set(key: \"_field\", value: \"total\")", + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + } + } + ] + }, + { + "title": "Average Speed", + "type": "stat", + "gridPos": { + "x": 12, + "y": 0, + "w": 6, + "h": 4 + }, + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "unit": "mph", + "decimals": 1, + "color": { + "mode": "fixed", + "fixedColor": "green" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + } + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> group()\n |> mean()\n |> set(key: \"_field\", value: \"avg\")", + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + } + } + ] + }, + { + "title": "Top Speed", + "type": "stat", + "gridPos": { + "x": 18, + "y": 0, + "w": 6, + "h": 4 + }, + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + }, + "fieldConfig": { + "defaults": { + "unit": "mph", + "decimals": 1, + "color": { + "mode": "fixed", + "fixedColor": "orange" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + } + } + }, + "options": { + "colorMode": "value", + "graphMode": "none", + "textMode": "value" + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> group()\n |> max()\n |> set(key: \"_field\", value: \"max\")", + "refId": "A", + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + } + } + ] + }, { "title": "Vehicle Speed (avg, min, max)", "type": "timeseries", "gridPos": { - "h": 10, - "w": 24, "x": 0, - "y": 0 + "y": 4, + "w": 24, + "h": 10 }, "fieldConfig": { "defaults": { "unit": "mph", "custom": { "lineWidth": 2, - "pointSize": 6, - "drawStyle": "points", - "showPoints": "always" + "pointSize": 3, + "drawStyle": "line", + "showPoints": "never", + "spanNulls": true, + "lineInterpolation": "smooth", + "thresholdsStyle": { + "mode": "area" + } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "transparent", + "value": null + }, + { + "color": "rgba(255, 0, 0, 0.1)", + "value": 25 + } + ] } }, "overrides": [ @@ -113,12 +327,43 @@ } } ] + }, + { + "matcher": { + "id": "byName", + "options": "raw" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "rgba(255, 255, 255, 0.4)", + "mode": "fixed" + } + }, + { + "id": "custom.drawStyle", + "value": "points" + }, + { + "id": "custom.showPoints", + "value": "always" + }, + { + "id": "custom.pointSize", + "value": 3 + }, + { + "id": "custom.lineWidth", + "value": 0 + } + ] } ] }, "targets": [ { - "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> aggregateWindow(every: v.windowPeriod, fn: max, createEmpty: false)\n |> yield(name: \"max\")", + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> drop(columns: [\"track_id\", \"camera_id\", \"unit\"])\n |> aggregateWindow(every: 15m, fn: max, createEmpty: false)\n |> set(key: \"_field\", value: \"max\")", "refId": "A", "datasource": { "type": "influxdb", @@ -126,7 +371,7 @@ } }, { - "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> yield(name: \"avg\")", + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> drop(columns: [\"track_id\", \"camera_id\", \"unit\"])\n |> aggregateWindow(every: 15m, fn: mean, createEmpty: false)\n |> set(key: \"_field\", value: \"avg\")", "refId": "B", "datasource": { "type": "influxdb", @@ -134,12 +379,20 @@ } }, { - "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> aggregateWindow(every: v.windowPeriod, fn: min, createEmpty: false)\n |> yield(name: \"min\")", + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> drop(columns: [\"track_id\", \"camera_id\", \"unit\"])\n |> aggregateWindow(every: 15m, fn: min, createEmpty: false)\n |> set(key: \"_field\", value: \"min\")", "refId": "C", "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" } + }, + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> drop(columns: [\"track_id\", \"camera_id\", \"unit\"])\n |> set(key: \"_field\", value: \"raw\")", + "refId": "D", + "datasource": { + "type": "influxdb", + "uid": "${DS_INFLUXDB}" + } } ], "datasource": { @@ -148,31 +401,35 @@ } }, { - "title": "Vehicles Detected", - "type": "timeseries", + "title": "Speed Distribution", + "type": "histogram", "gridPos": { - "h": 8, - "w": 12, "x": 0, - "y": 10 + "y": 14, + "w": 12, + "h": 8 }, "fieldConfig": { "defaults": { - "unit": "short", - "custom": { - "drawStyle": "bars", - "fillOpacity": 50, - "lineWidth": 1 - }, + "unit": "mph", "color": { - "fixedColor": "purple", - "mode": "fixed" + "mode": "palette-classic" } } }, + "options": { + "bucketSize": 5, + "combine": false, + "fillOpacity": 80, + "legendPlacement": "bottom", + "legend": { + "displayMode": "hidden", + "showLegend": false + } + }, "targets": [ { - "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"processing_summary\")\n |> filter(fn: (r) => r._field == \"vehicle_count\")\n |> aggregateWindow(every: v.windowPeriod, fn: sum, createEmpty: false)", + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")\n |> drop(columns: [\"track_id\", \"camera_id\", \"unit\"])\n |> group()", "refId": "A", "datasource": { "type": "influxdb", @@ -186,31 +443,33 @@ } }, { - "title": "Processing Time", + "title": "Detections", "type": "timeseries", "gridPos": { - "h": 8, - "w": 12, "x": 12, - "y": 10 + "y": 14, + "w": 12, + "h": 8 }, "fieldConfig": { "defaults": { - "unit": "s", + "unit": "short", "custom": { - "drawStyle": "points", - "pointSize": 5, - "showPoints": "always" + "drawStyle": "line", + "lineWidth": 2, + "showPoints": "never", + "spanNulls": true, + "lineInterpolation": "smooth" }, "color": { - "fixedColor": "orange", + "fixedColor": "purple", "mode": "fixed" } } }, "targets": [ { - "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"processing_summary\")\n |> filter(fn: (r) => r._field == \"processing_time_s\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"processing_summary\")\n |> filter(fn: (r) => r._field == \"vehicle_count\")\n |> drop(columns: [\"camera_id\"])\n |> aggregateWindow(every: 15m, fn: count, createEmpty: false)", "refId": "A", "datasource": { "type": "influxdb", @@ -221,34 +480,40 @@ "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" + }, + "options": { + "legend": { + "displayMode": "hidden", + "showLegend": false + } } }, { - "title": "Speed Distribution", - "type": "histogram", + "title": "Processing Time", + "type": "timeseries", "gridPos": { - "h": 8, - "w": 12, "x": 0, - "y": 18 + "y": 22, + "w": 12, + "h": 8 }, "fieldConfig": { "defaults": { - "unit": "mph", + "unit": "s", + "custom": { + "drawStyle": "points", + "pointSize": 5, + "showPoints": "always" + }, "color": { - "mode": "palette-classic" + "fixedColor": "orange", + "mode": "fixed" } } }, - "options": { - "bucketSize": 5, - "combine": false, - "fillOpacity": 80, - "legendPlacement": "bottom" - }, "targets": [ { - "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"vehicle_speed\")\n |> filter(fn: (r) => r._field == \"speed_avg\")", + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"processing_summary\")\n |> filter(fn: (r) => r._field == \"processing_time_s\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", "refId": "A", "datasource": { "type": "influxdb", @@ -259,16 +524,22 @@ "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" + }, + "options": { + "legend": { + "displayMode": "hidden", + "showLegend": false + } } }, { "title": "Detection Confidence", "type": "timeseries", "gridPos": { - "h": 8, - "w": 12, "x": 12, - "y": 18 + "y": 22, + "w": 12, + "h": 8 }, "fieldConfig": { "defaults": { @@ -299,32 +570,41 @@ "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" + }, + "options": { + "legend": { + "displayMode": "hidden", + "showLegend": false + } } }, { - "title": "HTTP Requests", + "title": "Requests / sec", "type": "timeseries", "gridPos": { - "h": 8, - "w": 12, "x": 0, - "y": 26 + "y": 30, + "w": 12, + "h": 8 }, "fieldConfig": { "defaults": { - "unit": "short", + "unit": "reqps", "custom": { - "drawStyle": "bars", - "fillOpacity": 30, + "drawStyle": "line", + "lineWidth": 2, + "showPoints": "never", + "spanNulls": true, + "lineInterpolation": "smooth", "stacking": { - "mode": "normal" + "mode": "none" } } } }, "targets": [ { - "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"http_request\")\n |> filter(fn: (r) => r._field == \"count\")\n |> group(columns: [\"path\"])\n |> aggregateWindow(every: v.windowPeriod, fn: sum, createEmpty: false)", + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"http_request\")\n |> filter(fn: (r) => r._field == \"count\")\n |> drop(columns: [\"method\", \"path\", \"status\"])\n |> aggregateWindow(every: 1m, fn: sum, createEmpty: false)\n |> map(fn: (r) => ({r with _value: float(v: r._value) / 60.0}))", "refId": "A", "datasource": { "type": "influxdb", @@ -335,30 +615,38 @@ "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" + }, + "options": { + "legend": { + "displayMode": "hidden", + "showLegend": false + } } }, { "title": "HTTP Request Duration", "type": "timeseries", "gridPos": { - "h": 8, - "w": 12, "x": 12, - "y": 26 + "y": 30, + "w": 12, + "h": 8 }, "fieldConfig": { "defaults": { "unit": "s", "custom": { - "drawStyle": "points", - "pointSize": 4, - "showPoints": "always" + "drawStyle": "line", + "lineWidth": 2, + "showPoints": "never", + "spanNulls": true, + "lineInterpolation": "smooth" } } }, "targets": [ { - "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"http_request\")\n |> filter(fn: (r) => r._field == \"duration_s\")\n |> group(columns: [\"path\"])\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)", + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"http_request\")\n |> filter(fn: (r) => r._field == \"duration_s\")\n |> drop(columns: [\"method\", \"path\", \"status\"])\n |> aggregateWindow(every: 1m, fn: mean, createEmpty: false)", "refId": "A", "datasource": { "type": "influxdb", @@ -369,6 +657,12 @@ "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" + }, + "options": { + "legend": { + "displayMode": "hidden", + "showLegend": false + } } } ], diff --git a/src/clockd/config.py b/src/clockd/config.py index 6299445..b4324ce 100644 --- a/src/clockd/config.py +++ b/src/clockd/config.py @@ -207,7 +207,22 @@ class MetricsConfig(BaseModel): class ServerConfig(BaseSettings): - model_config = SettingsConfigDict(env_prefix="CLOCKD_") + model_config = SettingsConfigDict(env_prefix="CLOCKD_", env_nested_delimiter="__") + + @classmethod + def settings_customise_sources( + cls, + settings_cls, + init_settings, + env_settings, + dotenv_settings, + file_secret_settings, + ): + # Env vars beat YAML (passed as init kwargs) so secrets can be injected + # at deploy time (e.g. from Kubernetes Secrets) without living in the + # config file. Nested fields use "__", e.g. + # CLOCKD_EVENT_SOURCES__HOME_NVR__UNIFI__PASSWORD. + return (env_settings, init_settings, dotenv_settings, file_secret_settings) host: str = "0.0.0.0" port: int = 8000 @@ -228,6 +243,7 @@ def validate_model(cls, v: str) -> str: max_workers: int = 2 job_ttl_seconds: int = 3600 cameras_dir: str = "configs/cameras" + max_cameras: int = 50 # max camera configs the API will create upload_dir: str = "/tmp/clockd_uploads" codeproject_ai: CodeProjectAIConfig = CodeProjectAIConfig() roboflow: RoboflowInferenceConfig = RoboflowInferenceConfig() diff --git a/src/clockd/routers/calibrate.py b/src/clockd/routers/calibrate.py index 440d312..e9750bb 100644 --- a/src/clockd/routers/calibrate.py +++ b/src/clockd/routers/calibrate.py @@ -15,6 +15,7 @@ logger = logging.getLogger(__name__) MAX_IMAGE_BYTES = 50 * 1024 * 1024 # 50MB limit for image uploads +MAX_IMAGE_DIM = 16384 # max pixels per side — a small file can decode to a huge frame router = APIRouter(prefix="/calibrate", tags=["calibrate"]) @@ -24,6 +25,12 @@ def _decode_image(data: bytes) -> np.ndarray: img = cv2.imdecode(arr, cv2.IMREAD_COLOR) if img is None: raise HTTPException(status_code=400, detail="Could not decode image") + h, w = img.shape[:2] + if w > MAX_IMAGE_DIM or h > MAX_IMAGE_DIM: + raise HTTPException( + status_code=400, + detail=f"Image dimensions {w}x{h} exceed the {MAX_IMAGE_DIM}px limit", + ) return img diff --git a/src/clockd/routers/cameras.py b/src/clockd/routers/cameras.py index 2c73f66..440b7b3 100644 --- a/src/clockd/routers/cameras.py +++ b/src/clockd/routers/cameras.py @@ -27,6 +27,12 @@ async def create_camera(camera: CameraConfig, request: Request) -> CameraConfig: cameras: dict[str, CameraConfig] = request.app.state.cameras if camera.camera_id in cameras: raise HTTPException(status_code=409, detail=f"Camera '{camera.camera_id}' already exists") + max_cameras = request.app.state.server_cfg.max_cameras + if len(cameras) >= max_cameras: + raise HTTPException( + status_code=409, + detail=f"Camera limit reached ({max_cameras}); raise max_cameras in the server config", + ) cameras_dir = request.app.state.server_cfg.cameras_dir save_camera(cameras_dir, camera) cameras[camera.camera_id] = camera diff --git a/src/clockd/routers/process.py b/src/clockd/routers/process.py index 4e46dbd..6528d55 100644 --- a/src/clockd/routers/process.py +++ b/src/clockd/routers/process.py @@ -7,6 +7,7 @@ from clockd.models import ProcessingResult from clockd.services.pipeline import process_video +from clockd.utils.log import sanitize_for_log from clockd.utils.video import cleanup, stream_upload_to_disk logger = logging.getLogger(__name__) @@ -40,7 +41,7 @@ async def process_endpoint( logger.info( "Upload received: camera=%s file=%s async=%s unit=%s", camera_id, - file.filename, + sanitize_for_log(file.filename), async_mode, unit, ) diff --git a/src/clockd/services/pipeline.py b/src/clockd/services/pipeline.py index 4008af6..c9f13fe 100644 --- a/src/clockd/services/pipeline.py +++ b/src/clockd/services/pipeline.py @@ -14,7 +14,7 @@ from clockd.services.detector import create_detector from clockd.services.view_transformer import ViewTransformer from clockd.utils.units import convert_speed, mph_to_ms -from clockd.utils.video import validate_video +from clockd.utils.video import MAX_FRAMES, validate_video logger = logging.getLogger(__name__) @@ -127,6 +127,13 @@ def process_video( frame_idx = 0 while cap.isOpened(): + # Backstop: container metadata (already validated) can under-report the + # real frame count, so cap the decode loop itself too. + if frame_idx >= MAX_FRAMES: + msg = f"Stopped at {MAX_FRAMES} frames; video contains more frames than its metadata reported" + warnings.append(msg) + logger.warning("camera=%s: %s", camera.camera_id, msg) + break ret, frame = cap.read() if not ret: break diff --git a/src/clockd/utils/log.py b/src/clockd/utils/log.py new file mode 100644 index 0000000..8d0cff6 --- /dev/null +++ b/src/clockd/utils/log.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +import re + +_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]") + + +def sanitize_for_log(value: str | None) -> str: + """Strip control characters (incl. newlines) so untrusted values can't forge log lines.""" + return _CONTROL_CHARS.sub("?", value or "") diff --git a/tests/test_api_cameras.py b/tests/test_api_cameras.py new file mode 100644 index 0000000..b0a8e05 --- /dev/null +++ b/tests/test_api_cameras.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import pytest + +from clockd.main import app + + +def _camera_body(camera_id: str) -> dict: + return { + "camera_id": camera_id, + "calibration": { + "source_points": [[0, 0], [100, 0], [100, 100], [0, 100]], + "target_width_m": 8.0, + "target_height_m": 40.0, + }, + } + + +@pytest.mark.asyncio +async def test_create_camera_limit_reached(client): + # The client fixture starts with one camera registered + app.state.server_cfg.max_cameras = 1 + resp = await client.post("/cameras", json=_camera_body("over_limit")) + assert resp.status_code == 409 + assert "Camera limit reached" in resp.json()["detail"] + + +@pytest.mark.asyncio +async def test_create_camera_under_limit(client): + app.state.server_cfg.max_cameras = 10 + resp = await client.post("/cameras", json=_camera_body("under_limit")) + assert resp.status_code == 201 + assert resp.json()["camera_id"] == "under_limit" diff --git a/tests/test_calibrate.py b/tests/test_calibrate.py index e2e1af7..a2d28e1 100644 --- a/tests/test_calibrate.py +++ b/tests/test_calibrate.py @@ -246,3 +246,16 @@ async def test_speed_test_apply_saves_config(client, tmp_path, sample_camera, se updated_cam = app.state.cameras["test_cam"] assert updated_cam.speed_calibration_factor == data["recommended_factor"] + + +@pytest.mark.asyncio +async def test_preview_rejects_oversized_image(client): + img = np.zeros((1, 20000, 3), dtype=np.uint8) + _, buf = cv2.imencode(".png", img) + resp = await client.post( + "/calibrate/preview", + data={"camera_id": "test_cam", "detect": "false"}, + files={"file": ("frame.png", buf.tobytes(), "image/png")}, + ) + assert resp.status_code == 400 + assert "exceed" in resp.json()["detail"] diff --git a/tests/test_config.py b/tests/test_config.py index 35d546d..c8372b2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -46,3 +46,34 @@ def test_load_cameras_empty_dir(tmp_path): cam_dir.mkdir() cameras = load_cameras(str(cam_dir)) assert cameras == {} + + +def test_env_overrides_yaml(tmp_path, monkeypatch): + path = tmp_path / "server.yaml" + path.write_text(yaml.dump({"port": 9000})) + monkeypatch.setenv("CLOCKD_PORT", "9100") + cfg = load_server_config(str(path)) + assert cfg.port == 9100 + + +def test_nested_env_secrets_merge_with_yaml(tmp_path, monkeypatch): + path = tmp_path / "server.yaml" + path.write_text( + yaml.dump( + { + "metrics": {"influxdb_v2": {"enabled": True, "url": "http://influx:8086"}}, + "event_sources": {"home_nvr": {"enabled": True, "unifi": {"host": "10.0.0.1"}}}, + } + ) + ) + monkeypatch.setenv("CLOCKD_METRICS__INFLUXDB_V2__TOKEN", "tok123") + monkeypatch.setenv("CLOCKD_EVENT_SOURCES__HOME_NVR__UNIFI__PASSWORD", "s3cret") + cfg = load_server_config(str(path)) + # Env-provided secrets land in the right nested fields... + assert cfg.metrics.influxdb_v2.token == "tok123" + assert cfg.event_sources["home_nvr"].unifi.password == "s3cret" + # ...without clobbering the non-secret YAML values around them + assert cfg.metrics.influxdb_v2.enabled is True + assert cfg.metrics.influxdb_v2.url == "http://influx:8086" + assert cfg.event_sources["home_nvr"].enabled is True + assert cfg.event_sources["home_nvr"].unifi.host == "10.0.0.1" diff --git a/tests/test_log_utils.py b/tests/test_log_utils.py new file mode 100644 index 0000000..36e45a8 --- /dev/null +++ b/tests/test_log_utils.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from clockd.utils.log import sanitize_for_log + + +def test_sanitize_strips_control_chars(): + assert sanitize_for_log("evil\nFAKE log line\r\x1b[31m") == "evil?FAKE log line??[31m" + + +def test_sanitize_none_returns_empty(): + assert sanitize_for_log(None) == "" + + +def test_sanitize_passthrough(): + assert sanitize_for_log("normal-video.mp4") == "normal-video.mp4" diff --git a/tests/test_pipeline_integration.py b/tests/test_pipeline_integration.py index a9c1b06..b792d40 100644 --- a/tests/test_pipeline_integration.py +++ b/tests/test_pipeline_integration.py @@ -239,3 +239,19 @@ def test_check_resolution_mismatch(): def test_check_resolution_none(): camera = _make_camera(resolution=None) assert _check_resolution(MagicMock(), camera) is None + + +def test_process_video_frame_cap(tmp_path, monkeypatch): + import clockd.services.pipeline as pipeline_mod + + video_path = _make_test_video(tmp_path, frames=60) + camera = _make_camera() + cfg = _make_server_cfg(tmp_path) + monkeypatch.setattr(pipeline_mod, "MAX_FRAMES", 30) + + mock_det = _mock_detector_returning(_make_detections([], [], [])) + with patch("clockd.services.pipeline.create_detector", return_value=mock_det): + result = process_video(video_path, camera, cfg, "mph") + + assert result.total_frames == 30 + assert any("Stopped at 30 frames" in w for w in result.warnings)