Author SHA1 Message Date
QiuSW bd964e8831 feat: implement T-019 reliable event ingress
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 15:41:07 +08:00
ila b7fe44eeb0 Merge T-019 issue mapping 2026-08-11 15:04:15 +08:00
QiuSW bebc23cb6a docs: map T-019 to issue 67
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 15:04:04 +08:00
ila 7b95c7d155 Merge T-019 task definition 2026-08-11 15:03:35 +08:00
QiuSW 5a82035cc3 docs: define T-019 event ingress
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 15:03:20 +08:00
ila 607860b79a Merge pull request #65: T-018 Sense NVR management console
Product acceptance confirmed on 2026-08-11. Closes #63.
2026-08-11 14:42:44 +08:00
QiuSW 7c202b7ac8 docs(T-018): record product acceptance 2026-08-11 14:42:25 +08:00
QiuSW 7db707e596 feat(sense): add loopback NVR management console
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:38:22 +08:00
ila a4427a6d2b Merge T-018 issue mapping
Maps T-018 to Gitea issue 63.
2026-08-11 14:06:26 +08:00
QiuSW e3b166c5af docs(tasks): map T-018 to issue 63
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:06:12 +08:00
ila e46f175551 Merge T-018 Sense console task definition
Defines the Sense-first NVR management vertical slice and defers Brain ingress.
2026-08-11 14:05:25 +08:00
QiuSW 2ca51aa2bc docs(tasks): define T-018 Sense console vertical slice
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:05:11 +08:00
ila 19a3233f11 Merge T-017 Brain single-stream visual prototype
Validated engineering-only Python/OpenCV prototype; closes #59.
2026-08-11 11:03:44 +08:00
QiuSW a2790c1f5e feat(brain): add single-stream visual prototype (T-017)
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 11:02:37 +08:00
ila 2e04737922 Merge T-017 issue mapping
Maps T-017 to Gitea issue 59.
2026-08-11 10:30:30 +08:00
QiuSW 2768cfeacb docs(tasks): map T-017 to issue 59
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 10:18:40 +08:00
ila 5c9318ceef Merge T-017 task definition
Defines the scoped Brain single-stream visual prototype task.
2026-08-11 10:17:33 +08:00
QiuSW 5de6f0c2bb docs(tasks): define T-017 Brain visual prototype
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 10:17:07 +08:00
ila 2b9a59fe8a Merge T-016 Sense audit relay
Harness governance / validate (push) Has been cancelled
Implements immutable Bell audit storage and fenced Sense Outbox delivery.
2026-08-11 00:26:08 +08:00
QiuSW 4be2386421 feat: deliver Sense audits to Bell (T-016)
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 00:24:32 +08:00
ila aca22f4667 Merge pull request '[T-016] 回填 Gitea Issue 映射' (#56) from governance/T-016-map into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:59:15 +08:00
QiuSW 88b92d8811 docs(tasks): map T-016 to issue 55
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:59:00 +08:00
ila 325e6ec255 Merge pull request '[T-016] 定义 Sense 到 Bell 审计 relay 任务' (#54) from bootstrap/T-016 into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:58:23 +08:00
QiuSW e3cb1c5bc5 docs(tasks): define T-016 audit relay
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:58:03 +08:00
ila 7e66fab042 Merge pull request '[T-015] 建立 Bell 不可变事件存储' (#53) from agent/codex/T-015 into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:54:27 +08:00
QiuSW 236964d242 feat(bell): add immutable event store [T-015]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:53:11 +08:00
ila 5b42f58f23 Merge pull request '[T-015] 回填 Gitea Issue 映射' (#52) from governance/T-015-map into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:37:22 +08:00
QiuSW 8b11b39093 docs(tasks): map T-015 to issue 51
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:37:08 +08:00
ila 31b08454f9 Merge pull request '[T-015] 定义 Bell 不可变事件存储任务' (#50) from bootstrap/T-015 into main
Harness governance / validate (push) Has been cancelled
2026-08-10 23:36:19 +08:00
QiuSW cefa6275bf docs(tasks): define T-015 Bell immutable events
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:36:01 +08:00
ila 5ab212cb5c Merge pull request '[T-014] 建立 Sense 本地 16 路批量收敛与容量基线' (#49)
Harness governance / validate (push) Has been cancelled
Closes #47
2026-08-10 23:18:52 +08:00
QiuSW b9e21a95d1 docs(task): record T-014 16-stream baseline
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-10 23:16:12 +08:00
QiuSW d029067aa7 fix(lab): refresh first T-014 batch ETags [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-10 22:39:13 +08:00
QiuSW d355f3d069 fix(lab): report frozen Sense Go toolchain [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-10 22:35:25 +08:00
QiuSW 71e5e6e599 fix(lab): schedule all T-014 stability samples [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 11:18:46 +08:00
QiuSW c3cfaff614 fix(lab): release T-014 process handles before cleanup [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 10:45:23 +08:00
QiuSW 707ca8e508 fix(lab): avoid PostgreSQL pipeline deadlock [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 10:11:36 +08:00
QiuSW 403fd6e554 fix(lab): bound T-014 process cleanup [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 09:52:28 +08:00
QiuSW 5d222436f1 feat(lab): add 16-stream capacity harness [T-014]
Harness governance / validate (push) Has been cancelled
2026-08-08 09:10:40 +08:00
QiuSW 3af6a16740 chore(task): claim T-014
Harness governance / validate (push) Has been cancelled
2026-08-08 08:56:00 +08:00
ila 8ebb8ced49 Merge pull request '[T-014] 回填 Gitea Issue 映射' (#48) from governance/T-014-map into main
Harness governance / validate (push) Has been cancelled
2026-08-08 08:54:35 +08:00
QiuSW 2b4ec7053b docs(task): map T-014 to Gitea issue 47
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-08 08:54:17 +08:00
ila 8f4717a322 Merge pull request '[T-014] 冻结本地 16 路批量收敛与容量基线规格' (#46) from governance/T-014-define into main
Harness governance / validate (push) Has been cancelled
2026-08-08 08:53:28 +08:00
QiuSW 951508b8b4 docs(task): define T-014 capacity baseline
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-08 08:52:59 +08:00
ila a8f50cf79f Merge pull request '[T-012] 补齐 Sense 对账安全闸、孤儿受控处置与多实例可观测性' (#45) from agent/codex/T-012 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 23:01:18 +08:00
QiuSW 12857fdf32 feat(sense): add reconciliation safety controls [T-012]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 23:00:03 +08:00
QiuSW 677ed732f7 chore(task): claim T-012
Harness governance / validate (push) Has been cancelled
2026-08-07 22:28:16 +08:00
ila 25357723a0 Merge pull request '[T-012] 回填 Gitea Issue 映射' (#44) from governance/T-012-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 22:26:01 +08:00
QiuSW b7a4a0faf8 docs(task): map T-012 to Gitea issue 43
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 22:25:42 +08:00
ila 872b6c9a6c Merge pull request '[T-012] 冻结对账安全闸与多实例可观测性规格' (#42) from governance/T-012-define into main
Harness governance / validate (push) Has been cancelled
2026-08-07 22:24:43 +08:00
QiuSW 6f65121987 docs(task): define T-012 reconcile safety work
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 22:24:20 +08:00
ila 6757ef2c05 Merge pull request '[T-011] 实现 Sense Control API v1 与 PostgreSQL 一致性边界' (#41) from agent/codex/T-011 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:57:18 +08:00
QiuSW a0d239811f feat(sense): implement Control API v1 [T-011]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:54:41 +08:00
QiuSW 0d3d7a22ed chore(task): renew T-011 write paths
Harness governance / validate (push) Has been cancelled
2026-08-07 20:39:59 +08:00
QiuSW 20b06884ad chore(task): claim T-011
Harness governance / validate (push) Has been cancelled
2026-08-07 20:14:37 +08:00
ila 6402d4384d Merge pull request '[T-011] 回填 Gitea Issue 映射' (#40) from governance/T-011-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:12:34 +08:00
QiuSW e8121a6a8e docs(task): map T-011 to Gitea issue 39
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:12:21 +08:00
ila 907d50d343 Merge pull request '[T-011] 定义 Sense Control API v1 实现任务' (#38) from governance/T-011-define into main
Harness governance / validate (push) Has been cancelled
2026-08-07 20:11:38 +08:00
QiuSW 3c1cce9d5f docs(task): define T-011 control API implementation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 20:11:14 +08:00
ila 31ac2c40ac [T-010] 建立 Area 准入投影与设备操作审计 Outbox 基础
Harness governance / validate (push) Has been cancelled
Closes #35
2026-08-07 18:24:47 +08:00
QiuSW 36786723e3 feat(store): add Area admission and audit outbox [T-010]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:23:24 +08:00
QiuSW 8abff0a566 chore(task): claim T-010
Harness governance / validate (push) Has been cancelled
2026-08-07 18:08:20 +08:00
ila 0b40cf16c3 Governance: map T-010 to Gitea issue 35
Harness governance / validate (push) Has been cancelled
Complete the task-to-Issue mapping before claim.
2026-08-07 18:06:49 +08:00
QiuSW 997e515152 docs(task): map T-010 to Gitea issue 35
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:06:29 +08:00
ila c188c4c9e0 Governance: define T-010 task
Harness governance / validate (push) Has been cancelled
Add T-010 specification before Issue creation.
2026-08-07 18:05:42 +08:00
QiuSW a9323bc9de docs(task): define T-010 area audit foundation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 18:05:20 +08:00
ila b6a752c014 [T-009] 建立 PostgreSQL 双 Schema 与 Bell 配额投影基础
Harness governance / validate (push) Has been cancelled
Closes #31
2026-08-07 17:54:45 +08:00
QiuSW 2ff7ff5618 feat(store): add PostgreSQL foundation [T-009]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 17:52:50 +08:00
QiuSW 04231d006a chore(task): claim T-009
Harness governance / validate (push) Has been cancelled
2026-08-07 17:32:48 +08:00
ila 8a4514076e Merge pull request 'Governance: [T-009] 回填 Gitea Issue 映射' (#32) from governance/T-009-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 17:30:45 +08:00
QiuSW d517d517d9 docs(task): map T-009 to Gitea issue 31
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 17:30:30 +08:00
ila 38c12bb5b7 Merge pull request 'Governance: [T-009] 冻结 PostgreSQL 基础任务规格' (#30) from governance/T-009-spec into main
Harness governance / validate (push) Has been cancelled
2026-08-07 17:29:46 +08:00
QiuSW d332797fcb docs(task): define T-009 PostgreSQL foundation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 17:29:16 +08:00
ila 14d29a47d1 Merge pull request '[T-008] 冻结 Sense 设备管理 API 与 Bell 配额只读投影契约' (#29) from agent/codex/T-008 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 17:16:22 +08:00
QiuSW b70df147ea docs(contract): freeze Sense control API v1 [T-008]
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 17:12:15 +08:00
QiuSW 1c63143106 chore(task): claim T-008
Harness governance / validate (push) Has been cancelled
2026-08-07 16:57:28 +08:00
ila cc47e14638 Merge pull request 'Governance: [T-008] 回填 Gitea Issue 映射' (#28) from governance/T-008-map into main
Harness governance / validate (push) Has been cancelled
2026-08-07 16:56:12 +08:00
QiuSW 461b823570 docs(task): map T-008 to Gitea issue 27
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 16:55:59 +08:00
ila 885dd0143e Merge pull request 'Governance: [T-008] 冻结控制面契约任务规格' (#26) from governance/T-008-spec into main
Harness governance / validate (push) Has been cancelled
2026-08-07 16:55:09 +08:00
QiuSW 11e675d45c docs(task): define T-008 control contracts
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 16:54:49 +08:00
ila 5db37c7142 Merge pull request '[T-006] 完成 Sense 单实机与五路混合源集成验收' (#25) from agent/codex/T-006 into main
Harness governance / validate (push) Has been cancelled
2026-08-07 16:38:41 +08:00
QiuSW 9567838045 feat(sense): complete T-006 five-stream integration
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-07 16:33:35 +08:00
QiuSW 08692e33e9 chore(task): claim T-006
Harness governance / validate (push) Has been cancelled
2026-08-07 15:16:10 +08:00
ila 94ae2f0048 Merge pull request 'T-001: 完成指定海康摄像头准入验证' (#24) from agent/codex/T-001 into main
Harness governance / validate (push) Has been cancelled
2026-08-05 17:41:41 +08:00
QiuSW c9251daadf docs(camera): approve single Hikvision baseline
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-05 17:38:54 +08:00
ila 56c07427c8 Merge pull request 'Governance: 固化单海康实机与合成流开发策略' (#23) from governance/single-camera-development into main
Harness governance / validate (push) Has been cancelled
2026-08-04 17:27:09 +08:00
QiuSW fd16f77b95 docs(requirements): adopt single-camera development strategy
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 17:25:33 +08:00
ila 155c927a62 Merge pull request 'Governance: 重构 T-001 为指定型号准入验证' (#21) from governance/T-001-single-model into main
Harness governance / validate (push) Has been cancelled
2026-08-04 16:04:59 +08:00
QiuSW 3db1cf94e3 docs(requirements): scope T-001 to one camera model
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 16:04:15 +08:00
ila aa62147702 Merge pull request '[T-003] ?? Sense M1 ???????' (#20) from agent/codex/T-003 into main
Harness governance / validate (push) Has been cancelled
2026-08-04 15:50:14 +08:00
QiuSW 5208891e02 feat(sense): establish M1 offline intake skeleton
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 15:45:50 +08:00
QiuSW bafd8d983e docs(tasks): claim T-003
Harness governance / validate (push) Has been cancelled
2026-08-04 15:12:01 +08:00
ila e28070dd03 Governance: audit merged PRs after branch cleanup
Harness governance / validate (push) Has been cancelled
兼容 Gitea 已合并分支清理后的不可变 PR 引用。
2026-08-04 15:08:04 +08:00
QiuSW ef0e76b201 fix(governance): audit merged PRs after branch cleanup
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 15:07:47 +08:00
ila 43c5c18eec Governance: add non-claimable waiting status
Harness governance / validate (push) Has been cancelled
为依赖/外部条件等待任务增加无 claim 状态。
2026-08-04 15:05:52 +08:00
QiuSW f85dbecf93 chore(governance): add waiting task status
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 15:05:33 +08:00
ila 4be15f2452 Map T-006 to issue 16
Harness governance / validate (push) Has been cancelled
完成真实设备集成任务映射。
2026-08-04 15:01:05 +08:00
QiuSW 06de7d0399 docs(tasks): map T-006 to issue 16
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 15:00:47 +08:00
ila 968de2783c Bootstrap T-006 real-camera integration gate
Harness governance / validate (push) Has been cancelled
建立 M1 真实设备集成验收规格。
2026-08-04 15:00:13 +08:00
QiuSW e2e75694cd docs(tasks): define T-006 camera integration gate
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:59:54 +08:00
ila a800068b8a Replan T-003 as hardware-independent Sense skeleton
Harness governance / validate (push) Has been cancelled
拆分无实机骨架与真实设备集成门禁。
2026-08-04 14:57:51 +08:00
QiuSW bb1010c09f docs(tasks): split T-003 from hardware validation
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:57:30 +08:00
ila 58a0056b1b Record T-001 hardware validation deferral
Harness governance / validate (push) Has been cancelled
保留 TODO 规格,记录暂缓与恢复条件。
2026-08-04 14:55:32 +08:00
QiuSW fca32413fa docs(tasks): keep deferred T-001 unclaimed
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:55:09 +08:00
QiuSW 6b18857305 docs(tasks): defer T-001 hardware validation
Harness governance / validate (push) Has been cancelled
2026-08-04 14:54:32 +08:00
ila d5cb2349cf Merge pull request #12: T-005 评审原稿归档
Harness governance / validate (push) Has been cancelled
保留 T-004 定稿,归档原始评审材料并补录缺失文档。
2026-08-04 14:47:08 +08:00
QiuSW c7d0ce3ed9 docs(raw): archive prototype review sources
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:46:12 +08:00
QiuSW ec9ae76a0d docs(tasks): claim T-005
Harness governance / validate (push) Has been cancelled
2026-08-04 14:44:27 +08:00
ila 697e652e9b Map T-005 to issue 10
Harness governance / validate (push) Has been cancelled
完成任务与工单映射。
2026-08-04 14:43:01 +08:00
QiuSW 5ed7397ff0 docs(tasks): map T-005 to issue 10
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:42:35 +08:00
ila ac92d04d71 Bootstrap T-005 review archive task
Harness governance / validate (push) Has been cancelled
建立 T-005 版本化任务规格。
2026-08-04 14:41:49 +08:00
QiuSW edde69ac8d docs(tasks): define T-005 review archive
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:41:24 +08:00
ila de2f1027ec Merge pull request #8: T-004 Sense 与 Bell 交互原型
Harness governance / validate (push) Has been cancelled
人工验收通过,完成 T-004。
2026-08-04 14:22:18 +08:00
QiuSW 547232b7b8 docs(tasks): complete T-004 prototype acceptance
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:20:37 +08:00
QiuSW 8115f584dd docs(design): unify Bell roster and escalation targets
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 14:14:06 +08:00
QiuSW e96cc2fbb5 docs(design): close Bell prototype review gaps
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 11:49:18 +08:00
QiuSW e0b0bfafeb docs(design): close Sense second-review gaps
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 11:08:41 +08:00
QiuSW e18a1ea83b docs(design): apply Sense prototype review
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 10:05:56 +08:00
ila d36b785f67 docs(task): expand T-004 review scope
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:37:48 +08:00
ila 122cde4938 docs(task): preserve complete T-004 revision record
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:07:26 +08:00
ila 9a82b17b10 docs(design): restore complete Bell prototype
Harness governance / validate (push) Has been cancelled
2026-08-04 09:07:23 +08:00
ila cce38003cb docs(design): restore complete Sense prototype
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:07:18 +08:00
ila 618174cbf2 docs(task): record product review revision
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:04:58 +08:00
ila 5251199205 docs(design): update Bell workflow
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 09:04:52 +08:00
ila 2715e557a9 docs(design): update Sense workflow
Harness governance / validate (push) Has been cancelled
2026-08-04 09:04:50 +08:00
QiuSW f43a4efa80 docs(design): add Sense and Bell prototypes
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-04 00:23:55 +08:00
QiuSW b643e42535 docs(tasks): claim T-004
Harness governance / validate (push) Has been cancelled
2026-08-04 00:02:20 +08:00
ila 32b9859a40 Map prototype task T-004 to issue 6
Harness governance / validate (push) Has been cancelled
Complete the task-to-issue mapping before claim.
2026-08-04 00:00:29 +08:00
213 changed files with 46418 additions and 193 deletions
+3
View File
@@ -6,6 +6,9 @@ agent_sessions.txt
gitea.env gitea.env
gitea.env.* gitea.env.*
!gitea.env.example !gitea.env.example
ip_camera.env
ip_camera.env.*
!ip_camera.env.example
# 常见构建与测试缓存 # 常见构建与测试缓存
__pycache__/ __pycache__/
+4 -1
View File
@@ -6,7 +6,7 @@
YoVision 是智能视频事件平台:统一接入 ONVIF/RTSP 摄像头与后续异构传感器,完成检测、规则判定、事件留证和分级预警。 YoVision 是智能视频事件平台:统一接入 ONVIF/RTSP 摄像头与后续异构传感器,完成检测、规则判定、事件留证和分级预警。
当前为 **M0:摄像头兼容性验证与需求定稿**。默认交付 16 路,单站点按 32/64/128 路横向扩展;16 只能是默认配额,不能成为代码、数据库、数组、分页或批量操作的硬上限。 当前已完成 M0、M1 和 M2 的本地基线,进入 **M3:Bell/Brain 最小事件闭环**。默认交付 16 路,单站点按 32/64/128 路横向扩展;16 只能是默认配额,不能成为代码、数据库、数组、分页或批量操作的硬上限。
## 固定阅读顺序 ## 固定阅读顺序
@@ -83,6 +83,9 @@ Gitea 不可用时,只能继续已经确认属于自己的任务;不得领
python scripts/validate_agent_context.py python scripts/validate_agent_context.py
python -m unittest discover -s tests -p "test_*.py" python -m unittest discover -s tests -p "test_*.py"
python scripts/validate_harness_governance.py python scripts/validate_harness_governance.py
go -C Bell test ./...
go -C Bell vet ./...
go -C Bell build ./...
``` ```
代码出现后,还必须执行 `docs/03-tech-stack.md` 中与本任务命中的模块测试;完整门禁、设备验收和容量验收按任务文件触发。提交前检查 `git status --short`、`git diff`、`git diff --cached` 与 `git diff --check`。 代码出现后,还必须执行 `docs/03-tech-stack.md` 中与本任务命中的模块测试;完整门禁、设备验收和容量验收按任务文件触发。提交前检查 `git status --short`、`git diff`、`git diff --cached` 与 `git diff --check`。
-1
View File
@@ -1 +0,0 @@
+59
View File
@@ -0,0 +1,59 @@
# Bell 事件存储、审计与 Brain 事件入口
Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的 Brain 事件 ingress:
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
- PostgreSQL `bell.events` 保存不可变事实;后续 outcome 追加到 `bell.event_outcomes`。
- `bell_runtime` 对事件、outcome、全局审计和 Brain 来源收据只有 `SELECT/INSERT`,没有 `UPDATE/DELETE/TRUNCATE` 或 migration owner 权限;只可清理两张短期 nonce 收据表。
- `cmd/bell-api` 默认只监听 `127.0.0.1:8081`,接收 HMAC 签名的 `/internal/v1/audit-events:batch`,把脱敏设备操作事实追加到 `bell.audit_events`。
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
- T-019 可选 `/internal/v1/event-candidates` 把 HMAC key 绑定到一个 `producer_id`,通过 `event_ingress_bindings` 解析数字事件身份并复查当前 Site/Area/Sense Device;缺失、删除、Area 不一致或 `non_imaging_only` 均失败关闭。
- `(producer_id, source_event_id)` 永久收据、最终事件和成功 nonce 响应同事务提交;相同 canonical candidate 返回原 Bell ID,不同 candidate 返回 `source_event_conflict`。
公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。两条 HMAC ingress 都是内部适配器,不得当成 Bell 公共协议或共用 key。
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
```powershell
go -C Bell run ./cmd/bell-api
```
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`017` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
```powershell
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
$env:BELL_EVENT_INGRESS_KEYS_FILE = 'D:\private\brain-event-keys.json'
$env:BELL_EVIDENCE_FORBIDDEN_NAMES_FILE = 'D:\private\forbidden-evidence-names.txt'
go -C Bell run ./cmd/bell-api
```
forbidden-names 文件每行一个不得出现在证据 URI 的租户/客户标记,至少一行、最多 256 行。key 文件格式见 `docs/contracts/README.md`。仓库不提供真实 secret、DSN、绑定或客户名称;绑定只能引用已经存在且同 Area/modality 的设备。
管理员在事务中核对逻辑资源后,可按下列列签名配置一条视频绑定;数字 ID 是冻结事件契约使用的稳定正整数,不是把文本 ID 强转为数字:
```sql
INSERT INTO bell.event_ingress_bindings(
producer_id, tenant_id, site_id, device_id,
logical_tenant_id, logical_site_id, logical_device_id, logical_area_id,
modality, enabled
) VALUES (
'brain-main', 1, 1, 1,
'tenant-logical-id', 'site-logical-id', 'device-logical-id', 'area-logical-id',
'video', true
);
```
外键只负责资源存在;runtime 还会复查 Area 属于同 Site、设备当前 Area/modality 一致、Site/Area 未删除且策略允许成像。换绑或停用由管理员显式更新/删除 binding,不能修改永久来源收据。
## 验证
```powershell
go -C Bell mod download
go -C Bell test ./...
go -C Bell vet ./...
go -C Bell build ./...
./scripts/test_postgres.ps1 -PgRoot D:\pgsql17
```
隔离 PostgreSQL harness 会创建临时 `bell_runtime` 登录成员,运行真实 repository、幂等冲突和不可变权限测试,然后停止并删除临时集群;不会读取或修改现有 `D:\pgsql17\data` 或 5432 服务。
+227
View File
@@ -0,0 +1,227 @@
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/audit"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
"yovision/bell/internal/store"
)
var version = "dev"
type configuration struct {
address string
dsn string
keyFile string
tlsCert string
tlsKey string
eventIngressEnabled bool
eventKeyFile string
forbiddenNamesFile string
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("Bell stopped", "error", err)
os.Exit(1)
}
}
func loadConfiguration() (configuration, error) {
value := configuration{
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
}
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
case "", "false":
case "true":
value.eventIngressEnabled = true
default:
return configuration{}, errors.New("BELL_EVENT_INGRESS_ENABLED must be true or false")
}
if value.dsn == "" {
return configuration{}, errors.New("BELL_DB_DSN is required")
}
if value.keyFile == "" || !filepath.IsAbs(value.keyFile) {
return configuration{}, errors.New("BELL_AUDIT_KEYS_FILE must be an absolute external path")
}
host, _, err := net.SplitHostPort(value.address)
if err != nil {
return configuration{}, errors.New("invalid BELL_HTTP_ADDR")
}
ip := net.ParseIP(host)
loopback := host == "localhost" || (ip != nil && ip.IsLoopback())
if !loopback && (value.tlsCert == "" || value.tlsKey == "" || !filepath.IsAbs(value.tlsCert) || !filepath.IsAbs(value.tlsKey)) {
return configuration{}, errors.New("non-loopback Bell bind requires absolute TLS certificate and key paths")
}
if (value.tlsCert == "") != (value.tlsKey == "") {
return configuration{}, errors.New("Bell TLS certificate and key must be configured together")
}
if value.eventIngressEnabled {
if value.eventKeyFile == "" || !filepath.IsAbs(value.eventKeyFile) {
return configuration{}, errors.New("BELL_EVENT_INGRESS_KEYS_FILE must be an absolute external path when event ingress is enabled")
}
if value.forbiddenNamesFile == "" || !filepath.IsAbs(value.forbiddenNamesFile) {
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
}
}
return value, nil
}
func run(logger *slog.Logger) error {
cfg, err := loadConfiguration()
if err != nil {
return err
}
pgConfig, err := pgx.ParseConfig(cfg.dsn)
if err != nil {
return errors.New("invalid Bell postgres DSN")
}
if pgConfig.RuntimeParams == nil {
pgConfig.RuntimeParams = make(map[string]string)
}
pgConfig.RuntimeParams["application_name"] = "yovision-bell"
db := stdlib.OpenDB(*pgConfig)
db.SetMaxOpenConns(16)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(30 * time.Minute)
defer db.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
repository, err := store.OpenPostgres(ctx, db)
if err != nil {
return err
}
if err := repository.AuditRelayReady(ctx); err != nil {
return err
}
keys, err := audit.LoadKeys(cfg.keyFile)
if err != nil {
return err
}
handler, err := audit.NewHandler(repository, keys)
if err != nil {
return err
}
mux := http.NewServeMux()
mux.Handle(audit.RelayPath, handler)
if cfg.eventIngressEnabled {
if err := repository.EventIngressReady(ctx); err != nil {
return err
}
eventKeys, err := ingress.LoadKeys(cfg.eventKeyFile)
if err != nil {
return err
}
forbiddenNames, err := loadForbiddenNames(cfg.forbiddenNamesFile)
if err != nil {
return err
}
guard, err := event.NewEvidenceGuard(forbiddenNames...)
if err != nil {
return err
}
factory, err := event.NewFactory(contracts.EventV01Schema, event.ULIDGenerator{}, repository, guard)
if err != nil {
return err
}
eventHandler, err := ingress.NewHandler(repository, eventKeys, factory)
if err != nil {
return err
}
mux.Handle(ingress.Path, eventHandler)
}
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
writeStatus(writer, http.StatusOK, "ok")
})
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, request *http.Request) {
if err := repository.AuditRelayReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
if cfg.eventIngressEnabled {
if err := repository.EventIngressReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
}
writeStatus(writer, http.StatusOK, "ready")
})
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
serverErrors := make(chan error, 1)
go func() {
logger.Info("Bell listening", "address", cfg.address, "version", version, "tls_enabled", cfg.tlsCert != "")
if cfg.tlsCert != "" {
serverErrors <- server.ListenAndServeTLS(cfg.tlsCert, cfg.tlsKey)
return
}
serverErrors <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
case serverErr := <-serverErrors:
if !errors.Is(serverErr, http.ErrServerClosed) {
return fmt.Errorf("serve Bell HTTP: %w", serverErr)
}
}
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdownContext)
}
func loadForbiddenNames(path string) ([]string, error) {
raw, err := os.ReadFile(path)
if err != nil || len(raw) > 64<<10 {
return nil, errors.New("read Bell evidence forbidden-names file")
}
var values []string
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
values = append(values, line)
}
if len(values) == 0 || len(values) > 256 {
return nil, errors.New("Bell evidence forbidden-names file must contain 1 to 256 names")
}
return values, nil
}
func writeStatus(writer http.ResponseWriter, status int, value string) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(status)
_, _ = fmt.Fprintf(writer, `{"status":%q}`, value)
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"path/filepath"
"testing"
)
func TestConfigurationRequiresDatabaseAndExternalKey(t *testing.T) {
t.Setenv("BELL_DB_DSN", "")
t.Setenv("BELL_AUDIT_KEYS_FILE", "")
if _, err := loadConfiguration(); err == nil {
t.Fatal("missing Bell database was accepted")
}
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", "relative.json")
if _, err := loadConfiguration(); err == nil {
t.Fatal("relative Bell key file was accepted")
}
}
func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "keys.json"))
t.Setenv("BELL_HTTP_ADDR", "0.0.0.0:8081")
if _, err := loadConfiguration(); err == nil {
t.Fatal("remote plaintext Bell bind was accepted")
}
t.Setenv("BELL_TLS_CERT_FILE", filepath.Join(t.TempDir(), "server.crt"))
t.Setenv("BELL_TLS_KEY_FILE", filepath.Join(t.TempDir(), "server.key"))
if _, err := loadConfiguration(); err != nil {
t.Fatalf("remote TLS Bell bind rejected: %v", err)
}
}
func TestEventIngressIsDisabledByDefaultAndRequiresExternalPolicy(t *testing.T) {
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "audit.json"))
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
value, err := loadConfiguration()
if err != nil || value.eventIngressEnabled {
t.Fatalf("default event ingress configuration: %+v %v", value, err)
}
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
if _, err := loadConfiguration(); err == nil {
t.Fatal("event ingress without keys and evidence policy was accepted")
}
t.Setenv("BELL_EVENT_INGRESS_KEYS_FILE", filepath.Join(t.TempDir(), "event-keys.json"))
t.Setenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE", filepath.Join(t.TempDir(), "names.txt"))
value, err = loadConfiguration()
if err != nil || !value.eventIngressEnabled {
t.Fatalf("valid event ingress configuration rejected: %+v %v", value, err)
}
}
+9
View File
@@ -0,0 +1,9 @@
// Package contracts embeds the frozen event contract used by Bell.
package contracts
import _ "embed"
// EventV01Schema is byte-identical to docs/raw/contracts/event-v0.1.schema.json.
//
//go:embed event-v0.1.schema.json
var EventV01Schema []byte
+283
View File
@@ -0,0 +1,283 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://yovision.local/contracts/event-v0.1.schema.json",
"title": "YoVision Event Instance v0.1",
"description": "推理侧 → 平台侧的唯一契约。冻结于 2026-08-03。所有顶层键必须存在(可为 null),不允许省略——省略与显式 null 无法区分,是这类系统最常见的排查陷阱。",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"id",
"source_event_id",
"tenant_id",
"site_id",
"device_id",
"sensors",
"kind",
"severity",
"confidence",
"occurred_at",
"detected_at",
"latency_seconds",
"config_version",
"rule",
"subject",
"observation",
"evidence",
"dedup_key",
"aggregated_into",
"outcome",
"outcome_source",
"outcome_reason",
"diagnostics",
"ext"
],
"properties": {
"schema_version": {
"description": "契约版本。破坏性变更必须递增主版本。",
"const": "0.1"
},
"id": {
"description": "平台侧生成的全局唯一事件 ID(ULID)。推理侧不得自行生成。",
"type": "string",
"pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"
},
"source_event_id": {
"description": "推理侧原始事件 ID,如 silver_pose 的 FALL-<session>-000001。用于回溯本地截图文件名(截图即按它命名)。会话内唯一,全局不保证唯一——不得用作主键。",
"type": "string",
"pattern": "^[A-Za-z0-9_-]{1,128}$"
},
"tenant_id": { "type": "integer", "minimum": 1 },
"site_id": { "type": "integer", "minimum": 1 },
"device_id": {
"description": "主传感器的平台设备实体主键。由推理侧的 source_id 经平台映射表解析得到。事件中不得冗余 RTSP 地址或任何凭据。多传感器融合事件的完整来源见 sensors。",
"type": "integer",
"minimum": 1
},
"sensors": {
"description": "参与本次判定的全部传感器。单摄像头事件为单元素数组。恰好一个元素的 role 为 primary,且其 device_id 必须等于顶层 device_id。",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["device_id", "modality", "role"],
"properties": {
"device_id": { "type": "integer", "minimum": 1 },
"modality": {
"description": "设备模态。决定隐私区域准入:privacy_flag 为真的区域只允许非成像模态。",
"type": "string",
"enum": ["video", "radar", "contact", "button", "wearable", "other"]
},
"role": {
"description": "primary=判定主依据;corroborating=佐证(如雷达判跌倒、门磁佐证无人离开)。",
"type": "string",
"enum": ["primary", "corroborating"]
}
}
}
},
"kind": {
"description": "事件类型。取值登记在 contracts/README.md 的类型注册表中,新增类型不需要升 schema 版本。v0.1 已登记:fall。",
"type": "string",
"pattern": "^[a-z][a-z0-9_]{2,63}$"
},
"severity": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"confidence": {
"description": "模型置信度。几何+状态机判定链路没有天然来源,必须填 null——不得用任意常量或阈值余量伪造。",
"type": ["number", "null"],
"minimum": 0,
"maximum": 1
},
"occurred_at": {
"description": "事发时刻(墙钟 UTC)。决定证据回捞窗口。推理侧若只有单调时钟,按 detected_at - latency_seconds 换算。",
"type": "string",
"format": "date-time"
},
"detected_at": {
"description": "判定成立时刻(墙钟 UTC)。决定 SLA 计算。必须 >= occurred_at。",
"type": "string",
"format": "date-time"
},
"latency_seconds": {
"description": "从可疑到确认的耗时。可由两时间戳相减,但显式存储:它是判定质量的直接指标——贴近确认窗口下限说明证据干脆,贴近上限是误报高发区,为误报排查的首选排序键。",
"type": "number",
"minimum": 0
},
"config_version": {
"description": "产出本事件时整套判定配置的版本。粒度高于 rule.version(阈值往往是全局的),用于调参后的回归对比。不得为空串。",
"type": "string",
"minLength": 1,
"maxLength": 128
},
"rule": {
"description": "命中的规则实体。推理侧无规则引擎时为 null,由平台侧按 kind 反查补全。",
"type": ["object", "null"],
"additionalProperties": false,
"required": ["id", "version", "code"],
"properties": {
"id": { "type": "string" },
"version": { "type": "integer", "minimum": 1 },
"code": { "type": "string" }
}
},
"subject": {
"type": "object",
"additionalProperties": false,
"required": ["class", "track_id", "attributes", "anon_id", "identity", "identity_status"],
"properties": {
"class": { "type": "string", "enum": ["person", "vehicle", "object"] },
"track_id": {
"description": "跟踪器内的短期标识,跨会话不保证稳定。",
"type": "string",
"minLength": 1
},
"attributes": {
"description": "A 类属性(年龄段、着装等)。未启用时为空对象,不是 null。",
"type": "object"
},
"anon_id": {
"description": "B+ 类 ReID 匿名标识,站点内会话级有效(≤30min),不做跨日长期关联。未启用为 null。",
"type": ["string", "null"]
},
"identity": {
"description": "C 类人脸身份。仅在租户已授权且比对命中时非 null。",
"type": ["object", "null"],
"additionalProperties": false,
"required": ["person_id", "library_id", "score"],
"properties": {
"person_id": { "type": "string" },
"library_id": { "type": "string" },
"score": { "type": "number", "minimum": 0, "maximum": 1 }
}
},
"identity_status": {
"description": "必须显式。只写 null 无法区分「没开这功能」与「比对失败」,后者是需要排查的故障。",
"type": "string",
"enum": ["not_enabled", "pending", "matched", "below_threshold", "no_candidate", "timeout"]
}
}
},
"observation": {
"description": "结构化观测。bbox/keypoint 序列是数据闭环的唯一原料——只有视频与截图无法用于训练。",
"type": ["object", "null"],
"additionalProperties": false,
"required": ["zone", "dwell_sec", "bbox_seq_uri", "keypoint_seq_uri", "signal_seq_uri"],
"properties": {
"zone": { "type": ["string", "null"] },
"dwell_sec": { "type": ["number", "null"], "minimum": 0 },
"bbox_seq_uri": {
"description": "视觉模态专用。非视觉事件为 null。",
"type": ["string", "null"],
"format": "uri"
},
"keypoint_seq_uri": {
"description": "COCO-17 关键点逐帧序列(JSONL)。视觉模态专用,P1 必补项。",
"type": ["string", "null"],
"format": "uri"
},
"signal_seq_uri": {
"description": "非视觉模态的结构化序列(雷达点云轨迹与多普勒、门磁状态变迁等,JSONL)。与 keypoint_seq_uri 平级——两者是各自模态的数据闭环原料,缺任一模态的序列,该模态就无法参与模型迭代。",
"type": ["string", "null"],
"format": "uri"
}
}
},
"evidence": {
"type": "object",
"additionalProperties": false,
"required": ["snapshot_uris", "clip_uri", "clip_range"],
"properties": {
"snapshot_uris": {
"description": "证据截图。**允许为空数组**:非成像模态(雷达、门磁)产出的事件本就没有画面,隐私区域更是禁止成像。不得据此假设每个事件都有图可看——值班台 UI 必须能渲染无画面事件。文件命名只允许包含事件 ID 与日期目录,绝不得含 RTSP 地址、凭据或客户名称,文件名会出现在日志、URL 与工单中。",
"type": "array",
"minItems": 0,
"items": { "type": "string", "format": "uri" }
},
"clip_uri": {
"description": "含 pre-roll 的证据片段。仅有截图不足以让值班员判断真假,是误报反馈闭环的前置条件。P1 必补项。",
"type": ["string", "null"],
"format": "uri"
},
"clip_range": {
"type": ["array", "null"],
"minItems": 2,
"maxItems": 2,
"items": { "type": "string", "format": "date-time" }
}
}
},
"dedup_key": {
"description": "跨机位/跨时间去重键,由平台侧构造。推理侧进程内按 source_event_id 的去重仍保留——它防的是同帧重复写盘,属不同层次。",
"type": ["string", "null"]
},
"aggregated_into": {
"description": "被合并入的事件 ID。非 null 时本事件不独立触发告警。",
"type": ["string", "null"],
"pattern": "^evt_[0-9A-HJKMNP-TV-Z]{26}$"
},
"outcome": {
"description": "处置结果。事件不可变,误判只能通过本字段标记,不得删改。subject_recovered 由推理侧状态机自动回传(确认后自行起身),无需等人工。",
"type": "string",
"enum": [
"unknown",
"true_positive",
"false_positive",
"subject_recovered",
"duplicate",
"test"
]
},
"outcome_source": {
"type": ["string", "null"],
"enum": ["auto", "manual", null]
},
"outcome_reason": { "type": ["string", "null"] },
"diagnostics": {
"description": "推理侧内部诊断量,仅用于排查,平台不得依赖其语义。单调时钟跨进程无意义,不得用于任何时间计算。",
"type": ["object", "null"],
"additionalProperties": true,
"properties": {
"fsm_state": {
"type": "string",
"enum": ["NORMAL", "SUSPECT", "CONFIRMED", "RECOVERING"]
},
"suspected_at_monotonic": { "type": "number" },
"confirmed_at_monotonic": { "type": "number" },
"horizontal_angle_degrees": { "type": ["number", "null"] },
"visible_joint_count": { "type": ["integer", "null"], "minimum": 0, "maximum": 17 }
}
},
"ext": {
"description": "厂商/场景扩展位。根对象 additionalProperties=false,任何未登记字段一律放这里,避免为实验性字段升版本。",
"type": "object"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
module yovision/bell
go 1.26.0
toolchain go1.26.5
require (
github.com/jackc/pgx/v5 v5.10.0
github.com/oklog/ulid/v2 v2.1.2
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
+33
View File
@@ -0,0 +1,33 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+363
View File
@@ -0,0 +1,363 @@
// Package audit authenticates and validates Sense audit relay batches.
package audit
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
RelayPath = "/internal/v1/audit-events:batch"
MaxBatchSize = 100
MaxBodyBytes = 1 << 20
HeaderKeyID = "X-YoVision-Key-Id"
HeaderTimestamp = "X-YoVision-Timestamp"
HeaderNonce = "X-YoVision-Nonce"
HeaderSignature = "X-YoVision-Signature"
)
var ErrReplayConflict = errors.New("audit relay replay conflict")
type Actor struct {
Type string `json:"type"`
ID string `json:"id"`
}
type ProjectionVersions struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
}
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TenantID string `json:"tenant_id"`
SiteID string `json:"site_id"`
DeviceID string `json:"device_id"`
Actor Actor `json:"actor"`
Reason *string `json:"reason"`
TraceID *string `json:"trace_id"`
AggregateGeneration int64 `json:"aggregate_generation"`
ProjectionVersions ProjectionVersions `json:"projection_versions"`
Data json.RawMessage `json:"data"`
OccurredAt time.Time `json:"occurred_at"`
}
type Envelope struct {
SchemaVersion int `json:"schema_version"`
Event Event `json:"event"`
}
type Result struct {
EventID string `json:"event_id"`
Status string `json:"status"`
ErrorCode *string `json:"error_code,omitempty"`
}
type BatchResponse struct {
Results []Result `json:"results"`
}
type Candidate struct {
Envelope Envelope
RecordHash [sha256.Size]byte
ErrorCode string
}
type Repository interface {
ProcessAuditBatch(context.Context, string, string, [sha256.Size]byte, []Candidate) ([]Result, error)
}
type Handler struct {
repository Repository
keys map[string][]byte
now func() time.Time
}
func NewHandler(repository Repository, keys map[string][]byte) (*Handler, error) {
if repository == nil || len(keys) == 0 {
return nil, errors.New("audit handler dependencies are required")
}
copyKeys := make(map[string][]byte, len(keys))
for id, secret := range keys {
if !keyIDPattern.MatchString(id) || len(secret) < 32 {
return nil, errors.New("invalid audit handler key")
}
copyKeys[id] = append([]byte(nil), secret...)
}
return &Handler{repository: repository, keys: copyKeys, now: time.Now}, nil
}
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != RelayPath {
writeError(writer, http.StatusNotFound, "not_found")
return
}
body, err := io.ReadAll(io.LimitReader(request.Body, MaxBodyBytes+1))
if err != nil || len(body) > MaxBodyBytes {
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large")
return
}
keyID := request.Header.Get(HeaderKeyID)
timestamp := request.Header.Get(HeaderTimestamp)
nonce := request.Header.Get(HeaderNonce)
provided := request.Header.Get(HeaderSignature)
secret, ok := h.keys[keyID]
seconds, timestampErr := strconv.ParseInt(timestamp, 10, 64)
nonceBytes, nonceErr := base64.RawURLEncoding.DecodeString(nonce)
signatureBytes, signatureErr := base64.RawURLEncoding.DecodeString(provided)
if !ok || timestampErr != nil || len(timestamp) < 10 || nonceErr != nil || len(nonceBytes) < 16 || len(nonceBytes) > 48 ||
signatureErr != nil || len(signatureBytes) != sha256.Size || absDuration(h.now().UTC().Sub(time.Unix(seconds, 0).UTC())) > 300*time.Second {
writeError(writer, http.StatusUnauthorized, "unauthorized")
return
}
expected := signature(secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
if !hmac.Equal(signatureBytes, expected) {
writeError(writer, http.StatusUnauthorized, "unauthorized")
return
}
candidates, err := decodeCandidates(body)
if err != nil {
writeError(writer, http.StatusBadRequest, "invalid_batch")
return
}
requestHash := sha256.Sum256(body)
results, err := h.repository.ProcessAuditBatch(request.Context(), keyID, nonce, requestHash, candidates)
if errors.Is(err, ErrReplayConflict) {
writeError(writer, http.StatusConflict, "replay_conflict")
return
}
if err != nil {
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable")
return
}
writeJSON(writer, http.StatusOK, BatchResponse{Results: results})
}
func canonicalString(method, path, timestamp, nonce string, body []byte) string {
digest := sha256.Sum256(body)
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
}
func signature(secret []byte, canonical string) []byte {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(canonical))
return mac.Sum(nil)
}
func absDuration(value time.Duration) time.Duration {
if value < 0 {
return -value
}
return value
}
type rawBatch struct {
Events []json.RawMessage `json:"events"`
}
func decodeCandidates(body []byte) ([]Candidate, error) {
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var batch rawBatch
if err := decoder.Decode(&batch); err != nil || len(batch.Events) < 1 || len(batch.Events) > MaxBatchSize {
return nil, errors.New("invalid audit batch")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid audit batch trailing data")
}
values := make([]Candidate, len(batch.Events))
for index, raw := range batch.Events {
values[index].RecordHash = sha256.Sum256(raw)
if !hasExactEnvelopeShape(raw) {
values[index].ErrorCode = "schema_invalid"
continue
}
itemDecoder := json.NewDecoder(bytes.NewReader(raw))
itemDecoder.DisallowUnknownFields()
if err := itemDecoder.Decode(&values[index].Envelope); err != nil {
values[index].ErrorCode = "schema_invalid"
continue
}
values[index].ErrorCode = validateEnvelope(values[index].Envelope)
}
return values, nil
}
var (
eventIDPattern = regexp.MustCompile(`^audit_[0-9a-f]{32}$`)
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
)
func validateEnvelope(value Envelope) string {
event := value.Event
if (value.SchemaVersion != 1 && value.SchemaVersion != 2) || !eventIDPattern.MatchString(event.EventID) ||
!logicalIDPattern.MatchString(event.TenantID) || !logicalIDPattern.MatchString(event.SiteID) || !logicalIDPattern.MatchString(event.DeviceID) ||
event.AggregateGeneration < 1 || event.OccurredAt.IsZero() || strings.TrimSpace(event.Actor.ID) == "" || utf8.RuneCountInString(event.Actor.ID) > 200 ||
(event.Actor.Type != "user" && event.Actor.Type != "service" && event.Actor.Type != "system") ||
(event.Reason != nil && utf8.RuneCountInString(*event.Reason) > 500) || (event.TraceID != nil && utf8.RuneCountInString(*event.TraceID) > 128) ||
(event.ProjectionVersions.QuotaSourceVersion != nil && *event.ProjectionVersions.QuotaSourceVersion < 1) ||
(event.ProjectionVersions.AreaPolicySourceVersion != nil && *event.ProjectionVersions.AreaPolicySourceVersion < 1) {
return "schema_invalid"
}
if event.EventType != "device.created" && event.EventType != "device.desired_state.accepted" &&
(event.EventType != "device.configuration.accepted" || value.SchemaVersion != 2) {
return "schema_invalid"
}
var data map[string]any
if err := json.Unmarshal(event.Data, &data); err != nil || data == nil {
return "schema_invalid"
}
expected := map[string]string{
"device.created": "device_created",
"device.desired_state.accepted": "desired_state_accepted",
"device.configuration.accepted": "configuration_accepted",
}[event.EventType]
if data["kind"] != expected || !validateData(event.EventType, data) || containsSensitiveKey(data) {
return "payload_invalid"
}
return ""
}
func hasExactEnvelopeShape(raw []byte) bool {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil || !exactRawKeys(envelope, "schema_version", "event") {
return false
}
var event map[string]json.RawMessage
if err := json.Unmarshal(envelope["event"], &event); err != nil || !exactRawKeys(event,
"event_id", "event_type", "tenant_id", "site_id", "device_id", "actor", "reason", "trace_id",
"aggregate_generation", "projection_versions", "data", "occurred_at",
) {
return false
}
var actor, projections map[string]json.RawMessage
return json.Unmarshal(event["actor"], &actor) == nil && exactRawKeys(actor, "type", "id") &&
json.Unmarshal(event["projection_versions"], &projections) == nil && exactRawKeys(projections, "quota_source_version", "area_policy_source_version")
}
func exactRawKeys(value map[string]json.RawMessage, expected ...string) bool {
if len(value) != len(expected) {
return false
}
for _, key := range expected {
if _, exists := value[key]; !exists {
return false
}
}
return true
}
func validateData(eventType string, data map[string]any) bool {
switch eventType {
case "device.created":
if !exactAnyKeys(data, "kind", "area_id", "modality", "capabilities", "desired_state") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
return false
}
if !member(stringValue(data["modality"]), "video", "radar", "contact", "button", "wearable", "other") || !member(stringValue(data["desired_state"]), "disabled", "enabled") {
return false
}
return validStringSet(data["capabilities"], 16, "video_capture", "audio_capture", "spatial_rule", "telemetry")
case "device.desired_state.accepted":
if !exactAnyKeys(data, "kind", "previous_desired_state", "desired_state", "changed") {
return false
}
_, changed := data["changed"].(bool)
return changed && member(stringValue(data["previous_desired_state"]), "disabled", "enabled") && member(stringValue(data["desired_state"]), "disabled", "enabled")
case "device.configuration.accepted":
if !exactAnyKeys(data, "kind", "changed", "changed_fields", "area_id") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
return false
}
_, changed := data["changed"].(bool)
return changed && validStringSet(data["changed_fields"], 5, "name", "area_id", "endpoint_ref", "credential_ref", "profile_token")
default:
return false
}
}
func exactAnyKeys(value map[string]any, expected ...string) bool {
if len(value) != len(expected) {
return false
}
for _, key := range expected {
if _, exists := value[key]; !exists {
return false
}
}
return true
}
func stringValue(value any) string {
result, _ := value.(string)
return result
}
func member(value string, allowed ...string) bool {
for _, candidate := range allowed {
if value == candidate {
return true
}
}
return false
}
func validStringSet(value any, maximum int, allowed ...string) bool {
items, ok := value.([]any)
if !ok || len(items) > maximum {
return false
}
seen := make(map[string]bool, len(items))
for _, item := range items {
text, ok := item.(string)
if !ok || !member(text, allowed...) || seen[text] {
return false
}
seen[text] = true
}
return true
}
func containsSensitiveKey(value any) bool {
forbidden := map[string]bool{"password": true, "stream_uri": true, "mediamtx_config": true}
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if forbidden[strings.ToLower(key)] || containsSensitiveKey(child) {
return true
}
}
case []any:
for _, child := range typed {
if containsSensitiveKey(child) {
return true
}
}
}
return false
}
func writeError(writer http.ResponseWriter, status int, code string) {
writeJSON(writer, status, map[string]string{"error": code})
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "no-store")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
+105
View File
@@ -0,0 +1,105 @@
package audit
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
type recordingRepository struct {
candidates []Candidate
results []Result
err error
}
func (r *recordingRepository) ProcessAuditBatch(_ context.Context, _, _ string, _ [sha256.Size]byte, values []Candidate) ([]Result, error) {
r.candidates = values
return r.results, r.err
}
func validBody(t *testing.T) []byte {
t.Helper()
value := map[string]any{"events": []any{map[string]any{
"schema_version": 1,
"event": map[string]any{
"event_id": "audit_00000000000000000000000000000001", "event_type": "device.created",
"tenant_id": "tenant", "site_id": "site", "device_id": "camera-1",
"actor": map[string]any{"type": "system", "id": "sense"}, "reason": nil, "trace_id": nil,
"aggregate_generation": 1,
"projection_versions": map[string]any{"quota_source_version": 1, "area_policy_source_version": 1},
"data": map[string]any{"kind": "device_created", "area_id": "area", "modality": "video", "capabilities": []any{"video_capture"}, "desired_state": "enabled"},
"occurred_at": "2026-08-11T00:00:00Z",
},
}}}
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return raw
}
func signedRequest(t *testing.T, body, secret []byte, timestamp time.Time, nonce string) *http.Request {
t.Helper()
request := httptest.NewRequest(http.MethodPost, RelayPath, bytes.NewReader(body))
stamp := strconv.FormatInt(timestamp.Unix(), 10)
request.Header.Set(HeaderKeyID, "sense-a")
request.Header.Set(HeaderTimestamp, stamp)
request.Header.Set(HeaderNonce, nonce)
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, RelayPath, stamp, nonce, body))))
return request
}
func TestHandlerAuthenticatesAndReturnsPerItemResults(t *testing.T) {
secret := bytes.Repeat([]byte{3}, 32)
repository := &recordingRepository{results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}}
handler, err := NewHandler(repository, map[string][]byte{"sense-a": secret})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 11, 0, 1, 0, 0, time.UTC)
handler.now = func() time.Time { return now }
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, validBody(t), secret, now, "AAAAAAAAAAAAAAAAAAAAAA"))
if response.Code != http.StatusOK || len(repository.candidates) != 1 || repository.candidates[0].ErrorCode != "" {
t.Fatalf("valid batch rejected: status=%d candidates=%+v", response.Code, repository.candidates)
}
}
func TestHandlerRejectsStaleOrTamperedRequests(t *testing.T) {
secret := bytes.Repeat([]byte{4}, 32)
repository := &recordingRepository{}
handler, _ := NewHandler(repository, map[string][]byte{"sense-a": secret})
now := time.Date(2026, 8, 11, 0, 10, 0, 0, time.UTC)
handler.now = func() time.Time { return now }
for _, request := range []*http.Request{
signedRequest(t, validBody(t), secret, now.Add(-301*time.Second), "BBBBBBBBBBBBBBBBBBBBBB"),
signedRequest(t, append(validBody(t), ' '), bytes.Repeat([]byte{5}, 32), now, "CCCCCCCCCCCCCCCCCCCCCC"),
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("unsafe request returned %d", response.Code)
}
}
}
func TestDecodeCandidatesRejectsSensitiveItemWithoutRejectingBatch(t *testing.T) {
body := validBody(t)
var value map[string]any
_ = json.Unmarshal(body, &value)
events := value["events"].([]any)
event := events[0].(map[string]any)["event"].(map[string]any)
event["data"].(map[string]any)["password"] = "must-not-persist"
body, _ = json.Marshal(value)
candidates, err := decodeCandidates(body)
if err != nil || len(candidates) != 1 || candidates[0].ErrorCode != "payload_invalid" {
t.Fatalf("unexpected per-item validation: %+v %v", candidates, err)
}
}
+47
View File
@@ -0,0 +1,47 @@
package audit
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
)
type keyDocument struct {
Version int `json:"version"`
Keys []struct {
KeyID string `json:"key_id"`
Secret string `json:"secret_base64url"`
} `json:"keys"`
}
func LoadKeys(path string) (map[string][]byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Bell audit key file")
}
var document keyDocument
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
return nil, errors.New("invalid Bell audit key file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid Bell audit key file")
}
values := make(map[string][]byte, len(document.Keys))
for _, item := range document.Keys {
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
if err != nil || !keyIDPattern.MatchString(item.KeyID) || len(secret) < 32 {
return nil, errors.New("invalid Bell audit key")
}
if _, exists := values[item.KeyID]; exists {
return nil, errors.New("duplicate Bell audit key ID")
}
values[item.KeyID] = secret
}
return values, nil
}
+303
View File
@@ -0,0 +1,303 @@
// Package event assembles and validates immutable Bell event facts.
package event
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net"
"net/url"
"regexp"
"strings"
"time"
"github.com/oklog/ulid/v2"
jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
)
const MaxPayloadBytes = 1 << 20
type ErrorCode string
const (
CodeInvalidJSON ErrorCode = "invalid_json"
CodePayloadTooLarge ErrorCode = "payload_too_large"
CodeUpstreamID ErrorCode = "upstream_id_forbidden"
CodeSchema ErrorCode = "schema_invalid"
CodeTimeOrder ErrorCode = "time_order_invalid"
CodeLatency ErrorCode = "latency_inconsistent"
CodeConfidence ErrorCode = "confidence_forbidden"
CodeEvidence ErrorCode = "evidence_unsafe"
CodePrimarySensor ErrorCode = "primary_sensor_invalid"
CodePrivacyDenied ErrorCode = "privacy_denied"
CodePrivacyUnavailable ErrorCode = "privacy_unavailable"
)
// ValidationError exposes a stable code without returning sensitive payloads.
type ValidationError struct {
Code ErrorCode
Err error
}
func (e *ValidationError) Error() string { return string(e.Code) }
func (e *ValidationError) Unwrap() error { return e.Err }
func validationError(code ErrorCode, err error) error {
return &ValidationError{Code: code, Err: err}
}
// IDGenerator is owned by Bell. Upstream candidates are not allowed to carry id.
type IDGenerator interface {
NewEventID() (string, error)
}
type ULIDGenerator struct{}
func (ULIDGenerator) NewEventID() (string, error) {
return "evt_" + ulid.Make().String(), nil
}
// PrivacyPolicy resolves the authoritative device/Area policy. Implementations
// must fail closed when the mapping is missing or stale.
type PrivacyPolicy interface {
VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error)
}
// EvidencePolicy checks every evidence/observation URI before persistence.
type EvidencePolicy interface {
ValidateURI(rawURI string) error
}
// EvidenceGuard rejects reusable credentials, network endpoints and configured
// customer/tenant names from persisted evidence URIs.
type EvidenceGuard struct {
forbidden []string
}
func NewEvidenceGuard(forbiddenNames ...string) (*EvidenceGuard, error) {
guard := &EvidenceGuard{}
for _, name := range forbiddenNames {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return nil, errors.New("forbidden evidence name cannot be blank")
}
guard.forbidden = append(guard.forbidden, name)
}
return guard, nil
}
var ipv4Like = regexp.MustCompile(`(?:^|[^0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:[^0-9]|$)`)
func (g *EvidenceGuard) ValidateURI(rawURI string) error {
parsed, err := url.Parse(rawURI)
if err != nil || parsed.Scheme == "" {
return errors.New("evidence URI is not absolute")
}
if parsed.User != nil || parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return errors.New("evidence URI contains reusable connection material")
}
if host := parsed.Hostname(); host != "" && net.ParseIP(host) != nil {
return errors.New("evidence URI contains an IP address")
}
lower := strings.ToLower(rawURI)
for _, marker := range []string{"password", "passwd", "credential", "secret", "token=", "rtsp://"} {
if strings.Contains(lower, marker) {
return errors.New("evidence URI contains a forbidden marker")
}
}
if ipv4Like.MatchString(lower) {
return errors.New("evidence URI contains an IPv4-like value")
}
for _, name := range g.forbidden {
if strings.Contains(lower, name) {
return errors.New("evidence URI contains a configured sensitive name")
}
}
return nil
}
type Sensor struct {
DeviceID int64 `json:"device_id"`
Modality string `json:"modality"`
Role string `json:"role"`
}
type storedShape struct {
ID string `json:"id"`
TenantID int64 `json:"tenant_id"`
SiteID int64 `json:"site_id"`
DeviceID int64 `json:"device_id"`
SourceEventID string `json:"source_event_id"`
Sensors []Sensor `json:"sensors"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Confidence *float64 `json:"confidence"`
OccurredAt time.Time `json:"occurred_at"`
DetectedAt time.Time `json:"detected_at"`
LatencySeconds float64 `json:"latency_seconds"`
Observation *struct {
BBoxSeqURI *string `json:"bbox_seq_uri"`
KeypointSeqURI *string `json:"keypoint_seq_uri"`
SignalSeqURI *string `json:"signal_seq_uri"`
} `json:"observation"`
Evidence struct {
SnapshotURIs []string `json:"snapshot_uris"`
ClipURI *string `json:"clip_uri"`
} `json:"evidence"`
}
// Event is a final, schema-valid immutable fact. JSON returns a defensive copy.
type Event struct {
shape storedShape
payload []byte
digest [sha256.Size]byte
}
func (e Event) ID() string { return e.shape.ID }
func (e Event) TenantID() int64 { return e.shape.TenantID }
func (e Event) SiteID() int64 { return e.shape.SiteID }
func (e Event) DeviceID() int64 { return e.shape.DeviceID }
func (e Event) SourceEventID() string { return e.shape.SourceEventID }
func (e Event) Sensors() []Sensor { return append([]Sensor(nil), e.shape.Sensors...) }
func (e Event) Kind() string { return e.shape.Kind }
func (e Event) Severity() string { return e.shape.Severity }
func (e Event) OccurredAt() time.Time { return e.shape.OccurredAt }
func (e Event) DetectedAt() time.Time { return e.shape.DetectedAt }
func (e Event) Digest() [sha256.Size]byte { return e.digest }
func (e Event) JSON() []byte { return bytes.Clone(e.payload) }
type Factory struct {
schema *jsonschema.Schema
ids IDGenerator
privacy PrivacyPolicy
evidence EvidencePolicy
}
func NewFactory(schemaJSON []byte, ids IDGenerator, privacy PrivacyPolicy, evidence EvidencePolicy) (*Factory, error) {
if ids == nil || privacy == nil || evidence == nil {
return nil, errors.New("event factory dependencies are required")
}
schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON))
if err != nil {
return nil, fmt.Errorf("parse event schema: %w", err)
}
compiler := jsonschema.NewCompiler()
compiler.AssertFormat()
if err := compiler.AddResource("event-v0.1.schema.json", schemaDoc); err != nil {
return nil, fmt.Errorf("register event schema: %w", err)
}
compiled, err := compiler.Compile("event-v0.1.schema.json")
if err != nil {
return nil, fmt.Errorf("compile event schema: %w", err)
}
return &Factory{schema: compiled, ids: ids, privacy: privacy, evidence: evidence}, nil
}
// Create turns a producer candidate into the final stored v0.1 event. The
// candidate must contain every v0.1 field except the Bell-owned id.
func (f *Factory) Create(ctx context.Context, candidate []byte) (Event, error) {
if len(candidate) > MaxPayloadBytes {
return Event{}, validationError(CodePayloadTooLarge, nil)
}
decoder := json.NewDecoder(bytes.NewReader(candidate))
decoder.UseNumber()
var object map[string]any
if err := decoder.Decode(&object); err != nil || object == nil {
return Event{}, validationError(CodeInvalidJSON, err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return Event{}, validationError(CodeInvalidJSON, errors.New("multiple JSON values"))
}
if _, exists := object["id"]; exists {
return Event{}, validationError(CodeUpstreamID, nil)
}
id, err := f.ids.NewEventID()
if err != nil {
return Event{}, fmt.Errorf("generate Bell event id: %w", err)
}
object["id"] = id
payload, err := json.Marshal(object)
if err != nil {
return Event{}, validationError(CodeInvalidJSON, err)
}
if len(payload) > MaxPayloadBytes {
return Event{}, validationError(CodePayloadTooLarge, nil)
}
instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(payload))
if err != nil {
return Event{}, validationError(CodeInvalidJSON, err)
}
if err := f.schema.Validate(instance); err != nil {
return Event{}, validationError(CodeSchema, nil)
}
var shape storedShape
if err := json.Unmarshal(payload, &shape); err != nil {
return Event{}, validationError(CodeInvalidJSON, err)
}
if err := f.assertSemantics(ctx, shape); err != nil {
return Event{}, err
}
return Event{shape: shape, payload: payload, digest: sha256.Sum256(payload)}, nil
}
func (f *Factory) assertSemantics(ctx context.Context, shape storedShape) error {
if shape.DetectedAt.Before(shape.OccurredAt) {
return validationError(CodeTimeOrder, nil)
}
actual := shape.DetectedAt.Sub(shape.OccurredAt).Seconds()
if math.Abs(actual-shape.LatencySeconds) >= 0.1 {
return validationError(CodeLatency, nil)
}
if shape.Confidence != nil {
return validationError(CodeConfidence, nil)
}
primary := 0
for _, sensor := range shape.Sensors {
if sensor.Role == "primary" {
primary++
if sensor.DeviceID != shape.DeviceID {
return validationError(CodePrimarySensor, nil)
}
}
if sensor.Modality == "video" {
allowed, err := f.privacy.VideoAllowed(ctx, shape.TenantID, shape.SiteID, sensor.DeviceID)
if err != nil {
return validationError(CodePrivacyUnavailable, nil)
}
if !allowed {
return validationError(CodePrivacyDenied, nil)
}
}
}
if primary != 1 {
return validationError(CodePrimarySensor, nil)
}
var uris []string
uris = append(uris, shape.Evidence.SnapshotURIs...)
if shape.Evidence.ClipURI != nil {
uris = append(uris, *shape.Evidence.ClipURI)
}
if shape.Observation != nil {
for _, value := range []*string{
shape.Observation.BBoxSeqURI,
shape.Observation.KeypointSeqURI,
shape.Observation.SignalSeqURI,
} {
if value != nil {
uris = append(uris, *value)
}
}
}
for _, rawURI := range uris {
if err := f.evidence.ValidateURI(rawURI); err != nil {
return validationError(CodeEvidence, nil)
}
}
return nil
}
+184
View File
@@ -0,0 +1,184 @@
package event_test
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"yovision/bell/contracts"
"yovision/bell/internal/event"
)
const fixedEventID = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
type fixedIDs struct{ id string }
func (f fixedIDs) NewEventID() (string, error) { return f.id, nil }
type privacy struct {
allowed bool
err error
calls int
}
func (p *privacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
p.calls++
return p.allowed, p.err
}
func contractPath(name string) string {
return filepath.Join("..", "..", "..", "docs", "raw", "contracts", name)
}
func candidate(t *testing.T, name string) []byte {
t.Helper()
raw, err := os.ReadFile(contractPath(name))
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
delete(object, "id")
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func mutate(t *testing.T, raw []byte, fn func(map[string]any)) []byte {
t.Helper()
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
fn(object)
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func factory(t *testing.T, policy *privacy) *event.Factory {
t.Helper()
guard, err := event.NewEvidenceGuard("private-customer")
if err != nil {
t.Fatal(err)
}
value, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, policy, guard)
if err != nil {
t.Fatal(err)
}
return value
}
func assertCode(t *testing.T, err error, code event.ErrorCode) {
t.Helper()
var validation *event.ValidationError
if !errors.As(err, &validation) || validation.Code != code {
t.Fatalf("expected %s, got %v", code, err)
}
}
func TestFrozenContractCopyIsExact(t *testing.T) {
raw, err := os.ReadFile(contractPath("event-v0.1.schema.json"))
if err != nil {
t.Fatal(err)
}
if string(raw) != string(contracts.EventV01Schema) {
t.Fatal("Bell contract copy drifted from the frozen source")
}
}
func TestFactoryAcceptsAllFrozenExamples(t *testing.T) {
for _, name := range []string{
"event-v0.1.example-current.json",
"event-v0.1.example-target.json",
"event-v0.1.example-radar.json",
} {
t.Run(name, func(t *testing.T) {
policy := &privacy{allowed: true}
created, err := factory(t, policy).Create(context.Background(), candidate(t, name))
if err != nil {
t.Fatal(err)
}
if created.ID() != fixedEventID || len(created.JSON()) == 0 {
t.Fatal("Bell did not assemble the final event")
}
if name == "event-v0.1.example-radar.json" && policy.calls != 0 {
t.Fatal("non-video event unexpectedly consulted video policy")
}
})
}
}
func TestFactoryRejectsUpstreamIDAndUnknownField(t *testing.T) {
policy := &privacy{allowed: true}
base := candidate(t, "event-v0.1.example-current.json")
withID := mutate(t, base, func(object map[string]any) { object["id"] = fixedEventID })
_, err := factory(t, policy).Create(context.Background(), withID)
assertCode(t, err, event.CodeUpstreamID)
unknown := mutate(t, base, func(object map[string]any) { object["surprise"] = true })
_, err = factory(t, policy).Create(context.Background(), unknown)
assertCode(t, err, event.CodeSchema)
}
func TestFactoryEnforcesCrossFieldAssertions(t *testing.T) {
base := candidate(t, "event-v0.1.example-current.json")
tests := []struct {
name string
code event.ErrorCode
edit func(map[string]any)
}{
{"time-order", event.CodeTimeOrder, func(v map[string]any) { v["occurred_at"] = "2026-08-03T10:31:23.000Z" }},
{"latency", event.CodeLatency, func(v map[string]any) { v["latency_seconds"] = 9.0 }},
{"confidence", event.CodeConfidence, func(v map[string]any) { v["confidence"] = 0.9 }},
{"primary", event.CodePrimarySensor, func(v map[string]any) {
v["sensors"] = []any{
map[string]any{"device_id": float64(5012), "modality": "video", "role": "primary"},
map[string]any{"device_id": float64(5013), "modality": "radar", "role": "primary"},
}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := factory(t, &privacy{allowed: true}).Create(context.Background(), mutate(t, base, test.edit))
assertCode(t, err, test.code)
})
}
}
func TestFactoryFailsClosedForPrivacyAndEvidence(t *testing.T) {
base := candidate(t, "event-v0.1.example-current.json")
_, err := factory(t, &privacy{err: errors.New("mapping unavailable")}).Create(context.Background(), base)
assertCode(t, err, event.CodePrivacyUnavailable)
_, err = factory(t, &privacy{allowed: false}).Create(context.Background(), base)
assertCode(t, err, event.CodePrivacyDenied)
unsafe := mutate(t, base, func(v map[string]any) {
evidence := v["evidence"].(map[string]any)
evidence["snapshot_uris"] = []any{"rtsp://user:password@10.0.0.1:554/private-customer.png"}
})
_, err = factory(t, &privacy{allowed: true}).Create(context.Background(), unsafe)
assertCode(t, err, event.CodeEvidence)
}
func TestFactoryRequiresFailClosedPoliciesAndPayloadLimit(t *testing.T) {
guard, err := event.NewEvidenceGuard()
if err != nil {
t.Fatal(err)
}
if _, err := event.NewFactory(contracts.EventV01Schema, fixedIDs{id: fixedEventID}, nil, guard); err == nil {
t.Fatal("nil privacy policy unexpectedly accepted")
}
_, err = factory(t, &privacy{allowed: true}).Create(context.Background(), make([]byte, event.MaxPayloadBytes+1))
assertCode(t, err, event.CodePayloadTooLarge)
}
+270
View File
@@ -0,0 +1,270 @@
// Package ingress authenticates Brain event candidates and delegates their
// atomic persistence to Bell's repository.
package ingress
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"yovision/bell/internal/event"
)
const (
Path = "/internal/v1/event-candidates"
MaxBodyBytes = 1 << 20
HeaderKeyID = "X-YoVision-Key-Id"
HeaderTimestamp = "X-YoVision-Timestamp"
HeaderNonce = "X-YoVision-Nonce"
HeaderSignature = "X-YoVision-Signature"
)
var (
ErrReplayConflict = errors.New("event ingress replay conflict")
ErrSourceConflict = errors.New("event ingress source event conflict")
ErrIdentityDenied = errors.New("event ingress identity denied")
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
producerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
)
type Result struct {
SchemaVersion int `json:"schema_version"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
EventID string `json:"event_id"`
Status string `json:"status"`
HTTPStatus int `json:"-"`
}
type Repository interface {
Replay(
context.Context,
string,
string,
[sha256.Size]byte,
string,
string,
[sha256.Size]byte,
) (Result, bool, error)
ProcessEvent(
context.Context,
string,
string,
[sha256.Size]byte,
string,
[sha256.Size]byte,
event.Event,
) (Result, error)
}
type Handler struct {
repository Repository
keys map[string]Key
factory *event.Factory
now func() time.Time
}
func NewHandler(repository Repository, keys map[string]Key, factory *event.Factory) (*Handler, error) {
if repository == nil || factory == nil || len(keys) == 0 {
return nil, errors.New("event ingress handler dependencies are required")
}
copyKeys := make(map[string]Key, len(keys))
for id, key := range keys {
if !keyIDPattern.MatchString(id) || !producerIDPattern.MatchString(key.ProducerID) || len(key.Secret) < 32 {
return nil, errors.New("invalid event ingress handler key")
}
copyKeys[id] = Key{ProducerID: key.ProducerID, Secret: append([]byte(nil), key.Secret...)}
}
return &Handler{repository: repository, keys: copyKeys, factory: factory, now: time.Now}, nil
}
type envelope struct {
SchemaVersion int `json:"schema_version"`
ProducerID string `json:"producer_id"`
Candidate json.RawMessage `json:"candidate"`
}
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != Path {
writeError(writer, http.StatusNotFound, "not_found", "")
return
}
body, err := io.ReadAll(io.LimitReader(request.Body, MaxBodyBytes+1))
if err != nil || len(body) > MaxBodyBytes {
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
return
}
keyID := request.Header.Get(HeaderKeyID)
timestamp := request.Header.Get(HeaderTimestamp)
nonce := request.Header.Get(HeaderNonce)
provided := request.Header.Get(HeaderSignature)
key, ok := h.keys[keyID]
seconds, timestampErr := strconv.ParseInt(timestamp, 10, 64)
nonceBytes, nonceErr := base64.RawURLEncoding.DecodeString(nonce)
signatureBytes, signatureErr := base64.RawURLEncoding.DecodeString(provided)
if !ok || timestampErr != nil || len(timestamp) < 10 || nonceErr != nil || len(nonceBytes) < 16 || len(nonceBytes) > 48 ||
signatureErr != nil || len(signatureBytes) != sha256.Size || absDuration(h.now().UTC().Sub(time.Unix(seconds, 0).UTC())) > 300*time.Second {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
expected := signature(key.Secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
if !hmac.Equal(signatureBytes, expected) {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
var value envelope
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil || value.SchemaVersion != 1 || !producerIDPattern.MatchString(value.ProducerID) || len(value.Candidate) == 0 {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
if value.ProducerID != key.ProducerID {
writeError(writer, http.StatusUnauthorized, "unauthorized", "")
return
}
canonicalCandidate, err := canonicalJSON(value.Candidate)
if err != nil {
writeError(writer, http.StatusBadRequest, "invalid_envelope", "")
return
}
sourceEventID, err := candidateSourceEventID(canonicalCandidate)
if err != nil {
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", "schema_invalid")
return
}
requestHash := sha256.Sum256(body)
candidateHash := sha256.Sum256(canonicalCandidate)
replay, found, err := h.repository.Replay(
request.Context(), keyID, nonce, requestHash, value.ProducerID, sourceEventID, candidateHash,
)
switch {
case errors.Is(err, ErrReplayConflict):
writeError(writer, http.StatusConflict, "replay_conflict", "")
return
case errors.Is(err, ErrSourceConflict):
writeError(writer, http.StatusConflict, "source_event_conflict", "")
return
case err != nil:
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
return
case found:
writeJSON(writer, replay.HTTPStatus, replay)
return
}
created, err := h.factory.Create(request.Context(), canonicalCandidate)
if err != nil {
handleFactoryError(writer, err)
return
}
result, err := h.repository.ProcessEvent(request.Context(), keyID, nonce, requestHash, value.ProducerID, candidateHash, created)
switch {
case errors.Is(err, ErrReplayConflict):
writeError(writer, http.StatusConflict, "replay_conflict", "")
case errors.Is(err, ErrSourceConflict):
writeError(writer, http.StatusConflict, "source_event_conflict", "")
case errors.Is(err, ErrIdentityDenied):
writeError(writer, http.StatusForbidden, "identity_denied", "")
case err != nil:
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
default:
writeJSON(writer, result.HTTPStatus, result)
}
}
func handleFactoryError(writer http.ResponseWriter, err error) {
var validation *event.ValidationError
if !errors.As(err, &validation) {
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "")
return
}
switch validation.Code {
case event.CodePayloadTooLarge:
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "")
case event.CodePrivacyDenied:
writeError(writer, http.StatusForbidden, "privacy_denied", "")
case event.CodePrivacyUnavailable:
writeError(writer, http.StatusServiceUnavailable, "privacy_unavailable", "")
default:
writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", string(validation.Code))
}
}
func canonicalJSON(raw []byte) ([]byte, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil || value == nil {
return nil, errors.New("invalid JSON")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("multiple JSON values")
}
return json.Marshal(value)
}
var sourceEventIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
func candidateSourceEventID(raw []byte) (string, error) {
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return "", errors.New("candidate is not an object")
}
var value string
if err := json.Unmarshal(object["source_event_id"], &value); err != nil || !sourceEventIDPattern.MatchString(value) {
return "", errors.New("candidate source event ID is invalid")
}
return value, nil
}
func canonicalString(method, path, timestamp, nonce string, body []byte) string {
digest := sha256.Sum256(body)
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
}
func signature(secret []byte, canonical string) []byte {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(canonical))
return mac.Sum(nil)
}
func absDuration(value time.Duration) time.Duration {
if value < 0 {
return -value
}
return value
}
func writeError(writer http.ResponseWriter, status int, code, detail string) {
value := map[string]string{"error": code}
if detail != "" {
value["detail_code"] = detail
}
writeJSON(writer, status, value)
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
+176
View File
@@ -0,0 +1,176 @@
package ingress
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"yovision/bell/contracts"
"yovision/bell/internal/event"
)
type ingressIDs struct{}
func (ingressIDs) NewEventID() (string, error) {
return "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", nil
}
type ingressPrivacy struct{}
func (ingressPrivacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
return true, nil
}
type fakeRepository struct {
replayResult Result
replayFound bool
replayErr error
processErr error
processed int
}
func (f *fakeRepository) Replay(context.Context, string, string, [sha256.Size]byte, string, string, [sha256.Size]byte) (Result, bool, error) {
return f.replayResult, f.replayFound, f.replayErr
}
func (f *fakeRepository) ProcessEvent(_ context.Context, _, _ string, _ [sha256.Size]byte, producer string, _ [sha256.Size]byte, value event.Event) (Result, error) {
f.processed++
if f.processErr != nil {
return Result{}, f.processErr
}
return Result{SchemaVersion: 1, ProducerID: producer, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}, nil
}
func ingressCandidate(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json"))
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
delete(object, "id")
object["kind"] = "zone_entry"
object["severity"] = "medium"
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func newIngressHandler(t *testing.T, repository Repository) (*Handler, []byte) {
t.Helper()
guard, err := event.NewEvidenceGuard("private-customer")
if err != nil {
t.Fatal(err)
}
factory, err := event.NewFactory(contracts.EventV01Schema, ingressIDs{}, ingressPrivacy{}, guard)
if err != nil {
t.Fatal(err)
}
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
t.Fatal(err)
}
handler, err := NewHandler(repository, map[string]Key{"brain-a": {ProducerID: "brain-main", Secret: secret}}, factory)
if err != nil {
t.Fatal(err)
}
handler.now = func() time.Time { return time.Unix(1_800_000_000, 0).UTC() }
return handler, secret
}
func signedRequest(t *testing.T, secret []byte, producer string, candidate []byte) *http.Request {
t.Helper()
body, err := json.Marshal(map[string]any{"schema_version": 1, "producer_id": producer, "candidate": json.RawMessage(candidate)})
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, Path, bytes.NewReader(body))
timestamp := strconv.FormatInt(1_800_000_000, 10)
nonce := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef"))
request.Header.Set(HeaderKeyID, "brain-a")
request.Header.Set(HeaderTimestamp, timestamp)
request.Header.Set(HeaderNonce, nonce)
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, Path, timestamp, nonce, body))))
return request
}
func TestHandlerAcceptsSignedCandidateAndRejectsProducerSpoofing(t *testing.T) {
repository := &fakeRepository{}
handler, secret := newIngressHandler(t, repository)
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusCreated || repository.processed != 1 {
t.Fatalf("signed candidate: status=%d body=%s processed=%d", response.Code, response.Body.String(), repository.processed)
}
var result Result
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || result.Status != "accepted" || result.EventID == "" {
t.Fatalf("invalid accepted response: %+v %v", result, err)
}
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-spoofed", ingressCandidate(t)))
if response.Code != http.StatusUnauthorized || repository.processed != 1 {
t.Fatalf("producer spoofing was not rejected: %d %s", response.Code, response.Body.String())
}
}
func TestHandlerReturnsDurableReplayAndStableConflicts(t *testing.T) {
repository := &fakeRepository{replayFound: true, replayResult: Result{
SchemaVersion: 1, ProducerID: "brain-main", SourceEventID: "source-1",
EventID: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", Status: "duplicate", HTTPStatus: 200,
}}
handler, secret := newIngressHandler(t, repository)
response := httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusOK || repository.processed != 0 {
t.Fatalf("durable replay did not bypass factory persistence: %d", response.Code)
}
repository.replayFound = false
repository.replayErr = ErrSourceConflict
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
if response.Code != http.StatusConflict {
t.Fatalf("source conflict status=%d body=%s", response.Code, response.Body.String())
}
}
func TestHandlerRejectsInvalidSignatureAndUpstreamPlatformID(t *testing.T) {
repository := &fakeRepository{}
handler, secret := newIngressHandler(t, repository)
request := signedRequest(t, secret, "brain-main", ingressCandidate(t))
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(make([]byte, 32)))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("invalid signature status=%d", response.Code)
}
var object map[string]any
if err := json.Unmarshal(ingressCandidate(t), &object); err != nil {
t.Fatal(err)
}
object["id"] = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
withID, _ := json.Marshal(object)
response = httptest.NewRecorder()
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", withID))
if response.Code != http.StatusUnprocessableEntity || repository.processed != 0 {
t.Fatalf("upstream ID status=%d body=%s", response.Code, response.Body.String())
}
}
+53
View File
@@ -0,0 +1,53 @@
package ingress
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
)
type keyDocument struct {
Version int `json:"version"`
Keys []struct {
KeyID string `json:"key_id"`
ProducerID string `json:"producer_id"`
Secret string `json:"secret_base64url"`
} `json:"keys"`
}
type Key struct {
ProducerID string
Secret []byte
}
func LoadKeys(path string) (map[string]Key, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Bell event ingress key file")
}
var document keyDocument
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
return nil, errors.New("invalid Bell event ingress key file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid Bell event ingress key file")
}
values := make(map[string]Key, len(document.Keys))
for _, item := range document.Keys {
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
if err != nil || !keyIDPattern.MatchString(item.KeyID) || !producerIDPattern.MatchString(item.ProducerID) || len(secret) < 32 {
return nil, errors.New("invalid Bell event ingress key")
}
if _, exists := values[item.KeyID]; exists {
return nil, errors.New("duplicate Bell event ingress key ID")
}
values[item.KeyID] = Key{ProducerID: item.ProducerID, Secret: append([]byte(nil), secret...)}
}
return values, nil
}
+138
View File
@@ -0,0 +1,138 @@
package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"yovision/bell/internal/audit"
)
func (p *Postgres) AuditRelayReady(ctx context.Context) error {
var version int64
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 4 {
return errors.New("postgres Bell schema migration v4 is required for audit relay")
}
var auditSelect, auditInsert, auditUpdate, auditDelete, auditTruncate bool
var receiptUse bool
if err := p.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'bell.audit_events', 'SELECT'),
has_table_privilege(current_user, 'bell.audit_events', 'INSERT'),
has_table_privilege(current_user, 'bell.audit_events', 'UPDATE'),
has_table_privilege(current_user, 'bell.audit_events', 'DELETE'),
has_table_privilege(current_user, 'bell.audit_events', 'TRUNCATE'),
has_table_privilege(current_user, 'bell.audit_relay_receipts', 'SELECT,INSERT,DELETE')`).Scan(
&auditSelect, &auditInsert, &auditUpdate, &auditDelete, &auditTruncate, &receiptUse,
); err != nil {
return errors.New("verify Bell audit relay privileges")
}
if !auditSelect || !auditInsert || auditUpdate || auditDelete || auditTruncate || !receiptUse {
return errors.New("Bell audit relay privileges violate append-only boundary")
}
return nil
}
func (p *Postgres) ProcessAuditBatch(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
candidates []audit.Candidate,
) ([]audit.Result, error) {
if len(candidates) < 1 || len(candidates) > audit.MaxBatchSize {
return nil, errors.New("invalid audit candidate batch")
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return nil, errors.New("begin Bell audit batch")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, keyID, nonce); err != nil {
return nil, errors.New("lock Bell audit receipt")
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.audit_relay_receipts WHERE expires_at <= clock_timestamp()`); err != nil {
return nil, errors.New("expire Bell audit receipts")
}
var existingHash, existingBody []byte
err = tx.QueryRowContext(ctx, `SELECT request_hash, response_body::text
FROM bell.audit_relay_receipts WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&existingHash, &existingBody)
if err == nil {
if !bytes.Equal(existingHash, requestHash[:]) {
return nil, audit.ErrReplayConflict
}
var response audit.BatchResponse
if err := json.Unmarshal(existingBody, &response); err != nil {
return nil, errors.New("decode stored Bell audit receipt")
}
if err := tx.Commit(); err != nil {
return nil, errors.New("commit Bell audit replay")
}
return response.Results, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("read Bell audit receipt")
}
results := make([]audit.Result, 0, len(candidates))
for _, candidate := range candidates {
if candidate.ErrorCode != "" {
code := candidate.ErrorCode
results = append(results, audit.Result{EventID: candidate.Envelope.Event.EventID, Status: "rejected", ErrorCode: &code})
continue
}
event := candidate.Envelope.Event
payload, err := json.Marshal(event)
if err != nil {
return nil, errors.New("encode Bell audit fact")
}
result, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_events(
source_system, event_id, schema_version, event_type, tenant_id, site_id,
device_id, actor_type, actor_id, reason, trace_id, aggregate_generation,
quota_source_version, area_policy_source_version, payload, occurred_at, record_hash
) VALUES ('sense',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16)
ON CONFLICT (source_system,event_id) DO NOTHING`,
event.EventID, candidate.Envelope.SchemaVersion, event.EventType, event.TenantID,
event.SiteID, event.DeviceID, event.Actor.Type, event.Actor.ID, event.Reason,
event.TraceID, event.AggregateGeneration,
event.ProjectionVersions.QuotaSourceVersion,
event.ProjectionVersions.AreaPolicySourceVersion,
payload, event.OccurredAt, candidate.RecordHash[:])
if err != nil {
return nil, fmt.Errorf("insert Bell audit fact: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return nil, errors.New("read Bell audit insert result")
}
if affected == 1 {
results = append(results, audit.Result{EventID: event.EventID, Status: "accepted"})
continue
}
var storedHash []byte
if err := tx.QueryRowContext(ctx, `SELECT record_hash FROM bell.audit_events
WHERE source_system='sense' AND event_id=$1`, event.EventID).Scan(&storedHash); err != nil {
return nil, errors.New("read existing Bell audit fact")
}
if bytes.Equal(storedHash, candidate.RecordHash[:]) {
results = append(results, audit.Result{EventID: event.EventID, Status: "duplicate"})
} else {
code := "id_conflict"
results = append(results, audit.Result{EventID: event.EventID, Status: "rejected", ErrorCode: &code})
}
}
encoded, err := json.Marshal(audit.BatchResponse{Results: results})
if err != nil {
return nil, errors.New("encode Bell audit response")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.audit_relay_receipts(
key_id, nonce, request_hash, response_status, response_body, expires_at
) VALUES ($1,$2,$3,200,$4::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], encoded); err != nil {
return nil, errors.New("insert Bell audit receipt")
}
if err := tx.Commit(); err != nil {
return nil, errors.New("commit Bell audit batch")
}
return results, nil
}
@@ -0,0 +1,82 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"yovision/bell/internal/audit"
)
func auditCandidate(t *testing.T, eventID, actorID string) audit.Candidate {
t.Helper()
data := json.RawMessage(`{"kind":"device_created","area_id":"area","modality":"video","capabilities":["video_capture"],"desired_state":"enabled"}`)
value := audit.Envelope{SchemaVersion: 1, Event: audit.Event{
EventID: eventID, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1",
Actor: audit.Actor{Type: "system", ID: actorID}, AggregateGeneration: 1,
ProjectionVersions: audit.ProjectionVersions{}, Data: data, OccurredAt: time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC),
}}
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return audit.Candidate{Envelope: value, RecordHash: sha256.Sum256(raw)}
}
func TestPostgresAuditBatchReceiptAndImmutableFact(t *testing.T) {
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
if dsn == "" {
t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
repository, err := OpenPostgres(ctx, db)
if err != nil {
t.Fatal(err)
}
if err := repository.AuditRelayReady(ctx); err != nil {
t.Fatal(err)
}
requestHash := sha256.Sum256([]byte("request-one"))
eventID := "audit_10000000000000000000000000000001"
results, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "sense")})
if err != nil || len(results) != 1 || results[0].Status != "accepted" {
t.Fatalf("first batch: %+v %v", results, err)
}
replayed, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, []audit.Candidate{auditCandidate(t, eventID, "ignored-by-receipt")})
if err != nil || replayed[0].Status != "accepted" {
t.Fatalf("receipt replay: %+v %v", replayed, err)
}
different := sha256.Sum256([]byte("request-two"))
if _, err := repository.ProcessAuditBatch(ctx, "sense-a", "AAAAAAAAAAAAAAAAAAAAAA", different, []audit.Candidate{auditCandidate(t, eventID, "sense")}); !errors.Is(err, audit.ErrReplayConflict) {
t.Fatalf("expected replay conflict, got %v", err)
}
duplicate, err := repository.ProcessAuditBatch(ctx, "sense-a", "BBBBBBBBBBBBBBBBBBBBBB", different, []audit.Candidate{auditCandidate(t, eventID, "sense")})
if err != nil || duplicate[0].Status != "duplicate" {
t.Fatalf("event duplicate: %+v %v", duplicate, err)
}
conflictHash := sha256.Sum256([]byte("request-three"))
conflict, err := repository.ProcessAuditBatch(ctx, "sense-a", "CCCCCCCCCCCCCCCCCCCCCC", conflictHash, []audit.Candidate{auditCandidate(t, eventID, "other")})
if err != nil || conflict[0].Status != "rejected" || conflict[0].ErrorCode == nil || *conflict[0].ErrorCode != "id_conflict" {
t.Fatalf("event conflict: %+v %v", conflict, err)
}
if _, err := db.ExecContext(ctx, `UPDATE bell.audit_events SET actor_id='mutated' WHERE event_id=$1`, eventID); err == nil {
t.Fatal("runtime updated immutable audit fact")
}
if _, err := db.ExecContext(ctx, `DELETE FROM bell.audit_events WHERE event_id=$1`, eventID); err == nil {
t.Fatal("runtime deleted immutable audit fact")
}
}
@@ -0,0 +1,289 @@
package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
)
func (p *Postgres) EventIngressReady(ctx context.Context) error {
var version int64
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 5 {
return errors.New("postgres Bell schema migration v5 is required for event ingress")
}
var bindingSelect bool
var receiptSelect, receiptInsert, receiptUpdate, receiptDelete, receiptTruncate bool
var nonceSelect, nonceInsert, nonceUpdate, nonceDelete, nonceTruncate bool
var deviceID, deviceArea, deviceModality bool
var deviceEndpoint, deviceCredential, deviceProfile, devicePath bool
var siteID, areaPolicy bool
err := p.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'bell.event_ingress_bindings', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'INSERT'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'UPDATE'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'DELETE'),
has_table_privilege(current_user, 'bell.event_ingress_receipts', 'TRUNCATE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'SELECT'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'INSERT'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'UPDATE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'DELETE'),
has_table_privilege(current_user, 'bell.event_ingress_nonces', 'TRUNCATE'),
has_column_privilege(current_user, 'sense.devices', 'id', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'area_id', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'modality', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'endpoint_ref', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'credential_ref', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'profile_token', 'SELECT'),
has_column_privilege(current_user, 'sense.devices', 'path_name', 'SELECT'),
has_column_privilege(current_user, 'bell.sites', 'id', 'SELECT'),
has_column_privilege(current_user, 'bell.areas', 'capture_policy', 'SELECT')`).Scan(
&bindingSelect,
&receiptSelect, &receiptInsert, &receiptUpdate, &receiptDelete, &receiptTruncate,
&nonceSelect, &nonceInsert, &nonceUpdate, &nonceDelete, &nonceTruncate,
&deviceID, &deviceArea, &deviceModality,
&deviceEndpoint, &deviceCredential, &deviceProfile, &devicePath,
&siteID, &areaPolicy,
)
if err != nil {
return errors.New("verify Bell event ingress privileges")
}
if !bindingSelect || !receiptSelect || !receiptInsert || receiptUpdate || receiptDelete || receiptTruncate ||
!nonceSelect || !nonceInsert || nonceUpdate || !nonceDelete || nonceTruncate ||
!deviceID || !deviceArea || !deviceModality || deviceEndpoint || deviceCredential || deviceProfile || devicePath ||
!siteID || !areaPolicy {
return errors.New("Bell event ingress privileges violate append-only boundary")
}
return nil
}
// VideoAllowed implements event.PrivacyPolicy using current Bell/Sense facts.
// Exact producer ownership is checked again in ProcessEvent.
func (p *Postgres) VideoAllowed(ctx context.Context, tenantID, siteID, deviceID int64) (bool, error) {
return bindingAllowed(ctx, p.db, "", tenantID, siteID, deviceID, "video")
}
type rowQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func bindingAllowed(
ctx context.Context,
query rowQuerier,
producerID string,
tenantID, siteID, deviceID int64,
modality string,
) (bool, error) {
producerClause := ""
arguments := []any{tenantID, siteID, deviceID, modality}
if producerID != "" {
producerClause = " AND binding.producer_id=$5"
arguments = append(arguments, producerID)
}
statement := `SELECT EXISTS (
SELECT 1
FROM bell.event_ingress_bindings AS binding
JOIN bell.sites AS site
ON site.tenant_id=binding.logical_tenant_id
AND site.id=binding.logical_site_id
JOIN bell.areas AS area
ON area.tenant_id=binding.logical_tenant_id
AND area.site_id=binding.logical_site_id
AND area.id=binding.logical_area_id
JOIN sense.devices AS device
ON device.tenant_id=binding.logical_tenant_id
AND device.site_id=binding.logical_site_id
AND device.id=binding.logical_device_id
AND device.area_id=binding.logical_area_id
AND device.modality=binding.modality
WHERE binding.tenant_id=$1 AND binding.site_id=$2 AND binding.device_id=$3
AND binding.modality=$4 AND binding.enabled
AND site.deleted_at IS NULL AND area.deleted_at IS NULL
AND (binding.modality <> 'video' OR area.capture_policy='video_allowed')` + producerClause + `
)`
var allowed bool
if err := query.QueryRowContext(ctx, statement, arguments...).Scan(&allowed); err != nil {
return false, fmt.Errorf("resolve Bell event ingress binding: %w", err)
}
return allowed, nil
}
func (p *Postgres) Replay(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
producerID, sourceEventID string,
candidateHash [sha256.Size]byte,
) (ingress.Result, bool, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return ingress.Result{}, false, errors.New("begin Bell event replay check")
}
defer tx.Rollback()
if err := lockIngress(ctx, tx, keyID, nonce, producerID, sourceEventID); err != nil {
return ingress.Result{}, false, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.event_ingress_nonces WHERE expires_at <= clock_timestamp()`); err != nil {
return ingress.Result{}, false, errors.New("expire Bell event ingress nonces")
}
if value, found, err := readNonce(ctx, tx, keyID, nonce, requestHash); err != nil || found {
if err == nil {
err = tx.Commit()
}
return value, found, err
}
var storedHash []byte
var eventID string
err = tx.QueryRowContext(ctx, `SELECT candidate_hash, event_id
FROM bell.event_ingress_receipts WHERE producer_id=$1 AND source_event_id=$2`, producerID, sourceEventID).Scan(&storedHash, &eventID)
if err == nil {
if !bytes.Equal(storedHash, candidateHash[:]) {
return ingress.Result{}, false, ingress.ErrSourceConflict
}
value := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: sourceEventID, EventID: eventID, Status: "duplicate", HTTPStatus: 200}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, value); err != nil {
return ingress.Result{}, false, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, false, errors.New("commit Bell source replay")
}
return value, true, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, false, errors.New("read Bell event source receipt")
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, false, errors.New("commit Bell event replay miss")
}
return ingress.Result{}, false, nil
}
func (p *Postgres) ProcessEvent(
ctx context.Context,
keyID, nonce string,
requestHash [sha256.Size]byte,
producerID string,
candidateHash [sha256.Size]byte,
value event.Event,
) (ingress.Result, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return ingress.Result{}, errors.New("begin Bell event ingress")
}
defer tx.Rollback()
if err := lockIngress(ctx, tx, keyID, nonce, producerID, value.SourceEventID()); err != nil {
return ingress.Result{}, err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM bell.event_ingress_nonces WHERE expires_at <= clock_timestamp()`); err != nil {
return ingress.Result{}, errors.New("expire Bell event ingress nonces")
}
if replay, found, err := readNonce(ctx, tx, keyID, nonce, requestHash); err != nil || found {
if err == nil {
err = tx.Commit()
}
return replay, err
}
var storedHash []byte
var storedEventID string
err = tx.QueryRowContext(ctx, `SELECT candidate_hash, event_id
FROM bell.event_ingress_receipts WHERE producer_id=$1 AND source_event_id=$2`, producerID, value.SourceEventID()).Scan(&storedHash, &storedEventID)
if err == nil {
if !bytes.Equal(storedHash, candidateHash[:]) {
return ingress.Result{}, ingress.ErrSourceConflict
}
result := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: value.SourceEventID(), EventID: storedEventID, Status: "duplicate", HTTPStatus: 200}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, result); err != nil {
return ingress.Result{}, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, errors.New("commit Bell source duplicate")
}
return result, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, errors.New("read Bell event source receipt")
}
for _, sensor := range value.Sensors() {
allowed, err := bindingAllowed(ctx, tx, producerID, value.TenantID(), value.SiteID(), sensor.DeviceID, sensor.Modality)
if err != nil {
return ingress.Result{}, err
}
if !allowed {
return ingress.Result{}, ingress.ErrIdentityDenied
}
}
digest := value.Digest()
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.events(
id, tenant_id, site_id, device_id, source_event_id, kind, severity,
occurred_at, detected_at, payload_hash, payload
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`,
value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(), value.SourceEventID(),
value.Kind(), value.Severity(), value.OccurredAt(), value.DetectedAt(), digest[:], value.JSON(),
); err != nil {
return ingress.Result{}, fmt.Errorf("insert Bell ingress event: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.event_ingress_receipts(
producer_id, source_event_id, candidate_hash, event_id
) VALUES ($1,$2,$3,$4)`, producerID, value.SourceEventID(), candidateHash[:], value.ID()); err != nil {
return ingress.Result{}, fmt.Errorf("insert Bell event source receipt: %w", err)
}
result := ingress.Result{SchemaVersion: 1, ProducerID: producerID, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}
if err := insertNonce(ctx, tx, keyID, nonce, requestHash, result); err != nil {
return ingress.Result{}, err
}
if err := tx.Commit(); err != nil {
return ingress.Result{}, errors.New("commit Bell event ingress")
}
return result, nil
}
func lockIngress(ctx context.Context, tx *sql.Tx, keyID, nonce, producerID, sourceEventID string) error {
for _, value := range []string{"event-nonce:" + keyID + ":" + nonce, "event-source:" + producerID + ":" + sourceEventID} {
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, value); err != nil {
return errors.New("lock Bell event ingress identity")
}
}
return nil
}
func readNonce(ctx context.Context, tx *sql.Tx, keyID, nonce string, requestHash [sha256.Size]byte) (ingress.Result, bool, error) {
var storedHash, body []byte
var status int
err := tx.QueryRowContext(ctx, `SELECT request_hash, response_status, response_body::text
FROM bell.event_ingress_nonces WHERE key_id=$1 AND nonce=$2`, keyID, nonce).Scan(&storedHash, &status, &body)
if errors.Is(err, sql.ErrNoRows) {
return ingress.Result{}, false, nil
}
if err != nil {
return ingress.Result{}, false, errors.New("read Bell event ingress nonce")
}
if !bytes.Equal(storedHash, requestHash[:]) {
return ingress.Result{}, false, ingress.ErrReplayConflict
}
var value ingress.Result
if err := json.Unmarshal(body, &value); err != nil {
return ingress.Result{}, false, errors.New("decode Bell event ingress nonce")
}
value.HTTPStatus = status
return value, true, nil
}
func insertNonce(ctx context.Context, tx *sql.Tx, keyID, nonce string, requestHash [sha256.Size]byte, value ingress.Result) error {
body, err := json.Marshal(value)
if err != nil {
return errors.New("encode Bell event ingress response")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.event_ingress_nonces(
key_id, nonce, request_hash, response_status, response_body, expires_at
) VALUES ($1,$2,$3,$4,$5::jsonb,clock_timestamp() + interval '10 minutes')`, keyID, nonce, requestHash[:], value.HTTPStatus, body); err != nil {
return errors.New("insert Bell event ingress nonce")
}
return nil
}
@@ -0,0 +1,197 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
)
type ingressStoreIDs struct{ id string }
func (value ingressStoreIDs) NewEventID() (string, error) { return value.id, nil }
func ingressStoreCandidate(t *testing.T, sourceEventID string) []byte {
t.Helper()
var object map[string]any
if err := json.Unmarshal(testCandidate(t, "brain-demo-v1"), &object); err != nil {
t.Fatal(err)
}
object["source_event_id"] = sourceEventID
object["tenant_id"] = float64(101)
object["site_id"] = float64(201)
object["device_id"] = float64(301)
object["sensors"] = []any{map[string]any{"device_id": float64(301), "modality": "video", "role": "primary"}}
object["kind"] = "zone_entry"
object["severity"] = "medium"
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func ingressStoreEvent(t *testing.T, repository *Postgres, id, sourceEventID string) event.Event {
t.Helper()
guard, err := event.NewEvidenceGuard("private-customer")
if err != nil {
t.Fatal(err)
}
factory, err := event.NewFactory(contracts.EventV01Schema, ingressStoreIDs{id}, repository, guard)
if err != nil {
t.Fatal(err)
}
value, err := factory.Create(context.Background(), ingressStoreCandidate(t, sourceEventID))
if err != nil {
t.Fatal(err)
}
return value
}
func TestPostgresEventIngressAtomicSourceReceipt(t *testing.T) {
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
adminDSN := os.Getenv("YOVISION_TEST_POSTGRES_ADMIN_DSN")
if dsn == "" || adminDSN == "" {
t.Skip("Bell runtime and admin PostgreSQL DSNs are not set")
}
admin, err := sql.Open("pgx", adminDSN)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
ctx := context.Background()
now := time.Now().UTC()
statements := []struct {
query string
args []any
}{
{`INSERT INTO bell.sites(tenant_id,id,name) VALUES ('ingress-tenant','ingress-site','Ingress Site')`, nil},
{`INSERT INTO bell.areas(tenant_id,site_id,id,name,capture_policy) VALUES ('ingress-tenant','ingress-site','ingress-area','Ingress Area','video_allowed')`, nil},
{`INSERT INTO sense.devices(id,tenant_id,site_id,serial_number,name,modality,desired_state,actual_state,area_id,created_at,updated_at)
VALUES ('ingress-device','ingress-tenant','ingress-site','INGRESS-SERIAL','Ingress Device','video','enabled','online','ingress-area',$1,$1)`, []any{now}},
{`INSERT INTO bell.event_ingress_bindings(
producer_id,tenant_id,site_id,device_id,logical_tenant_id,logical_site_id,logical_device_id,logical_area_id,modality
) VALUES ('brain-main',101,201,301,'ingress-tenant','ingress-site','ingress-device','ingress-area','video')`, nil},
}
for _, statement := range statements {
if _, err := admin.ExecContext(ctx, statement.query, statement.args...); err != nil {
t.Fatal(err)
}
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository, err := OpenPostgres(ctx, db)
if err != nil {
t.Fatal(err)
}
if err := repository.EventIngressReady(ctx); err != nil {
t.Fatal(err)
}
candidate := ingressStoreCandidate(t, "BRN-ingress-0001")
candidateHash := sha256.Sum256(candidate)
value := ingressStoreEvent(t, repository, "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2C", "BRN-ingress-0001")
requestHash := sha256.Sum256([]byte("first-request"))
result, err := repository.ProcessEvent(ctx, "brain-a", "AAAAAAAAAAAAAAAAAAAAAA", requestHash, "brain-main", candidateHash, value)
if err != nil || result.Status != "accepted" || result.HTTPStatus != 201 {
t.Fatalf("first ingress: %+v %v", result, err)
}
// This is the crash-after-Bell-commit case: Brain uses a new nonce before
// it has locally recorded the first response.
replayed, found, err := repository.Replay(
ctx, "brain-a", "BBBBBBBBBBBBBBBBBBBBBB", sha256.Sum256([]byte("retry-request")),
"brain-main", "BRN-ingress-0001", candidateHash,
)
if err != nil || !found || replayed.Status != "duplicate" || replayed.EventID != result.EventID {
t.Fatalf("durable source replay: %+v found=%v err=%v", replayed, found, err)
}
conflictHash := sha256.Sum256([]byte("changed-candidate"))
if _, _, err := repository.Replay(
ctx, "brain-a", "CCCCCCCCCCCCCCCCCCCCCC", sha256.Sum256([]byte("conflict-request")),
"brain-main", "BRN-ingress-0001", conflictHash,
); !errors.Is(err, ingress.ErrSourceConflict) {
t.Fatalf("expected source conflict, got %v", err)
}
if _, _, err := repository.Replay(
ctx, "brain-a", "AAAAAAAAAAAAAAAAAAAAAA", sha256.Sum256([]byte("different-request")),
"brain-main", "BRN-ingress-0001", candidateHash,
); !errors.Is(err, ingress.ErrReplayConflict) {
t.Fatalf("expected nonce replay conflict, got %v", err)
}
var wait sync.WaitGroup
errorsSeen := make(chan error, 8)
concurrentValues := make([]event.Event, 8)
for index := range concurrentValues {
id := fmt.Sprintf("evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2%c", "DEFGHJKM"[index])
concurrentValues[index] = ingressStoreEvent(t, repository, id, "BRN-ingress-0001")
}
for index := 0; index < 8; index++ {
wait.Add(1)
go func(index int) {
defer wait.Done()
nonce := base64Nonce(index)
response, err := repository.ProcessEvent(
ctx, "brain-a", nonce, sha256.Sum256([]byte(nonce)), "brain-main", candidateHash, concurrentValues[index],
)
if err != nil || response.Status != "duplicate" || response.EventID != result.EventID {
errorsSeen <- fmt.Errorf("concurrent duplicate %d: %+v %w", index, response, err)
}
}(index)
}
wait.Wait()
close(errorsSeen)
for err := range errorsSeen {
t.Error(err)
}
var eventCount, receiptCount int
if err := db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM bell.events WHERE tenant_id=101 AND site_id=201 AND source_event_id='BRN-ingress-0001'),
(SELECT count(*) FROM bell.event_ingress_receipts WHERE producer_id='brain-main' AND source_event_id='BRN-ingress-0001')`).Scan(&eventCount, &receiptCount); err != nil {
t.Fatal(err)
}
if eventCount != 1 || receiptCount != 1 {
t.Fatalf("concurrent ingress created events=%d receipts=%d", eventCount, receiptCount)
}
if _, err := admin.ExecContext(ctx, `UPDATE bell.areas SET capture_policy='non_imaging_only'
WHERE tenant_id='ingress-tenant' AND id='ingress-area'`); err != nil {
t.Fatal(err)
}
allowed, err := repository.VideoAllowed(ctx, 101, 201, 301)
if err != nil || allowed {
t.Fatalf("privacy change did not fail closed: allowed=%v err=%v", allowed, err)
}
guard, _ := event.NewEvidenceGuard("private-customer")
factory, _ := event.NewFactory(contracts.EventV01Schema, ingressStoreIDs{"evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2N"}, repository, guard)
_, err = factory.Create(ctx, ingressStoreCandidate(t, "BRN-ingress-0002"))
var validation *event.ValidationError
if !errors.As(err, &validation) || validation.Code != event.CodePrivacyDenied {
t.Fatalf("privacy denial code drift: %v", err)
}
if _, err := db.ExecContext(ctx, `UPDATE bell.event_ingress_receipts SET event_id=event_id
WHERE producer_id='brain-main' AND source_event_id='BRN-ingress-0001'`); err == nil {
t.Fatal("runtime updated immutable event source receipt")
}
}
func base64Nonce(index int) string {
return fmt.Sprintf("D%021d", index)
}
+149
View File
@@ -0,0 +1,149 @@
package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"time"
"unicode/utf8"
"yovision/bell/internal/event"
)
var ErrConflict = errors.New("immutable record id conflict")
type Postgres struct {
db *sql.DB
}
func OpenPostgres(ctx context.Context, db *sql.DB) (*Postgres, error) {
if db == nil {
return nil, errors.New("postgres database is required")
}
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping Bell postgres: %w", err)
}
var version int64
if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 3 {
return nil, errors.New("postgres Bell schema migration v3 is required")
}
var canInsert, canSelect, canUpdate, canDelete, canTruncate bool
if err := db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user, 'bell.events', 'INSERT'),
has_table_privilege(current_user, 'bell.events', 'SELECT'),
has_table_privilege(current_user, 'bell.events', 'UPDATE'),
has_table_privilege(current_user, 'bell.events', 'DELETE'),
has_table_privilege(current_user, 'bell.events', 'TRUNCATE')`).Scan(
&canInsert, &canSelect, &canUpdate, &canDelete, &canTruncate,
); err != nil {
return nil, fmt.Errorf("inspect Bell event privileges: %w", err)
}
if !canInsert || !canSelect || canUpdate || canDelete || canTruncate {
return nil, errors.New("Bell runtime event privileges violate append-only boundary")
}
return &Postgres{db: db}, nil
}
// InsertEvent is idempotent only for the same platform ID and exact payload.
func (p *Postgres) InsertEvent(ctx context.Context, value event.Event) (bool, error) {
digest := value.Digest()
result, err := p.db.ExecContext(ctx, `INSERT INTO bell.events(
id, tenant_id, site_id, device_id, source_event_id, kind, severity,
occurred_at, detected_at, payload_hash, payload
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)
ON CONFLICT (id) DO NOTHING`,
value.ID(), value.TenantID(), value.SiteID(), value.DeviceID(),
value.SourceEventID(), value.Kind(), value.Severity(), value.OccurredAt(),
value.DetectedAt(), digest[:], value.JSON(),
)
if err != nil {
return false, fmt.Errorf("insert immutable Bell event: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("read Bell event insert result: %w", err)
}
if rows == 1 {
return true, nil
}
var existing []byte
if err := p.db.QueryRowContext(ctx, `SELECT payload_hash FROM bell.events WHERE id=$1`, value.ID()).Scan(&existing); err != nil {
return false, fmt.Errorf("read existing Bell event digest: %w", err)
}
if !bytes.Equal(existing, digest[:]) {
return false, ErrConflict
}
return false, nil
}
type Outcome struct {
ID string `json:"id"`
EventID string `json:"event_id"`
Value string `json:"outcome"`
Source string `json:"source"`
Reason *string `json:"reason"`
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id"`
OccurredAt time.Time `json:"occurred_at"`
}
var outcomeID = regexp.MustCompile(`^out_[0-9A-HJKMNP-TV-Z]{26}$`)
var eventID = regexp.MustCompile(`^evt_[0-9A-HJKMNP-TV-Z]{26}$`)
func (o Outcome) validate() error {
if !outcomeID.MatchString(o.ID) || !eventID.MatchString(o.EventID) || o.ActorID == "" || o.OccurredAt.IsZero() {
return errors.New("invalid outcome identity")
}
validOutcome := map[string]bool{"unknown": true, "true_positive": true, "false_positive": true, "subject_recovered": true, "duplicate": true, "test": true}
if !validOutcome[o.Value] || (o.Source != "auto" && o.Source != "manual") {
return errors.New("invalid outcome value or source")
}
if o.ActorType != "user" && o.ActorType != "service" && o.ActorType != "system" {
return errors.New("invalid outcome actor type")
}
if o.Reason != nil && utf8.RuneCountInString(*o.Reason) > 500 {
return errors.New("outcome reason is too long")
}
return nil
}
// AppendOutcome never mutates the event or an earlier outcome record.
func (p *Postgres) AppendOutcome(ctx context.Context, value Outcome) (bool, error) {
if err := value.validate(); err != nil {
return false, err
}
encoded, err := json.Marshal(value)
if err != nil {
return false, fmt.Errorf("encode outcome: %w", err)
}
digest := sha256.Sum256(encoded)
result, err := p.db.ExecContext(ctx, `INSERT INTO bell.event_outcomes(
id, event_id, outcome, outcome_source, reason, actor_type, actor_id,
occurred_at, record_hash
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO NOTHING`, value.ID, value.EventID, value.Value, value.Source,
value.Reason, value.ActorType, value.ActorID, value.OccurredAt, digest[:])
if err != nil {
return false, fmt.Errorf("append Bell event outcome: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("read Bell outcome insert result: %w", err)
}
if rows == 1 {
return true, nil
}
var existing []byte
if err := p.db.QueryRowContext(ctx, `SELECT record_hash FROM bell.event_outcomes WHERE id=$1`, value.ID).Scan(&existing); err != nil {
return false, fmt.Errorf("read existing Bell outcome digest: %w", err)
}
if !bytes.Equal(existing, digest[:]) {
return false, ErrConflict
}
return false, nil
}
+120
View File
@@ -0,0 +1,120 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/event"
)
type storeIDs struct{ id string }
func (f storeIDs) NewEventID() (string, error) { return f.id, nil }
type allowVideo struct{}
func (allowVideo) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
return true, nil
}
func testCandidate(t *testing.T, configVersion string) []byte {
t.Helper()
path := filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var object map[string]any
if err := json.Unmarshal(raw, &object); err != nil {
t.Fatal(err)
}
delete(object, "id")
object["config_version"] = configVersion
encoded, err := json.Marshal(object)
if err != nil {
t.Fatal(err)
}
return encoded
}
func newEvent(t *testing.T, configVersion string) event.Event {
t.Helper()
guard, err := event.NewEvidenceGuard()
if err != nil {
t.Fatal(err)
}
factory, err := event.NewFactory(
contracts.EventV01Schema,
storeIDs{id: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"},
allowVideo{}, guard,
)
if err != nil {
t.Fatal(err)
}
value, err := factory.Create(context.Background(), testCandidate(t, configVersion))
if err != nil {
t.Fatal(err)
}
return value
}
func TestPostgresImmutableEventAndOutcome(t *testing.T) {
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
if dsn == "" {
t.Skip("YOVISION_TEST_BELL_POSTGRES_DSN is not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
ctx := context.Background()
repo, err := OpenPostgres(ctx, db)
if err != nil {
t.Fatal(err)
}
value := newEvent(t, "sp-v1-2026.07.20")
created, err := repo.InsertEvent(ctx, value)
if err != nil || !created {
t.Fatalf("first insert: created=%v err=%v", created, err)
}
created, err = repo.InsertEvent(ctx, value)
if err != nil || created {
t.Fatalf("idempotent replay: created=%v err=%v", created, err)
}
if _, err := repo.InsertEvent(ctx, newEvent(t, "sp-v1-conflict")); !errors.Is(err, ErrConflict) {
t.Fatalf("expected immutable conflict, got %v", err)
}
reason := "confirmed by operator"
outcome := Outcome{
ID: "out_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", EventID: value.ID(),
Value: "true_positive", Source: "manual", Reason: &reason,
ActorType: "user", ActorID: "operator-1", OccurredAt: time.Now().UTC(),
}
created, err = repo.AppendOutcome(ctx, outcome)
if err != nil || !created {
t.Fatalf("append outcome: created=%v err=%v", created, err)
}
created, err = repo.AppendOutcome(ctx, outcome)
if err != nil || created {
t.Fatalf("idempotent outcome replay: created=%v err=%v", created, err)
}
if _, err := db.ExecContext(ctx, `UPDATE bell.events SET kind='changed' WHERE id=$1`, value.ID()); err == nil {
t.Fatal("runtime unexpectedly updated immutable event")
}
if _, err := db.ExecContext(ctx, `DELETE FROM bell.event_outcomes WHERE id=$1`, outcome.ID); err == nil {
t.Fatal("runtime unexpectedly deleted immutable outcome")
}
}
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.venv/
artifacts/
*.local.json
*.url
-1
View File
@@ -1 +0,0 @@
+86
View File
@@ -0,0 +1,86 @@
# YoVision Brain 单路工程原型
T-017 提供可见、可重复的单路工程链路;T-019 为 `zone_entry` 候选增加默认关闭的可靠 Brain→Bell transport。它仍不是生产模型或 NVR:推理演示保持单路,事件先进入仓库外 SQLite Outbox,再由内部 HMAC client 投递给 Bell。
## 环境
- Python `3.10.11`
- NumPy `1.26.4`
- OpenCV `4.9.0.80`
本任务复用本机已验证版本;新环境显式安装:
```powershell
python -m pip install -r Brain/requirements-demo.txt
```
本机冻结的是 `opencv-python`;同一环境只能安装一种提供 `cv2` 命名空间的 OpenCV wheel。不要同时安装标准、headless 和 contrib 变体。后续生产容器若改用 headless,必须在独立任务核对 wheel、许可证与完整回归,不能在本版本号下静默替换包名。
## 启动
无需摄像头的确定性合成回放:
```powershell
python -m Brain.yovision_brain --source synthetic
```
打开 `http://127.0.0.1:8090/brain-demo`。合成人员框由 fixture 提供,页面和事件均显式标识,不得拿它证明模型效果。
使用真实流时,把完整 RTSP URL 写入仓库外绝对路径文件。推荐指向 Sense 管理的 MediaMTX path,而不是绕过 Sense 固化摄像头地址:
```powershell
python -m Brain.yovision_brain --source stream --stream-url-file D:\private\brain-stream.url
```
URL 文件只允许一行、最多 4096 字节,服务不会在状态、页面和错误中返回其内容。演示服务只接受 `127.0.0.1`、`localhost` 或 `::1`;没有暴露到局域网的开关。
真实流使用 OpenCV 内置 HOG/SVM 人员检测和轻量 centroid tracker,只验证可替换端口与区域链路。它不是 M3 生产模型,不能据此承诺召回率、误报率、GPU 容量或 16/128 路能力。
## 可选 Brain → Bell 事件投递
默认不传 `--event-ingress-config`,工程原型行为与 T-017 相同。启用时,配置、key 和 SQLite 文件都必须位于仓库外绝对路径;HTTP 只允许显式回环,非回环必须 HTTPS。key 文件也供 Bell event ingress 读取,但不得与 Sense 审计 key 混用:
```json
{"version":1,"keys":[{"key_id":"brain-a","producer_id":"brain-main","secret_base64url":"<至少32字节随机值的无填充base64url>"}]}
```
Brain 配置示例(占位 ID 必须与 Bell 管理员创建的 `bell.event_ingress_bindings` 一致):
```json
{
"version": 1,
"producer_id": "brain-main",
"tenant_id": 1,
"site_id": 1,
"device_id": 1,
"modality": "video",
"severity": "medium",
"config_version": "brain-demo-v1",
"bell_url": "http://127.0.0.1:8081/internal/v1/event-candidates",
"key_id": "brain-a",
"key_file": "D:\\private\\brain-event-keys.json",
"outbox_path": "D:\\private\\brain-event-outbox.sqlite3"
}
```
```powershell
python -m Brain.yovision_brain --source synthetic --event-ingress-config D:\private\brain-event-ingress.json
```
Outbox 使用 WAL 和 `synchronous=FULL`,首次打开即绑定 producer/tenant/site/device;更换身份必须使用新的 Outbox 路径,不能让旧队列借新 producer 发送。最多保留 10,000 条待投递;只有 Bell `accepted/duplicate` 才确认 delivered。网络、401 与 5xx 按 1~300 秒退避、最多 100 次;稳定 4xx 进入 dead letter。终态行不会自动删除。状态页只显示计数和稳定错误码,不显示 URL、key、payload 或数据库路径;client 显式忽略环境 HTTP proxy。
## 验证
```powershell
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
```
单文件 UI 原型位于 `docs/design/brain/index.html`,可直接打开;此时使用明确标识的离线原型数据。由本地服务打开时,同一文件消费回环 API,并用临时页面 token 保护区域写入。
## 边界
- `source_event_id` 由 Brain 产生,平台 `evt_` ULID 由 Bell 产生。
- 页面仍只保留最多 100 项的内存环;启用 T-019 后,可靠性由独立 SQLite Outbox 承担,不能从页面事件环推断投递状态。
- Bell 平台 ID 只由 Bell 生成;相同 producer/source candidate 重投返回原 ID,Brain 不改写 source ID 规避冲突。
- `D:\OPC\silver_pose` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。
+1
View File
@@ -0,0 +1 @@
"""YoVision Brain package root."""
+3
View File
@@ -0,0 +1,3 @@
# T-017 engineering prototype only. Do not infer the production Brain stack.
numpy==1.26.4
opencv-python==4.9.0.80
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from Brain.yovision_brain.domain import (
Box,
Detection,
Point,
Zone,
ZoneEntryEvaluator,
point_in_polygon,
zone_from_payload,
)
class GeometryTests(unittest.TestCase):
def setUp(self) -> None:
self.zone = Zone(
"zone-1",
"危险区域",
1,
(Point(0.4, 0.2), Point(0.8, 0.2), Point(0.8, 0.8), Point(0.4, 0.8)),
)
def test_point_in_polygon_includes_boundary(self) -> None:
self.assertTrue(point_in_polygon(Point(0.6, 0.5), self.zone.points))
self.assertTrue(point_in_polygon(Point(0.4, 0.5), self.zone.points))
self.assertFalse(point_in_polygon(Point(0.2, 0.5), self.zone.points))
def test_zone_requires_normalized_three_to_thirty_two_points(self) -> None:
with self.assertRaisesRegex(ValueError, "3 to 32"):
Zone("zone-1", "bad", 1, (Point(0, 0), Point(1, 1)))
with self.assertRaisesRegex(ValueError, "within"):
Point(1.1, 0.5)
with self.assertRaisesRegex(ValueError, "within"):
Point(float("nan"), 0.5)
with self.assertRaisesRegex(ValueError, "within"):
Box(0.1, 0.1, float("inf"), 0.9)
def test_zone_payload_rejects_unknown_fields_and_increments_version(self) -> None:
updated = zone_from_payload(
{"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]},
self.zone,
)
self.assertEqual(updated.version, 2)
self.assertEqual(updated.name, "新区域")
with self.assertRaisesRegex(ValueError, "unknown"):
zone_from_payload({"points": [], "tenant_id": "must-not-be-here"}, self.zone)
class ZoneEntryTests(unittest.TestCase):
def setUp(self) -> None:
self.zone = Zone(
"zone-1",
"危险区域",
1,
(Point(0.5, 0.2), Point(0.9, 0.2), Point(0.9, 0.9), Point(0.5, 0.9)),
)
ids = iter(("BRN-0001", "BRN-0002", "BRN-0003"))
self.evaluator = ZoneEntryEvaluator("device-ref", True, track_ttl_frames=2, event_id_factory=lambda: next(ids))
self.now = datetime(2026, 8, 11, 1, 2, 3, tzinfo=timezone.utc)
@staticmethod
def detection(track: str, center_x: float) -> Detection:
return Detection(track, "person", Box(center_x - 0.05, 0.3, center_x + 0.05, 0.8))
def test_first_seen_inside_does_not_fake_an_entry(self) -> None:
events, states = self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.7)])
self.assertEqual(events, [])
self.assertTrue(states["P-1"])
def test_entry_fires_once_until_track_exits_and_reenters(self) -> None:
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
events, _ = self.evaluator.evaluate(2, self.now, self.zone, [self.detection("P-1", 0.6)])
repeated, _ = self.evaluator.evaluate(3, self.now, self.zone, [self.detection("P-1", 0.7)])
self.evaluator.evaluate(4, self.now, self.zone, [self.detection("P-1", 0.3)])
reentered, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.6)])
self.assertEqual([item.source_event_id for item in events], ["BRN-0001"])
self.assertEqual(repeated, [])
self.assertEqual([item.source_event_id for item in reentered], ["BRN-0002"])
payload = events[0].as_dict()
self.assertNotIn("id", payload)
self.assertEqual(payload["confidence"], None)
self.assertTrue(payload["fixture"])
def test_expired_track_reappearing_inside_is_not_an_entry(self) -> None:
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
self.evaluator.evaluate(4, self.now, self.zone, [])
events, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.7)])
self.assertEqual(events, [])
if __name__ == "__main__":
unittest.main()
+291
View File
@@ -0,0 +1,291 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import tempfile
import threading
import unittest
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from Brain.yovision_brain.domain import EventCandidate
from Brain.yovision_brain.ingress import (
BrainEventIngress,
DeliveryResult,
EventIngressClient,
EventOutbox,
EventRelayWorker,
IngressConfig,
KeyMaterial,
PermanentDelivery,
RetryableDelivery,
load_config,
map_candidate,
)
EVENT_ID = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
def candidate(source_event_id: str = "BRN-test-0001", fixture: bool = True) -> EventCandidate:
return EventCandidate(
source_event_id=source_event_id,
kind="zone_entry",
source_ref="must-not-leave-brain",
track_id="P-1",
zone_id="zone-1",
zone_version=3,
occurred_at=datetime(2026, 8, 11, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z"),
confidence=None,
fixture=fixture,
)
def config(root: Path, bell_url: str = "http://127.0.0.1:8081/internal/v1/event-candidates") -> IngressConfig:
return IngressConfig(
producer_id="brain-main",
tenant_id=1,
site_id=2,
device_id=3,
modality="video",
severity="medium",
config_version="brain-demo-v1",
bell_url=bell_url,
key_id="brain-a",
key_file=root / "keys.json",
outbox_path=root / "outbox.sqlite3",
)
class ConfigAndMapperTests(unittest.TestCase):
def test_external_config_binds_key_and_rejects_remote_plaintext(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
secret = bytes(range(32))
key_file = root / "keys.json"
key_file.write_text(
json.dumps(
{
"version": 1,
"keys": [
{
"key_id": "brain-a",
"producer_id": "brain-main",
"secret_base64url": base64.urlsafe_b64encode(secret).rstrip(b"=").decode("ascii"),
}
],
}
),
encoding="utf-8",
)
config_file = root / "config.json"
document = {
"version": 1,
"producer_id": "brain-main",
"tenant_id": 1,
"site_id": 2,
"device_id": 3,
"modality": "video",
"severity": "medium",
"config_version": "brain-demo-v1",
"bell_url": "http://127.0.0.1:8081/internal/v1/event-candidates",
"key_id": "brain-a",
"key_file": str(key_file.resolve()),
"outbox_path": str((root / "outbox.sqlite3").resolve()),
}
config_file.write_text(json.dumps(document), encoding="utf-8")
loaded, key = load_config(str(config_file.resolve()))
self.assertEqual(("brain-main", secret), (loaded.producer_id, key.secret))
document["bell_url"] = "http://camera.example/internal/v1/event-candidates"
config_file.write_text(json.dumps(document), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "HTTPS"):
load_config(str(config_file.resolve()))
def test_mapper_produces_complete_candidate_without_sensitive_source(self) -> None:
with tempfile.TemporaryDirectory() as directory:
payload = json.loads(map_candidate(candidate(), config(Path(directory))))
self.assertNotIn("id", payload)
self.assertNotIn("source_ref", json.dumps(payload))
self.assertEqual("test", payload["outcome"])
self.assertEqual("auto", payload["outcome_source"])
self.assertIsNone(payload["confidence"])
self.assertEqual(payload["occurred_at"], payload["detected_at"])
self.assertEqual(0.0, payload["latency_seconds"])
self.assertEqual([{"device_id": 3, "modality": "video", "role": "primary"}], payload["sensors"])
expected = {
"schema_version", "source_event_id", "tenant_id", "site_id", "device_id", "sensors", "kind",
"severity", "confidence", "occurred_at", "detected_at", "latency_seconds", "config_version",
"rule", "subject", "observation", "evidence", "dedup_key", "aggregated_into", "outcome",
"outcome_source", "outcome_reason", "diagnostics", "ext",
}
self.assertEqual(expected, set(payload))
class OutboxTests(unittest.TestCase):
def test_crash_recovery_retry_and_terminal_records_are_durable(self) -> None:
clock = [1_000.0]
now = lambda: clock[0]
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "outbox.sqlite3"
payload = map_candidate(candidate(), config(Path(directory)))
outbox = EventOutbox(path, now=now)
self.assertTrue(outbox.enqueue(payload))
self.assertFalse(outbox.enqueue(payload))
leased = outbox.lease_one()
self.assertIsNotNone(leased)
outbox.close() # Simulate a process crash while the lease is held.
clock[0] += 31.0
outbox = EventOutbox(path, now=now)
recovered = outbox.lease_one()
self.assertIsNotNone(recovered)
self.assertEqual(2, recovered.attempt_count)
outbox.mark_delivered(recovered, DeliveryResult("duplicate", EVENT_ID))
self.assertEqual(1, outbox.status()["delivered"])
outbox.close()
outbox = EventOutbox(path, now=now)
status = outbox.status()
self.assertEqual((1, 0), (status["delivered"], status["queued"]))
outbox.close()
def test_source_id_conflict_is_not_silently_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
outbox = EventOutbox(root / "outbox.sqlite3")
outbox.enqueue(map_candidate(candidate(), config(root)))
changed = json.loads(map_candidate(candidate(), config(root)))
changed["config_version"] = "different"
with self.assertRaisesRegex(ValueError, "different candidate"):
outbox.enqueue(json.dumps(changed, sort_keys=True, separators=(",", ":")).encode())
outbox.close()
def test_crash_on_final_attempt_becomes_dead_letter(self) -> None:
clock = [1_000.0]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = root / "outbox.sqlite3"
outbox = EventOutbox(path, now=lambda: clock[0])
outbox.enqueue(map_candidate(candidate(), config(root)))
for _ in range(99):
item = outbox.lease_one()
self.assertIsNotNone(item)
outbox.mark_retry(item, "network_error")
clock[0] += 301.0
final = outbox.lease_one()
self.assertEqual(100, final.attempt_count)
outbox.close()
clock[0] += 31.0
outbox = EventOutbox(path, now=lambda: clock[0])
self.assertIsNone(outbox.lease_one())
self.assertEqual(1, outbox.status()["dead_letter"])
outbox.close()
def test_outbox_cannot_be_reused_for_another_producer_identity(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
path = root / "outbox.sqlite3"
outbox = EventOutbox(path)
original = config(root)
outbox.bind_identity(original)
outbox.close()
outbox = EventOutbox(path)
changed = IngressConfig(**{**original.__dict__, "producer_id": "brain-other"})
with self.assertRaisesRegex(ValueError, "different producer"):
outbox.bind_identity(changed)
outbox.close()
class _BellHandler(BaseHTTPRequestHandler):
secret = bytes(range(32))
verified = False
failure: str | None = None
def log_message(self, _format: str, *_args: object) -> None:
return
def do_POST(self) -> None: # noqa: N802
try:
length = int(self.headers["Content-Length"])
body = self.rfile.read(length)
timestamp = self.headers["X-YoVision-Timestamp"]
nonce = self.headers["X-YoVision-Nonce"]
canonical = "\n".join(("POST", self.path, timestamp, nonce, hashlib.sha256(body).hexdigest())).encode()
encoded_signature = self.headers["X-YoVision-Signature"]
supplied = base64.urlsafe_b64decode(encoded_signature + "=" * (-len(encoded_signature) % 4))
type(self).verified = hmac.compare_digest(supplied, hmac.new(self.secret, canonical, hashlib.sha256).digest())
envelope = json.loads(body)
response = json.dumps(
{
"schema_version": 1,
"producer_id": envelope["producer_id"],
"source_event_id": envelope["candidate"]["source_event_id"],
"event_id": EVENT_ID,
"status": "accepted",
}
).encode()
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(response)))
self.end_headers()
self.wfile.write(response)
except Exception as exc: # pragma: no cover - only improves test diagnostics
type(self).failure = repr(exc)
self.send_response(500)
self.end_headers()
class ClientAndWorkerTests(unittest.TestCase):
def test_client_signs_and_accepts_bell_response(self) -> None:
server = HTTPServer(("127.0.0.1", 0), _BellHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
client = EventIngressClient(
config(root, f"http://127.0.0.1:{server.server_port}/internal/v1/event-candidates"),
KeyMaterial("brain-a", "brain-main", _BellHandler.secret),
now=lambda: 1_800_000_000.0,
)
try:
result = client.deliver(map_candidate(candidate(), config(root)))
except RetryableDelivery as exc:
self.fail(f"test Bell handler failed: {_BellHandler.failure}; client={exc.code}")
self.assertEqual(("accepted", EVENT_ID), (result.status, result.event_id))
self.assertTrue(_BellHandler.verified)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2.0)
def test_worker_distinguishes_retryable_and_permanent_failures(self) -> None:
class FailingClient:
def __init__(self, failure: Exception) -> None:
self.failure = failure
def deliver(self, _payload: bytes) -> DeliveryResult:
raise self.failure
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
payload = map_candidate(candidate(), config(root))
outbox = EventOutbox(root / "retry.sqlite3", now=lambda: 100.0)
outbox.enqueue(payload)
EventRelayWorker(outbox, FailingClient(RetryableDelivery("unauthorized"))).run_once()
self.assertEqual((1, 0), (outbox.status()["queued"], outbox.status()["dead_letter"]))
outbox.close()
outbox = EventOutbox(root / "dead.sqlite3", now=lambda: 100.0)
outbox.enqueue(payload)
EventRelayWorker(outbox, FailingClient(PermanentDelivery("source_event_conflict"))).run_once()
self.assertEqual(1, outbox.status()["dead_letter"])
outbox.close()
if __name__ == "__main__":
unittest.main()
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from Brain.yovision_brain.domain import Box, Detection
from Brain.yovision_brain.runtime import DemoEngine, EventIngressUnavailable
from Brain.yovision_brain.source import FramePacket, SyntheticSource, cv2, np
class _TwoFrameSource:
fixture = True
mode = "test"
label = "test"
source_ref = "safe-source"
def __init__(self) -> None:
self.index = 0
def read(self) -> FramePacket:
boxes = (Box(0.10, 0.4, 0.20, 0.8), Box(0.60, 0.4, 0.70, 0.8))
detection = Detection("P-1", "person", boxes[min(self.index, 1)])
self.index += 1
return FramePacket(np.zeros((180, 320, 3), dtype=np.uint8), datetime.now(timezone.utc), (detection,))
def close(self) -> None:
return
class _Ingress:
def __init__(self, fail: bool = False) -> None:
self.items = []
self.fail = fail
def submit(self, value: object) -> None:
if self.fail:
raise RuntimeError("disk unavailable")
self.items.append(value)
def start(self) -> None:
return
def stop(self) -> None:
return
def status(self) -> dict[str, object]:
return {"enabled": True, "queued": len(self.items)}
@unittest.skipIf(cv2 is None, "pinned OpenCV package is not installed")
class RuntimeTests(unittest.TestCase):
def test_rejects_non_finite_fps(self) -> None:
with self.assertRaisesRegex(ValueError, "fps"):
DemoEngine(SyntheticSource(width=320, height=180), fps=float("nan"))
def test_synthetic_step_produces_safe_state_and_jpeg(self) -> None:
engine = DemoEngine(SyntheticSource(width=320, height=180), fps=2.0)
engine.step()
state = engine.state()
self.assertTrue(state["source"]["fixture"])
self.assertEqual(state["detector"]["name"], "scripted_fixture")
self.assertEqual(state["frame"]["width"], 320)
self.assertGreater(len(engine.frame_jpeg() or b""), 100)
self.assertNotIn("url", state["source"])
def test_zone_update_increments_version(self) -> None:
engine = DemoEngine(SyntheticSource(width=320, height=180))
updated = engine.update_zone({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
self.assertEqual(updated.version, 2)
self.assertEqual(engine.state()["zone"]["name"], "新区域")
def test_event_is_persisted_before_it_is_exposed(self) -> None:
ingress = _Ingress()
engine = DemoEngine(_TwoFrameSource(), event_ingress=ingress)
engine.step()
engine.step()
state = engine.state()
self.assertEqual(1, len(ingress.items))
self.assertEqual("outbox_persisted", state["events"][0]["delivery_status"])
self.assertEqual(1, state["event_ingress"]["queued"])
def test_outbox_failure_does_not_claim_delivery(self) -> None:
engine = DemoEngine(_TwoFrameSource(), event_ingress=_Ingress(fail=True))
engine.step()
with self.assertRaises(EventIngressUnavailable):
engine.step()
self.assertEqual([], engine.state()["events"])
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import http.client
import json
import threading
import unittest
from Brain.yovision_brain.domain import Point, Zone, zone_from_payload
from Brain.yovision_brain.server import DemoHTTPServer, DemoHandler, index_path, parse_bind
class FakeEngine:
def __init__(self) -> None:
self.zone = Zone("zone-demo-01", "测试区域", 1, (Point(0.1, 0.1), Point(0.9, 0.1), Point(0.5, 0.9)))
def state(self) -> dict[str, object]:
return {"source": {"label": "safe", "connected": True}, "events": [], "zone": {"version": self.zone.version}}
def frame_jpeg(self) -> bytes:
return b"\xff\xd8safe-jpeg\xff\xd9"
def update_zone(self, payload: object) -> Zone:
self.zone = zone_from_payload(payload, self.zone)
return self.zone
class BindTests(unittest.TestCase):
def test_only_explicit_loopback_is_allowed(self) -> None:
self.assertEqual(parse_bind("127.0.0.1:8090"), ("127.0.0.1", 8090))
self.assertEqual(parse_bind("localhost:8090"), ("localhost", 8090))
with self.assertRaisesRegex(ValueError, "loopback"):
parse_bind("0.0.0.0:8090")
with self.assertRaisesRegex(ValueError, "loopback"):
parse_bind("192.168.1.10:8090")
class HTTPTests(unittest.TestCase):
def setUp(self) -> None:
html = index_path().read_text(encoding="utf-8")
self.server = DemoHTTPServer(("127.0.0.1", 0), FakeEngine(), "test-token", html)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
def tearDown(self) -> None:
self.connection.close()
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
def test_index_substitutes_token_and_sets_security_headers(self) -> None:
self.connection.request("GET", "/brain-demo")
response = self.connection.getresponse()
body = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn('content="test-token"', body)
self.assertNotIn("__BRAIN_DEMO_TOKEN__", body)
self.assertEqual(response.getheader("X-Frame-Options"), "DENY")
self.assertIn("default-src 'self'", response.getheader("Content-Security-Policy"))
def test_zone_write_requires_token_and_rejects_unknown_fields(self) -> None:
body = json.dumps({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json"})
forbidden = self.connection.getresponse()
forbidden.read()
self.assertEqual(forbidden.status, 403)
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
accepted = self.connection.getresponse()
payload = json.loads(accepted.read())
self.assertEqual(accepted.status, 200)
self.assertEqual(payload["version"], 2)
invalid = json.dumps({"name": "bad", "points": [], "tenant_id": "leak"})
self.connection.request("PUT", "/api/v1/zones/active", body=invalid, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
rejected = self.connection.getresponse()
self.assertEqual(rejected.status, 400)
self.assertEqual(json.loads(rejected.read())["code"], "invalid_zone")
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from Brain.yovision_brain.domain import Box
from Brain.yovision_brain.source import CentroidTracker, read_stream_url
class StreamURLTests(unittest.TestCase):
def test_requires_absolute_single_rtsp_url_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "stream.url"
path.write_text("rtsp://user:secret@127.0.0.1:8554/camera\n", encoding="utf-8")
self.assertEqual(read_stream_url(str(path)), "rtsp://user:secret@127.0.0.1:8554/camera")
path.write_text("rtsp://127.0.0.1/a\nrtsp://127.0.0.1/b\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "exactly one") as caught:
read_stream_url(str(path))
self.assertNotIn("127.0.0.1", str(caught.exception))
def test_rejects_relative_path_without_echoing_input(self) -> None:
with self.assertRaisesRegex(ValueError, "absolute"):
read_stream_url("camera-secret.url")
def test_rejects_url_files_inside_repository(self) -> None:
repository_file = Path(__file__).resolve()
with self.assertRaisesRegex(ValueError, "outside the repository"):
read_stream_url(str(repository_file))
class TrackerTests(unittest.TestCase):
def test_nearby_boxes_retain_track_and_distant_box_gets_new_track(self) -> None:
tracker = CentroidTracker(max_distance=0.2)
first = tracker.update([(Box(0.1, 0.1, 0.2, 0.4), 0.8)], 1)
nearby = tracker.update([(Box(0.12, 0.1, 0.22, 0.4), 0.7)], 2)
distant = tracker.update([(Box(0.7, 0.1, 0.8, 0.4), 0.9)], 3)
self.assertEqual(first[0].track_id, nearby[0].track_id)
self.assertNotEqual(nearby[0].track_id, distant[0].track_id)
if __name__ == "__main__":
unittest.main()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
HTML_PATH = Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
class UIContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.html = HTML_PATH.read_text(encoding="utf-8")
def test_prototype_is_self_contained_and_labels_fixture_truthfully(self) -> None:
self.assertIn("合成回放不等于模型效果", self.html)
self.assertIn("离线 HTML 原型数据", self.html)
self.assertNotRegex(self.html, r'(?:src|href)=["\']https?://')
self.assertNotIn("@import url", self.html)
def test_accessibility_and_responsive_guards_are_present(self) -> None:
required = (
'name="viewport"',
'class="skip-link"',
'aria-live="polite"',
':focus-visible',
'prefers-reduced-motion',
'min-height: 44px',
'@media (max-width: 420px)',
'.toolbar { display: grid; grid-template-columns: 1fr; }',
'键盘用户可使用右侧坐标表单',
)
for marker in required:
with self.subTest(marker=marker):
self.assertIn(marker, self.html)
def test_no_structural_emoji_or_unescaped_secret_placeholder_in_text(self) -> None:
visible_without_script = re.sub(r"<script[\s\S]*?</script>", "", self.html)
self.assertNotRegex(visible_without_script, r"[\U0001F300-\U0001FAFF]")
self.assertEqual(self.html.count("__BRAIN_DEMO_TOKEN__"), 1)
def test_reliable_delivery_status_is_visible_without_configuration_details(self) -> None:
required = (
'id="event-ingress"',
'id="ingress-queued"',
'id="ingress-dead-letter"',
"Outbox 已持久化",
"Bell 返回 accepted 或 duplicate 后才计为已送达",
"event_ingress: {enabled: false}",
)
for marker in required:
with self.subTest(marker=marker):
self.assertIn(marker, self.html)
for forbidden in ("bell_url", "key_file", "outbox_path"):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, self.html)
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -0,0 +1,7 @@
"""Single-stream Brain engineering prototype.
This package intentionally exposes no Brain-to-Bell transport. T-018 owns
that contract and its delivery semantics.
"""
__version__ = "0.1.0"
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import argparse
import sys
from .ingress import BrainEventIngress
from .runtime import DemoEngine
from .server import parse_bind, serve
from .source import StreamSource, SyntheticSource, read_stream_url, require_opencv
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="YoVision Brain single-stream engineering prototype")
parser.add_argument("--source", choices=("synthetic", "stream"), default="synthetic")
parser.add_argument("--stream-url-file", help="absolute external file containing one RTSP URL")
parser.add_argument("--bind", default="127.0.0.1:8090", help="loopback bind address")
parser.add_argument("--fps", type=float, default=2.0, help="prototype processing FPS (0, 30]")
parser.add_argument("--event-ingress-config", help="absolute external Brain-to-Bell ingress configuration")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
require_opencv()
parse_bind(args.bind)
if args.source == "stream":
if not args.stream_url_file:
raise ValueError("--stream-url-file is required for stream mode")
source = StreamSource(read_stream_url(args.stream_url_file))
else:
if args.stream_url_file:
raise ValueError("--stream-url-file is only valid for stream mode")
source = SyntheticSource()
event_ingress = BrainEventIngress.from_file(args.event_ingress_config) if args.event_ingress_config else None
engine = DemoEngine(source, fps=args.fps, event_ingress=event_ingress)
except (RuntimeError, ValueError) as exc:
print(f"Brain demo configuration error: {exc}", file=sys.stderr)
return 2
print(f"Brain demo listening on http://{args.bind}/brain-demo")
print("Engineering prototype only; synthetic replay and HOG output are not production model evidence.")
try:
serve(engine, args.bind)
except KeyboardInterrupt:
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Iterable, Sequence
from uuid import uuid4
@dataclass(frozen=True)
class Point:
x: float
y: float
def __post_init__(self) -> None:
if not all(math.isfinite(value) for value in (self.x, self.y)) or not (
0.0 <= self.x <= 1.0 and 0.0 <= self.y <= 1.0
):
raise ValueError("point coordinates must be within [0, 1]")
@dataclass(frozen=True)
class Box:
x1: float
y1: float
x2: float
y2: float
def __post_init__(self) -> None:
values = (self.x1, self.y1, self.x2, self.y2)
if not all(math.isfinite(value) for value in values) or any(value < 0.0 or value > 1.0 for value in values):
raise ValueError("box coordinates must be within [0, 1]")
if self.x1 >= self.x2 or self.y1 >= self.y2:
raise ValueError("box must have positive area")
@property
def center(self) -> Point:
return Point((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
@dataclass(frozen=True)
class Detection:
track_id: str
class_name: str
box: Box
detector_score: float | None = None
def __post_init__(self) -> None:
if not self.track_id or len(self.track_id) > 128:
raise ValueError("track_id must contain 1 to 128 characters")
if self.class_name != "person":
raise ValueError("T-017 only supports anonymous person detections")
if self.detector_score is not None and not math.isfinite(self.detector_score):
raise ValueError("detector_score must be finite")
@dataclass(frozen=True)
class Zone:
zone_id: str
name: str
version: int
points: tuple[Point, ...]
def __post_init__(self) -> None:
if not self.zone_id or len(self.zone_id) > 128:
raise ValueError("zone_id must contain 1 to 128 characters")
if not self.name.strip() or len(self.name) > 80:
raise ValueError("zone name must contain 1 to 80 characters")
if self.version < 1:
raise ValueError("zone version must be positive")
if not 3 <= len(self.points) <= 32:
raise ValueError("zone must contain 3 to 32 points")
@dataclass(frozen=True)
class EventCandidate:
source_event_id: str
kind: str
source_ref: str
track_id: str
zone_id: str
zone_version: int
occurred_at: str
confidence: None
fixture: bool
def as_dict(self) -> dict[str, object]:
# Bell owns the platform `id`; it is deliberately absent here.
return {
"candidate_version": "brain-demo-v1",
"source_event_id": self.source_event_id,
"kind": self.kind,
"source_ref": self.source_ref,
"track_id": self.track_id,
"zone_id": self.zone_id,
"zone_version": self.zone_version,
"occurred_at": self.occurred_at,
"confidence": self.confidence,
"fixture": self.fixture,
}
def _on_segment(point: Point, start: Point, end: Point, epsilon: float = 1e-9) -> bool:
cross = (point.y - start.y) * (end.x - start.x) - (point.x - start.x) * (end.y - start.y)
if abs(cross) > epsilon:
return False
return (
min(start.x, end.x) - epsilon <= point.x <= max(start.x, end.x) + epsilon
and min(start.y, end.y) - epsilon <= point.y <= max(start.y, end.y) + epsilon
)
def point_in_polygon(point: Point, polygon: Sequence[Point]) -> bool:
if len(polygon) < 3:
return False
inside = False
previous = polygon[-1]
for current in polygon:
if _on_segment(point, previous, current):
return True
crosses = (current.y > point.y) != (previous.y > point.y)
if crosses:
boundary_x = (previous.x - current.x) * (point.y - current.y) / (previous.y - current.y) + current.x
if point.x < boundary_x:
inside = not inside
previous = current
return inside
class ZoneEntryEvaluator:
def __init__(
self,
source_ref: str,
fixture: bool,
track_ttl_frames: int = 8,
event_id_factory: Callable[[], str] | None = None,
) -> None:
if track_ttl_frames < 1:
raise ValueError("track_ttl_frames must be positive")
self._source_ref = source_ref
self._fixture = fixture
self._track_ttl_frames = track_ttl_frames
self._event_id_factory = event_id_factory or (lambda: f"BRN-{uuid4().hex}")
self._inside: dict[str, bool] = {}
self._last_seen: dict[str, int] = {}
def reset(self) -> None:
self._inside.clear()
self._last_seen.clear()
def evaluate(
self,
sequence: int,
occurred_at: datetime,
zone: Zone,
detections: Iterable[Detection],
) -> tuple[list[EventCandidate], dict[str, bool]]:
if sequence < 0:
raise ValueError("sequence cannot be negative")
if occurred_at.tzinfo is None:
raise ValueError("occurred_at must be timezone-aware")
expired = [
track_id
for track_id, last_seen in self._last_seen.items()
if sequence - last_seen > self._track_ttl_frames
]
for track_id in expired:
self._inside.pop(track_id, None)
self._last_seen.pop(track_id, None)
events: list[EventCandidate] = []
states: dict[str, bool] = {}
for detection in detections:
inside = point_in_polygon(detection.box.center, zone.points)
previous = self._inside.get(detection.track_id)
if previous is False and inside:
events.append(
EventCandidate(
source_event_id=self._event_id_factory(),
kind="zone_entry",
source_ref=self._source_ref,
track_id=detection.track_id,
zone_id=zone.zone_id,
zone_version=zone.version,
occurred_at=occurred_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
confidence=None,
fixture=self._fixture,
)
)
self._inside[detection.track_id] = inside
self._last_seen[detection.track_id] = sequence
states[detection.track_id] = inside
return events, states
def zone_from_payload(payload: object, current: Zone) -> Zone:
if not isinstance(payload, dict):
raise ValueError("request body must be an object")
if set(payload) - {"name", "points"}:
raise ValueError("unknown zone fields are not allowed")
name = payload.get("name", current.name)
raw_points = payload.get("points")
if not isinstance(name, str):
raise ValueError("zone name must be a string")
if not isinstance(raw_points, list):
raise ValueError("zone points must be an array")
points: list[Point] = []
for raw_point in raw_points:
if not isinstance(raw_point, dict) or set(raw_point) != {"x", "y"}:
raise ValueError("each point must contain only x and y")
x = raw_point["x"]
y = raw_point["y"]
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
raise ValueError("point coordinates must be numbers")
points.append(Point(float(x), float(y)))
return Zone(current.zone_id, name.strip(), current.version + 1, tuple(points))
+645
View File
@@ -0,0 +1,645 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import ipaddress
import json
import re
import secrets
import sqlite3
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
from .domain import EventCandidate
INGRESS_PATH = "/internal/v1/event-candidates"
MAX_PAYLOAD_BYTES = 1 << 20
MAX_QUEUED = 10_000
MAX_ATTEMPTS = 100
HTTP_TIMEOUT_SECONDS = 10.0
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
_SOURCE_ID = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
_EVENT_ID = re.compile(r"^evt_[0-9A-HJKMNP-TV-Z]{26}$")
_ERROR_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$")
_REPO_ROOT = Path(__file__).resolve().parents[2]
@dataclass(frozen=True)
class IngressConfig:
producer_id: str
tenant_id: int
site_id: int
device_id: int
modality: str
severity: str
config_version: str
bell_url: str
key_id: str
key_file: Path
outbox_path: Path
@dataclass(frozen=True)
class KeyMaterial:
key_id: str
producer_id: str
secret: bytes
@dataclass(frozen=True)
class LeasedEvent:
source_event_id: str
payload: bytes
attempt_count: int
@dataclass(frozen=True)
class DeliveryResult:
status: str
event_id: str
class RetryableDelivery(RuntimeError):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = _safe_error(code)
class PermanentDelivery(RuntimeError):
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = _safe_error(code)
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, _request: Request, _file_pointer: object, _code: int, _message: str, _headers: object, _new_url: str) -> None:
return None
def _is_within(path: Path, parent: Path) -> bool:
try:
path.relative_to(parent)
return True
except ValueError:
return False
def _external_absolute_path(raw: object, field: str, *, must_exist: bool) -> Path:
if not isinstance(raw, str) or not raw:
raise ValueError(f"{field} must be an absolute external path")
path = Path(raw)
if not path.is_absolute():
raise ValueError(f"{field} must be an absolute external path")
resolved = path.resolve(strict=must_exist)
if _is_within(resolved, _REPO_ROOT):
raise ValueError(f"{field} must stay outside the repository")
return resolved
def _load_json_file(path: Path, maximum: int = 64 << 10) -> object:
raw = path.read_bytes()
if len(raw) > maximum:
raise ValueError("external configuration file is too large")
try:
return json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise ValueError("external configuration is not valid JSON") from exc
def _positive_int(value: object, field: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError(f"{field} must be a positive integer")
return value
def _validate_bell_url(raw: object) -> str:
if not isinstance(raw, str):
raise ValueError("bell_url must be a URL")
parsed = urlsplit(raw)
if parsed.scheme not in {"http", "https"} or parsed.hostname is None or parsed.path != INGRESS_PATH:
raise ValueError("bell_url must target the versioned event ingress path")
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
raise ValueError("bell_url cannot contain credentials, query or fragment")
try:
port = parsed.port
except ValueError as exc:
raise ValueError("bell_url has an invalid port") from exc
if port is not None and not 1 <= port <= 65535:
raise ValueError("bell_url has an invalid port")
if parsed.scheme == "http":
host = parsed.hostname
loopback = host == "localhost"
if not loopback:
try:
loopback = ipaddress.ip_address(host).is_loopback
except ValueError:
loopback = False
if not loopback:
raise ValueError("non-loopback event ingress requires HTTPS")
return raw
def load_config(path_value: str) -> tuple[IngressConfig, KeyMaterial]:
config_path = _external_absolute_path(path_value, "event ingress config", must_exist=True)
value = _load_json_file(config_path)
expected = {
"version",
"producer_id",
"tenant_id",
"site_id",
"device_id",
"modality",
"severity",
"config_version",
"bell_url",
"key_id",
"key_file",
"outbox_path",
}
if not isinstance(value, dict) or set(value) != expected or value.get("version") != 1:
raise ValueError("invalid event ingress configuration shape")
producer_id = value["producer_id"]
key_id = value["key_id"]
config_version = value["config_version"]
if not isinstance(producer_id, str) or not _ID.fullmatch(producer_id):
raise ValueError("invalid producer_id")
if not isinstance(key_id, str) or not _ID.fullmatch(key_id):
raise ValueError("invalid key_id")
if not isinstance(config_version, str) or not 1 <= len(config_version) <= 128:
raise ValueError("config_version must contain 1 to 128 characters")
if value["modality"] != "video":
raise ValueError("T-019 Brain ingress supports only video primary sensors")
if value["severity"] not in {"low", "medium", "high", "critical"}:
raise ValueError("invalid severity")
key_file = _external_absolute_path(value["key_file"], "key_file", must_exist=True)
outbox_path = _external_absolute_path(value["outbox_path"], "outbox_path", must_exist=False)
if not outbox_path.parent.is_dir():
raise ValueError("outbox_path parent directory must already exist")
config = IngressConfig(
producer_id=producer_id,
tenant_id=_positive_int(value["tenant_id"], "tenant_id"),
site_id=_positive_int(value["site_id"], "site_id"),
device_id=_positive_int(value["device_id"], "device_id"),
modality="video",
severity=value["severity"],
config_version=config_version,
bell_url=_validate_bell_url(value["bell_url"]),
key_id=key_id,
key_file=key_file,
outbox_path=outbox_path,
)
document = _load_json_file(key_file)
if not isinstance(document, dict) or set(document) != {"version", "keys"} or document.get("version") != 1:
raise ValueError("invalid event ingress key document")
keys = document.get("keys")
if not isinstance(keys, list) or not keys:
raise ValueError("event ingress key document has no keys")
selected: KeyMaterial | None = None
seen: set[str] = set()
for item in keys:
if not isinstance(item, dict) or set(item) != {"key_id", "producer_id", "secret_base64url"}:
raise ValueError("invalid event ingress key entry")
item_key = item["key_id"]
item_producer = item["producer_id"]
encoded = item["secret_base64url"]
if not isinstance(item_key, str) or not _ID.fullmatch(item_key) or item_key in seen:
raise ValueError("invalid or duplicate event ingress key ID")
if not isinstance(item_producer, str) or not _ID.fullmatch(item_producer) or not isinstance(encoded, str):
raise ValueError("invalid event ingress key entry")
seen.add(item_key)
try:
secret = base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True)
except (ValueError, binascii.Error) as exc:
raise ValueError("invalid event ingress secret") from exc
if len(secret) < 32:
raise ValueError("event ingress secret must contain at least 32 bytes")
if item_key == key_id:
selected = KeyMaterial(item_key, item_producer, secret)
if selected is None or selected.producer_id != producer_id:
raise ValueError("selected key is not bound to the configured producer")
return config, selected
def map_candidate(value: EventCandidate, config: IngressConfig) -> bytes:
if not _SOURCE_ID.fullmatch(value.source_event_id):
raise ValueError("invalid source_event_id")
payload = {
"schema_version": "0.1",
"source_event_id": value.source_event_id,
"tenant_id": config.tenant_id,
"site_id": config.site_id,
"device_id": config.device_id,
"sensors": [{"device_id": config.device_id, "modality": config.modality, "role": "primary"}],
"kind": value.kind,
"severity": config.severity,
"confidence": None,
"occurred_at": value.occurred_at,
"detected_at": value.occurred_at,
"latency_seconds": 0.0,
"config_version": config.config_version,
"rule": None,
"subject": {
"class": "person",
"track_id": value.track_id,
"attributes": {},
"anon_id": None,
"identity": None,
"identity_status": "not_enabled",
},
"observation": {
"zone": value.zone_id,
"dwell_sec": 0.0,
"bbox_seq_uri": None,
"keypoint_seq_uri": None,
"signal_seq_uri": None,
},
"evidence": {"snapshot_uris": [], "clip_uri": None, "clip_range": None},
"dedup_key": None,
"aggregated_into": None,
"outcome": "test" if value.fixture else "unknown",
"outcome_source": "auto" if value.fixture else None,
"outcome_reason": None,
"diagnostics": None,
"ext": {"brain_candidate_version": "brain-demo-v1", "zone_version": value.zone_version},
}
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
if len(encoded) > MAX_PAYLOAD_BYTES:
raise ValueError("event candidate exceeds 1 MiB")
return encoded
class EventOutbox:
def __init__(self, path: Path, *, now: Callable[[], float] = time.time) -> None:
if not path.is_absolute() or _is_within(path.resolve(), _REPO_ROOT):
raise ValueError("outbox path must be absolute and external")
self._now = now
self._lock = threading.RLock()
self._connection = sqlite3.connect(str(path), timeout=5.0, isolation_level=None, check_same_thread=False)
self._connection.row_factory = sqlite3.Row
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA synchronous=FULL")
self._connection.execute("PRAGMA foreign_keys=ON")
self._connection.executescript(
"""
CREATE TABLE IF NOT EXISTS event_outbox (
source_event_id TEXT PRIMARY KEY,
candidate_hash BLOB NOT NULL,
payload BLOB NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued','delivering','delivered','dead_letter')),
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count BETWEEN 0 AND 100),
available_at REAL NOT NULL,
lease_until REAL,
bell_event_id TEXT,
last_error_code TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
delivered_at REAL,
dead_lettered_at REAL,
CHECK(length(candidate_hash)=32),
CHECK(length(payload)<=1048576),
CHECK((state='delivering')=(lease_until IS NOT NULL)),
CHECK(delivered_at IS NULL OR dead_lettered_at IS NULL)
);
CREATE INDEX IF NOT EXISTS event_outbox_due_idx
ON event_outbox(available_at, created_at, source_event_id)
WHERE state='queued';
CREATE TABLE IF NOT EXISTS event_outbox_identity (
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
producer_id TEXT NOT NULL,
tenant_id INTEGER NOT NULL CHECK(tenant_id>=1),
site_id INTEGER NOT NULL CHECK(site_id>=1),
device_id INTEGER NOT NULL CHECK(device_id>=1)
);
"""
)
def bind_identity(self, config: IngressConfig) -> None:
identity = (config.producer_id, config.tenant_id, config.site_id, config.device_id)
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
row = self._connection.execute(
"SELECT producer_id,tenant_id,site_id,device_id FROM event_outbox_identity WHERE singleton=1"
).fetchone()
if row is None:
self._connection.execute(
"""INSERT INTO event_outbox_identity(
singleton,producer_id,tenant_id,site_id,device_id
) VALUES (1,?,?,?,?)""",
identity,
)
elif tuple(row) != identity:
raise ValueError("event outbox is bound to a different producer or device identity")
self._connection.execute("COMMIT")
except Exception:
self._connection.execute("ROLLBACK")
raise
def enqueue(self, payload: bytes) -> bool:
if not 0 < len(payload) <= MAX_PAYLOAD_BYTES:
raise ValueError("event candidate payload must contain at most 1 MiB")
try:
candidate = json.loads(payload)
source_event_id = candidate["source_event_id"]
except (UnicodeError, json.JSONDecodeError, KeyError, TypeError) as exc:
raise ValueError("invalid event candidate payload") from exc
if not isinstance(source_event_id, str) or not _SOURCE_ID.fullmatch(source_event_id):
raise ValueError("invalid source_event_id")
digest = hashlib.sha256(payload).digest()
now = self._now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
existing = self._connection.execute(
"SELECT candidate_hash FROM event_outbox WHERE source_event_id=?", (source_event_id,)
).fetchone()
if existing is not None:
if not hmac.compare_digest(existing["candidate_hash"], digest):
raise ValueError("source_event_id already has a different candidate")
self._connection.execute("COMMIT")
return False
active = self._connection.execute(
"SELECT count(*) FROM event_outbox WHERE state IN ('queued','delivering')"
).fetchone()[0]
if active >= MAX_QUEUED:
raise RuntimeError("event outbox capacity exceeded")
self._connection.execute(
"""INSERT INTO event_outbox(
source_event_id,candidate_hash,payload,state,available_at,created_at,updated_at
) VALUES (?,?,?,'queued',?,?,?)""",
(source_event_id, digest, payload, now, now, now),
)
self._connection.execute("COMMIT")
return True
except Exception:
self._connection.execute("ROLLBACK")
raise
def lease_one(self, lease_seconds: float = 30.0) -> LeasedEvent | None:
now = self._now()
with self._lock:
self._connection.execute("BEGIN IMMEDIATE")
try:
self._connection.execute(
"""UPDATE event_outbox SET state='dead_letter', lease_until=NULL,
last_error_code='retry_exhausted', dead_lettered_at=?, updated_at=?
WHERE attempt_count>=? AND (
state='queued' OR (state='delivering' AND lease_until<=?)
)""",
(now, now, MAX_ATTEMPTS, now),
)
self._connection.execute(
"""UPDATE event_outbox SET state='queued', lease_until=NULL,
available_at=?, last_error_code='lease_expired', updated_at=?
WHERE state='delivering' AND lease_until<=? AND attempt_count<?""",
(now, now, now, MAX_ATTEMPTS),
)
row = self._connection.execute(
"""SELECT source_event_id,payload,attempt_count FROM event_outbox
WHERE state='queued' AND available_at<=? AND attempt_count<?
ORDER BY available_at,created_at,source_event_id LIMIT 1""",
(now, MAX_ATTEMPTS),
).fetchone()
if row is None:
self._connection.execute("COMMIT")
return None
attempt = row["attempt_count"] + 1
self._connection.execute(
"""UPDATE event_outbox SET state='delivering',attempt_count=?,lease_until=?,updated_at=?
WHERE source_event_id=? AND state='queued'""",
(attempt, now + lease_seconds, now, row["source_event_id"]),
)
self._connection.execute("COMMIT")
return LeasedEvent(row["source_event_id"], bytes(row["payload"]), attempt)
except Exception:
self._connection.execute("ROLLBACK")
raise
def mark_delivered(self, item: LeasedEvent, result: DeliveryResult) -> None:
now = self._now()
with self._lock:
changed = self._connection.execute(
"""UPDATE event_outbox SET state='delivered',lease_until=NULL,bell_event_id=?,
last_error_code=NULL,delivered_at=?,updated_at=?
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
(result.event_id, now, now, item.source_event_id, item.attempt_count),
).rowcount
if changed != 1:
raise RuntimeError("event outbox delivery lease was lost")
def mark_retry(self, item: LeasedEvent, code: str) -> None:
now = self._now()
if item.attempt_count >= MAX_ATTEMPTS:
self.mark_dead(item, "retry_exhausted")
return
delay = min(300.0, float(2 ** min(item.attempt_count - 1, 9)))
with self._lock:
changed = self._connection.execute(
"""UPDATE event_outbox SET state='queued',lease_until=NULL,available_at=?,
last_error_code=?,updated_at=?
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
(now + delay, _safe_error(code), now, item.source_event_id, item.attempt_count),
).rowcount
if changed != 1:
raise RuntimeError("event outbox retry lease was lost")
def mark_dead(self, item: LeasedEvent, code: str) -> None:
now = self._now()
with self._lock:
changed = self._connection.execute(
"""UPDATE event_outbox SET state='dead_letter',lease_until=NULL,
last_error_code=?,dead_lettered_at=?,updated_at=?
WHERE source_event_id=? AND state='delivering' AND attempt_count=?""",
(_safe_error(code), now, now, item.source_event_id, item.attempt_count),
).rowcount
if changed != 1:
raise RuntimeError("event outbox dead-letter lease was lost")
def status(self) -> dict[str, object]:
with self._lock:
counts = {row["state"]: row["count"] for row in self._connection.execute(
"SELECT state,count(*) AS count FROM event_outbox GROUP BY state"
)}
error = self._connection.execute(
"""SELECT last_error_code FROM event_outbox WHERE last_error_code IS NOT NULL
ORDER BY updated_at DESC LIMIT 1"""
).fetchone()
return {
"enabled": True,
"queued": counts.get("queued", 0),
"delivering": counts.get("delivering", 0),
"delivered": counts.get("delivered", 0),
"dead_letter": counts.get("dead_letter", 0),
"last_error_code": None if error is None else error["last_error_code"],
}
def close(self) -> None:
with self._lock:
self._connection.close()
class EventIngressClient:
def __init__(self, config: IngressConfig, key: KeyMaterial, *, now: Callable[[], float] = time.time) -> None:
self._config = config
self._key = key
self._now = now
# Internal event payloads must never be redirected through ambient
# HTTP(S)_PROXY settings.
self._opener = build_opener(ProxyHandler({}), _NoRedirect())
def deliver(self, candidate: bytes) -> DeliveryResult:
try:
candidate_object = json.loads(candidate)
except (UnicodeError, json.JSONDecodeError) as exc:
raise PermanentDelivery("candidate_invalid") from exc
body = json.dumps(
{"schema_version": 1, "producer_id": self._config.producer_id, "candidate": candidate_object},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
if len(body) > MAX_PAYLOAD_BYTES:
raise PermanentDelivery("payload_too_large")
timestamp = str(int(self._now()))
nonce = base64.urlsafe_b64encode(secrets.token_bytes(16)).rstrip(b"=").decode("ascii")
digest = hashlib.sha256(body).hexdigest()
canonical = "\n".join(("POST", INGRESS_PATH, timestamp, nonce, digest)).encode("utf-8")
signature_value = base64.urlsafe_b64encode(hmac.new(self._key.secret, canonical, hashlib.sha256).digest()).rstrip(b"=").decode("ascii")
request = Request(self._config.bell_url, data=body, method="POST")
request.add_header("Content-Type", "application/json")
request.add_header("X-YoVision-Key-Id", self._key.key_id)
request.add_header("X-YoVision-Timestamp", timestamp)
request.add_header("X-YoVision-Nonce", nonce)
request.add_header("X-YoVision-Signature", signature_value)
try:
with self._opener.open(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
status = response.status
response_body = response.read(64 << 10)
except HTTPError as exc:
status = exc.code
response_body = exc.read(64 << 10)
except (URLError, TimeoutError, OSError) as exc:
raise RetryableDelivery("network_error") from exc
if status in {200, 201}:
try:
value = json.loads(response_body)
response_status = value["status"]
event_id = value["event_id"]
if (
value["schema_version"] != 1
or value["producer_id"] != self._config.producer_id
or value["source_event_id"] != candidate_object["source_event_id"]
or response_status not in {"accepted", "duplicate"}
or not isinstance(event_id, str)
or not _EVENT_ID.fullmatch(event_id)
):
raise ValueError
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise RetryableDelivery("invalid_response") from exc
return DeliveryResult(response_status, event_id)
code = "http_error"
try:
error_value = json.loads(response_body)
if isinstance(error_value, dict) and isinstance(error_value.get("error"), str):
code = error_value["error"]
except (UnicodeError, json.JSONDecodeError):
pass
if status == 401 or status >= 500:
raise RetryableDelivery(code)
raise PermanentDelivery(code)
class EventRelayWorker:
def __init__(self, outbox: EventOutbox, client: EventIngressClient) -> None:
self._outbox = outbox
self._client = client
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._last_error_code: str | None = None
def run_once(self) -> bool:
item = self._outbox.lease_one()
if item is None:
return False
try:
result = self._client.deliver(item.payload)
except PermanentDelivery as exc:
self._outbox.mark_dead(item, exc.code)
except RetryableDelivery as exc:
self._outbox.mark_retry(item, exc.code)
except Exception:
self._outbox.mark_retry(item, "client_error")
else:
self._outbox.mark_delivered(item, result)
return True
def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(target=self._run, name="brain-event-relay", daemon=True)
self._thread.start()
def _run(self) -> None:
while not self._stop.is_set():
try:
worked = self.run_once()
self._last_error_code = None
except Exception:
self._last_error_code = "event_outbox_unavailable"
self._stop.wait(1.0)
continue
if not worked:
self._stop.wait(0.25)
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=HTTP_TIMEOUT_SECONDS + 2.0)
self._thread = None
def last_error_code(self) -> str | None:
return self._last_error_code
class BrainEventIngress:
def __init__(self, config: IngressConfig, key: KeyMaterial) -> None:
self._config = config
self._outbox = EventOutbox(config.outbox_path)
self._outbox.bind_identity(config)
self._worker = EventRelayWorker(self._outbox, EventIngressClient(config, key))
@classmethod
def from_file(cls, path: str) -> "BrainEventIngress":
config, key = load_config(path)
return cls(config, key)
def submit(self, value: EventCandidate) -> None:
self._outbox.enqueue(map_candidate(value, self._config))
def start(self) -> None:
self._worker.start()
def stop(self) -> None:
self._worker.stop()
self._outbox.close()
def status(self) -> dict[str, object]:
value = self._outbox.status()
if self._worker.last_error_code() is not None:
value["last_error_code"] = self._worker.last_error_code()
return value
def _safe_error(value: str) -> str:
if not isinstance(value, str) or not _ERROR_CODE.fullmatch(value):
return "unknown_error"
return value
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import math
import threading
import time
from collections import deque
from datetime import datetime, timezone
from typing import Any
from .domain import Detection, Point, Zone, ZoneEntryEvaluator, zone_from_payload
from .source import CentroidTracker, HOGPersonDetector, require_opencv
try:
import cv2 # type: ignore
except ImportError: # pragma: no cover
cv2 = None
DEFAULT_ZONE = Zone(
"zone-demo-01",
"楼梯口危险区",
1,
(Point(0.55, 0.30), Point(0.90, 0.30), Point(0.90, 0.88), Point(0.55, 0.88)),
)
class EventIngressUnavailable(RuntimeError):
pass
class DemoEngine:
def __init__(self, source: Any, fps: float = 2.0, event_limit: int = 100, event_ingress: Any | None = None) -> None:
if not math.isfinite(fps) or fps <= 0.0 or fps > 30.0:
raise ValueError("fps must be within (0, 30]")
if not 1 <= event_limit <= 100:
raise ValueError("event_limit must be within [1, 100]")
require_opencv()
self._source = source
self._fps = fps
self._detector = None if source.fixture else HOGPersonDetector()
self._tracker = CentroidTracker()
self._evaluator = ZoneEntryEvaluator(source.source_ref, source.fixture)
self._event_ingress = event_ingress
self._zone = DEFAULT_ZONE
self._events: deque[dict[str, object]] = deque(maxlen=event_limit)
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._sequence = 0
self._frame_jpeg: bytes | None = None
self._frame_width = 0
self._frame_height = 0
self._captured_at: str | None = None
self._detections: list[dict[str, object]] = []
self._latency_ms: float | None = None
self._connected = False
self._last_error_code: str | None = None
def start(self) -> None:
if self._thread is not None:
return
if self._event_ingress is not None:
self._event_ingress.start()
self._thread = threading.Thread(target=self._run, name="brain-demo", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=3.0)
self._thread = None
if self._event_ingress is not None:
self._event_ingress.stop()
self._source.close()
def _run(self) -> None:
interval = 1.0 / self._fps
while not self._stop.is_set():
started = time.perf_counter()
try:
self.step()
except EventIngressUnavailable:
with self._lock:
self._last_error_code = "event_outbox_unavailable"
except RuntimeError:
with self._lock:
self._connected = False
self._last_error_code = "source_unavailable"
elapsed = time.perf_counter() - started
self._stop.wait(max(0.0, interval - elapsed))
def step(self) -> None:
started = time.perf_counter()
packet = self._source.read()
self._sequence += 1
if packet.scripted_detections is not None:
detections = list(packet.scripted_detections)
else:
raw_boxes = self._detector.detect(packet.frame) if self._detector is not None else []
detections = self._tracker.update(raw_boxes, self._sequence)
with self._lock:
zone = self._zone
new_events, inside_by_track = self._evaluator.evaluate(self._sequence, packet.captured_at, zone, detections)
if self._event_ingress is not None:
try:
for item in new_events:
self._event_ingress.submit(item)
except Exception as exc:
raise EventIngressUnavailable("persist event candidate") from exc
ok, encoded = cv2.imencode(".jpg", packet.frame, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
if not ok:
raise RuntimeError("frame encoding failed")
height, width = packet.frame.shape[:2]
serialized_detections = [self._serialize_detection(item, inside_by_track.get(item.track_id, False)) for item in detections]
with self._lock:
for event in new_events:
serialized_event = event.as_dict()
if self._event_ingress is not None:
# This is an immutable handoff fact, not a live delivery
# status. Current counts live under event_ingress.
serialized_event["delivery_status"] = "outbox_persisted"
self._events.appendleft(serialized_event)
self._frame_jpeg = encoded.tobytes()
self._frame_width = int(width)
self._frame_height = int(height)
self._captured_at = packet.captured_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
self._detections = serialized_detections
self._latency_ms = round((time.perf_counter() - started) * 1000.0, 1)
self._connected = True
self._last_error_code = None
@staticmethod
def _serialize_detection(detection: Detection, inside: bool) -> dict[str, object]:
return {
"track_id": detection.track_id,
"class": detection.class_name,
"bbox": [detection.box.x1, detection.box.y1, detection.box.x2, detection.box.y2],
"detector_score": detection.detector_score,
"inside_zone": inside,
}
def update_zone(self, payload: object) -> Zone:
with self._lock:
updated = zone_from_payload(payload, self._zone)
self._zone = updated
self._evaluator.reset()
return updated
def frame_jpeg(self) -> bytes | None:
with self._lock:
return self._frame_jpeg
def state(self) -> dict[str, object]:
with self._lock:
zone = self._zone
if self._event_ingress is None:
ingress_status: dict[str, object] = {"enabled": False}
else:
try:
ingress_status = self._event_ingress.status()
except Exception:
ingress_status = {"enabled": True, "last_error_code": "event_outbox_unavailable"}
return {
"prototype": True,
"notice": "工程原型;合成回放不是模型输出,HOG 适配器不是生产检测模型。",
"source": {
"mode": self._source.mode,
"label": self._source.label,
"fixture": self._source.fixture,
"connected": self._connected,
"ref": self._source.source_ref,
},
"detector": {
"name": "scripted_fixture" if self._source.fixture else self._detector.name,
"production_ready": False,
},
"frame": {
"sequence": self._sequence,
"width": self._frame_width,
"height": self._frame_height,
"captured_at": self._captured_at,
},
"inference": {"target_fps": self._fps, "latency_ms": self._latency_ms},
"zone": {
"id": zone.zone_id,
"name": zone.name,
"version": zone.version,
"points": [{"x": point.x, "y": point.y} for point in zone.points],
},
"detections": list(self._detections),
"events": list(self._events),
"event_ingress": ingress_status,
"last_error_code": self._last_error_code,
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import hmac
import json
import secrets
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from .domain import Zone
MAX_BODY_BYTES = 64 * 1024
def parse_bind(value: str) -> tuple[str, int]:
parsed = urlsplit(f"//{value}")
host = parsed.hostname
try:
port = parsed.port
except ValueError as exc:
raise ValueError("invalid demo bind address") from exc
if host not in {"127.0.0.1", "localhost", "::1"} or port is None or not 1 <= port <= 65535:
raise ValueError("Brain demo must bind to an explicit loopback address and valid port")
return host, port
def index_path() -> Path:
return Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
class DemoHTTPServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address: tuple[str, int], engine: Any, token: str, html: str) -> None:
self.engine = engine
self.demo_token = token
self.html = html
super().__init__(address, DemoHandler)
class DemoHandler(BaseHTTPRequestHandler):
server: DemoHTTPServer
def log_message(self, _format: str, *_args: object) -> None:
# Do not echo request paths or headers; URL credentials never reach HTTP paths.
return
def _security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Cache-Control", "no-store")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
"img-src 'self' data: blob:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
)
def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self._security_headers()
self.end_headers()
self.wfile.write(body)
def _send_json(self, status: int, payload: object) -> None:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self._send_bytes(status, "application/json; charset=utf-8", body)
def do_GET(self) -> None: # noqa: N802
path = urlsplit(self.path).path
if path in {"/", "/brain-demo"}:
body = self.server.html.replace("__BRAIN_DEMO_TOKEN__", self.server.demo_token).encode("utf-8")
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
return
if path == "/healthz":
self._send_json(HTTPStatus.OK, {"status": "ok"})
return
if path == "/api/v1/state":
self._send_json(HTTPStatus.OK, self.server.engine.state())
return
if path == "/api/v1/frame.jpg":
frame = self.server.engine.frame_jpeg()
if frame is None:
self._send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"code": "frame_not_ready"})
return
self._send_bytes(HTTPStatus.OK, "image/jpeg", frame)
return
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
def do_PUT(self) -> None: # noqa: N802
if urlsplit(self.path).path != "/api/v1/zones/active":
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
return
raw_length = self.headers.get("Content-Length")
try:
length = int(raw_length or "-1")
except ValueError:
length = -1
if length < 0 or length > MAX_BODY_BYTES:
self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"code": "body_too_large"})
return
body = self.rfile.read(length)
supplied = self.headers.get("X-Brain-Demo-Token", "")
if not hmac.compare_digest(supplied, self.server.demo_token):
self._send_json(HTTPStatus.FORBIDDEN, {"code": "forbidden"})
return
try:
payload = json.loads(body.decode("utf-8"))
zone: Zone = self.server.engine.update_zone(payload)
except (UnicodeError, json.JSONDecodeError, ValueError):
self._send_json(HTTPStatus.BAD_REQUEST, {"code": "invalid_zone"})
return
self._send_json(
HTTPStatus.OK,
{
"id": zone.zone_id,
"name": zone.name,
"version": zone.version,
"points": [{"x": point.x, "y": point.y} for point in zone.points],
},
)
def build_server(engine: Any, bind: str = "127.0.0.1:8090", token: str | None = None) -> DemoHTTPServer:
html = index_path().read_text(encoding="utf-8")
return DemoHTTPServer(parse_bind(bind), engine, token or secrets.token_urlsafe(32), html)
def serve(engine: Any, bind: str) -> None:
server = build_server(engine, bind)
engine.start()
try:
server.serve_forever(poll_interval=0.25)
finally:
server.server_close()
engine.stop()
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlsplit
from .domain import Box, Detection
try:
import cv2 # type: ignore
import numpy as np # type: ignore
except ImportError: # pragma: no cover - exercised by the startup failure path
cv2 = None
np = None
@dataclass(frozen=True)
class FramePacket:
frame: Any
captured_at: datetime
scripted_detections: tuple[Detection, ...] | None
def require_opencv() -> None:
if cv2 is None or np is None:
raise RuntimeError("Brain demo requires the pinned NumPy and OpenCV packages")
def read_stream_url(path_value: str) -> str:
path = Path(path_value)
if not path.is_absolute():
raise ValueError("stream URL file must be an absolute external path")
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise ValueError("stream URL file does not exist") from exc
if not resolved.is_file():
raise ValueError("stream URL file does not exist")
repository_root = Path(__file__).resolve().parents[2]
try:
resolved.relative_to(repository_root)
except ValueError:
pass
else:
raise ValueError("stream URL file must be outside the repository")
try:
with resolved.open("rb") as stream:
raw = stream.read(4097)
except OSError as exc:
raise ValueError("stream URL file cannot be read") from exc
if len(raw) > 4096:
raise ValueError("stream URL file exceeds 4096 bytes")
try:
lines = raw.decode("utf-8").splitlines()
except UnicodeError as exc:
raise ValueError("stream URL file must be UTF-8") from exc
values = [line.strip() for line in lines if line.strip()]
if len(values) != 1:
raise ValueError("stream URL file must contain exactly one non-empty line")
parsed = urlsplit(values[0])
if parsed.scheme not in {"rtsp", "rtsps"} or not parsed.hostname:
raise ValueError("stream URL must be an RTSP URL")
return values[0]
class SyntheticSource:
mode = "synthetic"
label = "合成回放"
fixture = True
source_ref = "demo-camera-01"
def __init__(self, width: int = 960, height: int = 540) -> None:
require_opencv()
self.width = width
self.height = height
self._sequence = 0
def read(self) -> FramePacket:
self._sequence += 1
frame = np.zeros((self.height, self.width, 3), dtype=np.uint8)
frame[:] = (20, 28, 42)
cv2.rectangle(frame, (0, int(self.height * 0.72)), (self.width, self.height), (31, 42, 58), -1)
for x in range(0, self.width, 80):
cv2.line(frame, (x, int(self.height * 0.72)), (x + 80, self.height), (43, 57, 75), 1)
cv2.putText(frame, "SYNTHETIC FIXTURE - NOT MODEL OUTPUT", (24, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.72, (82, 190, 245), 2)
phase = ((self._sequence - 1) % 160) / 159.0
center_x = -0.04 + phase * 1.08
x1 = max(0.0, center_x - 0.04)
x2 = min(1.0, center_x + 0.04)
detections: tuple[Detection, ...] = ()
if x2 - x1 > 0.01:
box = Box(x1, 0.34, x2, 0.82)
detections = (Detection("P-DEMO-001", "person", box, None),)
px = int(center_x * self.width)
head_y = int(self.height * 0.40)
cv2.circle(frame, (px, head_y), 17, (195, 210, 225), -1)
cv2.line(frame, (px, head_y + 18), (px, int(self.height * 0.65)), (195, 210, 225), 12)
cv2.line(frame, (px, int(self.height * 0.52)), (px - 30, int(self.height * 0.60)), (195, 210, 225), 8)
cv2.line(frame, (px, int(self.height * 0.52)), (px + 30, int(self.height * 0.60)), (195, 210, 225), 8)
cv2.line(frame, (px, int(self.height * 0.65)), (px - 24, int(self.height * 0.79)), (195, 210, 225), 9)
cv2.line(frame, (px, int(self.height * 0.65)), (px + 24, int(self.height * 0.79)), (195, 210, 225), 9)
return FramePacket(frame, datetime.now(timezone.utc), detections)
def close(self) -> None:
return
class StreamSource:
mode = "stream"
label = "外部 MediaMTX / RTSP"
fixture = False
source_ref = "configured-video-source"
def __init__(self, stream_url: str) -> None:
require_opencv()
self._stream_url = stream_url
self._capture: Any = None
def _open(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = cv2.VideoCapture(self._stream_url)
self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
def read(self) -> FramePacket:
if self._capture is None or not self._capture.isOpened():
self._open()
ok, frame = self._capture.read()
if not ok or frame is None:
self._open()
raise RuntimeError("stream frame unavailable")
return FramePacket(frame, datetime.now(timezone.utc), None)
def close(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = None
class HOGPersonDetector:
name = "opencv_hog_person_demo"
production_ready = False
def __init__(self) -> None:
require_opencv()
self._hog = cv2.HOGDescriptor()
self._hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
def detect(self, frame: Any) -> list[tuple[Box, float]]:
height, width = frame.shape[:2]
scale = min(1.0, 960.0 / max(width, 1))
working = frame if scale == 1.0 else cv2.resize(frame, (int(width * scale), int(height * scale)))
boxes, weights = self._hog.detectMultiScale(working, winStride=(8, 8), padding=(8, 8), scale=1.05)
result: list[tuple[Box, float]] = []
work_height, work_width = working.shape[:2]
for raw_box, weight in zip(boxes, weights):
x, y, box_width, box_height = (int(value) for value in raw_box)
x1 = max(0.0, min(1.0, x / work_width))
y1 = max(0.0, min(1.0, y / work_height))
x2 = max(0.0, min(1.0, (x + box_width) / work_width))
y2 = max(0.0, min(1.0, (y + box_height) / work_height))
if x2 > x1 and y2 > y1:
result.append((Box(x1, y1, x2, y2), float(weight)))
return result
class CentroidTracker:
def __init__(self, max_distance: float = 0.18, ttl_frames: int = 8) -> None:
self._max_distance = max_distance
self._ttl_frames = ttl_frames
self._next_id = 1
self._tracks: dict[str, tuple[Box, int]] = {}
def update(self, boxes: Iterable[tuple[Box, float]], sequence: int) -> list[Detection]:
incoming = list(boxes)
available = set(self._tracks)
detections: list[Detection] = []
for box, score in incoming:
center = box.center
selected: str | None = None
selected_distance = self._max_distance
for track_id in available:
old_center = self._tracks[track_id][0].center
distance = ((center.x - old_center.x) ** 2 + (center.y - old_center.y) ** 2) ** 0.5
if distance < selected_distance:
selected = track_id
selected_distance = distance
if selected is None:
selected = f"P-{self._next_id:04d}"
self._next_id += 1
else:
available.remove(selected)
self._tracks[selected] = (box, sequence)
detections.append(Detection(selected, "person", box, score))
for track_id, (_, last_seen) in list(self._tracks.items()):
if sequence - last_seen > self._ttl_frames:
del self._tracks[track_id]
return detections
+4
View File
@@ -0,0 +1,4 @@
/bin/
/data/
*.db-shm
*.db-wal
-1
View File
@@ -1 +0,0 @@
+177
View File
@@ -0,0 +1,177 @@
# Sense M1/M2 接入骨架
本目录是 YoVision Sense 的接入与 NVR 管理面。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。T-018 增加默认关闭的回环工程控制台,用于展示设备、配额、收敛状态和最多 4 路按需 MediaMTX WebRTC 预览;它不包含录像/回放,也不是生产公网入口。默认关闭真实 ONVIF、公共业务路由、控制台和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
## 常用命令
```powershell
cd Sense
go mod download
go generate ./internal/mtx ./internal/controlapi
go test ./...
go vet ./...
go build -o bin/sense-api.exe ./cmd/sense-api
go run ./cmd/sense-api
```
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由,`/sense-console/` 还需独立显式开启。
常用环境变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `SENSE_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址 |
| `SENSE_ALLOW_NON_LOOPBACK` | `false` | 显式允许监听非回环地址;只应在可信网络及外部认证/防火墙就绪后开启 |
| `SENSE_DB_DRIVER` | `sqlite` | `sqlite` 或 `postgres`;生产切换必须显式选择 `postgres` |
| `SENSE_DB_DSN` | `file:data/sense.db` | 所选 driver 的私有 DSN;不得写入日志、文档或仓库,PostgreSQL 密码优先由 `PGPASSFILE`/环境密钥提供 |
| `SENSE_MEDIAMTX_URL` | `http://127.0.0.1:9997` | MediaMTX 控制 API;不得包含 userinfo |
| `SENSE_RECONCILE_INTERVAL` | `5s` | 对账周期 |
| `SENSE_RECONCILE_LEASE_DURATION` | `30s` | PostgreSQL due-row 租期;最大 5 分钟 |
| `SENSE_RECONCILE_OPERATION_TIMEOUT` | `20s` | 单项 ONVIF/MediaMTX deadline;必须严格短于租期 |
| `SENSE_PROBE_INTERVAL` | `10s` | path 探活周期 |
| `SENSE_INSTANCE_ID` | 随进程随机生成 | 最多 64 位低基数字符串;多实例部署建议显式注入唯一实例 ID |
| `SENSE_METRICS_ENABLED` | `true` | 是否注册低基数 Prometheus 文本 `/metrics` |
| `SENSE_ORPHAN_SCAN_ENABLED` | PostgreSQL 为 `true`,SQLite 为 `false` | 周期执行只读 MediaMTX Path 差异报告;从不自动删除 |
| `SENSE_ORPHAN_SCAN_INTERVAL` | `1m` | 孤儿只读扫描周期,最短 10 秒 |
| `SENSE_ONVIF_MODE` | `disabled` | `disabled` 或 `standard`;默认不访问真实摄像头 |
| `SENSE_ONVIF_RTSP_REWRITE_HOST` | 空 | NAT 或故障代理场景下重写 ONVIF 返回的 RTSP 主机 |
| `SENSE_ONVIF_RTSP_REWRITE_PORT` | `0` | 非零时重写 ONVIF 返回的 RTSP 端口 |
| `SENSE_ONVIF_RTSP_STRIP_QUERY` | `false` | 仅在已验证设备返回不可用查询串时显式移除;默认保留标准 URI 语义 |
| `SENSE_CONTROL_API_ENABLED` | `false` | 显式开启 Control API v1;只允许与 PostgreSQL 一起使用 |
| `SENSE_CONTROL_AUTH_MODE` | `static-sha256` | 首版外部摘要注册表适配器;token 格式不属于公共 API 契约 |
| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS |
| `SENSE_CONSOLE_ENABLED` | `false` | 显式开启 `/sense-console/`;T-018 只允许与回环 Control API 一起使用 |
| `SENSE_CONSOLE_WEBRTC_BASE_URL` | `http://127.0.0.1:8889` | MediaMTX WebRTC 浏览器入口基地址;必须为无 userinfo/path/query/fragment 的显式回环 HTTP(S) URL |
| `SENSE_AUDIT_RELAY_ENABLED` | `false` | 显式开启 PostgreSQL Outbox → Bell relay;SQLite 不支持 |
| `SENSE_AUDIT_RELAY_URL` | 空 | 精确指向 Bell `/internal/v1/audit-events:batch`;非回环必须 HTTPS |
| `SENSE_AUDIT_RELAY_KEY_FILE` | 空 | 仓库外绝对路径 version 1 JSON key 文件,secret 至少 32 字节 |
| `SENSE_AUDIT_RELAY_KEY_ID` | 空 | 本实例用于签名的 key ID |
| `SENSE_AUDIT_RELAY_INTERVAL` | `1s` | 队列轮询间隔,最短 1 秒 |
设备台账只保存 `env://<key>` 凭据引用。真实适配器从进程环境读取以下变量,不把秘密写入 SQLite、日志或 MediaMTX 错误:
```text
SENSE_CREDENTIAL_<KEY>_ONVIF_USERNAME
SENSE_CREDENTIAL_<KEY>_ONVIF_PASSWORD
SENSE_CREDENTIAL_<KEY>_RTSP_USERNAME
SENSE_CREDENTIAL_<KEY>_RTSP_PASSWORD
```
`cmd/sense-lab` 是回环实验室播种与脱敏收敛查询工具,不是已冻结的公共设备管理 API。`cmd/rtsp-fault-proxy` 只用于 T-006 控制真实上游网络路径故障。
MediaMTX `v1.19.3` 应作为独立二进制启动并只在可信网络开放 API。获取与 SHA-256 校验值见 `docs/03-tech-stack.md`。生成客户端使用固定版本工具和 vendored 官方 OpenAPI;`internal/mtx/generated/client.gen.go` 不可手改。
## T-009~T-012 PostgreSQL 17.10
初始化与增量 SQL 位于 `deploy/postgres/`,由高权限部署步骤按文件名前缀执行;Sense 进程不会自动创建角色、schema 或 Bell 对象。`bell_app` 拥有 Site/Area、配额、`capture_policy` 及两个版本化视图,`sense_app` 只能读取两个视图,不能读取或写入 Bell 源表。T-011 的 v4 schema 增加资源版本、24 小时幂等收据和 batch operation;T-012 的 v5 schema 增加数据库时钟租约、Path 历史归属及脱敏孤儿报告/处置结果。表中不保存 MediaMTX source URI。应用登录角色和密码由部署环境创建,不进入仓库。
PostgreSQL 新建设备必须携带匹配 tenant/Site 的 `area_id`。具有 `video_capture` 能力的设备在创建、移动 Area 和从 disabled 切到 enabled 时执行 Area 准入;`non_imaging_only` 拒绝成像设备但允许非成像设备。投影缺失、非法或版本回退只拒绝新变更,不关闭已有流。创建、配置修改和期望态受理都与对应脱敏 Outbox 事实同事务;停用后调和器只删除该设备的精确 MediaMTX path 并收敛为 offline,不枚举未知 path。开启 relay 后,Sense 用数据库 lease/fencing 批量投递,成功、重试和 dead letter 均保留本地事实;Sense 不访问 Bell schema。
Windows 本机集成测试从仓库根目录执行:
```powershell
./scripts/test_postgres.ps1 -PgRoot D:\pgsql17
```
脚本要求冻结的 PostgreSQL `17.10`,使用 `initdb` 创建临时 trust 集群并只监听随机回环端口,重放 migration、执行权限断言和 PostgreSQL repository 测试后停止并清理。它会核对现有 `5432` listener 前后未变化,不读取或修改 `D:\pgsql17\data`。生产安装和恢复边界见 `deploy/postgres/README.md`。
选择 PostgreSQL 运行前,管理员必须已经安装 migration,并私下设置无密码回显的连接环境:
```powershell
$env:SENSE_DB_DRIVER = 'postgres'
$env:SENSE_DB_DSN = '由部署环境私下设置'
go run ./cmd/sense-api
```
PostgreSQL 基础启动会检查 Sense v5 migration、当前角色对两个 Bell 投影视图和本地控制/对账表的最小权限;启用 relay 时额外要求 v6 和本地 Outbox 权限,并确认当前 Sense 登录不能访问 Bell 全局审计表。默认 SQLite 路径和 `cmd/sense-lab` 保持不变,但 SQLite 不实现生产 Area/Outbox、多实例租约、孤儿处置或 relay 语义,Control API、孤儿扫描与 relay 在 SQLite 下不会启动。
### 孤儿报告与受控处置
`sense-api` 的 PostgreSQL 模式默认每分钟只读枚举一次 MediaMTX 配置 Path,并持久化汇总。`unowned` 表示没有 Sense 历史归属证据,永远不会由 Sense 删除;只有 `owned_stale` 可以进入处置候选。需要人工处置时从 `Sense/` 执行:
```powershell
go run ./cmd/sense-orphan -mode report
go run ./cmd/sense-orphan -mode apply -scan-id scan_... -actor operator-id -confirm "DELETE scan_..."
```
`report` 输出不含 source URI 的 JSON 摘要。`apply` 会再次读取数据库和 MediaMTX,只处理仍属于原快照的 `owned_stale`,并要求快照不超过 15 分钟、候选不超过 128 且候选占当前全部配置 Path 不超过 10%;任何门禁失败均为零删除,没有 `force`。完整流程与恢复方法见 [`../docs/runbooks/sense-reconciliation.md`](../docs/runbooks/sense-reconciliation.md)。
### 开启 Control API
复制 [`api/control-auth.example.json`](api/control-auth.example.json) 到仓库外受限目录并替换占位项。`token_sha256` 是至少 128 bit 随机 Bearer token 的 64 位小写 SHA-256,不是 token 明文;`site_ids` 支持精确 ID 或 `"*"`,权限只接受 `sense.devices.read`、`sense.devices.write`。注册表在启动时读取,轮换后需要受控重启。
另在仓库外生成 cursor key。PowerShell 示例只把 key 写入指定秘密文件,不把值打印到日志:
```powershell
$cursorBytes = [byte[]]::new(32)
[Security.Cryptography.RandomNumberGenerator]::Fill($cursorBytes)
$cursorKey = [Convert]::ToBase64String($cursorBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
Set-Content -LiteralPath 'D:\private\sense-cursor.key' -Value $cursorKey -NoNewline
```
完成 PostgreSQL migration、专用登录角色和外部文件权限后,以私有环境开启:
```powershell
$env:SENSE_DB_DRIVER = 'postgres'
$env:SENSE_DB_DSN = '由部署环境私下设置'
$env:SENSE_CONTROL_API_ENABLED = 'true'
$env:SENSE_CONTROL_AUTH_FILE = 'D:\private\sense-auth.json'
$env:SENSE_CONTROL_CURSOR_KEY_FILE = 'D:\private\sense-cursor.key'
go run ./cmd/sense-api
```
业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。
### 开启 T-018 回环控制台
先按上一节完成 PostgreSQL、Control API、仓库外 auth/cursor 文件和 MediaMTX 启动,再增加:
```powershell
$env:SENSE_CONSOLE_ENABLED = 'true'
$env:SENSE_CONSOLE_WEBRTC_BASE_URL = 'http://127.0.0.1:8889'
go run ./cmd/sense-api
```
浏览器打开 `http://127.0.0.1:8080/sense-console/`,输入已授权的 Site ID 和原始 Bearer token。token 只保留在当前页面 JavaScript 内存,输入框随即清空,刷新页面后必须重新输入;不得把 token 放进 URL、截图、命令历史或文档。设备列表默认每页 16 项,只有 `video_capture + enabled + online + converged` 的设备可选择,最多同时启动 4 路预览。
播放使用 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,Sense 不代理媒体字节,也不复制播放器源码。浏览器是否能解码取决于摄像头编码;首选已验收的低码率 H.264 子码流,H.265 或带 B-frame 的 H.264 不能因“Path 在线”就宣称浏览器可播放。本版 Sense 和播放端都必须显式回环;非回环 HTTPS、正式会话认证和 MediaMTX 外部鉴权需另立任务。控制台不会实现或暗示常态录像、录像计划和回放。
Windows 本地准备 MediaMTX(从仓库根目录执行):
```powershell
$asset = "mediamtx_v1.19.3_windows_amd64.zip"
Invoke-WebRequest "https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/$asset" -OutFile "$env:TEMP\$asset"
if ((Get-FileHash "$env:TEMP\$asset" -Algorithm SHA256).Hash.ToLowerInvariant() -ne "5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe") { throw "MediaMTX checksum mismatch" }
Expand-Archive "$env:TEMP\$asset" -DestinationPath "$env:TEMP\yovision-mediamtx-v1.19.3" -Force
& "$env:TEMP\yovision-mediamtx-v1.19.3\mediamtx.exe" "Sense\deploy\mediamtx.yml"
```
Linux amd64 使用同版 `mediamtx_v1.19.3_linux_amd64.tar.gz`,SHA-256 为 `a7ba21268fccda3ebc43fdad76b87fddb85ce77e725b5cb637bca724b5394fbe`。不要把下载的二进制或摄像头凭据提交到仓库。
## T-006 Windows 集成验证
脚本会启动两套独立 MediaMTX、4 个独立 FFmpeg publisher、真实摄像头网络故障代理和 Sense,在临时目录播种 5 条期望态,执行四类恢复后再连续观察 30 分钟。脚本只输出脱敏计数与时间,不保存视频:
```powershell
./Sense/scripts/t006-integration.ps1 -CameraEnv D:\path\to\ip_camera.env
```
`ip_camera.env` 必须保持在 Git 忽略范围内。调试时可把 `-ObservationMinutes` 降为 1;正式 T-006 证据必须使用默认 30 分钟,且最终 `maximum_unconverged`、`final_unconverged` 都为 0。
## T-014 本地 16 路容量基线
T-014 不访问摄像头或客户网络。脚本使用隔离 PostgreSQL v5、真实 Control API、两套 MediaMTX 和 16 个独立的 FFmpeg `-c copy` 合成 publisher,验证 17 路配额拒绝、三轮批量启停、`16 → 0 → 16` Path 收敛、固定四路故障隔离/恢复,以及 30 分钟稳定性和资源观测。
从仓库根目录执行预检、短窗口调试和正式验收:
```powershell
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -ObservationMinutes 1 -OutputPath (Join-Path $env:TEMP 'yovision-t014-smoke.json')
./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal.json')
```
只有默认不少于 30 分钟且输出 `formal_eligible=true` 的单次完整运行可作正式证据;调试窗口固定标记为 false。脚本要求 PostgreSQL 17.10、MediaMTX v1.19.3、FFmpeg 8.1.2 和 `Sense/` 模块的 Go 1.26.5,只使用随机回环端口,运行后清理临时媒体、二进制、PGDATA 和秘密文件。结果 JSON 不含 token、DSN、端口、设备 ID 或流地址。
2026-08-10 正式结果为 `1800.1 s / 180` 个 10 秒样本,最大/最终 `unconverged=0`、最终在线 Path 16、帧错误 0;完整版本、资源数据、失败记录和适用边界见 [`../docs/research/sense-16-stream-capacity.md`](../docs/research/sense-16-stream-capacity.md)。该结果只是本机低码率合成负载的软件基线,不代表真实 16 摄像头、客户网络、录像、AI/GPU、64/128 路或生产 SLA。
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"principals": [
{
"token_sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"subject_id": "replace-with-service-id",
"actor_type": "service",
"tenant_id": "tenant-demo",
"site_ids": ["site-demo"],
"permissions": ["sense.devices.read", "sense.devices.write"]
}
]
}
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
func main() {
listenAddress := flag.String("listen", "127.0.0.1:10554", "local listen address")
upstreamAddress := flag.String("upstream", "", "upstream host:port")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if *upstreamAddress == "" {
logger.Error("upstream is required")
os.Exit(2)
}
if _, _, err := net.SplitHostPort(*upstreamAddress); err != nil {
logger.Error("upstream must be host:port")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := serve(ctx, *listenAddress, *upstreamAddress); err != nil {
logger.Error("RTSP fault proxy stopped", "error", err)
os.Exit(1)
}
}
func serve(ctx context.Context, listenAddress, upstreamAddress string) error {
listener, err := net.Listen("tcp", listenAddress)
if err != nil {
return fmt.Errorf("listen: %w", err)
}
defer listener.Close()
go func() {
<-ctx.Done()
_ = listener.Close()
}()
var connections sync.WaitGroup
defer connections.Wait()
for {
client, acceptErr := listener.Accept()
if acceptErr != nil {
if ctx.Err() != nil {
return nil
}
return fmt.Errorf("accept: %w", acceptErr)
}
connections.Add(1)
go func() {
defer connections.Done()
proxy(client, upstreamAddress)
}()
}
}
func proxy(client net.Conn, upstreamAddress string) {
defer client.Close()
upstream, err := net.DialTimeout("tcp", upstreamAddress, 5*time.Second)
if err != nil {
return
}
defer upstream.Close()
done := make(chan struct{}, 2)
copyOneWay := func(destination, source net.Conn) {
_, _ = io.Copy(destination, source)
done <- struct{}{}
}
go copyOneWay(upstream, client)
go copyOneWay(client, upstream)
<-done
}
+211
View File
@@ -0,0 +1,211 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"yovision/sense/internal/auditrelay"
"yovision/sense/internal/auth"
"yovision/sense/internal/config"
"yovision/sense/internal/console"
"yovision/sense/internal/controlapi"
"yovision/sense/internal/metrics"
"yovision/sense/internal/mtx"
"yovision/sense/internal/onvif"
"yovision/sense/internal/orphan"
"yovision/sense/internal/probe"
"yovision/sense/internal/reconcile"
"yovision/sense/internal/store"
)
var version = "dev"
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("Sense stopped", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
instanceID := cfg.InstanceID
if instanceID == "" {
instanceID, err = metrics.GenerateInstanceID()
if err != nil {
return err
}
}
registry := metrics.New(instanceID, version)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
repository, err := store.OpenRepository(ctx, cfg.DatabaseDriver, cfg.DatabaseDSN)
if err != nil {
return err
}
defer repository.Close()
var auditWorker *auditrelay.Worker
if cfg.AuditRelayEnabled {
relayStore, ok := repository.(auditrelay.Repository)
if !ok {
return errors.New("selected repository does not support audit relay")
}
if err := relayStore.AuditRelayReady(ctx); err != nil {
return err
}
secret, err := auditrelay.LoadKey(cfg.AuditRelayKeyFile, cfg.AuditRelayKeyID)
if err != nil {
return err
}
client, err := auditrelay.NewClient(cfg.AuditRelayURL, cfg.AuditRelayKeyID, secret, nil)
if err != nil {
return err
}
auditWorker, err = auditrelay.NewWorker(relayStore, client, instanceID)
if err != nil {
return err
}
}
var controlHandler http.Handler
if cfg.ControlAPIEnabled {
controlStore, ok := repository.(store.ControlRepository)
if !ok {
return errors.New("selected repository does not support Sense Control API")
}
authenticator, err := auth.LoadStaticSHA256(cfg.ControlAuthFile)
if err != nil {
return err
}
cursors, err := controlapi.LoadCursorCodec(cfg.ControlCursorKeyFile)
if err != nil {
return err
}
controlHandler = controlapi.NewHTTPHandler(controlStore, authenticator, cursors)
}
mediaClient, err := mtx.NewClient(cfg.MediaMTXURL, nil)
if err != nil {
return err
}
credentials := onvif.EnvCredentials{}
var cameraAdapter onvif.Adapter = onvif.UnavailableAdapter{}
if cfg.ONVIFMode == "standard" {
cameraAdapter = onvif.NewHTTPAdapter(credentials, nil, onvif.HTTPOptions{
RTSPRewriteHost: cfg.RTSPRewriteHost,
RTSPRewritePort: cfg.RTSPRewritePort,
StripRTSPQuery: cfg.RTSPStripQuery,
})
}
discovery := onvif.NewRouter(cameraAdapter, credentials)
reconciler := reconcile.NewWithOptions(repository, discovery, mediaClient, reconcile.Options{
InstanceID: instanceID, LeaseDuration: cfg.ReconcileLeaseDuration,
OperationTimeout: cfg.ReconcileOperationTimeout, Metrics: registry,
})
checker := probe.New(repository, mediaClient)
var orphanScanner *orphan.Manager
if cfg.OrphanScanEnabled {
orphanStore, ok := repository.(store.OrphanRepository)
if !ok {
return errors.New("selected repository does not support orphan scanning")
}
orphanScanner = orphan.New(orphanStore, mediaClient, instanceID, registry)
}
report := func(err error) {
// Domain and MediaMTX errors intentionally omit stream URIs and credentials.
logger.Warn("background convergence error", "error", err)
}
var background sync.WaitGroup
startBackground := func(run func()) {
background.Add(1)
go func() {
defer background.Done()
run()
}()
}
startBackground(func() { reconciler.Run(ctx, cfg.ReconcileInterval, report) })
startBackground(func() { checker.Run(ctx, cfg.ProbeInterval, report) })
if orphanScanner != nil {
startBackground(func() { orphanScanner.Run(ctx, cfg.OrphanScanInterval, report) })
}
if auditWorker != nil {
startBackground(func() { auditWorker.Run(ctx, cfg.AuditRelayInterval, report) })
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte(`{"status":"ready"}`))
})
if cfg.MetricsEnabled {
mux.Handle("GET /metrics", registry.Handler())
}
if cfg.ControlAPIEnabled {
mux.Handle("/api/v1/", controlHandler)
}
if cfg.ConsoleEnabled {
consoleHandler, err := console.NewHandler(cfg.ConsoleWebRTCBaseURL)
if err != nil {
return err
}
mux.Handle("/sense-console/", consoleHandler)
mux.HandleFunc("GET /sense-console", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Cache-Control", "no-store")
http.Redirect(writer, request, "/sense-console/", http.StatusTemporaryRedirect)
})
}
server := &http.Server{
Addr: cfg.HTTPAddress, Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
serverErrors := make(chan error, 1)
go func() {
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
"instance_id", instanceID,
"control_api_enabled", cfg.ControlAPIEnabled,
"console_enabled", cfg.ConsoleEnabled,
"audit_relay_enabled", cfg.AuditRelayEnabled)
serverErrors <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
case serverErr := <-serverErrors:
if !errors.Is(serverErr, http.ErrServerClosed) {
stop()
background.Wait()
return fmt.Errorf("serve HTTP: %w", serverErr)
}
}
stop()
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shutdownErr := server.Shutdown(shutdownContext)
background.Wait()
if shutdownErr != nil {
return fmt.Errorf("shutdown HTTP server: %w", shutdownErr)
}
return nil
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"yovision/sense/internal/device"
"yovision/sense/internal/store"
)
type manifest struct {
Site manifestSite `json:"site"`
Devices []manifestDevice `json:"devices"`
}
type manifestSite struct {
TenantID string `json:"tenant_id"`
ID string `json:"id"`
Name string `json:"name"`
MaxVideoChannels int `json:"max_video_channels"`
}
type manifestDevice struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
SiteID string `json:"site_id"`
SerialNumber string `json:"serial_number"`
Name string `json:"name"`
Capabilities []string `json:"capabilities"`
EndpointRef string `json:"endpoint_ref"`
CredentialRef string `json:"credential_ref"`
PathName string `json:"path_name"`
}
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string, output io.Writer) error {
if len(args) == 0 {
return errors.New("usage: sense-lab <seed|status>")
}
switch args[0] {
case "seed":
return seed(args[1:], output)
case "status":
return status(args[1:], output)
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
func seed(args []string, output io.Writer) error {
flags := flag.NewFlagSet("seed", flag.ContinueOnError)
flags.SetOutput(io.Discard)
dsn := flags.String("db", "", "SQLite DSN")
manifestPath := flags.String("manifest", "", "manifest JSON path")
if err := flags.Parse(args); err != nil {
return err
}
if *dsn == "" || *manifestPath == "" {
return errors.New("seed requires -db and -manifest")
}
file, err := os.Open(*manifestPath)
if err != nil {
return fmt.Errorf("open manifest: %w", err)
}
defer file.Close()
var value manifest
decoder := json.NewDecoder(io.LimitReader(file, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return fmt.Errorf("decode manifest: %w", err)
}
repository, err := store.OpenSQLite(context.Background(), *dsn)
if err != nil {
return err
}
defer repository.Close()
ctx := context.Background()
if err := repository.EnsureSite(ctx, device.Site{
TenantID: value.Site.TenantID, ID: value.Site.ID, Name: value.Site.Name,
MaxVideoChannels: value.Site.MaxVideoChannels,
}); err != nil {
return err
}
for _, input := range value.Devices {
capabilities := make([]device.Capability, 0, len(input.Capabilities))
for _, capability := range input.Capabilities {
capabilities = append(capabilities, device.Capability(capability))
}
if err := repository.CreateDevice(ctx, device.Device{
ID: input.ID, TenantID: input.TenantID, SiteID: input.SiteID,
SerialNumber: input.SerialNumber, Name: input.Name, Modality: device.ModalityVideo,
Capabilities: capabilities, DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
EndpointRef: input.EndpointRef, CredentialRef: input.CredentialRef, PathName: input.PathName,
}); err != nil {
return fmt.Errorf("create device %s: %w", input.ID, err)
}
}
return json.NewEncoder(output).Encode(map[string]int{"seeded": len(value.Devices)})
}
func status(args []string, output io.Writer) error {
flags := flag.NewFlagSet("status", flag.ContinueOnError)
flags.SetOutput(io.Discard)
dsn := flags.String("db", "", "SQLite DSN")
expect := flags.Int("expect", -1, "expected device count")
requireConverged := flags.Bool("require-converged", false, "fail when unconverged is non-zero")
if err := flags.Parse(args); err != nil {
return err
}
if *dsn == "" {
return errors.New("status requires -db")
}
repository, err := store.OpenSQLite(context.Background(), *dsn)
if err != nil {
return err
}
defer repository.Close()
snapshot, err := repository.ConvergenceSnapshot(context.Background())
if err != nil {
return err
}
if err := json.NewEncoder(output).Encode(snapshot); err != nil {
return err
}
if *expect >= 0 && snapshot.Total != *expect {
return fmt.Errorf("expected %d devices, got %d", *expect, snapshot.Total)
}
if *requireConverged && snapshot.Unconverged != 0 {
return fmt.Errorf("unconverged devices: %d", snapshot.Unconverged)
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
// sense-orphan is a local, PostgreSQL-backed operational command. Report is
// read-only; apply requires a fresh scan ID, actor and exact confirmation.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"yovision/sense/internal/config"
"yovision/sense/internal/metrics"
"yovision/sense/internal/mtx"
"yovision/sense/internal/orphan"
"yovision/sense/internal/store"
)
func main() {
if err := run(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "sense-orphan failed:", err)
os.Exit(1)
}
}
func run() error {
mode := flag.String("mode", "report", "report or apply")
scanID := flag.String("scan-id", "", "fresh scan ID required by apply")
actor := flag.String("actor", "", "operator identifier required by apply")
confirm := flag.String("confirm", "", "exact confirmation: DELETE <scan-id>")
flag.Parse()
cfg, err := config.Load()
if err != nil {
return err
}
if !strings.EqualFold(strings.TrimSpace(cfg.DatabaseDriver), store.DriverPostgres) {
return errors.New("sense-orphan requires SENSE_DB_DRIVER=postgres")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
repository, err := store.OpenPostgres(ctx, cfg.DatabaseDSN)
if err != nil {
return err
}
defer repository.Close()
media, err := mtx.NewClient(cfg.MediaMTXURL, &http.Client{Timeout: cfg.ReconcileOperationTimeout})
if err != nil {
return err
}
instanceID := cfg.InstanceID
if instanceID == "" {
instanceID, err = metrics.GenerateInstanceID()
if err != nil {
return err
}
}
manager := orphan.New(repository, media, instanceID, metrics.New(instanceID, "operator"))
encoder := json.NewEncoder(os.Stdout)
switch *mode {
case "report":
scan, err := manager.Report(ctx)
if err != nil {
return err
}
return encoder.Encode(struct {
ScanID string `json:"scan_id"`
Observed int `json:"observed"`
OwnedStale int `json:"owned_stale"`
Unowned int `json:"unowned"`
SafetyAllowed bool `json:"safety_allowed"`
SafetyReason string `json:"safety_reason"`
ExpiresAt string `json:"expires_at"`
}{
ScanID: scan.ID, Observed: scan.ObservedCount,
OwnedStale: scan.OwnedStaleCount, Unowned: scan.UnownedCount,
SafetyAllowed: scan.SafetyAllowed, SafetyReason: scan.SafetyReason,
ExpiresAt: scan.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
})
case "apply":
result, err := manager.Apply(ctx, *scanID, *actor, *confirm)
if encodeErr := encoder.Encode(result); encodeErr != nil {
return encodeErr
}
return err
default:
return errors.New("mode must be report or apply")
}
}
+21
View File
@@ -0,0 +1,21 @@
# T-006-only publisher fixture. This is a separate MediaMTX instance; Sense
# never edits these source paths. Each path has one independent FFmpeg process.
logLevel: warn
rtspAddress: 127.0.0.1:8555
rtspTransports: [tcp]
api: false
metrics: false
rtmp: false
hls: false
webrtc: false
srt: false
moq: false
paths:
synthetic-1:
source: publisher
synthetic-2:
source: publisher
synthetic-3:
source: publisher
synthetic-4:
source: publisher
+9
View File
@@ -0,0 +1,9 @@
# MediaMTX v1.19.3 minimal control-plane configuration for local T-003 work.
# Keep the API on loopback. Production authentication/network policy is a
# later deployment concern and must be in place before any non-loopback bind.
logLevel: info
api: true
apiAddress: 127.0.0.1:9997
metrics: true
metricsAddress: 127.0.0.1:9998
paths: {}
+46
View File
@@ -0,0 +1,46 @@
module yovision/sense
go 1.26.0
toolchain go1.26.5
require (
github.com/jackc/pgx/v5 v5.10.0
github.com/oapi-codegen/runtime v1.6.0
modernc.org/sqlite v1.54.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/getkin/kin-openapi v0.142.0 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/speakeasy-api/jsonpath v0.6.3 // indirect
github.com/speakeasy-api/openapi v1.24.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.48.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
+234
View File
@@ -0,0 +1,234 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w=
github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w=
github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM=
github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo=
github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU=
github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU=
github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI=
github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04=
github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -0,0 +1,132 @@
package auditrelay
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func testEnvelope(id string) Envelope {
return Envelope{SchemaVersion: 1, Event: Event{
EventID: id, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1",
Actor: Actor{Type: "system", ID: "sense"}, AggregateGeneration: 1,
ProjectionVersions: ProjectionVersions{}, Data: json.RawMessage(`{"kind":"device_created"}`), OccurredAt: time.Unix(1, 0).UTC(),
}}
}
func TestClientSignsCanonicalRequest(t *testing.T) {
secret := bytesOf(32, 7)
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
body := make([]byte, request.ContentLength)
_, _ = request.Body.Read(body)
canonical := CanonicalString(request.Method, request.URL.Path, request.Header.Get(HeaderTimestamp), request.Header.Get(HeaderNonce), body)
if request.Header.Get(HeaderSignature) != Signature(secret, canonical) {
t.Error("request signature did not match canonical vector")
}
_ = json.NewEncoder(writer).Encode(BatchResponse{Results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}})
}))
defer server.Close()
client, err := NewClient(server.URL+RelayPath, "sense-a", secret, server.Client())
if err != nil {
t.Fatal(err)
}
client.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
client.nonce = func() (string, error) { return "AAAAAAAAAAAAAAAAAAAAAA", nil }
results, err := client.Send(context.Background(), []Envelope{testEnvelope("audit_00000000000000000000000000000001")})
if err != nil || len(results) != 1 || results[0].Status != "accepted" {
t.Fatalf("unexpected relay result: %+v, %v", results, err)
}
}
func TestEndpointAndKeySecurity(t *testing.T) {
if _, err := ValidateEndpoint("http://example.com" + RelayPath); err == nil {
t.Fatal("remote plaintext relay URL was accepted")
}
if _, err := ValidateEndpoint("https://example.com" + RelayPath + "?secret=x"); err == nil {
t.Fatal("relay URL query was accepted")
}
secret := bytesOf(32, 9)
path := filepath.Join(t.TempDir(), "keys.json")
document := map[string]any{"version": 1, "keys": []any{map[string]any{"key_id": "sense-a", "secret_base64url": base64.RawURLEncoding.EncodeToString(secret)}}}
raw, _ := json.Marshal(document)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
loaded, err := LoadKey(path, "sense-a")
if err != nil || sha256.Sum256(loaded) != sha256.Sum256(secret) {
t.Fatalf("external key was not loaded: %v", err)
}
}
type fakeRepository struct {
queued []QueuedEvent
completed []Completion
}
func (*fakeRepository) AuditRelayReady(context.Context) error { return nil }
func (f *fakeRepository) ClaimAuditRelayBatch(context.Context, string, int, time.Duration) ([]QueuedEvent, error) {
return f.queued, nil
}
func (f *fakeRepository) CompleteAuditRelayBatch(_ context.Context, _ string, values []Completion) error {
f.completed = append([]Completion(nil), values...)
return nil
}
type fakeSender struct {
results []Result
err error
}
func (f fakeSender) Send(context.Context, []Envelope) ([]Result, error) { return f.results, f.err }
func TestWorkerDispositionAndBackoff(t *testing.T) {
first := "audit_00000000000000000000000000000001"
second := "audit_00000000000000000000000000000002"
repository := &fakeRepository{queued: []QueuedEvent{{Envelope: testEnvelope(first), LeaseToken: 1, AttemptCount: 1}, {Envelope: testEnvelope(second), LeaseToken: 2, AttemptCount: 10}}}
code := "schema_invalid"
worker, _ := NewWorker(repository, fakeSender{results: []Result{{EventID: first, Status: "accepted"}, {EventID: second, Status: "rejected", ErrorCode: &code}}}, "worker")
if err := worker.RelayOnce(context.Background()); err != nil {
t.Fatal(err)
}
if repository.completed[0].Disposition != Delivered || repository.completed[1].Disposition != DeadLetter {
t.Fatalf("unexpected dispositions: %+v", repository.completed)
}
repository.completed = nil
worker.sender = fakeSender{err: errors.New("network")}
if err := worker.RelayOnce(context.Background()); err == nil {
t.Fatal("network failure was hidden")
}
if repository.completed[0].RetryAfter != time.Second || repository.completed[1].RetryAfter != 300*time.Second {
t.Fatalf("retry bounds drifted: %+v", repository.completed)
}
}
func TestWorkerRetriesWholeBatchForIncompleteResponse(t *testing.T) {
first := "audit_00000000000000000000000000000001"
second := "audit_00000000000000000000000000000002"
repository := &fakeRepository{queued: []QueuedEvent{{Envelope: testEnvelope(first), LeaseToken: 1, AttemptCount: 1}, {Envelope: testEnvelope(second), LeaseToken: 2, AttemptCount: 2}}}
worker, _ := NewWorker(repository, fakeSender{results: []Result{{EventID: first, Status: "accepted"}}}, "worker")
if err := worker.RelayOnce(context.Background()); err == nil {
t.Fatal("incomplete response was accepted")
}
if len(repository.completed) != 2 || repository.completed[0].Disposition != Retry || repository.completed[1].Disposition != Retry {
t.Fatalf("incomplete response partially completed the batch: %+v", repository.completed)
}
}
func bytesOf(size int, value byte) []byte {
result := make([]byte, size)
for index := range result {
result[index] = value
}
return result
}
+131
View File
@@ -0,0 +1,131 @@
package auditrelay
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
HeaderKeyID = "X-YoVision-Key-Id"
HeaderTimestamp = "X-YoVision-Timestamp"
HeaderNonce = "X-YoVision-Nonce"
HeaderSignature = "X-YoVision-Signature"
RelayPath = "/internal/v1/audit-events:batch"
)
type Client struct {
endpoint *url.URL
keyID string
secret []byte
httpClient *http.Client
now func() time.Time
nonce func() (string, error)
}
func NewClient(rawURL, keyID string, secret []byte, client *http.Client) (*Client, error) {
endpoint, err := ValidateEndpoint(rawURL)
if err != nil {
return nil, err
}
if keyID == "" || len(secret) < 32 {
return nil, errors.New("audit relay key ID and 32-byte secret are required")
}
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &Client{endpoint: endpoint, keyID: keyID, secret: append([]byte(nil), secret...), httpClient: client, now: time.Now, nonce: randomNonce}, nil
}
func ValidateEndpoint(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || parsed.Path != RelayPath || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
return nil, errors.New("invalid Bell audit relay URL")
}
host := parsed.Hostname()
ip := net.ParseIP(host)
loopback := strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback())
if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) {
return nil, errors.New("Bell audit relay URL requires HTTPS outside loopback")
}
return parsed, nil
}
func CanonicalString(method, path, timestamp, nonce string, body []byte) string {
digest := sha256.Sum256(body)
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
}
func Signature(secret []byte, canonical string) string {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(canonical))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func randomNonce() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func (c *Client) Send(ctx context.Context, events []Envelope) ([]Result, error) {
if len(events) < 1 || len(events) > MaxBatchSize {
return nil, errors.New("audit relay batch must contain 1 to 100 events")
}
body, err := json.Marshal(BatchRequest{Events: events})
if err != nil || len(body) > MaxBodyBytes {
return nil, errors.New("encode audit relay batch")
}
requestContext, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
timestamp := strconv.FormatInt(c.now().UTC().Unix(), 10)
nonce, err := c.nonce()
if err != nil {
return nil, errors.New("generate audit relay nonce")
}
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, c.endpoint.String(), bytes.NewReader(body))
if err != nil {
return nil, errors.New("create audit relay request")
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set(HeaderKeyID, c.keyID)
request.Header.Set(HeaderTimestamp, timestamp)
request.Header.Set(HeaderNonce, nonce)
request.Header.Set(HeaderSignature, Signature(c.secret, CanonicalString(http.MethodPost, RelayPath, timestamp, nonce, body)))
response, err := c.httpClient.Do(request)
if err != nil {
return nil, errors.New("send audit relay request")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return nil, fmt.Errorf("Bell audit relay returned HTTP %d", response.StatusCode)
}
decoder := json.NewDecoder(io.LimitReader(response.Body, MaxBodyBytes+1))
decoder.DisallowUnknownFields()
var decoded BatchResponse
if err := decoder.Decode(&decoded); err != nil {
return nil, errors.New("decode audit relay response")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("audit relay response contains trailing data")
}
return decoded.Results, nil
}
+53
View File
@@ -0,0 +1,53 @@
package auditrelay
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"regexp"
)
var keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
type keyFile struct {
Version int `json:"version"`
Keys []struct {
KeyID string `json:"key_id"`
Secret string `json:"secret_base64url"`
} `json:"keys"`
}
func LoadKey(path, keyID string) ([]byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read audit relay key file")
}
var document keyFile
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
return nil, errors.New("invalid audit relay key file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid audit relay key file")
}
values := make(map[string][]byte, len(document.Keys))
for _, candidate := range document.Keys {
secret, err := base64.RawURLEncoding.DecodeString(candidate.Secret)
if err != nil || !keyIDPattern.MatchString(candidate.KeyID) || len(secret) < 32 {
return nil, errors.New("invalid audit relay secret")
}
if _, duplicate := values[candidate.KeyID]; duplicate {
return nil, errors.New("duplicate audit relay key ID")
}
values[candidate.KeyID] = secret
}
if secret, exists := values[keyID]; exists {
return secret, nil
}
return nil, errors.New("audit relay key ID not found")
}
+89
View File
@@ -0,0 +1,89 @@
// Package auditrelay delivers Sense-owned audit facts to Bell without sharing databases.
package auditrelay
import (
"context"
"encoding/json"
"errors"
"time"
)
const (
MaxBatchSize = 100
MaxBodyBytes = 1 << 20
LeaseDuration = 30 * time.Second
)
type Actor struct {
Type string `json:"type"`
ID string `json:"id"`
}
type ProjectionVersions struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
}
type Event struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
TenantID string `json:"tenant_id"`
SiteID string `json:"site_id"`
DeviceID string `json:"device_id"`
Actor Actor `json:"actor"`
Reason *string `json:"reason"`
TraceID *string `json:"trace_id"`
AggregateGeneration int64 `json:"aggregate_generation"`
ProjectionVersions ProjectionVersions `json:"projection_versions"`
Data json.RawMessage `json:"data"`
OccurredAt time.Time `json:"occurred_at"`
}
type Envelope struct {
SchemaVersion int `json:"schema_version"`
Event Event `json:"event"`
}
type BatchRequest struct {
Events []Envelope `json:"events"`
}
type Result struct {
EventID string `json:"event_id"`
Status string `json:"status"`
ErrorCode *string `json:"error_code,omitempty"`
}
type BatchResponse struct {
Results []Result `json:"results"`
}
type QueuedEvent struct {
Envelope
LeaseToken int64
AttemptCount int
}
type Disposition string
const (
Delivered Disposition = "delivered"
DeadLetter Disposition = "dead_letter"
Retry Disposition = "retry"
)
type Completion struct {
EventID string
LeaseToken int64
Disposition Disposition
ErrorCode string
RetryAfter time.Duration
}
var ErrLeaseLost = errors.New("audit relay lease lost")
type Repository interface {
AuditRelayReady(context.Context) error
ClaimAuditRelayBatch(context.Context, string, int, time.Duration) ([]QueuedEvent, error)
CompleteAuditRelayBatch(context.Context, string, []Completion) error
}
+126
View File
@@ -0,0 +1,126 @@
package auditrelay
import (
"context"
"errors"
"fmt"
"regexp"
"time"
)
var stableErrorCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
type Sender interface {
Send(context.Context, []Envelope) ([]Result, error)
}
type Worker struct {
repository Repository
sender Sender
owner string
}
func NewWorker(repository Repository, sender Sender, owner string) (*Worker, error) {
if repository == nil || sender == nil || owner == "" {
return nil, errors.New("audit relay worker dependencies are required")
}
return &Worker{repository: repository, sender: sender, owner: owner}, nil
}
func RetryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
if attempt > 9 {
return 300 * time.Second
}
delay := time.Second << (attempt - 1)
if delay > 300*time.Second {
return 300 * time.Second
}
return delay
}
func (w *Worker) RelayOnce(ctx context.Context) error {
queued, err := w.repository.ClaimAuditRelayBatch(ctx, w.owner, MaxBatchSize, LeaseDuration)
if err != nil || len(queued) == 0 {
return err
}
events := make([]Envelope, len(queued))
for index := range queued {
events[index] = queued[index].Envelope
}
results, sendErr := w.sender.Send(ctx, events)
if sendErr != nil {
completions := make([]Completion, len(queued))
for index, value := range queued {
completions[index] = Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Retry, ErrorCode: "delivery_failed", RetryAfter: RetryDelay(value.AttemptCount)}
}
if err := w.repository.CompleteAuditRelayBatch(ctx, w.owner, completions); err != nil {
return err
}
return sendErr
}
if len(results) != len(queued) {
return w.retryAll(ctx, queued, "invalid_response")
}
expected := make(map[string]bool, len(queued))
for _, value := range queued {
expected[value.Event.EventID] = true
}
byID := make(map[string]Result, len(results))
for _, result := range results {
validStatus := ((result.Status == "accepted" || result.Status == "duplicate") && result.ErrorCode == nil) ||
(result.Status == "rejected" && result.ErrorCode != nil && stableErrorCode.MatchString(*result.ErrorCode))
if !expected[result.EventID] || !validStatus {
return w.retryAll(ctx, queued, "invalid_response")
}
if _, duplicate := byID[result.EventID]; duplicate {
return w.retryAll(ctx, queued, "invalid_response")
}
byID[result.EventID] = result
}
completions := make([]Completion, 0, len(queued))
for _, value := range queued {
result, ok := byID[value.Event.EventID]
if !ok {
return w.retryAll(ctx, queued, "invalid_response")
}
switch result.Status {
case "accepted", "duplicate":
completions = append(completions, Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Delivered})
case "rejected":
completions = append(completions, Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: DeadLetter, ErrorCode: *result.ErrorCode})
}
}
return w.repository.CompleteAuditRelayBatch(ctx, w.owner, completions)
}
func (w *Worker) retryAll(ctx context.Context, queued []QueuedEvent, code string) error {
values := make([]Completion, len(queued))
for index, value := range queued {
values[index] = Completion{EventID: value.Event.EventID, LeaseToken: value.LeaseToken, Disposition: Retry, ErrorCode: code, RetryAfter: RetryDelay(value.AttemptCount)}
}
if err := w.repository.CompleteAuditRelayBatch(ctx, w.owner, values); err != nil {
return err
}
return fmt.Errorf("audit relay %s", code)
}
func (w *Worker) Run(ctx context.Context, interval time.Duration, report func(error)) {
if interval <= 0 {
interval = time.Second
}
for {
if err := w.RelayOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
report(err)
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
+181
View File
@@ -0,0 +1,181 @@
// Package auth defines the verified principal boundary for Sense HTTP APIs.
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"strings"
)
const (
PermissionDevicesRead = "sense.devices.read"
PermissionDevicesWrite = "sense.devices.write"
)
var (
ErrUnauthenticated = errors.New("unauthenticated")
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
)
type Principal struct {
SubjectID string
ActorType string
TenantID string
SiteIDs []string
Permissions map[string]struct{}
}
func (p Principal) AllowsSite(siteID string) bool {
for _, allowed := range p.SiteIDs {
if allowed == "*" || allowed == siteID {
return true
}
}
return false
}
func (p Principal) Has(permission string) bool {
_, ok := p.Permissions[permission]
return ok
}
type Authenticator interface {
Authenticate(context.Context, string) (Principal, error)
}
type staticFile struct {
Version int `json:"version"`
Principals []staticPrincipal `json:"principals"`
}
type staticPrincipal struct {
TokenSHA256 string `json:"token_sha256"`
SubjectID string `json:"subject_id"`
ActorType string `json:"actor_type"`
TenantID string `json:"tenant_id"`
SiteIDs []string `json:"site_ids"`
Permissions []string `json:"permissions"`
}
type staticEntry struct {
digest [sha256.Size]byte
principal Principal
}
// StaticSHA256 authenticates opaque tokens against externally provisioned
// SHA-256 digests. The source file is read only during process startup.
type StaticSHA256 struct {
entries []staticEntry
}
func LoadStaticSHA256(path string) (*StaticSHA256, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read static authentication registry")
}
decoder := json.NewDecoder(strings.NewReader(string(contents)))
decoder.DisallowUnknownFields()
var document staticFile
if err := decoder.Decode(&document); err != nil {
return nil, errors.New("decode static authentication registry")
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return nil, errors.New("static authentication registry has trailing JSON")
}
if document.Version != 1 || len(document.Principals) == 0 {
return nil, errors.New("static authentication registry must contain version 1 principals")
}
entries := make([]staticEntry, 0, len(document.Principals))
seenDigests := make(map[string]struct{}, len(document.Principals))
for index, value := range document.Principals {
entry, validationErr := parseStaticPrincipal(value)
if validationErr != nil {
return nil, fmt.Errorf("invalid static authentication principal %d: %w", index, validationErr)
}
if _, exists := seenDigests[value.TokenSHA256]; exists {
return nil, errors.New("duplicate static authentication token digest")
}
seenDigests[value.TokenSHA256] = struct{}{}
entries = append(entries, entry)
}
return &StaticSHA256{entries: entries}, nil
}
func parseStaticPrincipal(value staticPrincipal) (staticEntry, error) {
var entry staticEntry
if len(value.TokenSHA256) != sha256.Size*2 || value.TokenSHA256 != strings.ToLower(value.TokenSHA256) {
return entry, errors.New("token_sha256 must be 64 lowercase hexadecimal characters")
}
digest, err := hex.DecodeString(value.TokenSHA256)
if err != nil {
return entry, errors.New("token_sha256 must be hexadecimal")
}
copy(entry.digest[:], digest)
if strings.TrimSpace(value.SubjectID) == "" || len(value.SubjectID) > 200 {
return entry, errors.New("subject_id must contain 1 to 200 characters")
}
if value.ActorType != "user" && value.ActorType != "service" {
return entry, errors.New("actor_type must be user or service")
}
if !logicalIDPattern.MatchString(value.TenantID) {
return entry, errors.New("tenant_id is invalid")
}
if len(value.SiteIDs) == 0 {
return entry, errors.New("site_ids must not be empty")
}
sites := make([]string, 0, len(value.SiteIDs))
seenSites := make(map[string]struct{}, len(value.SiteIDs))
for _, siteID := range value.SiteIDs {
if siteID != "*" && !logicalIDPattern.MatchString(siteID) {
return entry, errors.New("site_ids contains an invalid site")
}
if _, exists := seenSites[siteID]; exists {
return entry, errors.New("site_ids contains a duplicate")
}
seenSites[siteID] = struct{}{}
sites = append(sites, siteID)
}
permissions := make(map[string]struct{}, len(value.Permissions))
for _, permission := range value.Permissions {
if permission != PermissionDevicesRead && permission != PermissionDevicesWrite {
return entry, errors.New("permissions contains an unsupported value")
}
if _, exists := permissions[permission]; exists {
return entry, errors.New("permissions contains a duplicate")
}
permissions[permission] = struct{}{}
}
if len(permissions) == 0 {
return entry, errors.New("permissions must not be empty")
}
entry.principal = Principal{
SubjectID: value.SubjectID, ActorType: value.ActorType, TenantID: value.TenantID,
SiteIDs: sites, Permissions: permissions,
}
return entry, nil
}
func (a *StaticSHA256) Authenticate(_ context.Context, token string) (Principal, error) {
if len(token) < 22 || len(token) > 4096 {
return Principal{}, ErrUnauthenticated
}
digest := sha256.Sum256([]byte(token))
match := -1
for index := range a.entries {
if subtle.ConstantTimeCompare(digest[:], a.entries[index].digest[:]) == 1 {
match = index
}
}
if match < 0 {
return Principal{}, ErrUnauthenticated
}
return a.entries[match].principal, nil
}
+57
View File
@@ -0,0 +1,57 @@
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStaticSHA256AuthenticatesScopedPrincipal(t *testing.T) {
token := "0123456789abcdef0123456789abcdef"
digest := sha256.Sum256([]byte(token))
path := filepath.Join(t.TempDir(), "auth.json")
document := `{"version":1,"principals":[{` +
`"token_sha256":"` + hex.EncodeToString(digest[:]) + `",` +
`"subject_id":"operator-1","actor_type":"user","tenant_id":"tenant-a",` +
`"site_ids":["site-a"],"permissions":["sense.devices.read"]}]}`
if err := os.WriteFile(path, []byte(document), 0o600); err != nil {
t.Fatal(err)
}
authenticator, err := LoadStaticSHA256(path)
if err != nil {
t.Fatal(err)
}
principal, err := authenticator.Authenticate(context.Background(), token)
if err != nil {
t.Fatal(err)
}
if principal.TenantID != "tenant-a" || !principal.AllowsSite("site-a") ||
principal.AllowsSite("site-b") || !principal.Has(PermissionDevicesRead) {
t.Fatalf("unexpected principal: %+v", principal)
}
if _, err := authenticator.Authenticate(context.Background(), strings.Repeat("x", 32)); err == nil {
t.Fatal("unknown token authenticated")
}
}
func TestStaticSHA256RejectsUnsafeRegistry(t *testing.T) {
tests := []string{
`{"version":1,"principals":[]}`,
`{"version":1,"principals":[{"token_sha256":"ABC","subject_id":"x","actor_type":"service","tenant_id":"tenant","site_ids":["*"],"permissions":["sense.devices.read"]}]}`,
`{"version":1,"principals":[{"token_sha256":"` + strings.Repeat("0", 64) + `","subject_id":"x","actor_type":"system","tenant_id":"tenant","site_ids":["*"],"permissions":["sense.devices.read"]}]}`,
`{"version":1,"principals":[{"token_sha256":"` + strings.Repeat("0", 64) + `","subject_id":"x","actor_type":"service","tenant_id":"tenant","site_ids":["*"],"permissions":["admin"]}]}`,
}
for index, document := range tests {
path := filepath.Join(t.TempDir(), "auth.json")
if err := os.WriteFile(path, []byte(document), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadStaticSHA256(path); err == nil {
t.Fatalf("unsafe registry %d was accepted", index)
}
}
}
+348
View File
@@ -0,0 +1,348 @@
// Package config loads and validates the Sense process configuration.
package config
import (
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
const (
defaultHTTPAddress = "127.0.0.1:8080"
defaultDatabaseDriver = "sqlite"
postgresDatabaseDriver = "postgres"
defaultDatabaseDSN = "file:data/sense.db"
defaultMediaMTXURL = "http://127.0.0.1:9997"
defaultReconcilePeriod = 5 * time.Second
defaultReconcileLease = 30 * time.Second
defaultOperationTimeout = 20 * time.Second
defaultProbePeriod = 10 * time.Second
defaultOrphanScanPeriod = time.Minute
defaultONVIFMode = "disabled"
defaultControlAuthMode = "static-sha256"
defaultAuditRelayPeriod = time.Second
defaultConsoleWebRTCURL = "http://127.0.0.1:8889"
)
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
type Config struct {
HTTPAddress string
AllowNonLoopback bool
DatabaseDriver string
DatabaseDSN string
MediaMTXURL string
ReconcileInterval time.Duration
ReconcileLeaseDuration time.Duration
ReconcileOperationTimeout time.Duration
ProbeInterval time.Duration
InstanceID string
MetricsEnabled bool
OrphanScanEnabled bool
OrphanScanInterval time.Duration
ONVIFMode string
RTSPRewriteHost string
RTSPRewritePort int
RTSPStripQuery bool
ControlAPIEnabled bool
ControlAuthMode string
ControlAuthFile string
ControlCursorKeyFile string
ControlAllowInsecureHTTP bool
ConsoleEnabled bool
ConsoleWebRTCBaseURL string
AuditRelayEnabled bool
AuditRelayURL string
AuditRelayKeyFile string
AuditRelayKeyID string
AuditRelayInterval time.Duration
}
func Load() (Config, error) {
allow, err := boolEnv("SENSE_ALLOW_NON_LOOPBACK", false)
if err != nil {
return Config{}, err
}
reconcilePeriod, err := durationEnv("SENSE_RECONCILE_INTERVAL", defaultReconcilePeriod)
if err != nil {
return Config{}, err
}
reconcileLease, err := durationEnv("SENSE_RECONCILE_LEASE_DURATION", defaultReconcileLease)
if err != nil {
return Config{}, err
}
operationTimeout, err := durationEnv("SENSE_RECONCILE_OPERATION_TIMEOUT", defaultOperationTimeout)
if err != nil {
return Config{}, err
}
probePeriod, err := durationEnv("SENSE_PROBE_INTERVAL", defaultProbePeriod)
if err != nil {
return Config{}, err
}
rewritePort, err := intEnv("SENSE_ONVIF_RTSP_REWRITE_PORT", 0)
if err != nil {
return Config{}, err
}
stripQuery, err := boolEnv("SENSE_ONVIF_RTSP_STRIP_QUERY", false)
if err != nil {
return Config{}, err
}
controlEnabled, err := boolEnv("SENSE_CONTROL_API_ENABLED", false)
if err != nil {
return Config{}, err
}
controlAllowInsecure, err := boolEnv("SENSE_CONTROL_ALLOW_INSECURE_HTTP", false)
if err != nil {
return Config{}, err
}
consoleEnabled, err := boolEnv("SENSE_CONSOLE_ENABLED", false)
if err != nil {
return Config{}, err
}
auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false)
if err != nil {
return Config{}, err
}
auditRelayInterval, err := durationEnv("SENSE_AUDIT_RELAY_INTERVAL", defaultAuditRelayPeriod)
if err != nil {
return Config{}, err
}
metricsEnabled, err := boolEnv("SENSE_METRICS_ENABLED", true)
if err != nil {
return Config{}, err
}
databaseDriver := stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver)
orphanDefault := strings.EqualFold(strings.TrimSpace(databaseDriver), postgresDatabaseDriver)
orphanEnabled, err := boolEnv("SENSE_ORPHAN_SCAN_ENABLED", orphanDefault)
if err != nil {
return Config{}, err
}
orphanPeriod, err := durationEnv("SENSE_ORPHAN_SCAN_INTERVAL", defaultOrphanScanPeriod)
if err != nil {
return Config{}, err
}
cfg := Config{
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
AllowNonLoopback: allow,
DatabaseDriver: databaseDriver,
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
ReconcileInterval: reconcilePeriod,
ReconcileLeaseDuration: reconcileLease,
ReconcileOperationTimeout: operationTimeout,
ProbeInterval: probePeriod,
InstanceID: stringEnv("SENSE_INSTANCE_ID", ""),
MetricsEnabled: metricsEnabled,
OrphanScanEnabled: orphanEnabled,
OrphanScanInterval: orphanPeriod,
ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode),
RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""),
RTSPRewritePort: rewritePort,
RTSPStripQuery: stripQuery,
ControlAPIEnabled: controlEnabled,
ControlAuthMode: stringEnv("SENSE_CONTROL_AUTH_MODE", defaultControlAuthMode),
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
ControlAllowInsecureHTTP: controlAllowInsecure,
ConsoleEnabled: consoleEnabled,
ConsoleWebRTCBaseURL: stringEnv("SENSE_CONSOLE_WEBRTC_BASE_URL", defaultConsoleWebRTCURL),
AuditRelayEnabled: auditRelayEnabled,
AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""),
AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""),
AuditRelayKeyID: stringEnv("SENSE_AUDIT_RELAY_KEY_ID", ""),
AuditRelayInterval: auditRelayInterval,
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) Validate() error {
host, _, err := net.SplitHostPort(c.HTTPAddress)
if err != nil {
return fmt.Errorf("invalid SENSE_HTTP_ADDR: %w", err)
}
ip := net.ParseIP(host)
isLoopback := host == "localhost" || (ip != nil && ip.IsLoopback())
if !isLoopback && !c.AllowNonLoopback {
return fmt.Errorf("non-loopback HTTP bind requires SENSE_ALLOW_NON_LOOPBACK=true")
}
if c.DatabaseDSN == "" {
return fmt.Errorf("SENSE_DB_DSN must not be empty")
}
databaseDriver := strings.ToLower(strings.TrimSpace(c.DatabaseDriver))
if databaseDriver == "" {
databaseDriver = defaultDatabaseDriver
}
if databaseDriver != "sqlite" && databaseDriver != "postgres" {
return fmt.Errorf("SENSE_DB_DRIVER must be sqlite or postgres")
}
if databaseDriver == "postgres" && strings.HasPrefix(c.DatabaseDSN, "file:") {
return fmt.Errorf("postgres SENSE_DB_DRIVER requires an explicit PostgreSQL SENSE_DB_DSN")
}
mediaURL, err := url.Parse(c.MediaMTXURL)
if err != nil || mediaURL.Scheme == "" || mediaURL.Host == "" {
return fmt.Errorf("invalid SENSE_MEDIAMTX_URL")
}
if mediaURL.User != nil {
return fmt.Errorf("SENSE_MEDIAMTX_URL must not contain credentials")
}
if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 {
return fmt.Errorf("loop intervals must be positive")
}
leaseDuration := c.ReconcileLeaseDuration
if leaseDuration == 0 {
leaseDuration = defaultReconcileLease
}
operationTimeout := c.ReconcileOperationTimeout
if operationTimeout == 0 {
operationTimeout = defaultOperationTimeout
}
if leaseDuration <= 0 || leaseDuration > 5*time.Minute {
return fmt.Errorf("SENSE_RECONCILE_LEASE_DURATION must be positive and at most 5m")
}
if operationTimeout <= 0 || operationTimeout >= leaseDuration {
return fmt.Errorf("SENSE_RECONCILE_OPERATION_TIMEOUT must be positive and shorter than the lease")
}
if c.InstanceID != "" && !instanceIDPattern.MatchString(c.InstanceID) {
return fmt.Errorf("invalid SENSE_INSTANCE_ID")
}
if c.OrphanScanEnabled {
if databaseDriver != postgresDatabaseDriver {
return fmt.Errorf("orphan scanning requires SENSE_DB_DRIVER=postgres")
}
orphanInterval := c.OrphanScanInterval
if orphanInterval == 0 {
orphanInterval = defaultOrphanScanPeriod
}
if orphanInterval < 10*time.Second {
return fmt.Errorf("SENSE_ORPHAN_SCAN_INTERVAL must be at least 10s")
}
}
if c.ONVIFMode != "" && c.ONVIFMode != "disabled" && c.ONVIFMode != "standard" {
return fmt.Errorf("SENSE_ONVIF_MODE must be disabled or standard")
}
if c.RTSPRewritePort < 0 || c.RTSPRewritePort > 65535 {
return fmt.Errorf("SENSE_ONVIF_RTSP_REWRITE_PORT must be between 0 and 65535")
}
if c.RTSPRewriteHost != "" {
if strings.TrimSpace(c.RTSPRewriteHost) != c.RTSPRewriteHost ||
strings.ContainsAny(c.RTSPRewriteHost, "/@") {
return fmt.Errorf("invalid SENSE_ONVIF_RTSP_REWRITE_HOST")
}
}
if c.ControlAPIEnabled {
if databaseDriver != "postgres" {
return fmt.Errorf("Sense Control API requires SENSE_DB_DRIVER=postgres")
}
if c.ControlAuthMode != defaultControlAuthMode {
return fmt.Errorf("SENSE_CONTROL_AUTH_MODE must be static-sha256")
}
if c.ControlAuthFile == "" || !filepath.IsAbs(c.ControlAuthFile) {
return fmt.Errorf("SENSE_CONTROL_AUTH_FILE must be an absolute external path")
}
if c.ControlCursorKeyFile == "" || !filepath.IsAbs(c.ControlCursorKeyFile) {
return fmt.Errorf("SENSE_CONTROL_CURSOR_KEY_FILE must be an absolute external path")
}
if !isLoopback && !c.ControlAllowInsecureHTTP {
return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true")
}
}
if c.ConsoleEnabled {
if !c.ControlAPIEnabled {
return fmt.Errorf("Sense console requires SENSE_CONTROL_API_ENABLED=true")
}
if !isLoopback {
return fmt.Errorf("Sense console requires an explicit loopback SENSE_HTTP_ADDR")
}
previewURL, err := url.Parse(c.ConsoleWebRTCBaseURL)
if err != nil || previewURL.Host == "" ||
(previewURL.Scheme != "http" && previewURL.Scheme != "https") ||
previewURL.User != nil || previewURL.RawQuery != "" || previewURL.Fragment != "" ||
(previewURL.Path != "" && previewURL.Path != "/") {
return fmt.Errorf("invalid SENSE_CONSOLE_WEBRTC_BASE_URL")
}
previewHost := previewURL.Hostname()
previewIP := net.ParseIP(previewHost)
if previewHost != "localhost" && (previewIP == nil || !previewIP.IsLoopback()) {
return fmt.Errorf("SENSE_CONSOLE_WEBRTC_BASE_URL must use an explicit loopback host")
}
}
if c.AuditRelayEnabled {
if databaseDriver != postgresDatabaseDriver {
return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres")
}
if c.AuditRelayKeyFile == "" || !filepath.IsAbs(c.AuditRelayKeyFile) {
return fmt.Errorf("SENSE_AUDIT_RELAY_KEY_FILE must be an absolute external path")
}
if !instanceIDPattern.MatchString(c.AuditRelayKeyID) {
return fmt.Errorf("invalid SENSE_AUDIT_RELAY_KEY_ID")
}
if c.AuditRelayInterval < time.Second {
return fmt.Errorf("SENSE_AUDIT_RELAY_INTERVAL must be at least 1s")
}
relayURL, err := url.Parse(c.AuditRelayURL)
if err != nil || relayURL.Host == "" || relayURL.Path != "/internal/v1/audit-events:batch" ||
relayURL.RawQuery != "" || relayURL.Fragment != "" || relayURL.User != nil {
return fmt.Errorf("invalid SENSE_AUDIT_RELAY_URL")
}
relayHost := relayURL.Hostname()
relayIP := net.ParseIP(relayHost)
relayLoopback := relayHost == "localhost" || (relayIP != nil && relayIP.IsLoopback())
if relayURL.Scheme != "https" && !(relayURL.Scheme == "http" && relayLoopback) {
return fmt.Errorf("SENSE_AUDIT_RELAY_URL requires HTTPS outside loopback")
}
}
return nil
}
func stringEnv(name, fallback string) string {
if value, ok := os.LookupEnv(name); ok {
return value
}
return fallback
}
func boolEnv(name string, fallback bool) (bool, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("invalid %s: %w", name, err)
}
return parsed, nil
}
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", name, err)
}
return parsed, nil
}
func intEnv(name string, fallback int) (int, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", name, err)
}
return parsed, nil
}
+232
View File
@@ -0,0 +1,232 @@
package config
import (
"path/filepath"
"testing"
"time"
)
func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
t.Parallel()
cfg := Config{
HTTPAddress: "0.0.0.0:8080",
DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997",
ReconcileInterval: 1,
ProbeInterval: 1,
}
if err := cfg.Validate(); err == nil {
t.Fatal("expected non-loopback bind to be rejected")
}
cfg.AllowNonLoopback = true
if err := cfg.Validate(); err != nil {
t.Fatalf("explicit non-loopback opt-in failed: %v", err)
}
}
func TestValidateReconcileLeaseAndOrphanProductionBoundary(t *testing.T) {
base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second,
ProbeInterval: time.Second, ReconcileLeaseDuration: 30 * time.Second,
ReconcileOperationTimeout: 20 * time.Second, OrphanScanEnabled: true,
OrphanScanInterval: time.Minute, InstanceID: "ins_edge-01",
}
if err := base.Validate(); err != nil {
t.Fatalf("valid multi-instance configuration failed: %v", err)
}
invalidTimeout := base
invalidTimeout.ReconcileOperationTimeout = invalidTimeout.ReconcileLeaseDuration
if err := invalidTimeout.Validate(); err == nil {
t.Fatal("operation timeout equal to the lease was accepted")
}
invalidInstance := base
invalidInstance.InstanceID = "tenant/site"
if err := invalidInstance.Validate(); err == nil {
t.Fatal("unbounded instance label was accepted")
}
sqlite := base
sqlite.DatabaseDriver, sqlite.DatabaseDSN = "sqlite", "file:test.db"
if err := sqlite.Validate(); err == nil {
t.Fatal("orphan scanner was accepted on SQLite")
}
}
func TestValidateRejectsCredentialsInMediaMTXURL(t *testing.T) {
t.Parallel()
cfg := Config{
HTTPAddress: "127.0.0.1:8080",
DatabaseDSN: "file:test.db",
MediaMTXURL: "http://" + "user" + ":" + "redacted" + "@127.0.0.1:9997",
ReconcileInterval: 1,
ProbeInterval: 1,
}
if err := cfg.Validate(); err == nil {
t.Fatal("expected credentials in MediaMTX URL to be rejected")
}
}
func TestValidateONVIFModeAndRewritePort(t *testing.T) {
t.Parallel()
cfg := Config{
HTTPAddress: "127.0.0.1:8080",
DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997",
ReconcileInterval: 1,
ProbeInterval: 1,
ONVIFMode: "standard",
RTSPRewriteHost: "127.0.0.1",
RTSPRewritePort: 10554,
}
if err := cfg.Validate(); err != nil {
t.Fatalf("valid ONVIF configuration failed: %v", err)
}
cfg.ONVIFMode = "vendor"
if err := cfg.Validate(); err == nil {
t.Fatal("unknown ONVIF mode must be rejected")
}
cfg.ONVIFMode = "standard"
cfg.RTSPRewritePort = 65536
if err := cfg.Validate(); err == nil {
t.Fatal("invalid RTSP rewrite port must be rejected")
}
}
func TestLoadRTSPStripQueryOptIn(t *testing.T) {
t.Setenv("SENSE_ONVIF_RTSP_STRIP_QUERY", "true")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if !cfg.RTSPStripQuery {
t.Fatal("explicit RTSP query stripping was not loaded")
}
}
func TestValidateDatabaseDriver(t *testing.T) {
t.Parallel()
cfg := Config{
HTTPAddress: "127.0.0.1:8080",
DatabaseDriver: "postgres",
DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997",
ReconcileInterval: 1,
ProbeInterval: 1,
}
if err := cfg.Validate(); err == nil {
t.Fatal("postgres driver must reject the SQLite default DSN")
}
cfg.DatabaseDSN = "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable"
if err := cfg.Validate(); err != nil {
t.Fatalf("valid PostgreSQL selection failed: %v", err)
}
cfg.DatabaseDriver = "mysql"
if err := cfg.Validate(); err == nil {
t.Fatal("unknown database driver must be rejected")
}
}
func TestValidateControlAPIRequiresPostgresAndExternalSecurityFiles(t *testing.T) {
cfg := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "sqlite", DatabaseDSN: "file:test.db",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: 1, ProbeInterval: 1,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
}
if err := cfg.Validate(); err == nil {
t.Fatal("Control API was accepted on SQLite")
}
cfg.DatabaseDriver = "postgres"
cfg.DatabaseDSN = "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable"
if err := cfg.Validate(); err != nil {
t.Fatalf("valid Control API configuration failed: %v", err)
}
cfg.ControlAuthFile = "relative-auth.json"
if err := cfg.Validate(); err == nil {
t.Fatal("repository-relative authentication file was accepted")
}
}
func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T) {
cfg := Config{
HTTPAddress: "0.0.0.0:8080", AllowNonLoopback: true,
DatabaseDriver: "postgres", DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: 1, ProbeInterval: 1,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
}
if err := cfg.Validate(); err == nil {
t.Fatal("non-loopback plaintext Control API was accepted without explicit risk acceptance")
}
cfg.ControlAllowInsecureHTTP = true
if err := cfg.Validate(); err != nil {
t.Fatalf("explicit non-loopback Control API risk acceptance failed: %v", err)
}
}
func TestValidateSenseConsoleSecurityBoundary(t *testing.T) {
base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second, ProbeInterval: time.Second,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
ConsoleEnabled: true, ConsoleWebRTCBaseURL: "http://127.0.0.1:8889",
}
if err := base.Validate(); err != nil {
t.Fatalf("valid loopback console rejected: %v", err)
}
withoutControl := base
withoutControl.ControlAPIEnabled = false
if err := withoutControl.Validate(); err == nil {
t.Fatal("console without Control API was accepted")
}
remoteBind := base
remoteBind.HTTPAddress, remoteBind.AllowNonLoopback = "0.0.0.0:8080", true
remoteBind.ControlAllowInsecureHTTP = true
if err := remoteBind.Validate(); err == nil {
t.Fatal("console on non-loopback Sense listener was accepted")
}
for _, invalidURL := range []string{
"http://media.example:8889", "ftp://127.0.0.1:8889", "http://127.0.0.1:8889/path",
"http://127.0.0.1:8889?token=hidden", "http://user@127.0.0.1:8889",
} {
candidate := base
candidate.ConsoleWebRTCBaseURL = invalidURL
if err := candidate.Validate(); err == nil {
t.Fatalf("invalid console WebRTC URL was accepted: %s", invalidURL)
}
}
}
func TestValidateAuditRelaySecurityBoundary(t *testing.T) {
base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second, ProbeInterval: time.Second,
AuditRelayEnabled: true, AuditRelayURL: "http://127.0.0.1:8081/internal/v1/audit-events:batch",
AuditRelayKeyFile: filepath.Join(t.TempDir(), "relay-keys.json"), AuditRelayKeyID: "sense-a", AuditRelayInterval: time.Second,
}
if err := base.Validate(); err != nil {
t.Fatalf("valid loopback relay rejected: %v", err)
}
remoteHTTP := base
remoteHTTP.AuditRelayURL = "http://bell.example/internal/v1/audit-events:batch"
if err := remoteHTTP.Validate(); err == nil {
t.Fatal("remote plaintext relay was accepted")
}
sqlite := base
sqlite.DatabaseDriver, sqlite.DatabaseDSN = "sqlite", "file:test.db"
if err := sqlite.Validate(); err == nil {
t.Fatal("SQLite audit relay was accepted")
}
relativeKey := base
relativeKey.AuditRelayKeyFile = "relay-keys.json"
if err := relativeKey.Validate(); err == nil {
t.Fatal("repository-relative relay key was accepted")
}
}
+227
View File
@@ -0,0 +1,227 @@
:root {
color-scheme: dark;
--bg: #06111f;
--sidebar: #071522;
--surface: #0d2033;
--surface-2: #112a40;
--surface-3: #16344d;
--border: #294762;
--text: #f5f9fc;
--muted: #a8bbcb;
--accent: #55dcc7;
--accent-strong: #2bbca9;
--accent-ink: #03231f;
--info: #6fc6ff;
--success: #71e19c;
--warning: #ffd16a;
--danger: #ff8a95;
--focus: #8bdcff;
--shadow: 0 18px 48px rgba(0, 0, 0, .26);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
line-height: 1.5;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body { margin: 0; min-width: 320px; min-height: 100dvh; background: radial-gradient(circle at 75% -10%, #103150 0, transparent 32rem), var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button, a, input, select { touch-action: manipulation; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: var(--info); }
[hidden] { display: none !important; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.skip-link { position: fixed; left: 12px; top: -80px; z-index: 1000; padding: 10px 14px; border-radius: 8px; background: var(--text); color: var(--bg); font-weight: 700; }
.skip-link:focus { top: 8px; }
.engineering-banner { min-height: 30px; display: grid; place-items: center; padding: 4px 16px; background: #f5c95b; color: #1e1705; font-size: 12px; font-weight: 800; letter-spacing: .04em; text-align: center; }
.shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: calc(100dvh - 30px); }
.sidebar { position: sticky; top: 0; height: calc(100dvh - 30px); display: flex; flex-direction: column; padding: 28px 14px 18px; border-right: 1px solid var(--border); background: color-mix(in srgb, var(--sidebar) 95%, transparent); }
.brand { display: flex; align-items: center; gap: 12px; padding: 8px 8px 28px; }
.brand strong, .brand small { display: block; }
.brand strong { font-size: 17px; }
.brand small { color: var(--muted); font-size: 12px; }
.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), #58a9ff); color: #06222a; font-weight: 900; box-shadow: 0 9px 24px rgba(85, 220, 199, .2); }
.primary-nav { display: grid; gap: 6px; }
.primary-nav button, .mobile-nav button { border: 0; cursor: pointer; }
.primary-nav button { min-height: 48px; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 8px; padding: 0 12px; border-radius: 12px; background: transparent; color: var(--muted); text-align: left; transition: background-color .18s ease, color .18s ease; }
.primary-nav button:hover { background: var(--surface); color: var(--text); }
.primary-nav button[aria-current="page"] { background: #123d3a; color: var(--text); box-shadow: inset 3px 0 var(--accent); }
.nav-count { min-width: 24px; padding: 2px 7px; border-radius: 999px; background: var(--surface-3); color: #d6e5ef; font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; }
.sidebar-foot { margin-top: auto; display: flex; align-items: center; gap: 9px; padding: 13px; border: 1px solid var(--border); border-radius: 12px; color: var(--muted); font-size: 12px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #73879a; box-shadow: 0 0 0 4px rgba(115, 135, 154, .13); }
.sidebar-foot.connected .status-dot { background: var(--success); box-shadow: 0 0 0 4px rgba(113, 225, 156, .13); }
.workspace { min-width: 0; }
.topbar { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 28px; border-bottom: 1px solid var(--border); background: rgba(8, 24, 39, .78); backdrop-filter: blur(18px); }
.topbar strong, .topbar .eyebrow { display: block; }
.topbar strong { margin-top: 2px; }
.topbar-actions, .heading-actions { display: flex; align-items: center; gap: 10px; }
.eyebrow { color: var(--info); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.connection-pill { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 13px; }
.connection-pill > .connection-dot { width: 7px; height: 7px; border-radius: 50%; background: #718497; }
.connection-pill.connected { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); }
.connection-pill.connected > .connection-dot { background: var(--success); }
.degraded-banner { display: flex; align-items: center; gap: 12px; padding: 12px 28px; border-bottom: 1px solid rgba(255, 138, 149, .38); background: rgba(96, 26, 39, .82); color: #ffe7ea; }
.degraded-banner span { flex: 1; color: #ffc4ca; }
main { padding: 32px clamp(18px, 3vw, 42px) 80px; }
.view { max-width: 1440px; margin: 0 auto; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 24px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 4px; font-size: clamp(26px, 3vw, 36px); line-height: 1.2; letter-spacing: -.025em; }
h2 { margin-bottom: 4px; font-size: 17px; }
.page-heading p, .panel-heading p { margin-bottom: 0; color: var(--muted); }
.button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; text-decoration: none; font-weight: 700; font-size: 13px; transition: background-color .18s ease, border-color .18s ease, opacity .18s ease; }
.button.primary { background: var(--accent); color: var(--accent-ink); }
.button.primary:hover { background: #7ae7d5; }
.button.secondary { border-color: var(--border); background: var(--surface); color: var(--text); }
.button.secondary:hover, .button.ghost:hover { border-color: #48708f; background: var(--surface-2); }
.button.ghost { border-color: transparent; background: transparent; color: var(--info); }
.button:disabled { cursor: not-allowed; opacity: .42; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
.metric-card, .panel { border: 1px solid var(--border); border-radius: 15px; background: linear-gradient(145deg, rgba(17, 42, 64, .82), rgba(10, 29, 47, .9)); box-shadow: var(--shadow); }
.metric-card { min-height: 140px; display: flex; flex-direction: column; justify-content: center; padding: 20px; }
.metric-card > span { color: var(--muted); font-size: 13px; }
.metric-card strong { margin: 7px 0 2px; font-size: 30px; font-variant-numeric: tabular-nums; }
.metric-card small { color: var(--muted); }
.metric-card.warning strong { color: var(--warning); }
.content-grid, .operations-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); gap: 18px; }
.panel { padding: 18px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.health-list { display: grid; gap: 8px; }
.health-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 13px; border: 1px solid rgba(74, 111, 140, .56); border-radius: 11px; background: rgba(4, 17, 29, .35); }
.health-item strong, .health-item small { display: block; }
.health-item small { margin-top: 2px; color: var(--muted); }
.health-item .value { color: var(--warning); font-weight: 800; font-variant-numeric: tabular-nums; }
.empty-compact { min-height: 100px; display: grid; place-items: center; padding: 18px; border: 1px dashed var(--border); border-radius: 11px; color: var(--muted); text-align: center; }
.boundary-card { position: relative; overflow: hidden; }
.boundary-card::after { content: ""; position: absolute; right: -45px; top: -45px; width: 130px; height: 130px; border-radius: 50%; background: rgba(85, 220, 199, .09); }
.boundary-label, .feature-state { display: inline-flex; padding: 4px 8px; border-radius: 999px; background: rgba(85, 220, 199, .12); color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .05em; }
.boundary-card h2 { margin-top: 14px; }
.boundary-card p, .boundary-card li { color: var(--muted); }
.boundary-card ul { padding-left: 18px; margin-bottom: 0; }
.monitor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.monitor-toolbar strong, .monitor-toolbar span { display: block; }
.monitor-toolbar span { color: var(--muted); font-size: 13px; }
.preview-grid { min-height: 500px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.preview-empty { grid-column: 1 / -1; min-height: 500px; display: grid; align-content: center; justify-items: center; padding: 30px; border: 1px dashed var(--border); border-radius: 15px; background: rgba(7, 20, 33, .55); color: var(--muted); text-align: center; }
.preview-empty > span { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 14px; border: 1px solid var(--border); border-radius: 50%; color: var(--accent); font-size: 24px; }
.preview-empty strong { color: var(--text); font-size: 18px; }
.preview-empty p { margin: 4px 0 0; }
.preview-card { overflow: hidden; border: 1px solid var(--border); border-radius: 15px; background: #02070b; box-shadow: var(--shadow); }
.preview-frame { position: relative; aspect-ratio: 16 / 9; background: #02070b; }
.preview-frame iframe { width: 100%; height: 100%; display: block; border: 0; }
.preview-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--surface); }
.preview-meta strong, .preview-meta small { display: block; }
.preview-meta small { color: var(--muted); }
.filter-panel { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(140px, .7fr)) auto; align-items: end; gap: 12px; margin-bottom: 14px; }
label > span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; font-weight: 700; }
input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: #081927; color: var(--text); }
input::placeholder { color: #72899c; }
select { cursor: pointer; }
.table-panel { padding: 0; overflow: hidden; }
.table-status { padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.device-table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 900px; }
th, td { padding: 13px 14px; border-bottom: 1px solid rgba(41, 71, 98, .75); text-align: left; vertical-align: middle; }
th { color: var(--muted); font-size: 11px; letter-spacing: .04em; }
td { font-size: 13px; }
td strong, td small { display: block; }
td small { margin-top: 2px; color: var(--muted); }
tbody tr:hover { background: rgba(34, 66, 92, .34); }
.select-preview { width: 44px; height: 44px; display: grid; place-items: center; border: 0; }
.select-preview input { width: 18px; min-height: auto; height: 18px; accent-color: var(--accent); }
.status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 800; white-space: nowrap; }
.status-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-badge.success { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); }
.status-badge.warning { color: var(--warning); border-color: rgba(255, 209, 106, .35); background: rgba(255, 209, 106, .08); }
.status-badge.danger { color: var(--danger); border-color: rgba(255, 138, 149, .35); background: rgba(255, 138, 149, .08); }
.device-cards { display: none; }
.pagination { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 14px; }
.pagination > span { color: var(--muted); font-size: 13px; }
.pagination > div { display: flex; gap: 8px; }
.honest-empty { max-width: 820px; min-height: 340px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding: clamp(28px, 5vw, 60px); }
.honest-empty h2 { margin: 18px 0 8px; font-size: 24px; }
.honest-empty > p { max-width: 70ch; color: var(--muted); }
.next-list { width: 100%; display: grid; gap: 8px; margin-top: 14px; }
.next-list span { padding: 12px; border: 1px solid var(--border); border-radius: 10px; color: var(--muted); }
.next-list strong { margin-right: 10px; color: var(--text); }
dialog { width: min(520px, calc(100% - 28px)); padding: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--text); box-shadow: 0 30px 90px rgba(0, 0, 0, .6); }
dialog::backdrop { background: rgba(0, 7, 13, .72); backdrop-filter: blur(4px); }
dialog form { padding: 22px; }
.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.dialog-heading h2 { margin-top: 5px; font-size: 22px; }
.icon-button { min-width: 44px; min-height: 44px; border: 1px solid var(--border); border-radius: 10px; background: transparent; cursor: pointer; font-size: 20px; }
dialog p { color: var(--muted); }
dialog label { display: block; margin-top: 14px; }
.form-error { margin-top: 12px; padding: 10px 12px; border: 1px solid rgba(255, 138, 149, .5); border-radius: 9px; background: rgba(120, 28, 43, .28); color: #ffdce0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.toast { position: fixed; right: 20px; bottom: 24px; z-index: 200; max-width: min(420px, calc(100% - 40px)); padding: 13px 16px; border: 1px solid var(--border); border-radius: 11px; background: #173148; color: var(--text); box-shadow: var(--shadow); }
.mobile-nav { display: none; }
@media (max-width: 1080px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.content-grid, .operations-grid { grid-template-columns: 1fr; }
.filter-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.engineering-banner { min-height: 38px; }
.shell { display: block; min-height: calc(100dvh - 38px); }
.sidebar { display: none; }
.topbar { min-height: 72px; padding: 12px 16px; }
.connection-pill { display: none; }
.topbar-actions .button { min-height: 44px; }
.degraded-banner { align-items: flex-start; flex-wrap: wrap; padding: 12px 16px; }
.degraded-banner span { flex-basis: calc(100% - 10px); }
main { padding: 24px 14px 92px; }
.page-heading { align-items: stretch; flex-direction: column; }
.page-heading .button, .heading-actions { width: 100%; }
.heading-actions .button { flex: 1; }
.metric-grid { grid-template-columns: 1fr 1fr; gap: 9px; }
.metric-card { min-height: 126px; padding: 15px; }
.metric-card strong { font-size: 25px; }
.panel { padding: 15px; }
.panel-heading, .monitor-toolbar { align-items: stretch; flex-direction: column; }
.preview-grid { grid-template-columns: 1fr; min-height: 420px; }
.preview-empty { min-height: 420px; }
.filter-panel { grid-template-columns: 1fr; }
input, select { min-height: 46px; font-size: 16px; }
.device-table-wrap { display: none; }
.device-cards { display: grid; gap: 10px; padding: 12px; }
.device-card { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: rgba(5, 18, 30, .45); }
.device-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.device-card strong, .device-card small { display: block; }
.device-card small { color: var(--muted); }
.device-card dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0 0; }
.device-card dt { color: var(--muted); font-size: 11px; }
.device-card dd { margin: 2px 0 0; font-size: 13px; }
.pagination { align-items: stretch; flex-direction: column; }
.pagination > div { display: grid; grid-template-columns: 1fr 1fr; }
.pagination .button { min-height: 44px; }
.mobile-nav { position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 100; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; border: 1px solid var(--border); border-radius: 15px; background: rgba(8, 24, 39, .96); box-shadow: 0 15px 40px rgba(0, 0, 0, .5); backdrop-filter: blur(18px); }
.mobile-nav button { min-height: 48px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 12px; }
.mobile-nav button[aria-current="page"] { background: #153c3a; color: var(--accent); font-weight: 800; }
dialog form { padding: 18px; }
.dialog-actions { display: grid; grid-template-columns: 1fr 1fr; }
.dialog-actions .button { min-height: 46px; }
.toast { left: 14px; right: 14px; bottom: 78px; max-width: none; }
}
@media (max-width: 420px) {
.metric-grid { grid-template-columns: 1fr; }
.topbar { gap: 8px; }
.topbar strong { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
+594
View File
@@ -0,0 +1,594 @@
"use strict";
const state = {
token: "",
siteId: "",
config: { page_size: 16, max_active_previews: 4, webrtc_base_url: "" },
items: [],
quota: null,
cursors: [null],
pageIndex: 0,
nextCursor: null,
selected: new Map(),
previewing: new Set(),
loading: false,
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const labels = {
modality: { video: "视频", radar: "雷达", contact: "门磁", button: "按钮", wearable: "可穿戴", other: "其他" },
desired: { enabled: "启用", disabled: "停用" },
actual: { online: "在线", offline: "离线", failed: "失败", pending: "收敛中" },
adapter: { ready: "适配器就绪", pending: "适配器处理中", adapter_not_ready: "适配器未交付", authentication_failed: "认证失败", unavailable: "适配器不可用" },
capability: { video_capture: "成像", audio_capture: "音频", spatial_rule: "空间配置", telemetry: "遥测" },
};
function showToast(message) {
const toast = $("#toast");
toast.textContent = message;
toast.hidden = false;
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => { toast.hidden = true; }, 4200);
}
function setText(selector, value) {
const target = $(selector);
if (target) target.textContent = String(value);
}
function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
}).format(date);
}
function isPreviewEligible(device) {
return Array.isArray(device.capabilities) && device.capabilities.includes("video_capture") &&
device.desired_state === "enabled" && device.actual_state === "online" && device.converged === true;
}
function attentionNeeded(device) {
return device.converged !== true || device.actual_state === "offline" || device.actual_state === "failed" ||
device.adapter_status === "authentication_failed" || device.adapter_status === "unavailable";
}
function statusFor(device) {
if (device.actual_state === "online" && device.converged) return { text: "在线 · 已收敛", tone: "success" };
if (device.actual_state === "failed" || device.adapter_status === "authentication_failed") return { text: labels.adapter[device.adapter_status] || "失败", tone: "danger" };
if (device.actual_state === "offline") return { text: "离线", tone: "danger" };
return { text: device.converged ? (labels.actual[device.actual_state] || "未知") : "等待收敛", tone: "warning" };
}
function createStatusBadge(device) {
const value = statusFor(device);
const badge = document.createElement("span");
badge.className = `status-badge ${value.tone}`;
badge.textContent = value.text;
return badge;
}
function setConnected(connected) {
const pill = $("#connectionPill");
const foot = $(".sidebar-foot");
pill.classList.toggle("connected", connected);
foot.classList.toggle("connected", connected);
setText("#connectionText", connected ? "已连接" : "未连接");
setText("#sessionState", connected ? "会话仅驻留当前页面" : "尚未建立会话");
}
function setLoading(loading) {
state.loading = loading;
$("#refreshDevices").disabled = loading || !state.token;
$("#refreshOperations").disabled = loading || !state.token;
$("#retryLoad").disabled = loading || !state.token;
$("#prevPage").disabled = loading || state.pageIndex === 0;
$("#nextPage").disabled = loading || !state.nextCursor;
setText("#deviceTableStatus", loading ? "正在读取最新状态…" : state.token ? "已读取当前页真实设备状态。" : "请先建立会话。");
}
function showDegraded(title, message) {
setText("#degradedTitle", title);
setText("#degradedMessage", message);
$("#degradedBanner").hidden = false;
}
function clearDegraded() {
$("#degradedBanner").hidden = true;
}
async function apiFetch(path) {
const response = await fetch(path, {
headers: { Authorization: `Bearer ${state.token}`, Accept: "application/json" },
cache: "no-store",
});
let payload = null;
try { payload = await response.json(); } catch (_) { payload = null; }
if (!response.ok) {
const error = new Error(payload && payload.code ? payload.code : `http_${response.status}`);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
function buildDevicePath(cursor) {
const params = new URLSearchParams();
params.set("limit", String(state.config.page_size));
const modality = $("#modalityFilter").value;
const desired = $("#desiredFilter").value;
const actual = $("#actualFilter").value;
if (modality) params.set("modality", modality);
if (desired) params.set("desired_state", desired);
if (actual) params.set("actual_state", actual);
if (cursor) params.set("cursor", cursor);
return `/api/v1/sites/${encodeURIComponent(state.siteId)}/devices?${params.toString()}`;
}
async function loadDevices(options = {}) {
if (!state.token || state.loading) return;
const cursor = state.cursors[state.pageIndex] || null;
setLoading(true);
clearDegraded();
try {
const payload = await apiFetch(buildDevicePath(cursor));
state.items = Array.isArray(payload.items) ? payload.items : [];
state.quota = payload.quota || null;
state.nextCursor = payload.page && payload.page.has_more ? payload.page.next_cursor : null;
for (const device of state.items) {
if (state.selected.has(device.id)) {
if (isPreviewEligible(device)) state.selected.set(device.id, device);
else state.selected.delete(device.id);
}
}
setConnected(true);
renderAll();
if (options.announce !== false) showToast(`已刷新第 ${state.pageIndex + 1} 页,共 ${state.items.length} 台设备`);
return true;
} catch (error) {
handleLoadError(error);
throw error;
} finally {
setLoading(false);
}
}
function handleLoadError(error) {
let title = "无法读取最新状态";
let message = "未知不会显示为在线。请检查 Sense 进程、网络和依赖后重试。";
if (error.status === 401) {
state.token = "";
setConnected(false);
title = "会话无效或已过期";
message = "Bearer token 已从页面内存清除,请重新建立会话。";
} else if (error.status === 403) {
title = "当前角色没有设备读取权限";
message = "页面不会尝试绕过权限;请使用具备 devices:read 的会话。";
} else if (error.status === 404) {
title = "无法访问该站点";
message = "目标不存在或当前会话无权访问,页面不会区分这两种情况。";
} else if (error.payload && ["quota_projection_unavailable", "area_policy_unavailable"].includes(error.payload.code)) {
title = "策略投影暂时不可用";
message = "已有链路不会因此被静默停用;相关新写入应保持禁用。";
}
showDegraded(title, message);
state.items = [];
state.quota = null;
renderAll();
}
function visibleItems() {
const query = $("#deviceSearch").value.trim().toLocaleLowerCase("zh-CN");
if (!query) return state.items;
return state.items.filter((device) => `${device.name} ${device.serial_number}`.toLocaleLowerCase("zh-CN").includes(query));
}
function renderAll() {
renderDevices();
renderMetrics();
renderHealth();
renderOperations();
renderSelection();
}
function renderMetrics() {
const used = state.quota && Number.isInteger(state.quota.used_video_channels) ? state.quota.used_video_channels : null;
const max = state.quota && Number.isInteger(state.quota.max_video_channels) ? state.quota.max_video_channels : null;
setText("#quotaMetric", used === null || max === null ? "—" : `${used} / ${max}`);
setText("#quotaHint", state.quota ? `投影状态:${state.quota.status || "未知"}` : "连接后读取 Bell 投影");
setText("#pageMetric", state.items.length);
setText("#convergedMetric", state.items.filter((device) => device.converged === true).length);
const attention = state.items.filter(attentionNeeded).length;
setText("#attentionMetric", attention);
setText("#deviceCount", state.items.length);
setText("#issueCount", attention);
}
function addTextCell(row, primary, secondary) {
const cell = document.createElement("td");
const strong = document.createElement("strong");
strong.textContent = primary || "—";
cell.appendChild(strong);
if (secondary) {
const small = document.createElement("small");
small.textContent = secondary;
cell.appendChild(small);
}
row.appendChild(cell);
}
function createPreviewCheckbox(device) {
const label = document.createElement("label");
label.className = "select-preview";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = state.selected.has(device.id);
checkbox.disabled = !isPreviewEligible(device);
checkbox.setAttribute("aria-label", checkbox.disabled ? `${device.name} 当前不可预览` : `选择 ${device.name} 进行预览`);
checkbox.addEventListener("change", () => toggleSelection(device, checkbox));
label.appendChild(checkbox);
return label;
}
function renderDevices() {
const rows = $("#deviceRows");
const cards = $("#deviceCards");
rows.replaceChildren();
cards.replaceChildren();
const items = visibleItems();
for (const device of items) {
const row = document.createElement("tr");
const selectCell = document.createElement("td");
selectCell.appendChild(createPreviewCheckbox(device));
row.appendChild(selectCell);
addTextCell(row, device.name, device.serial_number);
addTextCell(row, labels.modality[device.modality] || device.modality, (device.capabilities || []).map((value) => labels.capability[value] || value).join(" / "));
const statusCell = document.createElement("td");
statusCell.appendChild(createStatusBadge(device));
row.appendChild(statusCell);
addTextCell(row, labels.desired[device.desired_state] || device.desired_state, labels.actual[device.actual_state] || device.actual_state);
addTextCell(row, device.area_id || "—", device.projection_versions && device.projection_versions.area_policy_source_version ? `策略 v${device.projection_versions.area_policy_source_version}` : "策略版本未知");
addTextCell(row, formatTime(device.updated_at), device.next_attempt_at ? `重试 ${formatTime(device.next_attempt_at)}` : "");
rows.appendChild(row);
const card = document.createElement("article");
card.className = "device-card";
const cardHead = document.createElement("div");
cardHead.className = "device-card-head";
const identity = document.createElement("div");
const name = document.createElement("strong");
const serial = document.createElement("small");
name.textContent = device.name;
serial.textContent = device.serial_number;
identity.append(name, serial);
cardHead.append(identity, createPreviewCheckbox(device));
const status = createStatusBadge(device);
const details = document.createElement("dl");
const pairs = [
["状态", "", status],
["模态", labels.modality[device.modality] || device.modality],
["期望 / 实际", `${labels.desired[device.desired_state] || device.desired_state} / ${labels.actual[device.actual_state] || device.actual_state}`],
["Area", device.area_id || "—"],
];
for (const [term, value, node] of pairs) {
const wrapper = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = term;
if (node) dd.appendChild(node); else dd.textContent = value;
wrapper.append(dt, dd);
details.appendChild(wrapper);
}
card.append(cardHead, details);
cards.appendChild(card);
}
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有符合条件的设备。" : "请先建立会话。";
cards.appendChild(empty.cloneNode(true));
const cell = document.createElement("td");
cell.colSpan = 7;
cell.appendChild(empty);
const row = document.createElement("tr");
row.appendChild(cell);
rows.appendChild(row);
}
setText("#pageLabel", `第 ${state.pageIndex + 1} 页 · 每页 ${state.config.page_size} · 当前显示 ${items.length} 项`);
$("#prevPage").disabled = state.loading || state.pageIndex === 0;
$("#nextPage").disabled = state.loading || !state.nextCursor;
}
function toggleSelection(device, checkbox) {
if (checkbox.checked) {
if (!isPreviewEligible(device)) {
checkbox.checked = false;
showToast("只有在线、已启用且已收敛的视频设备可以预览");
return;
}
if (!state.selected.has(device.id) && state.selected.size >= state.config.max_active_previews) {
checkbox.checked = false;
showToast(`最多同时选择 ${state.config.max_active_previews} 路预览`);
return;
}
state.selected.set(device.id, device);
} else {
state.selected.delete(device.id);
if (state.previewing.has(device.id)) stopPreview(device.id);
}
renderSelection();
renderDevices();
}
function renderSelection() {
setText("#selectionCount", state.selected.size);
setText("#previewCount", `${state.previewing.size}/${state.config.max_active_previews}`);
$("#startPreview").disabled = state.selected.size === 0;
$("#stopAll").disabled = state.previewing.size === 0;
}
function previewURL(deviceID) {
const url = new URL(`${state.config.webrtc_base_url}/devices/${encodeURIComponent(deviceID)}`);
url.searchParams.set("controls", "true");
url.searchParams.set("muted", "true");
url.searchParams.set("autoplay", "true");
url.searchParams.set("playsInline", "true");
return url.toString();
}
function startSelectedPreviews() {
if (state.selected.size === 0) return;
state.previewing = new Set(state.selected.keys());
const grid = $("#previewGrid");
grid.replaceChildren();
for (const device of state.selected.values()) {
const card = document.createElement("article");
card.className = "preview-card";
card.dataset.deviceId = device.id;
const frame = document.createElement("div");
frame.className = "preview-frame";
const iframe = document.createElement("iframe");
iframe.title = `${device.name} 实时预览`;
iframe.loading = "eager";
iframe.allow = "autoplay; fullscreen";
iframe.sandbox = "allow-scripts allow-same-origin";
iframe.referrerPolicy = "no-referrer";
iframe.src = previewURL(device.id);
frame.appendChild(iframe);
const meta = document.createElement("div");
meta.className = "preview-meta";
const identity = document.createElement("div");
const name = document.createElement("strong");
const note = document.createElement("small");
name.textContent = device.name;
note.textContent = "MediaMTX WebRTC · 按需读取";
identity.append(name, note);
const stop = document.createElement("button");
stop.type = "button";
stop.className = "button secondary";
stop.textContent = "停止";
stop.addEventListener("click", () => stopPreview(device.id));
meta.append(identity, stop);
card.append(frame, meta);
grid.appendChild(card);
}
switchView("monitor");
renderSelection();
showToast(`已按需启动 ${state.previewing.size} 路预览`);
}
function stopPreview(deviceID) {
state.previewing.delete(deviceID);
const card = $(`[data-device-id="${CSS.escape(deviceID)}"]`);
if (card) {
const iframe = card.querySelector("iframe");
if (iframe) iframe.removeAttribute("src");
card.remove();
}
if (state.previewing.size === 0) renderPreviewEmpty();
renderSelection();
}
function stopAllPreviews() {
for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src");
state.previewing.clear();
renderPreviewEmpty();
renderSelection();
showToast("全部预览已停止");
}
function renderPreviewEmpty() {
const grid = $("#previewGrid");
grid.replaceChildren();
const empty = document.createElement("div");
empty.className = "preview-empty";
const mark = document.createElement("span");
mark.setAttribute("aria-hidden", "true");
mark.textContent = "▷";
const title = document.createElement("strong");
title.textContent = "尚未启动预览";
const note = document.createElement("p");
note.textContent = "前往设备页选择最多 4 路运行中的视频设备。";
empty.append(mark, title, note);
grid.appendChild(empty);
}
function renderHealth() {
const container = $("#overviewHealth");
container.replaceChildren();
if (!state.token || state.items.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有设备事实。" : "建立会话后显示真实状态。";
container.appendChild(empty);
return;
}
const groups = [
["在线且已收敛", state.items.filter((device) => device.actual_state === "online" && device.converged).length, "设备可用于按需预览"],
["等待收敛", state.items.filter((device) => !device.converged).length, "期望 generation 尚未被完整观察"],
["离线或失败", state.items.filter((device) => ["offline", "failed"].includes(device.actual_state)).length, "查看重试时间和稳定错误码"],
];
for (const [title, count, note] of groups) container.appendChild(createHealthItem(title, count, note));
}
function createHealthItem(title, count, note) {
const item = document.createElement("div");
item.className = "health-item";
const text = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = title;
small.textContent = note;
text.append(strong, small);
const value = document.createElement("span");
value.className = "value";
value.textContent = String(count);
item.append(text, value);
return item;
}
function renderOperations() {
const container = $("#operationIssues");
container.replaceChildren();
const issues = state.items.filter(attentionNeeded);
if (issues.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有已知对账差异;这不代表未加载设备或其他基础设施正常。" : "建立会话后显示。";
container.appendChild(empty);
return;
}
for (const device of issues) {
const message = device.last_error_code ? `错误 ${device.last_error_code}` : `generation ${device.observed_generation || 0} / ${device.generation || 0}`;
container.appendChild(createHealthItem(device.name, device.failure_count || 0, device.next_attempt_at ? `${message} · 下次 ${formatTime(device.next_attempt_at)}` : message));
}
}
function switchView(name) {
$$(".view").forEach((view) => {
const active = view.dataset.page === name;
view.hidden = !active;
view.classList.toggle("active", active);
});
$$('[data-view]').forEach((button) => {
const active = button.dataset.view === name;
button.toggleAttribute("aria-current", active);
});
$("#main-content").focus({ preventScroll: true });
window.scrollTo({ top: 0, behavior: "auto" });
}
function resetPaging() {
state.cursors = [null];
state.pageIndex = 0;
state.nextCursor = null;
}
async function connect(event) {
event.preventDefault();
const siteInput = $("#siteInput");
const tokenInput = $("#tokenInput");
const site = siteInput.value.trim();
const token = tokenInput.value;
const errorBox = $("#contextError");
errorBox.hidden = true;
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(site)) {
errorBox.textContent = "Site ID 格式无效:只能使用 1~64 位字母、数字、点、下划线、冒号或连字符。";
errorBox.hidden = false;
siteInput.focus();
return;
}
if (!token || token.length > 4096) {
errorBox.textContent = "请输入有效的 Bearer token。";
errorBox.hidden = false;
tokenInput.focus();
return;
}
state.siteId = site;
state.token = token;
tokenInput.value = "";
setText("#siteContext", site);
resetPaging();
try {
await loadDevices({ announce: false });
$("#contextDialog").close();
showToast("会话已建立;token 仅驻留当前页面内存");
} catch (error) {
errorBox.textContent = error.status === 401 ? "认证失败,请检查 token 后重试。" : "无法读取该站点,请按页面提示检查权限或服务状态。";
errorBox.hidden = false;
$("#contextDialog").showModal();
}
}
async function initialize() {
try {
const response = await fetch("/sense-console/config", { cache: "no-store" });
if (!response.ok) throw new Error("config unavailable");
state.config = await response.json();
if (state.config.page_size !== 16 || state.config.max_active_previews !== 4 || state.config.recording_and_playback !== false) {
throw new Error("unsafe console bounds");
}
} catch (_) {
showDegraded("控制台配置不可用", "页面保持只读且不会尝试连接视频,请检查 Sense 启动配置。");
$$("button, input, select").forEach((control) => { control.disabled = true; });
return;
}
setConnected(false);
renderAll();
$("#contextDialog").showModal();
}
$$('[data-view]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view)));
$$('[data-view-target]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.viewTarget)));
$("#changeContext").addEventListener("click", () => $("#contextDialog").showModal());
$("#closeContext").addEventListener("click", () => $("#contextDialog").close());
$("#cancelContext").addEventListener("click", () => $("#contextDialog").close());
$("#contextForm").addEventListener("submit", connect);
$("#refreshDevices").addEventListener("click", () => loadDevices());
$("#refreshOperations").addEventListener("click", () => loadDevices());
$("#retryLoad").addEventListener("click", () => state.token ? loadDevices() : $("#contextDialog").showModal());
$("#startPreview").addEventListener("click", startSelectedPreviews);
$("#stopAll").addEventListener("click", stopAllPreviews);
$("#deviceSearch").addEventListener("input", renderDevices);
for (const selector of ["#modalityFilter", "#desiredFilter", "#actualFilter"]) {
$(selector).addEventListener("change", () => {
resetPaging();
if (state.token) loadDevices();
});
}
$("#clearFilters").addEventListener("click", () => {
$("#deviceSearch").value = "";
$("#modalityFilter").value = "";
$("#desiredFilter").value = "";
$("#actualFilter").value = "";
resetPaging();
if (state.token) loadDevices(); else renderDevices();
});
$("#nextPage").addEventListener("click", async () => {
if (!state.nextCursor) return;
state.cursors[state.pageIndex + 1] = state.nextCursor;
state.pageIndex += 1;
try { await loadDevices(); } catch (_) { state.pageIndex -= 1; }
});
$("#prevPage").addEventListener("click", async () => {
if (state.pageIndex === 0) return;
state.pageIndex -= 1;
try { await loadDevices(); } catch (_) { state.pageIndex += 1; }
});
window.addEventListener("beforeunload", () => {
state.token = "";
for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src");
});
initialize();
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>YoVision Sense · 接入运维工作台</title>
<link rel="stylesheet" href="/sense-console/app.css">
<script defer src="/sense-console/app.js"></script>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="engineering-banner" role="note">
回环工程控制台 · 非录像/回放 · 不代表生产公网入口
</div>
<div class="shell">
<aside class="sidebar" aria-label="Sense 主导航">
<div class="brand">
<span class="brand-mark" aria-hidden="true">S</span>
<span><strong>YoVision Sense</strong><small>接入运维工作台</small></span>
</div>
<nav class="primary-nav">
<button type="button" data-view="overview" aria-current="page"><span aria-hidden="true">◫</span>运行总览</button>
<button type="button" data-view="monitor"><span aria-hidden="true">▷</span>实时监控 <span class="nav-count" id="previewCount">0/4</span></button>
<button type="button" data-view="devices"><span aria-hidden="true">▣</span>设备 <span class="nav-count" id="deviceCount">0</span></button>
<button type="button" data-view="onboarding"><span aria-hidden="true">⇧</span>接入任务</button>
<button type="button" data-view="operations"><span aria-hidden="true">⌁</span>运维中心 <span class="nav-count" id="issueCount">0</span></button>
</nav>
<div class="sidebar-foot">
<span class="status-dot" aria-hidden="true"></span>
<span id="sessionState">尚未建立会话</span>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<div>
<span class="eyebrow">当前站点</span>
<strong id="siteContext">未选择</strong>
</div>
<div class="topbar-actions">
<span class="connection-pill" id="connectionPill"><span class="connection-dot" aria-hidden="true"></span><span id="connectionText">未连接</span></span>
<button type="button" class="button secondary" id="changeContext">连接设置</button>
</div>
</header>
<div class="degraded-banner" id="degradedBanner" role="status" hidden>
<strong id="degradedTitle">无法读取最新状态</strong>
<span id="degradedMessage">未知不会显示为在线,请检查会话后重试。</span>
<button type="button" class="button ghost" id="retryLoad">重试</button>
</div>
<main id="main-content" tabindex="-1">
<section class="view active" data-page="overview" aria-labelledby="overviewTitle">
<div class="page-heading">
<div><span class="eyebrow">SENSE / OVERVIEW</span><h1 id="overviewTitle">运行总览</h1><p>先看已加载事实,再进入设备或运维中心处理。</p></div>
<button type="button" class="button primary" data-view-target="devices">查看设备</button>
</div>
<div class="metric-grid" aria-label="当前页运行指标">
<article class="metric-card"><span>视频配额</span><strong id="quotaMetric">—</strong><small id="quotaHint">连接后读取 Bell 投影</small></article>
<article class="metric-card"><span>当前页设备</span><strong id="pageMetric">0</strong><small>默认每页 16,不代表站点总数</small></article>
<article class="metric-card"><span>当前页已收敛</span><strong id="convergedMetric">0</strong><small>期望态与实际态分开计算</small></article>
<article class="metric-card warning"><span>当前页需关注</span><strong id="attentionMetric">0</strong><small>失败、离线或尚未收敛</small></article>
</div>
<div class="content-grid">
<article class="panel">
<div class="panel-heading"><div><h2>接入健康</h2><p>只汇总当前设备页,不推断未加载设备。</p></div><button type="button" class="button ghost" data-view-target="operations">进入运维中心</button></div>
<div class="health-list" id="overviewHealth"><div class="empty-compact">建立会话后显示真实状态。</div></div>
</article>
<article class="panel boundary-card">
<span class="boundary-label">本纵切边界</span>
<h2>实时监看,不替代客户 NVR</h2>
<p>常态录像仍留在客户现有 NVR;当前仅验证 Sense 设备管理面和 MediaMTX 按需播放。录像计划、录像索引与回放未实现。</p>
<ul><li>同时最多 4 路预览</li><li>刷新后 Bearer token 自动丢失</li><li>Sense 与播放端均限回环地址</li></ul>
</article>
</div>
</section>
<section class="view" data-page="monitor" aria-labelledby="monitorTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / LIVE</span><h1 id="monitorTitle">实时监控</h1><p>从当前页设备中选择,只有明确在线且已收敛的视频设备可预览。</p></div>
<div class="heading-actions"><button type="button" class="button secondary" id="stopAll">停止全部</button><button type="button" class="button primary" id="startPreview">开始预览</button></div>
</div>
<div class="monitor-toolbar panel">
<div><strong>已选择 <span id="selectionCount">0</span> / 4</strong><span>默认不自动播放,不加载未选择设备。</span></div>
<button type="button" class="button ghost" data-view-target="devices">选择设备</button>
</div>
<div class="preview-grid" id="previewGrid" aria-live="polite">
<div class="preview-empty"><span aria-hidden="true">▷</span><strong>尚未启动预览</strong><p>前往设备页选择最多 4 路运行中的视频设备。</p></div>
</div>
</section>
<section class="view" data-page="devices" aria-labelledby="devicesTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / DEVICES</span><h1 id="devicesTitle">设备</h1><p>统一台账按模态与能力展示;列表只读取脱敏 Control API 字段。</p></div>
<button type="button" class="button primary" id="refreshDevices">刷新状态</button>
</div>
<section class="panel filter-panel" aria-label="设备筛选">
<label><span>当前页搜索</span><input type="search" id="deviceSearch" placeholder="名称 / 序列号" autocomplete="off"></label>
<label><span>模态</span><select id="modalityFilter"><option value="">全部模态</option><option value="video">视频</option><option value="radar">雷达</option><option value="contact">门磁</option><option value="button">按钮</option><option value="wearable">可穿戴</option><option value="other">其他</option></select></label>
<label><span>期望态</span><select id="desiredFilter"><option value="">全部期望态</option><option value="enabled">启用</option><option value="disabled">停用</option></select></label>
<label><span>实际态</span><select id="actualFilter"><option value="">全部实际态</option><option value="online">在线</option><option value="offline">离线</option><option value="failed">失败</option><option value="pending">收敛中</option></select></label>
<button type="button" class="button secondary" id="clearFilters">清除筛选</button>
</section>
<div class="table-panel panel">
<div class="table-status" id="deviceTableStatus" role="status">请先建立会话。</div>
<div class="device-table-wrap">
<table>
<thead><tr><th scope="col">预览</th><th scope="col">设备</th><th scope="col">模态 / 能力</th><th scope="col">状态</th><th scope="col">期望 / 实际</th><th scope="col">Area</th><th scope="col">最后变化</th></tr></thead>
<tbody id="deviceRows"></tbody>
</table>
</div>
<div class="device-cards" id="deviceCards"></div>
<div class="pagination"><span id="pageLabel">第 1 页 · 每页 16</span><div><button type="button" class="button secondary" id="prevPage" disabled>上一页</button><button type="button" class="button secondary" id="nextPage" disabled>下一页</button></div></div>
</div>
</section>
<section class="view" data-page="onboarding" aria-labelledby="onboardingTitle" hidden>
<div class="page-heading"><div><span class="eyebrow">SENSE / ONBOARDING</span><h1 id="onboardingTitle">接入任务</h1><p>批量导入仍是已确认需求,但不属于本次设备与实时监看纵切。</p></div></div>
<article class="panel honest-empty">
<span class="feature-state">尚未实现</span><h2>批量导入与逐项激活将在独立任务完成</h2>
<p>当前 Control API 支持单设备创建和最多 128 项的批量启停,但没有冻结批量 CSV 创建契约。本页不会用假上传或假成功伪装交付。</p>
<div class="next-list"><span><strong>已具备</strong>设备查询、期望态修改、批量启停与结果查询</span><span><strong>后续补齐</strong>模板下载、逐行校验、只重试失败项和任务历史</span></div>
</article>
</section>
<section class="view" data-page="operations" aria-labelledby="operationsTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / OPERATIONS</span><h1 id="operationsTitle">运维中心</h1><p>仅展示接入控制面的事实,不混入 Bell 业务预警。</p></div>
<a class="button secondary" href="/metrics" target="_blank" rel="noreferrer">打开低基数指标</a>
</div>
<div class="operations-grid">
<article class="panel"><div class="panel-heading"><div><h2>当前页对账差异</h2><p>失败、退避与尚未观察到当前 generation。</p></div><button type="button" class="button ghost" id="refreshOperations">刷新</button></div><div id="operationIssues" class="health-list"><div class="empty-compact">建立会话后显示。</div></div></article>
<article class="panel boundary-card"><span class="boundary-label">数据边界</span><h2>完整运维 API 尚未冻结</h2><p>分片、边缘隧道、补传、孤儿扫描和运维告警已经有后端能力或设计,但本纵切只消费设备 Control API;未取到的数据不会显示为正常。</p></article>
</div>
</section>
</main>
</div>
</div>
<nav class="mobile-nav" aria-label="移动端主导航">
<button type="button" data-view="overview" aria-current="page">总览</button><button type="button" data-view="monitor">监控</button><button type="button" data-view="devices">设备</button><button type="button" data-view="onboarding">接入</button><button type="button" data-view="operations">运维</button>
</nav>
<dialog id="contextDialog" aria-labelledby="contextTitle">
<form method="dialog" id="contextForm">
<div class="dialog-heading"><div><span class="eyebrow">LOOPBACK SESSION</span><h2 id="contextTitle">连接 Sense Control API</h2></div><button type="button" class="icon-button" id="closeContext" aria-label="关闭连接设置">×</button></div>
<p>Site ID 只用于当前页面上下文;Bearer token 仅保存在内存,刷新页面后自动丢失。</p>
<label><span>Site ID</span><input id="siteInput" name="site" required maxlength="128" autocomplete="off" placeholder="例如 school-main"></label>
<label><span>Bearer token</span><input id="tokenInput" name="token" type="password" required autocomplete="off" placeholder="不会保存或回显"></label>
<div class="form-error" id="contextError" role="alert" hidden></div>
<div class="dialog-actions"><button type="button" class="button secondary" id="cancelContext">取消</button><button type="submit" class="button primary" id="connectButton">连接并读取</button></div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
// Package console serves the loopback-only Sense engineering console.
package console
import (
"embed"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
//go:embed assets/index.html assets/app.css assets/app.js
var assets embed.FS
type handler struct {
previewBaseURL string
previewOrigin string
}
// NewHandler builds the self-contained console handler. Config validation also
// enforces this boundary; validating here keeps the package safe in isolation.
func NewHandler(previewBaseURL string) (http.Handler, error) {
parsed, err := url.Parse(previewBaseURL)
if err != nil || parsed.Host == "" ||
(parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Path != "" && parsed.Path != "/") {
return nil, errors.New("invalid Sense console WebRTC base URL")
}
host := parsed.Hostname()
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return nil, errors.New("Sense console WebRTC base URL is not loopback")
}
base := strings.TrimRight(parsed.String(), "/")
return &handler{
previewBaseURL: base,
previewOrigin: parsed.Scheme + "://" + parsed.Host,
}, nil
}
func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
h.securityHeaders(writer)
if request.Method != http.MethodGet && request.Method != http.MethodHead {
writer.Header().Set("Allow", "GET, HEAD")
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
return
}
switch request.URL.Path {
case "/sense-console/":
h.serveAsset(writer, request, "assets/index.html", "text/html; charset=utf-8")
case "/sense-console/app.css":
h.serveAsset(writer, request, "assets/app.css", "text/css; charset=utf-8")
case "/sense-console/app.js":
h.serveAsset(writer, request, "assets/app.js", "text/javascript; charset=utf-8")
case "/sense-console/config":
writer.Header().Set("Content-Type", "application/json")
if request.Method == http.MethodHead {
writer.WriteHeader(http.StatusOK)
return
}
_ = json.NewEncoder(writer).Encode(map[string]any{
"webrtc_base_url": h.previewBaseURL,
"page_size": 16,
"max_active_previews": 4,
"recording_and_playback": false,
})
default:
http.NotFound(writer, request)
}
}
func (h *handler) securityHeaders(writer http.ResponseWriter) {
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("Content-Security-Policy", fmt.Sprintf(
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src %s; img-src 'self' data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
h.previewOrigin,
))
writer.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
}
func (h *handler) serveAsset(writer http.ResponseWriter, request *http.Request, path, contentType string) {
contents, err := assets.ReadFile(path)
if err != nil {
http.Error(writer, "asset unavailable", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(contents)))
writer.WriteHeader(http.StatusOK)
if request.Method == http.MethodGet {
_, _ = writer.Write(contents)
}
}
+95
View File
@@ -0,0 +1,95 @@
package console
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandlerServesSelfContainedConsoleWithSecurityHeaders(t *testing.T) {
handler, err := NewHandler("http://127.0.0.1:8889/")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/sense-console/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", response.Code)
}
body := response.Body.String()
for _, required := range []string{"YoVision Sense", "/sense-console/app.css", "/sense-console/app.js"} {
if !strings.Contains(body, required) {
t.Fatalf("console HTML lacks %q", required)
}
}
for _, forbidden := range []string{"http://", "https://", "<script>", "<style>"} {
if strings.Contains(body, forbidden) {
t.Fatalf("console HTML contains forbidden inline/external marker %q", forbidden)
}
}
csp := response.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-src http://127.0.0.1:8889") ||
!strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("unexpected CSP: %s", csp)
}
if response.Header().Get("Cache-Control") != "no-store" ||
response.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers are incomplete")
}
}
func TestHandlerReturnsBoundedRuntimeConfig(t *testing.T) {
handler, err := NewHandler("http://localhost:8889")
if err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/sense-console/config", nil))
var payload struct {
WebRTCBaseURL string `json:"webrtc_base_url"`
PageSize int `json:"page_size"`
MaxActivePreviews int `json:"max_active_previews"`
RecordingAndPlayback bool `json:"recording_and_playback"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.WebRTCBaseURL != "http://localhost:8889" || payload.PageSize != 16 ||
payload.MaxActivePreviews != 4 || payload.RecordingAndPlayback {
t.Fatalf("unexpected runtime config: %+v", payload)
}
}
func TestHandlerRejectsRemoteOrCredentialedPreviewBase(t *testing.T) {
for _, value := range []string{
"https://media.example", "http://user@127.0.0.1:8889", "http://127.0.0.1:8889/path",
} {
if _, err := NewHandler(value); err == nil {
t.Fatalf("invalid preview base was accepted: %s", value)
}
}
}
func TestHandlerRejectsWritesAndUnknownAssets(t *testing.T) {
handler, err := NewHandler("http://127.0.0.1:8889")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
method string
path string
want int
}{
{http.MethodPost, "/sense-console/", http.StatusMethodNotAllowed},
{http.MethodGet, "/sense-console/missing", http.StatusNotFound},
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.want {
t.Fatalf("%s %s: got %d, want %d", test.method, test.path, response.Code, test.want)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package controlapi implements the frozen Sense Control API v1.
package controlapi
// The input is the repository-owned public contract frozen by T-008.
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../../docs/contracts/sense-control-v1.openapi.json
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
package controlapi
import (
"crypto/rand"
"encoding/hex"
"errors"
"math/big"
"time"
)
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
func newULID(prefix string, now time.Time) (string, error) {
value := make([]byte, 16)
milliseconds := uint64(now.UTC().UnixMilli())
value[0] = byte(milliseconds >> 40)
value[1] = byte(milliseconds >> 32)
value[2] = byte(milliseconds >> 24)
value[3] = byte(milliseconds >> 16)
value[4] = byte(milliseconds >> 8)
value[5] = byte(milliseconds)
if _, err := rand.Read(value[6:]); err != nil {
return "", errors.New("generate identifier randomness")
}
number := new(big.Int).SetBytes(value)
base := big.NewInt(32)
remainder := new(big.Int)
encoded := make([]byte, 26)
for index := len(encoded) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
encoded[index] = crockford[remainder.Int64()]
}
return prefix + string(encoded), nil
}
func newTraceID() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", errors.New("generate trace identifier")
}
return "trace_" + hex.EncodeToString(value), nil
}
+66
View File
@@ -0,0 +1,66 @@
package controlapi
import (
"yovision/sense/internal/store"
)
func publicDevice(value store.ControlDevice) Device {
capabilities := make([]Capability, len(value.Capabilities))
for index := range value.Capabilities {
capabilities[index] = Capability(value.Capabilities[index])
}
tenantID := LogicalID(value.TenantID)
generation, observed := value.Generation, value.ObservedGeneration
converged, failureCount := value.Converged, value.FailureCount
endpointConfigured, credentialConfigured := value.EndpointConfigured, value.CredentialConfigured
createdAt, updatedAt := value.CreatedAt, value.UpdatedAt
result := Device{
Id: value.ID, TenantId: &tenantID, SiteId: value.SiteID,
SerialNumber: value.SerialNumber, Name: value.Name, Modality: Modality(value.Modality),
Capabilities: &capabilities, AreaId: value.AreaID,
DesiredState: DesiredState(value.DesiredState), ActualState: ActualState(value.ActualState),
AdapterStatus: AdapterStatus(value.AdapterStatus),
EndpointConfigured: &endpointConfigured, CredentialConfigured: &credentialConfigured,
Generation: &generation, ObservedGeneration: &observed, Converged: &converged,
FailureCount: &failureCount, NextAttemptAt: value.NextAttemptAt,
LastErrorCode: value.LastErrorCode, CreatedAt: &createdAt, UpdatedAt: &updatedAt,
}
if value.ProjectionVersions.QuotaSourceVersion != nil {
version := *value.ProjectionVersions.QuotaSourceVersion
result.ProjectionVersions.QuotaSourceVersion = &version
}
if value.ProjectionVersions.AreaPolicySourceVersion != nil {
version := *value.ProjectionVersions.AreaPolicySourceVersion
result.ProjectionVersions.AreaPolicySourceVersion = &version
}
result.ProjectionVersions.SyncedAt = value.ProjectionVersions.SyncedAt
return result
}
func publicQuota(value store.ControlSiteQuota) SiteQuotaStatus {
return SiteQuotaStatus{
Status: SiteQuotaStatusStatus(value.Status), UsedVideoChannels: value.UsedVideoChannels,
MaxVideoChannels: value.MaxVideoChannels,
AvailableVideoChannels: value.AvailableVideoChannels,
OverLimit: value.OverLimit, SourceVersion: value.SourceVersion, SyncedAt: value.SyncedAt,
}
}
func publicOperation(value store.ControlBatchOperation) BatchOperation {
results := make([]BatchItemResult, 0, len(value.Results))
for _, item := range value.Results {
var code *ErrorCode
if item.ErrorCode != nil {
converted := ErrorCode(*item.ErrorCode)
code = &converted
}
results = append(results, BatchItemResult{
DeviceId: item.DeviceID, Status: BatchItemResultStatus(item.Status),
ErrorCode: code, Message: item.Message, Generation: item.Generation,
})
}
return BatchOperation{
Id: value.ID, Status: BatchOperationStatus(value.Status), SubmittedAt: value.SubmittedAt,
CompletedAt: value.CompletedAt, Results: results, TraceId: value.TraceID,
}
}
@@ -0,0 +1,7 @@
package: controlapi
output: generated.gen.go
generate:
models: true
std-http-server: true
output-options:
skip-prune: false
+108
View File
@@ -0,0 +1,108 @@
package controlapi
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"os"
"strings"
"time"
)
var ErrInvalidCursor = errors.New("invalid cursor")
type CursorPosition struct {
CreatedAt time.Time
DeviceID string
}
type cursorPayload struct {
Version int `json:"v"`
TenantID string `json:"t"`
SiteID string `json:"s"`
FilterHash string `json:"f"`
CreatedAt string `json:"c"`
DeviceID string `json:"d"`
}
type CursorCodec struct {
key []byte
}
func LoadCursorCodec(path string) (*CursorCodec, error) {
contents, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read Control API cursor key")
}
key, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(contents)))
if err != nil || len(key) < 32 {
return nil, errors.New("Control API cursor key must be base64url for at least 32 bytes")
}
return NewCursorCodec(key), nil
}
func NewCursorCodec(key []byte) *CursorCodec {
copyOfKey := append([]byte(nil), key...)
return &CursorCodec{key: copyOfKey}
}
func (c *CursorCodec) Encode(tenantID, siteID, filterHash string, position CursorPosition) (string, error) {
payload, err := json.Marshal(cursorPayload{
Version: 1, TenantID: tenantID, SiteID: siteID, FilterHash: filterHash,
CreatedAt: position.CreatedAt.UTC().Format(time.RFC3339Nano), DeviceID: position.DeviceID,
})
if err != nil {
return "", errors.New("encode cursor payload")
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, c.key)
_, _ = mac.Write([]byte(encoded))
return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func (c *CursorCodec) Decode(value, tenantID, siteID, filterHash string) (CursorPosition, error) {
var position CursorPosition
parts := strings.Split(value, ".")
if len(parts) != 2 || len(value) > 512 {
return position, ErrInvalidCursor
}
provided, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || len(provided) != sha256.Size {
return position, ErrInvalidCursor
}
mac := hmac.New(sha256.New, c.key)
_, _ = mac.Write([]byte(parts[0]))
if subtle.ConstantTimeCompare(provided, mac.Sum(nil)) != 1 {
return position, ErrInvalidCursor
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return position, ErrInvalidCursor
}
var payload cursorPayload
decoder := json.NewDecoder(strings.NewReader(string(payloadBytes)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil || payload.Version != 1 ||
payload.TenantID != tenantID || payload.SiteID != siteID || payload.FilterHash != filterHash {
return position, ErrInvalidCursor
}
createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt)
if err != nil || payload.DeviceID == "" {
return position, ErrInvalidCursor
}
position.CreatedAt = createdAt.UTC()
position.DeviceID = payload.DeviceID
return position, nil
}
func filterFingerprint(values ...string) string {
hash := sha256.New()
for _, value := range values {
_, _ = hash.Write([]byte{byte(len(value) >> 8), byte(len(value))})
_, _ = hash.Write([]byte(value))
}
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
}
@@ -0,0 +1,58 @@
package controlapi
import (
"bytes"
"strings"
"testing"
"time"
)
func TestCursorIsScopedAndTamperEvident(t *testing.T) {
codec := NewCursorCodec(bytes.Repeat([]byte{7}, 32))
position := CursorPosition{CreatedAt: time.Date(2026, 8, 7, 1, 2, 3, 4, time.UTC), DeviceID: "dev_1"}
value, err := codec.Encode("tenant-a", "site-a", "filters", position)
if err != nil {
t.Fatal(err)
}
decoded, err := codec.Decode(value, "tenant-a", "site-a", "filters")
if err != nil || decoded.DeviceID != position.DeviceID || !decoded.CreatedAt.Equal(position.CreatedAt) {
t.Fatalf("cursor did not round trip: %+v %v", decoded, err)
}
for name, candidate := range map[string]string{
"tenant": "tenant-b", "site": "site-b", "filter": "other",
} {
tenant, site, filter := "tenant-a", "site-a", "filters"
switch name {
case "tenant":
tenant = candidate
case "site":
site = candidate
case "filter":
filter = candidate
}
if _, err := codec.Decode(value, tenant, site, filter); err == nil {
t.Fatalf("cursor was accepted across %s scope", name)
}
}
tampered := value[:len(value)-1] + strings.ToUpper(value[len(value)-1:])
if tampered == value {
tampered = value[:len(value)-1] + "A"
}
if _, err := codec.Decode(tampered, "tenant-a", "site-a", "filters"); err == nil {
t.Fatal("tampered cursor was accepted")
}
}
func TestGeneratedIdentifiersMatchContractShape(t *testing.T) {
value, err := newULID("op_", time.Now())
if err != nil {
t.Fatal(err)
}
if len(value) != 29 || !strings.HasPrefix(value, "op_") {
t.Fatalf("invalid operation ID %q", value)
}
trace, err := newTraceID()
if err != nil || len(trace) != 38 {
t.Fatalf("invalid trace ID %q: %v", trace, err)
}
}
+601
View File
@@ -0,0 +1,601 @@
package controlapi
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"yovision/sense/internal/auth"
"yovision/sense/internal/device"
"yovision/sense/internal/store"
)
type principalContextKey struct{}
type traceContextKey struct{}
type Server struct {
store store.ControlRepository
authenticator auth.Authenticator
cursors *CursorCodec
now func() time.Time
}
func NewHTTPHandler(
repository store.ControlRepository, authenticator auth.Authenticator, cursors *CursorCodec,
) http.Handler {
server := &Server{
store: repository, authenticator: authenticator, cursors: cursors, now: time.Now,
}
generated := HandlerWithOptions(server, StdHTTPServerOptions{ErrorHandlerFunc: server.bindError})
return server.authenticate(generated)
}
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
traceID, err := newTraceID()
if err != nil {
http.Error(writer, "service unavailable", http.StatusServiceUnavailable)
return
}
ctx := context.WithValue(request.Context(), traceContextKey{}, traceID)
request = request.WithContext(ctx)
values := request.Header.Values("Authorization")
if len(values) != 1 {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
parts := strings.Fields(values[0])
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
principal, err := s.authenticator.Authenticate(request.Context(), parts[1])
if err != nil {
writer.Header().Set("WWW-Authenticate", `Bearer realm="sense-control"`)
s.writeProblem(writer, request, http.StatusUnauthorized, ErrorCodeUnauthenticated, "authentication is required")
return
}
ctx = context.WithValue(request.Context(), principalContextKey{}, principal)
next.ServeHTTP(writer, request.WithContext(ctx))
})
}
func principalFromContext(ctx context.Context) auth.Principal {
value, _ := ctx.Value(principalContextKey{}).(auth.Principal)
return value
}
func traceFromContext(ctx context.Context) string {
value, _ := ctx.Value(traceContextKey{}).(string)
return value
}
func (s *Server) bindError(writer http.ResponseWriter, request *http.Request, err error) {
var required *RequiredHeaderError
if errors.As(err, &required) && required.ParamName == "If-Match" {
s.writeProblem(writer, request, http.StatusPreconditionRequired,
ErrorCodePreconditionRequired, "If-Match is required")
return
}
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "request parameters are invalid")
}
func (s *Server) requireSite(
writer http.ResponseWriter, request *http.Request, siteID, permission string,
) (auth.Principal, bool) {
principal := principalFromContext(request.Context())
if !validLogicalID(siteID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "site_id is invalid")
return auth.Principal{}, false
}
if !principal.AllowsSite(siteID) {
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
return auth.Principal{}, false
}
if !principal.Has(permission) {
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
return auth.Principal{}, false
}
return principal, true
}
func (s *Server) writeProblem(
writer http.ResponseWriter, request *http.Request, status int, code ErrorCode, message string,
) {
traceID := traceFromContext(request.Context())
writer.Header().Set("Content-Type", "application/problem+json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Trace-ID", traceID)
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(Problem{
Type: "/problems/" + string(code), Title: http.StatusText(status), Status: status,
Code: code, Message: message, TraceId: traceID, FieldErrors: []FieldError{},
})
}
func writeJSON(writer http.ResponseWriter, status int, traceID string, value any) {
writer.Header().Set("Content-Type", "application/json")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Trace-ID", traceID)
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func (s *Server) ListDevices(
writer http.ResponseWriter, request *http.Request, siteID SiteID, params ListDevicesParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
if !ok {
return
}
limit := 50
if params.Limit != nil {
limit = *params.Limit
}
if limit < 1 || limit > 100 ||
(params.Modality != nil && !Modality(*params.Modality).Valid()) ||
(params.Capability != nil && !Capability(*params.Capability).Valid()) ||
(params.DesiredState != nil && !DesiredState(*params.DesiredState).Valid()) ||
(params.ActualState != nil && !ActualState(*params.ActualState).Valid()) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "list filters are invalid")
return
}
filterHash := filterFingerprint(
optionalString(params.Modality), optionalString(params.Capability),
optionalString(params.DesiredState), optionalString(params.ActualState),
)
filter := store.ControlListFilter{Limit: limit}
if params.Modality != nil {
value := device.Modality(*params.Modality)
filter.Modality = &value
}
if params.Capability != nil {
value := device.Capability(*params.Capability)
filter.Capability = &value
}
if params.DesiredState != nil {
value := device.DesiredState(*params.DesiredState)
filter.DesiredState = &value
}
if params.ActualState != nil {
value := device.ActualState(*params.ActualState)
filter.ActualState = &value
}
if params.Cursor != nil {
position, err := s.cursors.Decode(*params.Cursor, principal.TenantID, siteID, filterHash)
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "cursor is invalid")
return
}
filter.AfterCreated = &position.CreatedAt
filter.AfterDeviceID = position.DeviceID
}
page, err := s.store.ListControlDevices(request.Context(), principal.TenantID, siteID, filter)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
items := make([]Device, 0, len(page.Items))
for _, value := range page.Items {
items = append(items, publicDevice(value))
}
var nextCursor *string
if page.HasMore && len(page.Items) > 0 {
last := page.Items[len(page.Items)-1]
value, err := s.cursors.Encode(principal.TenantID, siteID, filterHash,
CursorPosition{CreatedAt: last.CreatedAt, DeviceID: last.ID})
if err != nil {
s.writeProblem(writer, request, http.StatusInternalServerError, ErrorCodeInternalError, "response could not be created")
return
}
nextCursor = &value
}
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), DevicePage{
Items: items, Page: PageInfo{Limit: limit, HasMore: page.HasMore, NextCursor: nextCursor},
Quota: publicQuota(page.Quota),
})
}
func optionalString[T ~string](value *T) string {
if value == nil {
return ""
}
return string(*value)
}
func (s *Server) CreateDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, params CreateDeviceParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if err := rejectTopLevelNulls(contents); err != nil {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, err.Error())
return
}
var body DeviceCreate
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if code, message := validateDeviceCreate(body); code != "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
return
}
desired := Disabled
if body.DesiredState != nil {
desired = *body.DesiredState
}
body.DesiredState = &desired
canonical, _ := json.Marshal(body)
requestHash := sha256.Sum256(canonical)
now := s.now().UTC()
deviceID, err := newULID("dev_", now)
if err != nil {
s.writeProblem(writer, request, http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "identifier could not be generated")
return
}
capabilities := make([]device.Capability, len(body.Capabilities))
for index := range body.Capabilities {
capabilities[index] = device.Capability(body.Capabilities[index])
}
value := device.Device{
ID: deviceID, TenantID: principal.TenantID, SiteID: siteID, AreaID: body.AreaId,
SerialNumber: body.SerialNumber, Name: body.Name, Modality: device.Modality(body.Modality),
Capabilities: capabilities, DesiredState: device.DesiredState(desired),
ActualState: device.ActualPending, PathName: "devices/" + deviceID,
Generation: 1, ResourceVersion: 1,
}
if body.EndpointRef != nil {
value.EndpointRef = *body.EndpointRef
}
if body.CredentialRef != nil {
value.CredentialRef = *body.CredentialRef
}
if body.ProfileToken != nil {
value.ProfileToken = *body.ProfileToken
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
result, err := s.store.CreateControlDevice(ctx, store.ControlCreateRequest{
Scope: store.IdempotencyScope{
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
Operation: "createDevice", Key: params.IdempotencyKey,
RequestHash: requestHash, TraceID: traceID,
},
Device: value,
})
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writer.Header().Set("Location", result.Location)
writeJSON(writer, http.StatusCreated, result.TraceID, publicDevice(result.Device))
}
func validateDeviceCreate(body DeviceCreate) (string, string) {
if !validLogicalID(body.AreaId) || !validLength(body.SerialNumber, 1, 128) ||
!validLength(body.Name, 1, 200) || strings.TrimSpace(body.SerialNumber) == "" ||
strings.TrimSpace(body.Name) == "" || !body.Modality.Valid() ||
len(body.Capabilities) < 1 || len(body.Capabilities) > 16 {
return "invalid_request", "device fields are invalid"
}
seen := make(map[Capability]struct{}, len(body.Capabilities))
hasVideo := false
for _, capability := range body.Capabilities {
if !capability.Valid() {
return "invalid_request", "device capability is invalid"
}
if _, exists := seen[capability]; exists {
return "invalid_request", "device capabilities contain a duplicate"
}
seen[capability] = struct{}{}
hasVideo = hasVideo || capability == VideoCapture
}
if body.Modality == Video && !hasVideo {
return "invalid_request", "video modality requires video_capture"
}
if hasVideo && (body.EndpointRef == nil || body.CredentialRef == nil) {
return "adapter_not_ready", "video capture requires endpoint and credential references"
}
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
}
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
return "endpoint_credentials_forbidden", "credential reference is invalid"
}
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
return "invalid_request", "profile token is invalid"
}
if body.DesiredState != nil && !body.DesiredState.Valid() {
return "invalid_request", "desired_state is invalid"
}
return "", ""
}
func auditContext(principal auth.Principal, reason, traceID string) store.AuditContext {
actorType := store.AuditActorService
if principal.ActorType == "user" {
actorType = store.AuditActorUser
}
return store.AuditContext{
ActorType: actorType, ActorID: principal.SubjectID, Reason: reason, TraceID: traceID,
}
}
func (s *Server) GetDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesRead)
if !ok {
return
}
if !validLogicalID(deviceID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id is invalid")
return
}
value, err := s.store.GetControlDevice(request.Context(), principal.TenantID, siteID, deviceID)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
writer.Header().Set("ETag", store.DeviceETag(value.ID, value.ResourceVersion))
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicDevice(value))
}
func (s *Server) UpdateDevice(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
params UpdateDeviceParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
return
}
contents, err := readRequestBody(request, "application/merge-patch+json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(contents, &fields); err != nil || len(fields) == 0 {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch must contain a field")
return
}
for _, value := range fields {
if string(value) == "null" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "patch fields cannot be null")
return
}
}
var body DevicePatch
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if code, message := validateDevicePatch(body); code != "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCode(code), message)
return
}
patch := store.ControlPatch{
Name: body.Name, EndpointRef: body.EndpointRef,
CredentialRef: body.CredentialRef, ProfileToken: body.ProfileToken,
}
if body.AreaId != nil {
value := string(*body.AreaId)
patch.AreaID = &value
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, "", traceID))
result, err := s.store.PatchControlDevice(
ctx, principal.TenantID, siteID, deviceID, params.IfMatch, patch,
)
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
})
}
func validateDevicePatch(body DevicePatch) (string, string) {
if body.Name != nil && (!validLength(*body.Name, 1, 200) || strings.TrimSpace(*body.Name) == "") {
return "invalid_request", "name is invalid"
}
if body.AreaId != nil && !validLogicalID(*body.AreaId) {
return "invalid_request", "area_id is invalid"
}
if body.EndpointRef != nil && !validateEndpoint(*body.EndpointRef) {
return "endpoint_credentials_forbidden", "endpoint reference is invalid"
}
if body.CredentialRef != nil && (!validLength(*body.CredentialRef, 1, 512) || strings.TrimSpace(*body.CredentialRef) == "") {
return "endpoint_credentials_forbidden", "credential reference is invalid"
}
if body.ProfileToken != nil && !validLength(*body.ProfileToken, 1, 256) {
return "invalid_request", "profile token is invalid"
}
return "", ""
}
func (s *Server) SetDeviceDesiredState(
writer http.ResponseWriter, request *http.Request, siteID SiteID, deviceID DeviceID,
params SetDeviceDesiredStateParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !validLogicalID(deviceID) || !validStrongETag(params.IfMatch) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "device_id or If-Match is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var body DesiredStateChange
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if !body.DesiredState.Valid() || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "desired_state or reason is invalid")
return
}
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
result, err := s.store.SetControlDesiredState(
ctx, principal.TenantID, siteID, deviceID, params.IfMatch,
device.DesiredState(body.DesiredState),
)
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("ETag", result.ETag)
writeJSON(writer, http.StatusAccepted, result.TraceID, MutationReceipt{
Device: publicDevice(result.Device), AcceptedAt: result.AcceptedAt, TraceId: result.TraceID,
})
}
func (s *Server) BatchSetDeviceDesiredState(
writer http.ResponseWriter, request *http.Request, siteID SiteID,
params BatchSetDeviceDesiredStateParams,
) {
principal, ok := s.requireSite(writer, request, siteID, auth.PermissionDevicesWrite)
if !ok {
return
}
if !idempotencyKeyRegex.MatchString(params.IdempotencyKey) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "Idempotency-Key is invalid")
return
}
contents, err := readRequestBody(request, "application/json")
if err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
var body BatchDesiredStateRequest
if err := decodeStrictJSON(contents, &body); err != nil {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, err.Error())
return
}
if len(body.Items) > 128 {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeBatchTooLarge, "batch contains more than 128 items")
return
}
if len(body.Items) == 0 || !validLength(body.Reason, 1, 500) || strings.TrimSpace(body.Reason) == "" {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch items or reason is invalid")
return
}
items := make([]store.ControlBatchItem, 0, len(body.Items))
for _, item := range body.Items {
if !validLogicalID(item.DeviceId) || !validStrongETag(item.Etag) || !item.DesiredState.Valid() {
s.writeProblem(writer, request, http.StatusUnprocessableEntity, ErrorCodeInvalidRequest, "batch item is invalid")
return
}
items = append(items, store.ControlBatchItem{
DeviceID: item.DeviceId, ETag: item.Etag,
DesiredState: device.DesiredState(item.DesiredState),
})
}
canonical, _ := json.Marshal(body)
requestHash := sha256.Sum256(canonical)
traceID := traceFromContext(request.Context())
ctx := store.WithAuditContext(request.Context(), auditContext(principal, body.Reason, traceID))
operation, err := s.store.BatchSetControlDesiredState(ctx, store.ControlBatchRequest{
Scope: store.IdempotencyScope{
PrincipalID: principal.SubjectID, TenantID: principal.TenantID, SiteID: siteID,
Operation: "batchSetDeviceDesiredState", Key: params.IdempotencyKey,
RequestHash: requestHash, TraceID: traceID,
},
Reason: body.Reason, Items: items,
})
if err != nil {
s.writeStoreError(writer, request, err, true)
return
}
writer.Header().Set("Location", "/api/v1/operations/"+operation.ID)
writeJSON(writer, http.StatusAccepted, operation.TraceID, publicOperation(operation))
}
func (s *Server) GetOperation(
writer http.ResponseWriter, request *http.Request, operationID OperationID,
) {
principal := principalFromContext(request.Context())
if !principal.Has(auth.PermissionDevicesRead) {
s.writeProblem(writer, request, http.StatusForbidden, ErrorCodeForbidden, "permission is required")
return
}
if !operationIDRegex.MatchString(operationID) {
s.writeProblem(writer, request, http.StatusBadRequest, ErrorCodeInvalidRequest, "operation_id is invalid")
return
}
operation, err := s.store.GetControlOperation(request.Context(), principal.TenantID, operationID)
if err != nil {
s.writeStoreError(writer, request, err, false)
return
}
if !principal.AllowsSite(operation.SiteID) {
s.writeProblem(writer, request, http.StatusNotFound, ErrorCodeNotFound, "resource was not found")
return
}
writeJSON(writer, http.StatusOK, traceFromContext(request.Context()), publicOperation(operation))
}
func (s *Server) writeStoreError(
writer http.ResponseWriter, request *http.Request, err error, mutation bool,
) {
status, code, message := http.StatusInternalServerError, ErrorCodeInternalError, "request could not be completed"
switch {
case errors.Is(err, store.ErrNotFound):
status, code, message = http.StatusNotFound, ErrorCodeNotFound, "resource was not found"
case errors.Is(err, store.ErrETagMismatch):
status, code, message = http.StatusPreconditionFailed, ErrorCodeEtagMismatch, "device ETag does not match"
case errors.Is(err, store.ErrIdempotencyConflict):
status, code, message = http.StatusConflict, ErrorCodeIdempotencyConflict, "Idempotency-Key was used with another request"
case errors.Is(err, store.ErrDuplicateSerialNumber):
status, code, message = http.StatusConflict, ErrorCodeDuplicateSerialNumber, "serial_number already exists in this site"
case errors.Is(err, store.ErrAreaPolicyDenied):
status, code, message = http.StatusUnprocessableEntity, ErrorCodeAreaPolicyDenied, "Area policy denies this change"
case errors.Is(err, store.ErrAreaPolicyUnavailable), errors.Is(err, store.ErrAreaPolicyInvalid):
status, code, message = http.StatusServiceUnavailable, ErrorCodeAreaPolicyUnavailable, "Area policy is unavailable"
case errors.Is(err, store.ErrQuotaProjectionUnavailable):
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionUnavailable, "Site quota is unavailable"
case errors.Is(err, store.ErrQuotaProjectionInvalid):
status, code, message = http.StatusServiceUnavailable, ErrorCodeQuotaProjectionInvalid, "Site quota is invalid"
default:
var quotaError *device.QuotaExceededError
if errors.As(err, &quotaError) {
status, code, message = http.StatusConflict, ErrorCodeQuotaExceeded, "Site video channel quota is exceeded"
} else if mutation {
status, code, message = http.StatusServiceUnavailable, ErrorCodeServiceUnavailable, "device change could not be accepted"
}
}
s.writeProblem(writer, request, status, code, message)
}
var _ ServerInterface = (*Server)(nil)
+272
View File
@@ -0,0 +1,272 @@
package controlapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"yovision/sense/internal/auth"
"yovision/sense/internal/device"
"yovision/sense/internal/store"
)
const testOperationID = "op_01K20Y8Q3E0000000000000000"
type fixedAuthenticator struct {
principal auth.Principal
err error
}
func (a fixedAuthenticator) Authenticate(context.Context, string) (auth.Principal, error) {
return a.principal, a.err
}
type fakeControlStore struct {
device store.ControlDevice
err error
}
func (f *fakeControlStore) ListControlDevices(context.Context, string, string, store.ControlListFilter) (store.ControlDevicePage, error) {
return store.ControlDevicePage{
Items: []store.ControlDevice{f.device}, Quota: store.ControlSiteQuota{Status: "current"},
}, f.err
}
func (f *fakeControlStore) CreateControlDevice(_ context.Context, request store.ControlCreateRequest) (store.ControlCreateResult, error) {
return store.ControlCreateResult{
Device: f.device, AcceptedAt: time.Now(), TraceID: request.Scope.TraceID,
ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion),
Location: "/api/v1/sites/site-a/devices/" + f.device.ID,
}, f.err
}
func (f *fakeControlStore) GetControlDevice(context.Context, string, string, string) (store.ControlDevice, error) {
return f.device, f.err
}
func (f *fakeControlStore) PatchControlDevice(_ context.Context, _, _, _, _ string, _ store.ControlPatch) (store.ControlMutationResult, error) {
return f.mutation(), f.err
}
func (f *fakeControlStore) SetControlDesiredState(_ context.Context, _, _, _, _ string, _ device.DesiredState) (store.ControlMutationResult, error) {
return f.mutation(), f.err
}
func (f *fakeControlStore) mutation() store.ControlMutationResult {
return store.ControlMutationResult{
Device: f.device, AcceptedAt: time.Now(), TraceID: "trace_0123456789abcdef0123456789abcdef",
ETag: store.DeviceETag(f.device.ID, f.device.ResourceVersion),
}
}
func (f *fakeControlStore) BatchSetControlDesiredState(_ context.Context, request store.ControlBatchRequest) (store.ControlBatchOperation, error) {
completed := time.Now().UTC()
generation := f.device.Generation
return store.ControlBatchOperation{
ID: testOperationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID,
Status: "succeeded", SubmittedAt: completed, CompletedAt: &completed,
TraceID: request.Scope.TraceID,
Results: []store.ControlBatchItemResult{{
DeviceID: f.device.ID, Status: "succeeded", Generation: &generation,
}},
}, f.err
}
func (f *fakeControlStore) GetControlOperation(context.Context, string, string) (store.ControlBatchOperation, error) {
completed := time.Now().UTC()
return store.ControlBatchOperation{
ID: testOperationID, TenantID: "tenant-a", SiteID: "site-a", Status: "succeeded",
SubmittedAt: completed, CompletedAt: &completed, Results: []store.ControlBatchItemResult{},
TraceID: "trace_0123456789abcdef0123456789abcdef",
}, f.err
}
func testHTTPHandler(repository *fakeControlStore, permissions ...string) http.Handler {
grants := make(map[string]struct{}, len(permissions))
for _, permission := range permissions {
grants[permission] = struct{}{}
}
principal := auth.Principal{
SubjectID: "operator-1", ActorType: "user", TenantID: "tenant-a",
SiteIDs: []string{"site-a"}, Permissions: grants,
}
return NewHTTPHandler(repository, fixedAuthenticator{principal: principal}, NewCursorCodec(bytes.Repeat([]byte{4}, 32)))
}
func testControlDevice() store.ControlDevice {
quotaVersion, areaVersion := int64(1), int64(2)
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
return store.ControlDevice{
ID: "dev_01K20Y8Q3E0000000000000000", TenantID: "tenant-a", SiteID: "site-a",
SerialNumber: "serial-1", Name: "Camera", Modality: device.ModalityVideo,
Capabilities: []device.Capability{device.CapabilityVideoCapture}, AreaID: "area-a",
DesiredState: device.DesiredDisabled, ActualState: device.ActualPending,
AdapterStatus: "pending", EndpointConfigured: true, CredentialConfigured: true,
Generation: 1, ResourceVersion: 1, FailureCount: 0,
ProjectionVersions: store.ControlProjectionVersions{
QuotaSourceVersion: &quotaVersion, AreaPolicySourceVersion: &areaVersion, SyncedAt: &now,
},
CreatedAt: now, UpdatedAt: now,
}
}
func performRequest(handler http.Handler, method, path, contentType, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(method, path, strings.NewReader(body))
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
return recorder
}
func TestAllFrozenControlRoutesReturnContractShapes(t *testing.T) {
value := testControlDevice()
repository := &fakeControlStore{device: value}
handler := testHTTPHandler(repository, auth.PermissionDevicesRead, auth.PermissionDevicesWrite)
etag := store.DeviceETag(value.ID, value.ResourceVersion)
list := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices", "", "")
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"quota"`) {
t.Fatalf("list route failed: %d %s", list.Code, list.Body.String())
}
createBody := `{"serial_number":"serial-1","name":"Camera","modality":"video",` +
`"capabilities":["video_capture"],"area_id":"area-a",` +
`"endpoint_ref":"onvif://camera","credential_ref":"env://CAMERA"}`
create := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(createBody))
create.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
create.Header.Set("Content-Type", "application/json")
create.Header.Set("Idempotency-Key", "create-request-0001")
createResult := httptest.NewRecorder()
handler.ServeHTTP(createResult, create)
if createResult.Code != http.StatusCreated || createResult.Header().Get("ETag") == "" ||
strings.Contains(createResult.Body.String(), "endpoint_ref") || strings.Contains(createResult.Body.String(), "credential_ref") {
t.Fatalf("create route failed or leaked write-only data: %d %s", createResult.Code, createResult.Body.String())
}
get := performRequest(handler, http.MethodGet, "/api/v1/sites/site-a/devices/"+value.ID, "", "")
if get.Code != http.StatusOK || get.Header().Get("ETag") != etag {
t.Fatalf("get route failed: %d %s", get.Code, get.Body.String())
}
patch := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"Updated"}`))
patch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
patch.Header.Set("Content-Type", "application/merge-patch+json")
patch.Header.Set("If-Match", etag)
patchResult := httptest.NewRecorder()
handler.ServeHTTP(patchResult, patch)
if patchResult.Code != http.StatusAccepted || !strings.Contains(patchResult.Body.String(), `"accepted_at"`) {
t.Fatalf("patch route failed: %d %s", patchResult.Code, patchResult.Body.String())
}
desired := httptest.NewRequest(http.MethodPut, "/api/v1/sites/site-a/devices/"+value.ID+"/desired-state", strings.NewReader(`{"desired_state":"enabled","reason":"test"}`))
desired.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
desired.Header.Set("Content-Type", "application/json")
desired.Header.Set("If-Match", etag)
desiredResult := httptest.NewRecorder()
handler.ServeHTTP(desiredResult, desired)
if desiredResult.Code != http.StatusAccepted {
t.Fatalf("desired-state route failed: %d %s", desiredResult.Code, desiredResult.Body.String())
}
batchBody := `{"items":[{"device_id":"` + value.ID + `","etag":"` + strings.ReplaceAll(etag, `"`, `\"`) +
`","desired_state":"enabled"}],"reason":"test"}`
batch := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices:batchDesiredState", strings.NewReader(batchBody))
batch.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
batch.Header.Set("Content-Type", "application/json")
batch.Header.Set("Idempotency-Key", "batch-request-0001")
batchResult := httptest.NewRecorder()
handler.ServeHTTP(batchResult, batch)
if batchResult.Code != http.StatusAccepted || batchResult.Header().Get("Location") == "" {
t.Fatalf("batch route failed: %d %s", batchResult.Code, batchResult.Body.String())
}
operation := performRequest(handler, http.MethodGet, "/api/v1/operations/"+testOperationID, "", "")
if operation.Code != http.StatusOK || !strings.Contains(operation.Body.String(), `"results"`) {
t.Fatalf("operation route failed: %d %s", operation.Code, operation.Body.String())
}
}
func TestAuthenticationScopeAndPreconditionsUseStableProblems(t *testing.T) {
value := testControlDevice()
handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesRead)
unauthenticated := httptest.NewRecorder()
handler.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/api/v1/sites/site-a/devices", nil))
if unauthenticated.Code != http.StatusUnauthorized || unauthenticated.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("missing auth did not return 401: %d", unauthenticated.Code)
}
hidden := performRequest(handler, http.MethodGet, "/api/v1/sites/site-b/devices", "", "")
if hidden.Code != http.StatusNotFound {
t.Fatalf("out-of-scope site was not hidden: %d", hidden.Code)
}
missingPrecondition := performRequest(handler, http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID,
"application/merge-patch+json", `{"name":"Updated"}`)
if missingPrecondition.Code != http.StatusPreconditionRequired {
t.Fatalf("missing If-Match did not return 428: %d %s", missingPrecondition.Code, missingPrecondition.Body.String())
}
var problem Problem
if err := json.Unmarshal(missingPrecondition.Body.Bytes(), &problem); err != nil || problem.Code != ErrorCodePreconditionRequired || problem.TraceId == "" {
t.Fatalf("invalid Problem response: %+v %v", problem, err)
}
}
func TestStrictJSONAndStoreConflictsAreMapped(t *testing.T) {
value := testControlDevice()
handler := testHTTPHandler(&fakeControlStore{device: value}, auth.PermissionDevicesWrite)
etag := store.DeviceETag(value.ID, value.ResourceVersion)
duplicate := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID,
strings.NewReader(`{"name":"one","name":"two"}`))
duplicate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
duplicate.Header.Set("Content-Type", "application/merge-patch+json")
duplicate.Header.Set("If-Match", etag)
result := httptest.NewRecorder()
handler.ServeHTTP(result, duplicate)
if result.Code != http.StatusBadRequest {
t.Fatalf("duplicate JSON property was accepted: %d %s", result.Code, result.Body.String())
}
nullCreate := httptest.NewRequest(http.MethodPost, "/api/v1/sites/site-a/devices", strings.NewReader(
`{"serial_number":"serial","name":"Camera","modality":"radar",`+
`"capabilities":["telemetry"],"area_id":"area-a","profile_token":null}`))
nullCreate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
nullCreate.Header.Set("Content-Type", "application/json")
nullCreate.Header.Set("Idempotency-Key", "null-create-0001")
nullResult := httptest.NewRecorder()
handler.ServeHTTP(nullResult, nullCreate)
if nullResult.Code != http.StatusUnprocessableEntity {
t.Fatalf("explicit null was accepted: %d %s", nullResult.Code, nullResult.Body.String())
}
conflictHandler := testHTTPHandler(&fakeControlStore{device: value, err: store.ErrETagMismatch}, auth.PermissionDevicesWrite)
request := httptest.NewRequest(http.MethodPatch, "/api/v1/sites/site-a/devices/"+value.ID, strings.NewReader(`{"name":"two"}`))
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
request.Header.Set("Content-Type", "application/merge-patch+json")
request.Header.Set("If-Match", etag)
recorder := httptest.NewRecorder()
conflictHandler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusPreconditionFailed || !strings.Contains(recorder.Body.String(), "etag_mismatch") {
t.Fatalf("ETag mismatch was not mapped: %d %s", recorder.Code, recorder.Body.String())
}
}
func TestProjectionVersionsAlwaysEmitRequiredNullableKeys(t *testing.T) {
encoded, err := json.Marshal(ProjectionVersions{})
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"quota_source_version", "area_policy_source_version", "synced_at"} {
if !strings.Contains(string(encoded), `"`+key+`":null`) {
t.Fatalf("required nullable key %s was omitted: %s", key, encoded)
}
}
}
+153
View File
@@ -0,0 +1,153 @@
package controlapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"regexp"
"strings"
"unicode/utf8"
)
const maximumRequestBody = 1 << 20
var (
logicalIDRegex = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`)
idempotencyKeyRegex = regexp.MustCompile(`^[A-Za-z0-9._:-]{16,128}$`)
operationIDRegex = regexp.MustCompile(`^op_[0-9A-HJKMNP-TV-Z]{26}$`)
strongETagRegex = regexp.MustCompile(`^"[A-Za-z0-9_-]{24}"$`)
)
func readRequestBody(request *http.Request, expectedMediaType string) ([]byte, error) {
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
if err != nil || mediaType != expectedMediaType {
return nil, fmt.Errorf("Content-Type must be %s", expectedMediaType)
}
contents, err := io.ReadAll(io.LimitReader(request.Body, maximumRequestBody+1))
if err != nil {
return nil, errors.New("read request body")
}
if len(contents) == 0 || len(contents) > maximumRequestBody {
return nil, errors.New("request body is empty or too large")
}
if err := rejectDuplicateJSONKeys(contents); err != nil {
return nil, err
}
return contents, nil
}
func decodeStrictJSON(contents []byte, destination any) error {
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return errors.New("request body does not match the API schema")
}
if decoder.Decode(&struct{}{}) != io.EOF {
return errors.New("request body contains trailing JSON")
}
return nil
}
func rejectTopLevelNulls(contents []byte) error {
var fields map[string]json.RawMessage
if err := json.Unmarshal(contents, &fields); err != nil || fields == nil {
return errors.New("request body must be a JSON object")
}
for _, value := range fields {
if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
return errors.New("request body properties cannot be null")
}
}
return nil
}
func rejectDuplicateJSONKeys(contents []byte) error {
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.UseNumber()
var visit func(int) error
visit = func(depth int) error {
if depth > 64 {
return errors.New("request body nesting is too deep")
}
token, err := decoder.Token()
if err != nil {
return errors.New("request body is not valid JSON")
}
delimiter, ok := token.(json.Delim)
if !ok {
return nil
}
switch delimiter {
case '{':
seen := make(map[string]struct{})
for decoder.More() {
keyToken, err := decoder.Token()
if err != nil {
return errors.New("request body is not valid JSON")
}
key, ok := keyToken.(string)
if !ok {
return errors.New("request body is not a JSON object")
}
if _, exists := seen[key]; exists {
return errors.New("request body contains a duplicate property")
}
seen[key] = struct{}{}
if err := visit(depth + 1); err != nil {
return err
}
}
end, err := decoder.Token()
if err != nil || end != json.Delim('}') {
return errors.New("request body is not valid JSON")
}
case '[':
for decoder.More() {
if err := visit(depth + 1); err != nil {
return err
}
}
end, err := decoder.Token()
if err != nil || end != json.Delim(']') {
return errors.New("request body is not valid JSON")
}
default:
return errors.New("request body is not valid JSON")
}
return nil
}
if err := visit(0); err != nil {
return err
}
if _, err := decoder.Token(); err != io.EOF {
return errors.New("request body contains trailing JSON")
}
return nil
}
func validLogicalID(value string) bool {
return logicalIDRegex.MatchString(value)
}
func validLength(value string, minimum, maximum int) bool {
length := utf8.RuneCountInString(value)
return utf8.ValidString(value) && length >= minimum && length <= maximum
}
func validateEndpoint(value string) bool {
if !validLength(value, 1, 2048) {
return false
}
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme != "" && parsed.User == nil &&
!strings.ContainsAny(value, "\r\n")
}
func validStrongETag(value string) bool {
return strongETagRegex.MatchString(value) && value != "*" && !strings.Contains(value, ",")
}
+19
View File
@@ -0,0 +1,19 @@
package controlapi
import "encoding/json"
// MarshalJSON preserves the OpenAPI-required nullable projection keys. The
// generator represents JSON null as nil pointers but marks read-only pointers
// omitempty, so the default encoder would otherwise violate the v1 wire shape.
func (value ProjectionVersions) MarshalJSON() ([]byte, error) {
type projectionWire struct {
QuotaSourceVersion *int64 `json:"quota_source_version"`
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
SyncedAt any `json:"synced_at"`
}
return json.Marshal(projectionWire{
QuotaSourceVersion: value.QuotaSourceVersion,
AreaPolicySourceVersion: value.AreaPolicySourceVersion,
SyncedAt: value.SyncedAt,
})
}
+184
View File
@@ -0,0 +1,184 @@
// Package device contains the Sense device-ledger domain model.
package device
import (
"errors"
"fmt"
"net/url"
"slices"
"strings"
"time"
)
const (
DefaultVideoChannels = 16
MaximumVideoChannels = 128
)
type Modality string
const (
ModalityVideo Modality = "video"
ModalityRadar Modality = "radar"
ModalityContact Modality = "contact"
ModalityButton Modality = "button"
ModalityWearable Modality = "wearable"
ModalityOther Modality = "other"
)
type Capability string
const (
CapabilityVideoCapture Capability = "video_capture"
CapabilityAudioCapture Capability = "audio_capture"
CapabilitySpatialRule Capability = "spatial_rule"
CapabilityTelemetry Capability = "telemetry"
)
type DesiredState string
const (
DesiredDisabled DesiredState = "disabled"
DesiredEnabled DesiredState = "enabled"
)
type ActualState string
const (
ActualPending ActualState = "pending"
ActualOnline ActualState = "online"
ActualOffline ActualState = "offline"
ActualFailed ActualState = "failed"
)
type Site struct {
TenantID string
ID string
Name string
MaxVideoChannels int
}
func (s *Site) ApplyDefaults() {
if s.MaxVideoChannels == 0 {
s.MaxVideoChannels = DefaultVideoChannels
}
}
func (s Site) Validate() error {
if strings.TrimSpace(s.TenantID) == "" || strings.TrimSpace(s.ID) == "" {
return errors.New("tenant ID and site ID are required")
}
if s.MaxVideoChannels < 1 || s.MaxVideoChannels > MaximumVideoChannels {
return fmt.Errorf("max video channels must be between 1 and %d", MaximumVideoChannels)
}
return nil
}
type Device struct {
ID string
TenantID string
SiteID string
AreaID string
SerialNumber string
Name string
Modality Modality
Capabilities []Capability
DesiredState DesiredState
ActualState ActualState
EndpointRef string
CredentialRef string
ProfileToken string
PathName string
Generation int64
ResourceVersion int64
QuotaSourceVersion int64
AreaPolicySourceVersion int64
CreatedAt time.Time
UpdatedAt time.Time
}
func (d Device) Validate() error {
if strings.TrimSpace(d.ID) == "" || strings.TrimSpace(d.TenantID) == "" || strings.TrimSpace(d.SiteID) == "" {
return errors.New("device ID, tenant ID and site ID are required")
}
if strings.TrimSpace(d.SerialNumber) == "" || strings.TrimSpace(d.Name) == "" {
return errors.New("serial number and device name are required")
}
if !validModality(d.Modality) {
return fmt.Errorf("unsupported modality %q", d.Modality)
}
if d.DesiredState != DesiredEnabled && d.DesiredState != DesiredDisabled {
return fmt.Errorf("unsupported desired state %q", d.DesiredState)
}
if d.ActualState != ActualPending && d.ActualState != ActualOnline && d.ActualState != ActualOffline && d.ActualState != ActualFailed {
return fmt.Errorf("unsupported actual state %q", d.ActualState)
}
if d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture) {
if strings.TrimSpace(d.EndpointRef) == "" || strings.TrimSpace(d.PathName) == "" {
return errors.New("enabled video devices require endpoint ref and path name")
}
}
if d.EndpointRef != "" {
endpoint, err := url.Parse(d.EndpointRef)
if err != nil || endpoint.Scheme == "" {
return errors.New("endpoint ref must be an absolute URI")
}
if endpoint.User != nil {
return errors.New("endpoint ref must not contain credentials")
}
}
if d.PathName != "" && !validPathName(d.PathName) {
return errors.New("path name must contain safe ASCII segments")
}
seen := make(map[Capability]struct{}, len(d.Capabilities))
for _, capability := range d.Capabilities {
if !validCapability(capability) {
return fmt.Errorf("unsupported capability %q", capability)
}
if _, ok := seen[capability]; ok {
return fmt.Errorf("duplicate capability %q", capability)
}
seen[capability] = struct{}{}
}
return nil
}
func (d Device) HasCapability(capability Capability) bool {
return slices.Contains(d.Capabilities, capability)
}
func (d Device) ConsumesVideoChannel() bool {
return d.DesiredState == DesiredEnabled && d.HasCapability(CapabilityVideoCapture)
}
func validModality(value Modality) bool {
return slices.Contains([]Modality{ModalityVideo, ModalityRadar, ModalityContact, ModalityButton, ModalityWearable, ModalityOther}, value)
}
func validCapability(value Capability) bool {
return slices.Contains([]Capability{CapabilityVideoCapture, CapabilityAudioCapture, CapabilitySpatialRule, CapabilityTelemetry}, value)
}
func validPathName(value string) bool {
if strings.HasPrefix(value, "/") || strings.HasSuffix(value, "/") || strings.Contains(value, "//") || strings.Contains(value, "..") {
return false
}
for _, character := range value {
if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || strings.ContainsRune("-_/.", character) {
continue
}
return false
}
return value != ""
}
type QuotaExceededError struct {
TenantID string
SiteID string
Limit int
}
func (e *QuotaExceededError) Error() string {
return fmt.Sprintf("video channel quota exceeded for site %s/%s (limit %d)", e.TenantID, e.SiteID, e.Limit)
}
+30
View File
@@ -0,0 +1,30 @@
package device
import "testing"
func TestDeviceRejectsCredentialsInEndpointReference(t *testing.T) {
t.Parallel()
value := validVideoDevice()
value.EndpointRef = "http://" + "user" + ":" + "redacted" + "@camera.invalid/onvif"
if err := value.Validate(); err == nil {
t.Fatal("expected endpoint credentials to be rejected")
}
}
func TestDeviceRejectsUnsafeMediaPath(t *testing.T) {
t.Parallel()
value := validVideoDevice()
value.PathName = "tenant/../another-camera"
if err := value.Validate(); err == nil {
t.Fatal("expected unsafe path name to be rejected")
}
}
func validVideoDevice() Device {
return Device{
ID: "camera", TenantID: "tenant", SiteID: "site", SerialNumber: "serial", Name: "Camera",
Modality: ModalityVideo, Capabilities: []Capability{CapabilityVideoCapture},
DesiredState: DesiredEnabled, ActualState: ActualPending,
EndpointRef: "onvif://camera", PathName: "sense/tenant/site/camera",
}
}
+189
View File
@@ -0,0 +1,189 @@
// Package metrics exposes a deliberately small, low-cardinality Prometheus
// surface without adding a runtime dependency. Tenant, Site, device and Path
// identifiers never become labels.
package metrics
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
)
func GenerateInstanceID() (string, error) {
value := make([]byte, 12)
if _, err := rand.Read(value); err != nil {
return "", errors.New("generate Sense instance identifier")
}
return "ins_" + hex.EncodeToString(value), nil
}
type Registry struct {
instanceID string
version string
reconcileRunOK atomic.Uint64
reconcileRunError atomic.Uint64
reconcileItemOK atomic.Uint64
reconcileItemError atomic.Uint64
reconcileItemLost atomic.Uint64
reconcileDurationNS atomic.Uint64
reconcileTotal atomic.Int64
reconcileUnconverged atomic.Int64
orphanScanOK atomic.Uint64
orphanScanError atomic.Uint64
orphanObserved atomic.Int64
orphanOwnedStale atomic.Int64
orphanUnowned atomic.Int64
orphanBlockedRatio atomic.Uint64
orphanBlockedAge atomic.Uint64
orphanBlockedScope atomic.Uint64
orphanDeleted atomic.Uint64
orphanDeleteFailed atomic.Uint64
}
func New(instanceID, version string) *Registry {
return &Registry{instanceID: instanceID, version: version}
}
func (r *Registry) ObserveReconcileRun(err error, duration time.Duration) {
if err == nil {
r.reconcileRunOK.Add(1)
} else {
r.reconcileRunError.Add(1)
}
if duration < 0 {
duration = 0
}
r.reconcileDurationNS.Store(uint64(duration))
}
func (r *Registry) ObserveReconcileItem(result string) {
switch result {
case "success":
r.reconcileItemOK.Add(1)
case "lease_lost":
r.reconcileItemLost.Add(1)
default:
r.reconcileItemError.Add(1)
}
}
func (r *Registry) SetConvergence(total, unconverged int) {
r.reconcileTotal.Store(int64(total))
r.reconcileUnconverged.Store(int64(unconverged))
}
func (r *Registry) ObserveOrphanScan(observed, ownedStale, unowned int, err error) {
if err == nil {
r.orphanScanOK.Add(1)
r.orphanObserved.Store(int64(observed))
r.orphanOwnedStale.Store(int64(ownedStale))
r.orphanUnowned.Store(int64(unowned))
} else {
r.orphanScanError.Add(1)
}
}
func (r *Registry) ObserveOrphanCleanupBlocked(reason string) {
switch reason {
case "snapshot_expired":
r.orphanBlockedAge.Add(1)
case "ratio_exceeded":
r.orphanBlockedRatio.Add(1)
default:
r.orphanBlockedScope.Add(1)
}
}
func (r *Registry) ObserveOrphanCleanup(deleted, failed int) {
if deleted > 0 {
r.orphanDeleted.Add(uint64(deleted))
}
if failed > 0 {
r.orphanDeleteFailed.Add(uint64(failed))
}
}
func (r *Registry) Handler() http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
writer.Header().Set("Cache-Control", "no-store")
_, _ = writer.Write([]byte(r.render()))
})
}
func (r *Registry) render() string {
var output strings.Builder
writeMetric(&output, "sense_build_info", "Sense process build and instance information.",
fmt.Sprintf("{instance_id=%s,version=%s}", quoteLabel(r.instanceID), quoteLabel(r.version)), 1)
writeMetric(&output, "sense_reconcile_runs_total", "Reconciliation runs by result.",
`{result="success"}`, r.reconcileRunOK.Load())
writeSample(&output, "sense_reconcile_runs_total", `{result="error"}`, r.reconcileRunError.Load())
writeMetric(&output, "sense_reconcile_items_total", "Reconciliation items by fixed result.",
`{result="success"}`, r.reconcileItemOK.Load())
writeSample(&output, "sense_reconcile_items_total", `{result="error"}`, r.reconcileItemError.Load())
writeSample(&output, "sense_reconcile_items_total", `{result="lease_lost"}`, r.reconcileItemLost.Load())
writeMetric(&output, "sense_reconcile_last_duration_seconds", "Duration of the last reconciliation run.",
"", float64(r.reconcileDurationNS.Load())/float64(time.Second))
writeMetric(&output, "sense_reconcile_devices", "Enabled video devices by convergence state.",
`{state="total"}`, r.reconcileTotal.Load())
writeSample(&output, "sense_reconcile_devices", `{state="unconverged"}`, r.reconcileUnconverged.Load())
writeMetric(&output, "sense_orphan_scan_runs_total", "MediaMTX orphan scans by result.",
`{result="success"}`, r.orphanScanOK.Load())
writeSample(&output, "sense_orphan_scan_runs_total", `{result="error"}`, r.orphanScanError.Load())
writeMetric(&output, "sense_orphan_paths", "Path counts from the last successful orphan scan.",
`{classification="observed"}`, r.orphanObserved.Load())
writeSample(&output, "sense_orphan_paths", `{classification="owned_stale"}`, r.orphanOwnedStale.Load())
writeSample(&output, "sense_orphan_paths", `{classification="unowned"}`, r.orphanUnowned.Load())
writeMetric(&output, "sense_orphan_cleanup_blocked_total", "Orphan cleanups blocked by a fixed safety reason.",
`{reason="ratio_exceeded"}`, r.orphanBlockedRatio.Load())
writeSample(&output, "sense_orphan_cleanup_blocked_total", `{reason="snapshot_expired"}`, r.orphanBlockedAge.Load())
writeSample(&output, "sense_orphan_cleanup_blocked_total", `{reason="scope_changed"}`, r.orphanBlockedScope.Load())
writeMetric(&output, "sense_orphan_cleanup_items_total", "Owned stale path cleanup results.",
`{result="deleted"}`, r.orphanDeleted.Load())
writeSample(&output, "sense_orphan_cleanup_items_total", `{result="failed"}`, r.orphanDeleteFailed.Load())
return output.String()
}
func writeMetric(builder *strings.Builder, name, help, labels string, value any) {
metricType := "gauge"
if strings.HasSuffix(name, "_total") {
metricType = "counter"
}
builder.WriteString("# HELP ")
builder.WriteString(name)
builder.WriteByte(' ')
builder.WriteString(help)
builder.WriteByte('\n')
builder.WriteString("# TYPE ")
builder.WriteString(name)
builder.WriteByte(' ')
builder.WriteString(metricType)
builder.WriteByte('\n')
writeSample(builder, name, labels, value)
}
func writeSample(builder *strings.Builder, name, labels string, value any) {
builder.WriteString(name)
builder.WriteString(labels)
builder.WriteByte(' ')
switch typed := value.(type) {
case float64:
builder.WriteString(strconv.FormatFloat(typed, 'f', 6, 64))
default:
builder.WriteString(fmt.Sprint(typed))
}
builder.WriteByte('\n')
}
func quoteLabel(value string) string {
replacer := strings.NewReplacer(`\`, `\\`, "\n", `\n`, `"`, `\"`)
return `"` + replacer.Replace(value) + `"`
}
+84
View File
@@ -0,0 +1,84 @@
package metrics
import (
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestHandlerUsesOnlyFixedLowCardinalityLabels(t *testing.T) {
t.Parallel()
registry := New("ins_test-1", "v-test")
registry.ObserveReconcileRun(nil, 1500*time.Millisecond)
registry.ObserveReconcileItem("success")
registry.ObserveReconcileItem("lease_lost")
registry.SetConvergence(16, 2)
registry.ObserveOrphanScan(20, 1, 3, nil)
registry.ObserveOrphanCleanupBlocked("ratio_exceeded")
registry.ObserveOrphanCleanup(1, 1)
request := httptest.NewRequest("GET", "/metrics", nil)
response := httptest.NewRecorder()
registry.Handler().ServeHTTP(response, request)
body := response.Body.String()
for _, expected := range []string{
`sense_build_info{instance_id="ins_test-1",version="v-test"} 1`,
`sense_reconcile_items_total{result="lease_lost"} 1`,
`sense_reconcile_devices{state="unconverged"} 2`,
`sense_orphan_paths{classification="owned_stale"} 1`,
`sense_orphan_cleanup_blocked_total{reason="ratio_exceeded"} 1`,
} {
if !strings.Contains(body, expected) {
t.Fatalf("metrics output lacks %q:\n%s", expected, body)
}
}
for _, forbidden := range []string{
"tenant_id", "site_id", "device_id", "path_name", "rtsp://", "credential",
} {
if strings.Contains(body, forbidden) {
t.Fatalf("high-cardinality or secret-bearing label appeared: %q", forbidden)
}
}
if response.Header().Get("Cache-Control") != "no-store" {
t.Fatal("metrics responses must not be cached")
}
}
func TestRegistryIsSafeForConcurrentObservers(t *testing.T) {
t.Parallel()
registry := New("ins_concurrent", "dev")
var wait sync.WaitGroup
for index := 0; index < 32; index++ {
wait.Add(1)
go func() {
defer wait.Done()
for count := 0; count < 100; count++ {
registry.ObserveReconcileItem("success")
registry.SetConvergence(128, count%17)
}
}()
}
wait.Wait()
response := httptest.NewRecorder()
registry.Handler().ServeHTTP(response, httptest.NewRequest("GET", "/metrics", nil))
if !strings.Contains(response.Body.String(), `sense_reconcile_items_total{result="success"} 3200`) {
t.Fatalf("concurrent counter lost updates: %s", response.Body.String())
}
}
func TestGenerateInstanceIDShape(t *testing.T) {
t.Parallel()
first, err := GenerateInstanceID()
if err != nil {
t.Fatal(err)
}
second, err := GenerateInstanceID()
if err != nil {
t.Fatal(err)
}
if len(first) != 28 || !strings.HasPrefix(first, "ins_") || first == second {
t.Fatalf("unexpected instance identifiers: %q %q", first, second)
}
}
+200
View File
@@ -0,0 +1,200 @@
package mtx
import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
mediamtxapi "yovision/sense/internal/mtx/generated"
)
var ErrPathNotFound = errors.New("MediaMTX path not found")
type APIError struct {
Operation string
StatusCode int
}
func (e *APIError) Error() string {
return fmt.Sprintf("MediaMTX %s failed with HTTP status %d", e.Operation, e.StatusCode)
}
type PathConfig struct {
Name string
Source string
}
type pathAPI interface {
ConfigPathsAddWithResponse(context.Context, string, mediamtxapi.ConfigPathsAddJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsAddResponse, error)
ConfigPathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsGetResponse, error)
ConfigPathsPatchWithResponse(context.Context, string, mediamtxapi.ConfigPathsPatchJSONRequestBody, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsPatchResponse, error)
ConfigPathsDeleteWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsDeleteResponse, error)
ConfigPathsListWithResponse(context.Context, *mediamtxapi.ConfigPathsListParams, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.ConfigPathsListResponse, error)
PathsGetWithResponse(context.Context, string, ...mediamtxapi.RequestEditorFn) (*mediamtxapi.PathsGetResponse, error)
}
// ListPathNames enumerates only configuration names. Sources are deliberately
// discarded so inventory and orphan reports cannot expose stream URIs.
func (c *Client) ListPathNames(ctx context.Context) ([]string, error) {
const (
itemsPerPage = 100
maxPages = 1000
)
result := make(map[string]struct{})
seenPages := make(map[string]struct{})
for page := 0; page < maxPages; page++ {
pageValue, limitValue := page, itemsPerPage
response, err := c.api.ConfigPathsListWithResponse(ctx, &mediamtxapi.ConfigPathsListParams{
Page: &pageValue, ItemsPerPage: &limitValue,
})
if err != nil {
return nil, fmt.Errorf("MediaMTX list paths transport: %w", err)
}
if response.StatusCode() != http.StatusOK || response.JSON200 == nil ||
response.JSON200.PageCount == nil || response.JSON200.Items == nil {
return nil, &APIError{Operation: "list paths", StatusCode: response.StatusCode()}
}
pageCount := *response.JSON200.PageCount
if pageCount < 0 || pageCount > maxPages {
return nil, &APIError{Operation: "list paths pagination", StatusCode: response.StatusCode()}
}
pageNames := make([]string, 0, len(*response.JSON200.Items))
for _, item := range *response.JSON200.Items {
if item.Name == nil || strings.TrimSpace(*item.Name) == "" {
return nil, &APIError{Operation: "list paths response", StatusCode: response.StatusCode()}
}
pageNames = append(pageNames, *item.Name)
result[*item.Name] = struct{}{}
}
sort.Strings(pageNames)
signature := strings.Join(pageNames, "\x00")
if page > 0 && signature != "" {
if _, duplicate := seenPages[signature]; duplicate {
return nil, &APIError{Operation: "list paths repeated page", StatusCode: response.StatusCode()}
}
}
seenPages[signature] = struct{}{}
if int64(page+1) >= pageCount {
values := make([]string, 0, len(result))
for name := range result {
values = append(values, name)
}
sort.Strings(values)
return values, nil
}
}
return nil, &APIError{Operation: "list paths page limit", StatusCode: http.StatusOK}
}
type Client struct {
api pathAPI
}
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
options := make([]mediamtxapi.ClientOption, 0, 1)
if httpClient != nil {
options = append(options, mediamtxapi.WithHTTPClient(httpClient))
}
generated, err := mediamtxapi.NewClientWithResponses(strings.TrimRight(baseURL, "/"), options...)
if err != nil {
return nil, fmt.Errorf("create MediaMTX client: %w", err)
}
return &Client{api: generated}, nil
}
func newClientWithAPI(api pathAPI) *Client {
return &Client{api: api}
}
func (c *Client) CreatePath(ctx context.Context, name, source string) error {
response, err := c.api.ConfigPathsAddWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
if err != nil {
return fmt.Errorf("MediaMTX create path transport: %w", err)
}
if response.StatusCode() != http.StatusOK {
return &APIError{Operation: "create path", StatusCode: response.StatusCode()}
}
return nil
}
func (c *Client) GetPath(ctx context.Context, name string) (PathConfig, error) {
response, err := c.api.ConfigPathsGetWithResponse(ctx, name)
if err != nil {
return PathConfig{}, fmt.Errorf("MediaMTX read path transport: %w", err)
}
if response.StatusCode() == http.StatusNotFound {
return PathConfig{}, ErrPathNotFound
}
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
return PathConfig{}, &APIError{Operation: "read path", StatusCode: response.StatusCode()}
}
result := PathConfig{Name: name}
if response.JSON200.Name != nil {
result.Name = *response.JSON200.Name
}
if response.JSON200.Source != nil {
result.Source = *response.JSON200.Source
}
return result, nil
}
func (c *Client) DeletePath(ctx context.Context, name string) error {
response, err := c.api.ConfigPathsDeleteWithResponse(ctx, name)
if err != nil {
return fmt.Errorf("MediaMTX delete path transport: %w", err)
}
if response.StatusCode() == http.StatusNotFound {
// Deletion is an idempotent convergence operation. A missing exact path
// already satisfies the disabled desired state.
return nil
}
if response.StatusCode() != http.StatusOK {
return &APIError{Operation: "delete path", StatusCode: response.StatusCode()}
}
return nil
}
// EnsurePath converges one desired path. It never enumerates or deletes orphans.
func (c *Client) EnsurePath(ctx context.Context, name, source string) (bool, error) {
current, err := c.GetPath(ctx, name)
if errors.Is(err, ErrPathNotFound) {
if err := c.CreatePath(ctx, name, source); err != nil {
return false, err
}
return true, nil
}
if err != nil {
return false, err
}
if current.Source == source {
return false, nil
}
response, err := c.api.ConfigPathsPatchWithResponse(ctx, name, mediamtxapi.PathConf{Source: &source})
if err != nil {
return false, fmt.Errorf("MediaMTX patch path transport: %w", err)
}
if response.StatusCode() != http.StatusOK {
return false, &APIError{Operation: "patch path", StatusCode: response.StatusCode()}
}
return true, nil
}
func (c *Client) PathReady(ctx context.Context, name string) (bool, error) {
response, err := c.api.PathsGetWithResponse(ctx, name)
if err != nil {
return false, fmt.Errorf("MediaMTX probe path transport: %w", err)
}
if response.StatusCode() == http.StatusNotFound {
return false, ErrPathNotFound
}
if response.StatusCode() != http.StatusOK || response.JSON200 == nil {
return false, &APIError{Operation: "probe path", StatusCode: response.StatusCode()}
}
if response.JSON200.Online == nil || response.JSON200.Available == nil {
return false, &APIError{Operation: "probe path response", StatusCode: response.StatusCode()}
}
return *response.JSON200.Online && *response.JSON200.Available, nil
}
+232
View File
@@ -0,0 +1,232 @@
package mtx
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"sync"
"testing"
)
type fakeMediaMTX struct {
mu sync.Mutex
paths map[string]string
mutations int
}
func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
writer.Header().Set("Content-Type", "application/json")
if request.URL.Path == "/v3/config/paths/list" {
page, _ := strconv.Atoi(request.URL.Query().Get("page"))
limit, _ := strconv.Atoi(request.URL.Query().Get("itemsPerPage"))
if limit <= 0 {
limit = 100
}
names := make([]string, 0, len(f.paths))
for name := range f.paths {
names = append(names, name)
}
sort.Strings(names)
start := page * limit
if start > len(names) {
start = len(names)
}
end := start + limit
if end > len(names) {
end = len(names)
}
items := make([]map[string]any, 0, end-start)
for _, name := range names[start:end] {
items = append(items, map[string]any{"name": name})
}
pageCount := 0
if len(names) > 0 {
pageCount = (len(names) + limit - 1) / limit
}
_ = json.NewEncoder(writer).Encode(map[string]any{
"itemCount": len(names), "pageCount": pageCount, "items": items,
})
return
}
prefixes := map[string]string{
"/v3/config/paths/get/": "get",
"/v3/config/paths/add/": "add",
"/v3/config/paths/patch/": "patch",
"/v3/config/paths/delete/": "delete",
"/v3/paths/get/": "runtime",
}
for prefix, operation := range prefixes {
if !strings.HasPrefix(request.URL.Path, prefix) {
continue
}
name := strings.TrimPrefix(request.URL.Path, prefix)
source, exists := f.paths[name]
switch operation {
case "get":
if !exists {
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
return
}
_ = json.NewEncoder(writer).Encode(map[string]any{"name": name, "source": source})
case "add", "patch":
var body struct {
Source string `json:"source"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
http.Error(writer, `{}`, http.StatusBadRequest)
return
}
f.paths[name] = body.Source
f.mutations++
_, _ = writer.Write([]byte(`{}`))
case "delete":
if !exists {
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
return
}
delete(f.paths, name)
f.mutations++
_, _ = writer.Write([]byte(`{}`))
case "runtime":
if !exists {
http.Error(writer, `{"error":"not found"}`, http.StatusNotFound)
return
}
_, _ = writer.Write([]byte(`{"online":true,"available":true}`))
}
return
}
http.NotFound(writer, request)
}
func TestListPathNamesUsesPaginationAndDoesNotReturnSources(t *testing.T) {
t.Parallel()
paths := make(map[string]string)
for index := 0; index < 205; index++ {
paths[fmt.Sprintf("camera-%03d", index)] = fmt.Sprintf("rtsp://secret.invalid/%d", index)
}
fake := &fakeMediaMTX{paths: paths}
server := httptest.NewServer(fake)
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
values, err := client.ListPathNames(context.Background())
if err != nil {
t.Fatal(err)
}
if len(values) != 205 || values[0] != "camera-000" || values[204] != "camera-204" {
t.Fatalf("unexpected path inventory: len=%d first=%q last=%q", len(values), values[0], values[len(values)-1])
}
for _, value := range values {
if strings.Contains(value, "rtsp") || strings.Contains(value, "secret") {
t.Fatalf("source leaked from path inventory: %q", value)
}
}
}
func TestListPathNamesRejectsRepeatedPage(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
if request.URL.Path != "/v3/config/paths/list" {
http.NotFound(writer, request)
return
}
_, _ = writer.Write([]byte(`{"itemCount":2,"pageCount":2,"items":[{"name":"same"}]}`))
}))
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
if _, err := client.ListPathNames(context.Background()); err == nil ||
!strings.Contains(err.Error(), "repeated page") {
t.Fatalf("repeated pagination was accepted: %v", err)
}
}
func TestGeneratedClientCreateReadDeleteMapping(t *testing.T) {
t.Parallel()
fake := &fakeMediaMTX{paths: make(map[string]string)}
server := httptest.NewServer(fake)
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := client.CreatePath(ctx, "camera-1", "rtsp://media.invalid/camera-1"); err != nil {
t.Fatal(err)
}
path, err := client.GetPath(ctx, "camera-1")
if err != nil {
t.Fatal(err)
}
if path.Source != "rtsp://media.invalid/camera-1" {
t.Fatalf("unexpected source mapping: %+v", path)
}
if err := client.DeletePath(ctx, "camera-1"); err != nil {
t.Fatal(err)
}
if _, err := client.GetPath(ctx, "camera-1"); err != ErrPathNotFound {
t.Fatalf("expected not found after delete, got %v", err)
}
}
func TestEnsurePathIsIdempotentAndCanPatch(t *testing.T) {
t.Parallel()
fake := &fakeMediaMTX{paths: make(map[string]string)}
server := httptest.NewServer(fake)
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
changed, err := client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
if err != nil || !changed {
t.Fatalf("first ensure: changed=%v err=%v", changed, err)
}
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/first")
if err != nil || changed {
t.Fatalf("second ensure must be idempotent: changed=%v err=%v", changed, err)
}
changed, err = client.EnsurePath(ctx, "camera-2", "rtsp://media.invalid/second")
if err != nil || !changed {
t.Fatalf("changed source must patch: changed=%v err=%v", changed, err)
}
if fake.mutations != 2 {
t.Fatalf("expected create + patch, got %d mutations", fake.mutations)
}
ready, err := client.PathReady(ctx, "camera-2")
if err != nil || !ready {
t.Fatalf("runtime probe: ready=%v err=%v", ready, err)
}
}
func TestAPIErrorDoesNotLeakSource(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprint(writer, `{"error":"upstream included a secret"}`)
}))
defer server.Close()
client, err := NewClient(server.URL, server.Client())
if err != nil {
t.Fatal(err)
}
secretSource := "rtsp://" + "user" + ":" + "redacted" + "@camera.invalid/live"
err = client.CreatePath(context.Background(), "camera", secretSource)
if err == nil || strings.Contains(err.Error(), secretSource) || strings.Contains(err.Error(), "secret") {
t.Fatalf("error must be redacted, got %v", err)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package mtx wraps the generated MediaMTX control API client.
package mtx
// The input is the official API document vendored from the frozen MediaMTX tag.
//go:generate go tool oapi-codegen -config oapi-codegen.yaml ../../api/vendor/mediamtx-v1.19.3.openapi.yaml
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
package: mediamtxapi
output: generated/client.gen.go
generate:
models: true
client: true
output-options:
skip-prune: false
+62
View File
@@ -0,0 +1,62 @@
package onvif
import (
"fmt"
"net/url"
"os"
"regexp"
"strings"
)
type Credentials struct {
ONVIFUsername string
ONVIFPassword string
RTSPUsername string
RTSPPassword string
}
type CredentialProvider interface {
Resolve(reference string) (Credentials, error)
}
type EnvCredentials struct {
LookupEnv func(string) (string, bool)
}
var credentialKey = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
func (p EnvCredentials) Resolve(reference string) (Credentials, error) {
parsed, err := url.Parse(reference)
if err != nil || parsed.Scheme != "env" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return Credentials{}, fmt.Errorf("credential reference must use env://<key>")
}
key := strings.Trim(strings.TrimSpace(parsed.Host+parsed.Path), "/")
if !credentialKey.MatchString(key) {
return Credentials{}, fmt.Errorf("credential reference contains an invalid key")
}
lookup := p.LookupEnv
if lookup == nil {
lookup = os.LookupEnv
}
prefix := "SENSE_CREDENTIAL_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_"))
read := func(suffix string) string {
value, _ := lookup(prefix + suffix)
return value
}
result := Credentials{
ONVIFUsername: read("_ONVIF_USERNAME"),
ONVIFPassword: read("_ONVIF_PASSWORD"),
RTSPUsername: read("_RTSP_USERNAME"),
RTSPPassword: read("_RTSP_PASSWORD"),
}
if result.ONVIFUsername == "" || result.ONVIFPassword == "" {
return Credentials{}, fmt.Errorf("ONVIF credentials are not configured for reference")
}
if result.RTSPUsername == "" {
result.RTSPUsername = result.ONVIFUsername
}
if result.RTSPPassword == "" {
result.RTSPPassword = result.ONVIFPassword
}
return result, nil
}
+126
View File
@@ -0,0 +1,126 @@
package onvif
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
type FakeScenario struct {
Result ProbeResult `json:"result"`
ProbeError ErrorCode `json:"probe_error,omitempty"`
ClockError ErrorCode `json:"clock_error,omitempty"`
DelayMillis int `json:"delay_millis,omitempty"`
}
type fakeFixture struct {
Scenarios map[string]FakeScenario `json:"scenarios"`
}
// Fake is deterministic and intended only for tests and offline development.
type Fake struct {
mu sync.Mutex
scenarios map[string]FakeScenario
probeCalls map[string]int
clockSyncCalls map[string]int
}
func NewFake(scenarios map[string]FakeScenario) *Fake {
copyOfScenarios := make(map[string]FakeScenario, len(scenarios))
for key, value := range scenarios {
copyOfScenarios[key] = value
}
return &Fake{
scenarios: copyOfScenarios, probeCalls: make(map[string]int), clockSyncCalls: make(map[string]int),
}
}
func LoadFakeFixture(path string) (*Fake, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read ONVIF fixture: %w", err)
}
var fixture fakeFixture
if err := json.Unmarshal(data, &fixture); err != nil {
return nil, fmt.Errorf("decode ONVIF fixture: %w", err)
}
return NewFake(fixture.Scenarios), nil
}
func (f *Fake) Probe(ctx context.Context, target Target) (ProbeResult, error) {
scenario, ok := f.scenario(target.EndpointRef)
if !ok {
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
}
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
return ProbeResult{}, &Error{Code: ErrorTimeout, Err: err}
}
f.mu.Lock()
f.probeCalls[target.EndpointRef]++
f.mu.Unlock()
if scenario.ProbeError != "" {
return ProbeResult{}, &Error{Code: scenario.ProbeError, Err: fmt.Errorf("fixture probe failure")}
}
if scenario.Result.StreamURI == "" || len(scenario.Result.Profiles) == 0 {
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("fixture lacks profile or stream URI")}
}
return scenario.Result, nil
}
func (f *Fake) SetSystemDateAndTime(ctx context.Context, target Target, _ time.Time) error {
scenario, ok := f.scenario(target.EndpointRef)
if !ok {
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("fixture endpoint is not configured")}
}
if err := waitForFakeDelay(ctx, scenario.DelayMillis); err != nil {
return &Error{Code: ErrorTimeout, Err: err}
}
f.mu.Lock()
f.clockSyncCalls[target.EndpointRef]++
f.mu.Unlock()
if scenario.ClockError != "" {
return &Error{Code: scenario.ClockError, Err: fmt.Errorf("fixture clock failure")}
}
return nil
}
func (f *Fake) ProbeCalls(endpointRef string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.probeCalls[endpointRef]
}
func (f *Fake) ClockSyncCalls(endpointRef string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.clockSyncCalls[endpointRef]
}
func (f *Fake) scenario(endpointRef string) (FakeScenario, bool) {
f.mu.Lock()
defer f.mu.Unlock()
scenario, ok := f.scenarios[endpointRef]
return scenario, ok
}
func waitForFakeDelay(ctx context.Context, milliseconds int) error {
if milliseconds <= 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
timer := time.NewTimer(time.Duration(milliseconds) * time.Millisecond)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+56
View File
@@ -0,0 +1,56 @@
package onvif
import (
"context"
"path/filepath"
"testing"
"time"
)
func TestFakeMapsProfilesStreamAndClockSync(t *testing.T) {
t.Parallel()
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
if err != nil {
t.Fatal(err)
}
target := Target{EndpointRef: "onvif://camera-ok", CredentialRef: "secret://camera-ok"}
result, err := fake.Probe(context.Background(), target)
if err != nil {
t.Fatal(err)
}
if len(result.Profiles) != 2 || result.StreamURI != "rtsp://media.invalid/camera-ok" {
t.Fatalf("unexpected fixture mapping: %+v", result)
}
if err := fake.SetSystemDateAndTime(context.Background(), target, time.Now()); err != nil {
t.Fatal(err)
}
if fake.ProbeCalls(target.EndpointRef) != 1 || fake.ClockSyncCalls(target.EndpointRef) != 1 {
t.Fatal("expected one probe and one clock-sync call")
}
}
func TestFakeMapsAuthenticationFailure(t *testing.T) {
t.Parallel()
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
if err != nil {
t.Fatal(err)
}
_, err = fake.Probe(context.Background(), Target{EndpointRef: "onvif://camera-auth"})
if CodeOf(err) != ErrorAuthentication {
t.Fatalf("expected authentication error, got %v", err)
}
}
func TestFakeHonorsCancellationAsTimeout(t *testing.T) {
t.Parallel()
fake, err := LoadFakeFixture(filepath.Join("testdata", "scenarios.json"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
_, err = fake.Probe(ctx, Target{EndpointRef: "onvif://camera-slow"})
if CodeOf(err) != ErrorTimeout {
t.Fatalf("expected timeout error, got %v", err)
}
}
+342
View File
@@ -0,0 +1,342 @@
package onvif
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
deviceNamespace = "http://www.onvif.org/ver10/device/wsdl"
mediaNamespace = "http://www.onvif.org/ver10/media/wsdl"
)
type HTTPOptions struct {
RTSPRewriteHost string
RTSPRewritePort int
StripRTSPQuery bool
}
type HTTPAdapter struct {
credentials CredentialProvider
client *http.Client
options HTTPOptions
now func() time.Time
random io.Reader
}
func NewHTTPAdapter(credentials CredentialProvider, client *http.Client, options HTTPOptions) *HTTPAdapter {
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
return &HTTPAdapter{
credentials: credentials,
client: client,
options: options,
now: time.Now,
random: rand.Reader,
}
}
func (a *HTTPAdapter) Probe(ctx context.Context, target Target) (ProbeResult, error) {
endpoint, credentials, err := a.target(target)
if err != nil {
return ProbeResult{}, err
}
infoBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetDeviceInformation",
`<tds:GetDeviceInformation xmlns:tds="`+deviceNamespace+`"/>`, credentials)
if err != nil {
return ProbeResult{}, err
}
var info deviceInformationEnvelope
if err := xml.Unmarshal(infoBody, &info); err != nil {
return ProbeResult{}, invalidResponse("decode device information")
}
servicesBody, err := a.call(ctx, endpoint, deviceNamespace+"/GetServices",
`<tds:GetServices xmlns:tds="`+deviceNamespace+`"><tds:IncludeCapability>false</tds:IncludeCapability></tds:GetServices>`, credentials)
if err != nil {
return ProbeResult{}, err
}
var services servicesEnvelope
if err := xml.Unmarshal(servicesBody, &services); err != nil {
return ProbeResult{}, invalidResponse("decode services")
}
mediaEndpoint, err := externalMediaEndpoint(endpoint, services.Body.Response.Services)
if err != nil {
return ProbeResult{}, err
}
profilesBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetProfiles",
`<trt:GetProfiles xmlns:trt="`+mediaNamespace+`"/>`, credentials)
if err != nil {
return ProbeResult{}, err
}
var profilesResponse profilesEnvelope
if err := xml.Unmarshal(profilesBody, &profilesResponse); err != nil {
return ProbeResult{}, invalidResponse("decode profiles")
}
profiles := make([]Profile, 0, len(profilesResponse.Body.Response.Profiles))
selectedToken := ""
for _, value := range profilesResponse.Body.Response.Profiles {
video := value.VideoEncoder != nil
profiles = append(profiles, Profile{Token: value.Token, Name: value.Name, VideoEncoder: video})
if selectedToken == "" && video && value.Token != "" {
selectedToken = value.Token
}
}
if selectedToken == "" {
return ProbeResult{}, invalidResponse("no video profile")
}
streamRequest := `<trt:GetStreamUri xmlns:trt="` + mediaNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
`<trt:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></trt:StreamSetup>` +
`<trt:ProfileToken>` + escapeXML(selectedToken) + `</trt:ProfileToken></trt:GetStreamUri>`
streamBody, err := a.call(ctx, mediaEndpoint, mediaNamespace+"/GetStreamUri", streamRequest, credentials)
if err != nil {
return ProbeResult{}, err
}
var streamResponse streamURIEnvelope
if err := xml.Unmarshal(streamBody, &streamResponse); err != nil {
return ProbeResult{}, invalidResponse("decode stream URI")
}
streamURI, err := a.rewriteStreamURI(endpoint, streamResponse.Body.Response.MediaURI.URI, credentials)
if err != nil {
return ProbeResult{}, err
}
return ProbeResult{
Manufacturer: info.Body.Response.Manufacturer,
Model: info.Body.Response.Model,
FirmwareVersion: info.Body.Response.FirmwareVersion,
SerialNumber: info.Body.Response.SerialNumber,
Profiles: profiles,
StreamURI: streamURI,
}, nil
}
func (a *HTTPAdapter) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
endpoint, credentials, err := a.target(target)
if err != nil {
return err
}
utc := value.UTC()
body := `<tds:SetSystemDateAndTime xmlns:tds="` + deviceNamespace + `" xmlns:tt="http://www.onvif.org/ver10/schema">` +
`<tds:DateTimeType>Manual</tds:DateTimeType><tds:DaylightSavings>false</tds:DaylightSavings>` +
`<tds:UTCDateTime><tt:Time><tt:Hour>` + strconv.Itoa(utc.Hour()) + `</tt:Hour><tt:Minute>` + strconv.Itoa(utc.Minute()) +
`</tt:Minute><tt:Second>` + strconv.Itoa(utc.Second()) + `</tt:Second></tt:Time><tt:Date><tt:Year>` + strconv.Itoa(utc.Year()) +
`</tt:Year><tt:Month>` + strconv.Itoa(int(utc.Month())) + `</tt:Month><tt:Day>` + strconv.Itoa(utc.Day()) +
`</tt:Day></tt:Date></tds:UTCDateTime></tds:SetSystemDateAndTime>`
_, err = a.call(ctx, endpoint, deviceNamespace+"/SetSystemDateAndTime", body, credentials)
return err
}
func (a *HTTPAdapter) target(target Target) (*url.URL, Credentials, error) {
endpoint, err := url.Parse(target.EndpointRef)
if err != nil || endpoint.Host == "" || endpoint.User != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
return nil, Credentials{}, invalidResponse("invalid ONVIF endpoint")
}
credentials, err := a.credentials.Resolve(target.CredentialRef)
if err != nil {
return nil, Credentials{}, &Error{Code: ErrorAuthentication, Err: err}
}
return endpoint, credentials, nil
}
func (a *HTTPAdapter) call(ctx context.Context, endpoint *url.URL, action, body string, credentials Credentials) ([]byte, error) {
nonce := make([]byte, 20)
if _, err := io.ReadFull(a.random, nonce); err != nil {
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create authentication nonce")}
}
created := a.now().UTC().Format("2006-01-02T15:04:05Z")
digestInput := append(append(append([]byte{}, nonce...), []byte(created)...), []byte(credentials.ONVIFPassword)...)
digest := sha1.Sum(digestInput)
envelope := `<?xml version="1.0" encoding="UTF-8"?>` +
`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">` +
`<s:Header><wsse:Security s:mustUnderstand="1"><wsse:UsernameToken><wsse:Username>` + escapeXML(credentials.ONVIFUsername) +
`</wsse:Username><wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">` +
base64.StdEncoding.EncodeToString(digest[:]) + `</wsse:Password><wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">` +
base64.StdEncoding.EncodeToString(nonce) + `</wsse:Nonce><wsu:Created>` + created +
`</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header><s:Body>` + body + `</s:Body></s:Envelope>`
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader(envelope))
if err != nil {
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("create ONVIF request")}
}
request.Header.Set("Content-Type", `application/soap+xml; charset=utf-8; action="`+action+`"`)
response, err := a.client.Do(request)
if err != nil {
code := ErrorUnavailable
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
code = ErrorTimeout
}
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF transport failed")}
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
if err != nil {
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("read ONVIF response")}
}
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return nil, &Error{Code: ErrorAuthentication, Err: fmt.Errorf("ONVIF authorization failed")}
}
if fault := soapFault(responseBody); fault != "" {
code := ErrorInvalidReply
lower := strings.ToLower(fault)
if strings.Contains(lower, "authoriz") || strings.Contains(lower, "notauthorized") {
code = ErrorAuthentication
}
return nil, &Error{Code: code, Err: fmt.Errorf("ONVIF SOAP fault")}
}
if response.StatusCode != http.StatusOK {
return nil, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("ONVIF returned HTTP status %d", response.StatusCode)}
}
return responseBody, nil
}
func externalMediaEndpoint(deviceEndpoint *url.URL, services []service) (*url.URL, error) {
for _, value := range services {
if value.Namespace != mediaNamespace || value.XAddr == "" {
continue
}
mediaEndpoint, err := url.Parse(value.XAddr)
if err != nil || mediaEndpoint.Host == "" {
return nil, invalidResponse("invalid media service address")
}
mediaEndpoint.Scheme = deviceEndpoint.Scheme
mediaEndpoint.Host = deviceEndpoint.Host
mediaEndpoint.User = nil
return mediaEndpoint, nil
}
return nil, invalidResponse("media service is unavailable")
}
func (a *HTTPAdapter) rewriteStreamURI(deviceEndpoint *url.URL, raw string, credentials Credentials) (string, error) {
stream, err := url.Parse(raw)
if err != nil || stream.Host == "" || (stream.Scheme != "rtsp" && stream.Scheme != "rtsps") {
return "", invalidResponse("invalid stream URI")
}
host := a.options.RTSPRewriteHost
if host == "" {
host = deviceEndpoint.Hostname()
}
port := a.options.RTSPRewritePort
if port == 0 {
if parsedPort := stream.Port(); parsedPort != "" {
value, parseErr := strconv.Atoi(parsedPort)
if parseErr != nil {
return "", invalidResponse("invalid stream port")
}
port = value
}
}
if port > 0 {
stream.Host = net.JoinHostPort(host, strconv.Itoa(port))
} else {
stream.Host = host
}
stream.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
if a.options.StripRTSPQuery {
stream.RawQuery = ""
stream.ForceQuery = false
}
return stream.String(), nil
}
func invalidResponse(message string) error {
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("%s", message)}
}
func escapeXML(value string) string {
var buffer bytes.Buffer
_ = xml.EscapeText(&buffer, []byte(value))
return buffer.String()
}
func soapFault(body []byte) string {
decoder := xml.NewDecoder(bytes.NewReader(body))
inFault := false
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
return ""
}
if err != nil {
return ""
}
switch value := token.(type) {
case xml.StartElement:
if value.Name.Local == "Fault" {
inFault = true
}
if inFault && (value.Name.Local == "Text" || value.Name.Local == "faultstring") {
var message string
if decoder.DecodeElement(&message, &value) == nil {
return message
}
}
case xml.EndElement:
if value.Name.Local == "Fault" {
return "SOAP fault"
}
}
}
}
type deviceInformationEnvelope struct {
Body struct {
Response struct {
Manufacturer string `xml:"Manufacturer"`
Model string `xml:"Model"`
FirmwareVersion string `xml:"FirmwareVersion"`
SerialNumber string `xml:"SerialNumber"`
} `xml:"GetDeviceInformationResponse"`
} `xml:"Body"`
}
type service struct {
Namespace string `xml:"Namespace"`
XAddr string `xml:"XAddr"`
}
type servicesEnvelope struct {
Body struct {
Response struct {
Services []service `xml:"Service"`
} `xml:"GetServicesResponse"`
} `xml:"Body"`
}
type profileResponse struct {
Token string `xml:"token,attr"`
Name string `xml:"Name"`
VideoEncoder *struct{} `xml:"VideoEncoderConfiguration"`
}
type profilesEnvelope struct {
Body struct {
Response struct {
Profiles []profileResponse `xml:"Profiles"`
} `xml:"GetProfilesResponse"`
} `xml:"Body"`
}
type streamURIEnvelope struct {
Body struct {
Response struct {
MediaURI struct {
URI string `xml:"Uri"`
} `xml:"MediaUri"`
} `xml:"GetStreamUriResponse"`
} `xml:"Body"`
}
+136
View File
@@ -0,0 +1,136 @@
package onvif
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
type staticCredentials struct {
value Credentials
err error
}
func (s staticCredentials) Resolve(string) (Credentials, error) {
return s.value, s.err
}
func TestHTTPAdapterDiscoversMediaAndRewritesNATStream(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !strings.Contains(request.Header.Get("Content-Type"), "action=") {
t.Fatal("SOAP action is required")
}
body := ""
switch {
case strings.Contains(request.Header.Get("Content-Type"), "GetDeviceInformation"):
body = `<tds:GetDeviceInformationResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Manufacturer>HIKVISION</tds:Manufacturer><tds:Model>camera</tds:Model><tds:FirmwareVersion>v1</tds:FirmwareVersion><tds:SerialNumber>serial</tds:SerialNumber></tds:GetDeviceInformationResponse>`
case strings.Contains(request.Header.Get("Content-Type"), "GetServices"):
body = `<tds:GetServicesResponse xmlns:tds="http://www.onvif.org/ver10/device/wsdl"><tds:Service><tds:Namespace>http://www.onvif.org/ver10/media/wsdl</tds:Namespace><tds:XAddr>http://192.0.2.10/onvif/Media</tds:XAddr></tds:Service></tds:GetServicesResponse>`
case strings.Contains(request.Header.Get("Content-Type"), "GetProfiles"):
body = `<trt:GetProfilesResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl"><trt:Profiles token="main"><trt:Name>Main</trt:Name><trt:VideoEncoderConfiguration/></trt:Profiles></trt:GetProfilesResponse>`
case strings.Contains(request.Header.Get("Content-Type"), "GetStreamUri"):
body = `<trt:GetStreamUriResponse xmlns:trt="http://www.onvif.org/ver10/media/wsdl"><trt:MediaUri><trt:Uri>rtsp://192.0.2.10:554/Streaming/Channels/101?transportmode=unicast&amp;profile=Profile_1</trt:Uri></trt:MediaUri></trt:GetStreamUriResponse>`
default:
http.Error(writer, "unexpected action", http.StatusBadRequest)
return
}
writer.Header().Set("Content-Type", "application/soap+xml")
_, _ = fmt.Fprintf(writer, `<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body>%s</s:Body></s:Envelope>`, body)
}))
defer server.Close()
credentials := Credentials{
ONVIFUsername: "onvif-user", ONVIFPassword: "onvif-password",
RTSPUsername: "rtsp-user", RTSPPassword: "rtsp-password",
}
adapter := NewHTTPAdapter(staticCredentials{value: credentials}, server.Client(), HTTPOptions{
RTSPRewriteHost: "127.0.0.1", RTSPRewritePort: 10554, StripRTSPQuery: true,
})
result, err := adapter.Probe(context.Background(), Target{
EndpointRef: server.URL + "/onvif/device_service", CredentialRef: "env://camera",
})
if err != nil {
t.Fatal(err)
}
if result.Manufacturer != "HIKVISION" || result.Model != "camera" || len(result.Profiles) != 1 {
t.Fatalf("unexpected probe result: %+v", result)
}
stream, err := url.Parse(result.StreamURI)
if err != nil {
t.Fatal(err)
}
if stream.Host != "127.0.0.1:10554" || stream.Path != "/Streaming/Channels/101" || stream.RawQuery != "" {
t.Fatalf("unexpected rewritten stream address: host=%s path=%s", stream.Host, stream.Path)
}
password, _ := stream.User.Password()
if stream.User.Username() != credentials.RTSPUsername || password != credentials.RTSPPassword {
t.Fatal("RTSP credentials were not injected")
}
}
func TestHTTPAdapterPreservesRTSPQueryByDefault(t *testing.T) {
t.Parallel()
adapter := NewHTTPAdapter(staticCredentials{}, nil, HTTPOptions{})
streamURI, err := adapter.rewriteStreamURI(
&url.URL{Scheme: "http", Host: "camera.example:8008"},
"rtsp://192.0.2.10:554/live?profile=main",
Credentials{RTSPUsername: "user", RTSPPassword: "secret"},
)
if err != nil {
t.Fatal(err)
}
stream, err := url.Parse(streamURI)
if err != nil {
t.Fatal(err)
}
if stream.RawQuery != "profile=main" {
t.Fatalf("RTSP query was unexpectedly changed: %q", stream.RawQuery)
}
}
func TestHTTPAdapterMapsAuthorizationFaultWithoutLeakingSecret(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusBadRequest)
_, _ = writer.Write([]byte(`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><s:Fault><s:Reason><s:Text>The action requires authorization</s:Text></s:Reason></s:Fault></s:Body></s:Envelope>`))
}))
defer server.Close()
secret := "not-for-errors"
adapter := NewHTTPAdapter(staticCredentials{value: Credentials{
ONVIFUsername: "user", ONVIFPassword: secret, RTSPUsername: "user", RTSPPassword: secret,
}}, server.Client(), HTTPOptions{})
_, err := adapter.Probe(context.Background(), Target{EndpointRef: server.URL, CredentialRef: "env://camera"})
if CodeOf(err) != ErrorAuthentication || strings.Contains(err.Error(), secret) {
t.Fatalf("expected redacted authentication error, got %v", err)
}
}
func TestEnvCredentialsAndDirectRTSPRouting(t *testing.T) {
t.Parallel()
values := map[string]string{
"SENSE_CREDENTIAL_CAMERA_ONVIF_USERNAME": "onvif",
"SENSE_CREDENTIAL_CAMERA_ONVIF_PASSWORD": "onvif-secret",
"SENSE_CREDENTIAL_CAMERA_RTSP_USERNAME": "rtsp",
"SENSE_CREDENTIAL_CAMERA_RTSP_PASSWORD": "rtsp-secret",
}
provider := EnvCredentials{LookupEnv: func(name string) (string, bool) {
value, ok := values[name]
return value, ok
}}
resolved, err := provider.Resolve("env://camera")
if err != nil || resolved.RTSPUsername != "rtsp" {
t.Fatalf("resolve credentials: %+v err=%v", resolved, err)
}
router := NewRouter(UnavailableAdapter{}, provider)
result, err := router.Probe(context.Background(), Target{
EndpointRef: "rtsp://127.0.0.1:8555/synthetic-1",
})
if err != nil || result.StreamURI != "rtsp://127.0.0.1:8555/synthetic-1" {
t.Fatalf("route direct RTSP: %+v err=%v", result, err)
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package onvif defines the ONVIF boundary used by Sense.
package onvif
import (
"context"
"errors"
"fmt"
"time"
)
type Target struct {
// EndpointRef identifies a device endpoint but must not contain credentials.
EndpointRef string
// CredentialRef is an opaque secret-store reference, never a password.
CredentialRef string
}
type Profile struct {
Token string `json:"token"`
Name string `json:"name"`
VideoEncoder bool `json:"video_encoder"`
}
type ProbeResult struct {
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
FirmwareVersion string `json:"firmware_version"`
SerialNumber string `json:"serial_number"`
Profiles []Profile `json:"profiles"`
StreamURI string `json:"stream_uri"`
}
type Adapter interface {
Probe(ctx context.Context, target Target) (ProbeResult, error)
SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error
}
type ErrorCode string
const (
ErrorAuthentication ErrorCode = "authentication_failed"
ErrorTimeout ErrorCode = "timeout"
ErrorUnavailable ErrorCode = "unavailable"
ErrorInvalidReply ErrorCode = "invalid_response"
)
type Error struct {
Code ErrorCode
Err error
}
func (e *Error) Error() string {
if e.Err == nil {
return string(e.Code)
}
return fmt.Sprintf("%s: %v", e.Code, e.Err)
}
func (e *Error) Unwrap() error { return e.Err }
func CodeOf(err error) ErrorCode {
var onvifError *Error
if errors.As(err, &onvifError) {
return onvifError.Code
}
if errors.Is(err, context.DeadlineExceeded) {
return ErrorTimeout
}
return ErrorUnavailable
}
+50
View File
@@ -0,0 +1,50 @@
package onvif
import (
"context"
"fmt"
"net/url"
"time"
)
type Router struct {
camera Adapter
credentials CredentialProvider
}
func NewRouter(camera Adapter, credentials CredentialProvider) *Router {
return &Router{camera: camera, credentials: credentials}
}
func (r *Router) Probe(ctx context.Context, target Target) (ProbeResult, error) {
endpoint, err := url.Parse(target.EndpointRef)
if err != nil || endpoint.Host == "" || endpoint.User != nil {
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("endpoint reference is invalid")}
}
switch endpoint.Scheme {
case "http", "https":
return r.camera.Probe(ctx, target)
case "rtsp", "rtsps":
if target.CredentialRef != "" {
credentials, resolveErr := r.credentials.Resolve(target.CredentialRef)
if resolveErr != nil {
return ProbeResult{}, &Error{Code: ErrorAuthentication, Err: resolveErr}
}
endpoint.User = url.UserPassword(credentials.RTSPUsername, credentials.RTSPPassword)
}
return ProbeResult{
Profiles: []Profile{{Token: "direct", Name: "direct", VideoEncoder: true}},
StreamURI: endpoint.String(),
}, nil
default:
return ProbeResult{}, &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("unsupported endpoint scheme")}
}
}
func (r *Router) SetSystemDateAndTime(ctx context.Context, target Target, value time.Time) error {
endpoint, err := url.Parse(target.EndpointRef)
if err != nil || (endpoint.Scheme != "http" && endpoint.Scheme != "https") {
return &Error{Code: ErrorInvalidReply, Err: fmt.Errorf("clock sync requires an ONVIF endpoint")}
}
return r.camera.SetSystemDateAndTime(ctx, target, value)
}
+28
View File
@@ -0,0 +1,28 @@
{
"scenarios": {
"onvif://camera-ok": {
"result": {
"manufacturer": "YoVision Fixture",
"model": "Offline Camera",
"firmware_version": "0.0-fixture",
"serial_number": "REDACTED-001",
"profiles": [
{"token": "main", "name": "Main stream", "video_encoder": true},
{"token": "sub", "name": "Sub stream", "video_encoder": true}
],
"stream_uri": "rtsp://media.invalid/camera-ok"
}
},
"onvif://camera-auth": {
"probe_error": "authentication_failed",
"result": {}
},
"onvif://camera-slow": {
"delay_millis": 100,
"result": {
"profiles": [{"token": "main", "name": "Main stream", "video_encoder": true}],
"stream_uri": "rtsp://media.invalid/camera-slow"
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
package onvif
import (
"context"
"fmt"
"time"
)
// UnavailableAdapter keeps the process boundary explicit until T-006 supplies
// a real, whitelist-validated ONVIF adapter. It must not be mistaken for a
// compatibility implementation.
type UnavailableAdapter struct{}
func (UnavailableAdapter) Probe(context.Context, Target) (ProbeResult, error) {
return ProbeResult{}, &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
}
func (UnavailableAdapter) SetSystemDateAndTime(context.Context, Target, time.Time) error {
return &Error{Code: ErrorUnavailable, Err: fmt.Errorf("real ONVIF adapter is not configured")}
}
+400
View File
@@ -0,0 +1,400 @@
// Package orphan reports MediaMTX configuration paths that do not match the
// current Sense ledger. Only paths with durable Sense ownership evidence can
// ever enter the controlled cleanup set.
package orphan
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"regexp"
"sort"
"strings"
"time"
"yovision/sense/internal/metrics"
"yovision/sense/internal/store"
)
const (
defaultLeaseDuration = 30 * time.Second
defaultOperationTimeout = 20 * time.Second
reportTTL = 15 * time.Minute
maxCleanupItems = 128
crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
)
var (
ErrLeaseHeld = errors.New("orphan operation lease held")
ErrConfirmation = errors.New("orphan cleanup confirmation invalid")
ErrSnapshotExpired = errors.New("orphan scan snapshot expired")
ErrSafetyBlocked = errors.New("orphan cleanup safety gate blocked")
operatorIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
)
type Runtime interface {
ListPathNames(context.Context) ([]string, error)
DeletePath(context.Context, string) error
}
type Manager struct {
repository store.OrphanRepository
runtime Runtime
instanceID string
metrics *metrics.Registry
now func() time.Time
leaseDuration time.Duration
operationTimeout time.Duration
}
type CleanupResult struct {
ScanID string `json:"scan_id"`
Deleted int `json:"deleted"`
Failed int `json:"failed"`
}
func New(
repository store.OrphanRepository,
runtime Runtime,
instanceID string,
registry *metrics.Registry,
) *Manager {
return &Manager{
repository: repository, runtime: runtime, instanceID: instanceID, metrics: registry,
now: time.Now, leaseDuration: defaultLeaseDuration, operationTimeout: defaultOperationTimeout,
}
}
func (m *Manager) Report(ctx context.Context) (store.OrphanScan, error) {
now := m.now().UTC()
token, err := randomToken()
if err != nil {
return store.OrphanScan{}, err
}
acquired, err := m.repository.AcquireOperationalLease(
ctx, store.OperationalLeaseOrphanScan, m.instanceID, token, now, m.leaseDuration,
)
if err != nil {
m.observeScan(0, 0, 0, err)
return store.OrphanScan{}, err
}
if !acquired {
return store.OrphanScan{}, ErrLeaseHeld
}
saved := false
defer func() {
if saved {
return
}
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = m.repository.ReleaseOperationalLease(
releaseCtx, store.OperationalLeaseOrphanScan, m.instanceID, token, m.now().UTC(),
)
}()
operationCtx, cancel := context.WithTimeout(ctx, m.operationTimeout)
paths, err := m.runtime.ListPathNames(operationCtx)
cancel()
if err != nil {
m.observeScan(0, 0, 0, err)
return store.OrphanScan{}, fmt.Errorf("list MediaMTX paths: %w", err)
}
ownership, err := m.repository.ListMediaPathOwnership(ctx)
if err != nil {
m.observeScan(0, 0, 0, err)
return store.OrphanScan{}, err
}
findings, stale, unowned := classify(paths, ownership)
allowed, reason := safetyGate(stale, len(paths))
completedAt := m.now().UTC()
acquired, err = m.repository.AcquireOperationalLease(
ctx, store.OperationalLeaseOrphanScan, m.instanceID, token, completedAt, m.leaseDuration,
)
if err != nil || !acquired {
if err == nil {
err = store.ErrOperationalLeaseLost
}
m.observeScan(len(paths), stale, unowned, err)
return store.OrphanScan{}, err
}
id, err := newScanID(completedAt)
if err != nil {
m.observeScan(0, 0, 0, err)
return store.OrphanScan{}, err
}
scan := store.OrphanScan{
ID: id, InstanceID: m.instanceID, ObservedCount: len(paths),
OwnedStaleCount: stale, UnownedCount: unowned,
SafetyAllowed: allowed, SafetyReason: reason,
CompletedAt: completedAt, ExpiresAt: completedAt.Add(reportTTL), Findings: findings,
}
if err := m.repository.SaveOrphanScan(ctx, scan, m.instanceID, token); err != nil {
m.observeScan(len(paths), stale, unowned, err)
return store.OrphanScan{}, err
}
saved = true
m.observeScan(len(paths), stale, unowned, nil)
return scan, nil
}
func classify(
paths []string,
ownership []store.MediaPathOwnership,
) ([]store.OrphanFinding, int, int) {
history := make(map[string]store.MediaPathOwnership, len(ownership))
for _, value := range ownership {
history[value.PathName] = value
}
unique := make(map[string]struct{}, len(paths))
for _, path := range paths {
unique[path] = struct{}{}
}
ordered := make([]string, 0, len(unique))
for path := range unique {
ordered = append(ordered, path)
}
sort.Strings(ordered)
findings := make([]store.OrphanFinding, 0)
stale, unowned := 0, 0
for _, path := range ordered {
value, known := history[path]
switch {
case known && value.CurrentClaim:
continue
case known:
stale++
findings = append(findings, store.OrphanFinding{
PathName: path, Classification: store.OrphanOwnedStale, DeviceID: value.DeviceID,
})
default:
unowned++
findings = append(findings, store.OrphanFinding{
PathName: path, Classification: store.OrphanUnowned,
})
}
}
return findings, stale, unowned
}
func safetyGate(candidates, observed int) (bool, string) {
switch {
case candidates <= 0:
return false, "no_candidates"
case observed <= 0:
return false, "empty_inventory"
case candidates > maxCleanupItems:
return false, "scope_exceeded"
case candidates*100 > observed*10:
return false, "ratio_exceeded"
default:
return true, "allowed"
}
}
func (m *Manager) Apply(
ctx context.Context,
scanID, actorID, confirmation string,
) (CleanupResult, error) {
result := CleanupResult{ScanID: scanID}
if !operatorIDPattern.MatchString(actorID) || confirmation != "DELETE "+scanID {
m.blocked("scope_changed")
return result, ErrConfirmation
}
now := m.now().UTC()
token, err := randomToken()
if err != nil {
return result, err
}
acquired, err := m.repository.AcquireOperationalLease(
ctx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, now, m.leaseDuration,
)
if err != nil {
return result, err
}
if !acquired {
return result, ErrLeaseHeld
}
defer func() {
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = m.repository.ReleaseOperationalLease(
releaseCtx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, m.now().UTC(),
)
}()
scan, err := m.repository.GetOrphanScan(ctx, scanID)
if err != nil {
m.blocked("scope_changed")
return result, err
}
if !scan.ExpiresAt.After(now) {
m.blocked("snapshot_expired")
return result, ErrSnapshotExpired
}
if !scan.SafetyAllowed {
m.blocked(scan.SafetyReason)
return result, fmt.Errorf("%w: %s", ErrSafetyBlocked, scan.SafetyReason)
}
operationCtx, cancel := context.WithTimeout(ctx, m.operationTimeout)
paths, err := m.runtime.ListPathNames(operationCtx)
cancel()
if err != nil {
return result, fmt.Errorf("refresh MediaMTX path inventory: %w", err)
}
ownership, err := m.repository.ListMediaPathOwnership(ctx)
if err != nil {
return result, err
}
candidates := cleanupCandidates(scan, paths, ownership)
if len(candidates) == 0 {
return result, nil
}
allowed, reason := safetyGate(len(candidates), len(uniqueNames(paths)))
if !allowed {
m.blocked(reason)
return result, fmt.Errorf("%w: %s", ErrSafetyBlocked, reason)
}
var failures []error
for _, path := range candidates {
now = m.now().UTC()
acquired, err = m.repository.AcquireOperationalLease(
ctx, store.OperationalLeaseOrphanCleanup, m.instanceID, token, now, m.leaseDuration,
)
if err != nil || !acquired {
if err == nil {
err = ErrLeaseHeld
}
failures = append(failures, err)
break
}
itemCtx, itemCancel := context.WithTimeout(ctx, m.operationTimeout)
deleteErr := m.runtime.DeletePath(itemCtx, path)
itemCancel()
status, code := "deleted", ""
if deleteErr != nil {
status, code = "failed", "media_error"
result.Failed++
failures = append(failures, fmt.Errorf("delete owned stale path: %w", deleteErr))
} else {
result.Deleted++
}
if err := m.repository.RecordOrphanCleanup(
ctx, scanID, path, actorID, status, code, m.now().UTC(),
); err != nil {
failures = append(failures, err)
break
}
}
if m.metrics != nil {
m.metrics.ObserveOrphanCleanup(result.Deleted, result.Failed)
}
return result, errors.Join(failures...)
}
func cleanupCandidates(
scan store.OrphanScan,
paths []string,
ownership []store.MediaPathOwnership,
) []string {
runtime := uniqueNames(paths)
history := make(map[string]store.MediaPathOwnership, len(ownership))
for _, value := range ownership {
history[value.PathName] = value
}
values := make([]string, 0)
for _, finding := range scan.Findings {
if finding.Classification != store.OrphanOwnedStale || finding.Deleted {
continue
}
_, present := runtime[finding.PathName]
owner, known := history[finding.PathName]
if present && known && !owner.CurrentClaim && owner.DeviceID == finding.DeviceID {
values = append(values, finding.PathName)
}
}
sort.Strings(values)
return values
}
func uniqueNames(values []string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
result[value] = struct{}{}
}
return result
}
func (m *Manager) Run(ctx context.Context, interval time.Duration, report func(error)) {
run := func() {
_, err := m.Report(ctx)
if err != nil && !errors.Is(err, ErrLeaseHeld) && ctx.Err() == nil && report != nil {
report(err)
}
}
run()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}
func (m *Manager) observeScan(observed, stale, unowned int, err error) {
if m.metrics != nil {
m.metrics.ObserveOrphanScan(observed, stale, unowned, err)
}
}
func (m *Manager) blocked(reason string) {
if m.metrics != nil {
m.metrics.ObserveOrphanCleanupBlocked(reason)
}
}
func randomToken() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", errors.New("generate fencing token")
}
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
result := make([]byte, 22)
number := new(big.Int).SetBytes(value)
base := big.NewInt(int64(len(alphabet)))
remainder := new(big.Int)
for index := len(result) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
result[index] = alphabet[remainder.Int64()]
}
return string(result), nil
}
func newScanID(now time.Time) (string, error) {
value := make([]byte, 16)
milliseconds := uint64(now.UTC().UnixMilli())
value[0], value[1], value[2] = byte(milliseconds>>40), byte(milliseconds>>32), byte(milliseconds>>24)
value[3], value[4], value[5] = byte(milliseconds>>16), byte(milliseconds>>8), byte(milliseconds)
if _, err := rand.Read(value[6:]); err != nil {
return "", errors.New("generate orphan scan identifier")
}
number := new(big.Int).SetBytes(value)
base := big.NewInt(32)
remainder := new(big.Int)
encoded := make([]byte, 26)
for index := len(encoded) - 1; index >= 0; index-- {
number.QuoRem(number, base, remainder)
encoded[index] = crockford[remainder.Int64()]
}
return "scan_" + string(encoded), nil
}
func Confirmation(scanID string) string {
return strings.Join([]string{"DELETE", scanID}, " ")
}
+272
View File
@@ -0,0 +1,272 @@
package orphan
import (
"context"
"errors"
"sort"
"sync"
"testing"
"time"
"yovision/sense/internal/store"
)
type fakeLease struct {
owner, token string
expires time.Time
}
type fakeRepository struct {
mu sync.Mutex
leases map[string]fakeLease
ownership []store.MediaPathOwnership
scans map[string]store.OrphanScan
deleted map[string]map[string]bool
}
func newFakeRepository() *fakeRepository {
return &fakeRepository{
leases: make(map[string]fakeLease), scans: make(map[string]store.OrphanScan),
deleted: make(map[string]map[string]bool),
}
}
func (f *fakeRepository) AcquireOperationalLease(
_ context.Context,
name, owner, token string,
now time.Time,
duration time.Duration,
) (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
current, exists := f.leases[name]
if exists && current.expires.After(now) && (current.owner != owner || current.token != token) {
return false, nil
}
f.leases[name] = fakeLease{owner: owner, token: token, expires: now.Add(duration)}
return true, nil
}
func (f *fakeRepository) ReleaseOperationalLease(
_ context.Context,
name, owner, token string,
now time.Time,
) error {
f.mu.Lock()
defer f.mu.Unlock()
current := f.leases[name]
if current.owner == owner && current.token == token {
current.expires = now
f.leases[name] = current
}
return nil
}
func (f *fakeRepository) ListMediaPathOwnership(context.Context) ([]store.MediaPathOwnership, error) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]store.MediaPathOwnership(nil), f.ownership...), nil
}
func (f *fakeRepository) SaveOrphanScan(
_ context.Context,
scan store.OrphanScan,
owner, token string,
) error {
f.mu.Lock()
defer f.mu.Unlock()
lease := f.leases[store.OperationalLeaseOrphanScan]
if lease.owner != owner || lease.token != token || !lease.expires.After(scan.CompletedAt) {
return store.ErrOperationalLeaseLost
}
f.scans[scan.ID] = cloneScan(scan)
lease.expires = scan.CompletedAt
f.leases[store.OperationalLeaseOrphanScan] = lease
return nil
}
func (f *fakeRepository) GetOrphanScan(_ context.Context, id string) (store.OrphanScan, error) {
f.mu.Lock()
defer f.mu.Unlock()
scan, exists := f.scans[id]
if !exists {
return store.OrphanScan{}, store.ErrOrphanScanNotFound
}
result := cloneScan(scan)
for index := range result.Findings {
result.Findings[index].Deleted = f.deleted[id][result.Findings[index].PathName]
}
return result, nil
}
func (f *fakeRepository) RecordOrphanCleanup(
_ context.Context,
scanID, pathName, _, status, _ string,
_ time.Time,
) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.deleted[scanID] == nil {
f.deleted[scanID] = make(map[string]bool)
}
if status == "deleted" {
f.deleted[scanID][pathName] = true
}
return nil
}
func cloneScan(value store.OrphanScan) store.OrphanScan {
value.Findings = append([]store.OrphanFinding(nil), value.Findings...)
return value
}
type fakeRuntime struct {
paths map[string]bool
fail map[string]bool
deleted []string
}
func (f *fakeRuntime) ListPathNames(context.Context) ([]string, error) {
values := make([]string, 0, len(f.paths))
for path, present := range f.paths {
if present {
values = append(values, path)
}
}
sort.Strings(values)
return values, nil
}
func (f *fakeRuntime) DeletePath(_ context.Context, path string) error {
if f.fail[path] {
return errors.New("redacted media failure")
}
delete(f.paths, path)
f.deleted = append(f.deleted, path)
return nil
}
func TestSafetyGateUsesExactTenPercentBoundary(t *testing.T) {
t.Parallel()
if allowed, reason := safetyGate(1, 10); !allowed || reason != "allowed" {
t.Fatalf("exact 10%% boundary was rejected: %v %s", allowed, reason)
}
if allowed, reason := safetyGate(1, 9); allowed || reason != "ratio_exceeded" {
t.Fatalf("more than 10%% was accepted: %v %s", allowed, reason)
}
if allowed, reason := safetyGate(129, 2000); allowed || reason != "scope_exceeded" {
t.Fatalf("129-item scope was accepted: %v %s", allowed, reason)
}
}
func TestReportClassifiesOwnershipAndApplyNeverDeletesUnowned(t *testing.T) {
repository := newFakeRepository()
repository.ownership = []store.MediaPathOwnership{
{PathName: "owned-stale", DeviceID: "old-device"},
{PathName: "owned-current", DeviceID: "live-device", CurrentClaim: true},
}
runtime := &fakeRuntime{paths: map[string]bool{
"owned-stale": true, "owned-current": true,
"unowned-1": true, "unowned-2": true, "unowned-3": true, "unowned-4": true,
"unowned-5": true, "unowned-6": true, "unowned-7": true, "unowned-8": true,
}, fail: make(map[string]bool)}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
manager := New(repository, runtime, "ins_test", nil)
manager.now = func() time.Time { return now }
scan, err := manager.Report(context.Background())
if err != nil {
t.Fatal(err)
}
if scan.ObservedCount != 10 || scan.OwnedStaleCount != 1 || scan.UnownedCount != 8 || !scan.SafetyAllowed {
t.Fatalf("unexpected report: %+v", scan)
}
result, err := manager.Apply(
context.Background(), scan.ID, "operator-1", Confirmation(scan.ID),
)
if err != nil {
t.Fatal(err)
}
if result.Deleted != 1 || len(runtime.deleted) != 1 || runtime.deleted[0] != "owned-stale" {
t.Fatalf("cleanup escaped owned stale set: result=%+v deleted=%v", result, runtime.deleted)
}
for _, path := range []string{"owned-current", "unowned-1", "unowned-8"} {
if !runtime.paths[path] {
t.Fatalf("cleanup deleted protected path %q", path)
}
}
}
func TestApplyBlocksExpiredOrOversizedSnapshotBeforeDelete(t *testing.T) {
repository := newFakeRepository()
repository.ownership = []store.MediaPathOwnership{{PathName: "stale", DeviceID: "old"}}
runtime := &fakeRuntime{paths: map[string]bool{"stale": true}, fail: make(map[string]bool)}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
manager := New(repository, runtime, "ins_test", nil)
manager.now = func() time.Time { return now }
scan, err := manager.Report(context.Background())
if err != nil {
t.Fatal(err)
}
if scan.SafetyAllowed || scan.SafetyReason != "ratio_exceeded" {
t.Fatalf("single-path 100%% cleanup was not blocked: %+v", scan)
}
if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSafetyBlocked) {
t.Fatalf("expected ratio gate, got %v", err)
}
if len(runtime.deleted) != 0 {
t.Fatal("ratio-blocked cleanup mutated MediaMTX")
}
for index := 0; index < 9; index++ {
runtime.paths[string(rune('a'+index))] = true
}
scan, err = manager.Report(context.Background())
if err != nil || !scan.SafetyAllowed {
t.Fatalf("expected a fresh 1/10 executable report: %+v %v", scan, err)
}
now = now.Add(reportTTL)
if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSnapshotExpired) {
t.Fatalf("expected expired snapshot, got %v", err)
}
if len(runtime.deleted) != 0 {
t.Fatal("expired cleanup mutated MediaMTX")
}
}
func TestApplyIsRetryableAndCannotExpandBeyondSnapshot(t *testing.T) {
repository := newFakeRepository()
repository.ownership = []store.MediaPathOwnership{
{PathName: "stale-a", DeviceID: "old-a"},
{PathName: "stale-b", DeviceID: "old-b"},
}
runtime := &fakeRuntime{paths: make(map[string]bool), fail: map[string]bool{"stale-b": true}}
runtime.paths["stale-a"], runtime.paths["stale-b"] = true, true
for index := 0; index < 18; index++ {
runtime.paths[string(rune(0x100+index))] = true
}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
manager := New(repository, runtime, "ins_test", nil)
manager.now = func() time.Time { return now }
scan, err := manager.Report(context.Background())
if err != nil || !scan.SafetyAllowed {
t.Fatalf("expected 2/20 report: %+v %v", scan, err)
}
repository.ownership = append(repository.ownership,
store.MediaPathOwnership{PathName: "new-stale", DeviceID: "new-old"})
runtime.paths["new-stale"] = true
result, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID))
if err == nil || result.Deleted != 1 || result.Failed != 1 {
t.Fatalf("expected one partial failure: %+v %v", result, err)
}
if !runtime.paths["new-stale"] {
t.Fatal("cleanup expanded beyond the approved snapshot")
}
runtime.fail["stale-b"] = false
result, err = manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID))
if err != nil || result.Deleted != 1 || result.Failed != 0 {
t.Fatalf("failed item was not retryable: %+v %v", result, err)
}
if !runtime.paths["new-stale"] {
t.Fatal("retry expanded beyond the approved snapshot")
}
}

Some files were not shown because too many files have changed in this diff Show More