Compare commits
137
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477afa6ba2 | ||
|
|
e019f50915 | ||
|
|
e76a4ac0c8 | ||
|
|
3b2569ce04 | ||
|
|
47b91b691c | ||
|
|
bd964e8831 | ||
|
|
b7fe44eeb0 | ||
|
|
bebc23cb6a | ||
|
|
7b95c7d155 | ||
|
|
5a82035cc3 | ||
|
|
607860b79a | ||
|
|
7c202b7ac8 | ||
|
|
7db707e596 | ||
|
|
a4427a6d2b | ||
|
|
e3b166c5af | ||
|
|
e46f175551 | ||
|
|
2ca51aa2bc | ||
|
|
19a3233f11 | ||
|
|
a2790c1f5e | ||
|
|
2e04737922 | ||
|
|
2768cfeacb | ||
|
|
5c9318ceef | ||
|
|
5de6f0c2bb | ||
|
|
2b9a59fe8a | ||
|
|
4be2386421 | ||
|
|
aca22f4667 | ||
|
|
88b92d8811 | ||
|
|
325e6ec255 | ||
|
|
e3cb1c5bc5 | ||
|
|
7e66fab042 | ||
|
|
236964d242 | ||
|
|
5b42f58f23 | ||
|
|
8b11b39093 | ||
|
|
31b08454f9 | ||
|
|
cefa6275bf | ||
|
|
5ab212cb5c | ||
|
|
b9e21a95d1 | ||
|
|
d029067aa7 | ||
|
|
d355f3d069 | ||
|
|
71e5e6e599 | ||
|
|
c3cfaff614 | ||
|
|
707ca8e508 | ||
|
|
403fd6e554 | ||
|
|
5d222436f1 | ||
|
|
3af6a16740 | ||
|
|
8ebb8ced49 | ||
|
|
2b4ec7053b | ||
|
|
8f4717a322 | ||
|
|
951508b8b4 | ||
|
|
a8f50cf79f | ||
|
|
12857fdf32 | ||
|
|
677ed732f7 | ||
|
|
25357723a0 | ||
|
|
b7a4a0faf8 | ||
|
|
872b6c9a6c | ||
|
|
6f65121987 | ||
|
|
6757ef2c05 | ||
|
|
a0d239811f | ||
|
|
0d3d7a22ed | ||
|
|
20b06884ad | ||
|
|
6402d4384d | ||
|
|
e8121a6a8e | ||
|
|
907d50d343 | ||
|
|
3c1cce9d5f | ||
|
|
31ac2c40ac | ||
|
|
36786723e3 | ||
|
|
8abff0a566 | ||
|
|
0b40cf16c3 | ||
|
|
997e515152 | ||
|
|
c188c4c9e0 | ||
|
|
a9323bc9de | ||
|
|
b6a752c014 | ||
|
|
2ff7ff5618 | ||
|
|
04231d006a | ||
|
|
8a4514076e | ||
|
|
d517d517d9 | ||
|
|
38c12bb5b7 | ||
|
|
d332797fcb | ||
|
|
14d29a47d1 | ||
|
|
b70df147ea | ||
|
|
1c63143106 | ||
|
|
cc47e14638 | ||
|
|
461b823570 | ||
|
|
885dd0143e | ||
|
|
11e675d45c | ||
|
|
5db37c7142 | ||
|
|
9567838045 | ||
|
|
08692e33e9 | ||
|
|
94ae2f0048 | ||
|
|
c9251daadf | ||
|
|
56c07427c8 | ||
|
|
fd16f77b95 | ||
|
|
155c927a62 | ||
|
|
3db1cf94e3 | ||
|
|
aa62147702 | ||
|
|
5208891e02 | ||
|
|
bafd8d983e | ||
|
|
e28070dd03 | ||
|
|
ef0e76b201 | ||
|
|
43c5c18eec | ||
|
|
f85dbecf93 | ||
|
|
4be15f2452 | ||
|
|
06de7d0399 | ||
|
|
968de2783c | ||
|
|
e2e75694cd | ||
|
|
a800068b8a | ||
|
|
bb1010c09f | ||
|
|
58a0056b1b | ||
|
|
fca32413fa | ||
|
|
6b18857305 | ||
|
|
d5cb2349cf | ||
|
|
c7d0ce3ed9 | ||
|
|
ec9ae76a0d | ||
|
|
697e652e9b | ||
|
|
5ed7397ff0 | ||
|
|
ac92d04d71 | ||
|
|
edde69ac8d | ||
|
|
de2f1027ec | ||
|
|
547232b7b8 | ||
|
|
8115f584dd | ||
|
|
e96cc2fbb5 | ||
|
|
e0b0bfafeb | ||
|
|
e18a1ea83b | ||
|
|
d36b785f67 | ||
|
|
122cde4938 | ||
|
|
9a82b17b10 | ||
|
|
cce38003cb | ||
|
|
618174cbf2 | ||
|
|
5251199205 | ||
|
|
2715e557a9 | ||
|
|
f43a4efa80 | ||
|
|
b643e42535 | ||
|
|
32b9859a40 | ||
|
|
7ee33d27f7 | ||
|
|
6362d35868 | ||
|
|
3a81cc44b4 | ||
|
|
633a906778 |
@@ -6,6 +6,9 @@ agent_sessions.txt
|
||||
gitea.env
|
||||
gitea.env.*
|
||||
!gitea.env.example
|
||||
ip_camera.env
|
||||
ip_camera.env.*
|
||||
!ip_camera.env.example
|
||||
|
||||
# 常见构建与测试缓存
|
||||
__pycache__/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
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 -m unittest discover -s tests -p "test_*.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`。
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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 服务。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
artifacts/
|
||||
*.local.json
|
||||
*.url
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -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` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。
|
||||
@@ -0,0 +1 @@
|
||||
"""YoVision Brain package root."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
/data/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
+177
@@ -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。
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
+3766
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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: {}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "aError) {
|
||||
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)
|
||||
@@ -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: "aVersion, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ",")
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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) + `"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,7 @@
|
||||
package: mediamtxapi
|
||||
output: generated/client.gen.go
|
||||
generate:
|
||||
models: true
|
||||
client: true
|
||||
output-options:
|
||||
skip-prune: false
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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&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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")}
|
||||
}
|
||||
@@ -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}, " ")
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user