Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
+140
-5
@@ -1,20 +1,20 @@
|
||||
# Sense M1 骨架
|
||||
# Sense M1/M2 接入骨架
|
||||
|
||||
本目录是 YoVision Sense 的无实机接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;当前只能用确定性 fake、MediaMTX 假 HTTP 服务及可选合成 RTSP 源验证,不能据此宣称任何真实摄像头兼容性。
|
||||
本目录是 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
|
||||
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`,设备管理公共 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/` 还需独立显式开启。
|
||||
|
||||
常用环境变量:
|
||||
|
||||
@@ -22,13 +22,122 @@ Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`
|
||||
| --- | --- | --- |
|
||||
| `SENSE_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址 |
|
||||
| `SENSE_ALLOW_NON_LOOPBACK` | `false` | 显式允许监听非回环地址;只应在可信网络及外部认证/防火墙就绪后开启 |
|
||||
| `SENSE_DB_DSN` | `file:data/sense.db` | SQLite DSN;凭据不得放入该值 |
|
||||
| `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
|
||||
@@ -40,3 +149,29 @@ Expand-Archive "$env:TEMP\$asset" -DestinationPath "$env:TEMP\yovision-mediamtx-
|
||||
```
|
||||
|
||||
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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+112
-14
@@ -12,9 +12,15 @@ import (
|
||||
"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"
|
||||
@@ -35,37 +41,108 @@ func run(logger *slog.Logger) error {
|
||||
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.OpenSQLite(ctx, cfg.DatabaseDSN)
|
||||
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
|
||||
}
|
||||
|
||||
// T-003 deliberately has no real camera adapter. T-006 replaces this port
|
||||
// only after the device whitelist and five-camera evidence are available.
|
||||
reconciler := reconcile.New(repository, onvif.UnavailableAdapter{}, mediaClient)
|
||||
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
|
||||
background.Add(2)
|
||||
go func() {
|
||||
defer background.Done()
|
||||
reconciler.Run(ctx, cfg.ReconcileInterval, report)
|
||||
}()
|
||||
go func() {
|
||||
defer background.Done()
|
||||
checker.Run(ctx, cfg.ProbeInterval, report)
|
||||
}()
|
||||
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) {
|
||||
@@ -78,6 +155,23 @@ func run(logger *slog.Logger) error {
|
||||
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,
|
||||
@@ -88,7 +182,11 @@ func run(logger *slog.Logger) error {
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version)
|
||||
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()
|
||||
}()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
)
|
||||
@@ -17,6 +18,9 @@ require (
|
||||
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
|
||||
|
||||
@@ -52,6 +52,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
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=
|
||||
@@ -109,6 +117,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+252
-17
@@ -6,25 +6,62 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHTTPAddress = "127.0.0.1:8080"
|
||||
defaultDatabaseDSN = "file:data/sense.db"
|
||||
defaultMediaMTXURL = "http://127.0.0.1:9997"
|
||||
defaultReconcilePeriod = 5 * time.Second
|
||||
defaultProbePeriod = 10 * time.Second
|
||||
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
|
||||
DatabaseDSN string
|
||||
MediaMTXURL string
|
||||
ReconcileInterval time.Duration
|
||||
ProbeInterval time.Duration
|
||||
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) {
|
||||
@@ -36,18 +73,91 @@ func Load() (Config, error) {
|
||||
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,
|
||||
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
|
||||
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
|
||||
ReconcileInterval: reconcilePeriod,
|
||||
ProbeInterval: probePeriod,
|
||||
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
|
||||
@@ -68,6 +178,16 @@ func (c Config) Validate() error {
|
||||
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")
|
||||
@@ -78,6 +198,109 @@ func (c Config) Validate() error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -111,3 +334,15 @@ func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -20,6 +24,35 @@ func TestValidateRejectsNonLoopbackByDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -33,3 +66,167 @@ func TestValidateRejectsCredentialsInMediaMTXURL(t *testing.T) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -75,21 +75,26 @@ func (s Site) Validate() error {
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string
|
||||
TenantID string
|
||||
SiteID string
|
||||
SerialNumber string
|
||||
Name string
|
||||
Modality Modality
|
||||
Capabilities []Capability
|
||||
DesiredState DesiredState
|
||||
ActualState ActualState
|
||||
EndpointRef string
|
||||
CredentialRef string
|
||||
PathName string
|
||||
Generation int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
mediamtxapi "yovision/sense/internal/mtx/generated"
|
||||
@@ -31,9 +32,63 @@ type pathAPI interface {
|
||||
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
|
||||
}
|
||||
@@ -92,7 +147,9 @@ func (c *Client) DeletePath(ctx context.Context, name string) error {
|
||||
return fmt.Errorf("MediaMTX delete path transport: %w", err)
|
||||
}
|
||||
if response.StatusCode() == http.StatusNotFound {
|
||||
return ErrPathNotFound
|
||||
// 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()}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -21,6 +23,38 @@ func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Reque
|
||||
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",
|
||||
@@ -72,6 +106,54 @@ func (f *fakeMediaMTX) ServeHTTP(writer http.ResponseWriter, request *http.Reque
|
||||
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)}
|
||||
|
||||
@@ -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,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,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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const defaultBatchSize = 128
|
||||
type Repository interface {
|
||||
ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error)
|
||||
UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error
|
||||
RequestReconcile(ctx context.Context, id string, now time.Time) error
|
||||
}
|
||||
|
||||
type RuntimePaths interface {
|
||||
@@ -50,7 +51,13 @@ func (c *Checker) RunOnce(ctx context.Context) error {
|
||||
if probeErr == nil && ready {
|
||||
state = device.ActualOnline
|
||||
}
|
||||
if updateErr := c.repository.UpdateActualState(ctx, value.ID, state, c.now().UTC()); updateErr != nil {
|
||||
now := c.now().UTC()
|
||||
if probeErr != nil {
|
||||
if requestErr := c.repository.RequestReconcile(ctx, value.ID, now); requestErr != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("request device %s reconciliation: %w", value.ID, requestErr))
|
||||
}
|
||||
}
|
||||
if updateErr := c.repository.UpdateActualState(ctx, value.ID, state, now); updateErr != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("update device %s health: %w", value.ID, updateErr))
|
||||
}
|
||||
if probeErr != nil {
|
||||
|
||||
@@ -10,8 +10,17 @@ import (
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
devices []device.Device
|
||||
states map[string]device.ActualState
|
||||
devices []device.Device
|
||||
states map[string]device.ActualState
|
||||
requested map[string]int
|
||||
}
|
||||
|
||||
func (f *fakeRepository) RequestReconcile(_ context.Context, id string, _ time.Time) error {
|
||||
if f.requested == nil {
|
||||
f.requested = make(map[string]int)
|
||||
}
|
||||
f.requested[id]++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ListEnabledVideoDevices(context.Context, int) ([]device.Device, error) {
|
||||
@@ -50,4 +59,7 @@ func TestCheckerMapsReadyAndUnavailablePaths(t *testing.T) {
|
||||
if repository.states["online"] != device.ActualOnline || repository.states["offline"] != device.ActualOffline {
|
||||
t.Fatalf("unexpected actual states: %+v", repository.states)
|
||||
}
|
||||
if repository.requested["offline"] != 1 || repository.requested["online"] != 0 {
|
||||
t.Fatalf("unexpected reconcile requests: %+v", repository.requested)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
"yovision/sense/internal/metrics"
|
||||
"yovision/sense/internal/onvif"
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
@@ -15,51 +19,149 @@ import (
|
||||
const defaultBatchSize = 128
|
||||
|
||||
type Repository interface {
|
||||
ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]store.ReconcileCandidate, error)
|
||||
MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error
|
||||
MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error
|
||||
ClaimDueReconcile(context.Context, store.ReconcileClaim) ([]store.ReconcileCandidate, error)
|
||||
RenewReconcileLease(context.Context, string, string, string, time.Time, time.Duration) (bool, error)
|
||||
CompleteReconcile(context.Context, string, int64, string, string, time.Time) error
|
||||
FailReconcile(context.Context, string, int, time.Time, string, string, string, time.Time) error
|
||||
ConvergenceSnapshot(context.Context) (store.ConvergenceSnapshot, error)
|
||||
}
|
||||
|
||||
type MediaPaths interface {
|
||||
EnsurePath(ctx context.Context, name, source string) (bool, error)
|
||||
DeletePath(ctx context.Context, name string) error
|
||||
}
|
||||
|
||||
type Reconciler struct {
|
||||
repository Repository
|
||||
discovery onvif.Adapter
|
||||
media MediaPaths
|
||||
now func() time.Time
|
||||
baseBackoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
batchSize int
|
||||
repository Repository
|
||||
discovery onvif.Adapter
|
||||
media MediaPaths
|
||||
now func() time.Time
|
||||
baseBackoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
batchSize int
|
||||
instanceID string
|
||||
leaseDuration time.Duration
|
||||
operationTimeout time.Duration
|
||||
metrics *metrics.Registry
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
InstanceID string
|
||||
LeaseDuration time.Duration
|
||||
OperationTimeout time.Duration
|
||||
Metrics *metrics.Registry
|
||||
}
|
||||
|
||||
func New(repository Repository, discovery onvif.Adapter, media MediaPaths) *Reconciler {
|
||||
return NewWithOptions(repository, discovery, media, Options{})
|
||||
}
|
||||
|
||||
func NewWithOptions(
|
||||
repository Repository,
|
||||
discovery onvif.Adapter,
|
||||
media MediaPaths,
|
||||
options Options,
|
||||
) *Reconciler {
|
||||
if options.InstanceID == "" {
|
||||
options.InstanceID = "single"
|
||||
}
|
||||
if options.LeaseDuration <= 0 {
|
||||
options.LeaseDuration = 30 * time.Second
|
||||
}
|
||||
if options.OperationTimeout <= 0 {
|
||||
options.OperationTimeout = 20 * time.Second
|
||||
}
|
||||
if options.OperationTimeout >= options.LeaseDuration {
|
||||
options.OperationTimeout = options.LeaseDuration / 2
|
||||
}
|
||||
return &Reconciler{
|
||||
repository: repository, discovery: discovery, media: media,
|
||||
now: time.Now, baseBackoff: time.Second, maxBackoff: time.Minute, batchSize: defaultBatchSize,
|
||||
instanceID: options.InstanceID, leaseDuration: options.LeaseDuration,
|
||||
operationTimeout: options.OperationTimeout, metrics: options.Metrics,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) RunOnce(ctx context.Context) error {
|
||||
func (r *Reconciler) RunOnce(ctx context.Context) (runErr error) {
|
||||
started := time.Now()
|
||||
defer func() {
|
||||
if r.metrics != nil {
|
||||
r.metrics.ObserveReconcileRun(runErr, time.Since(started))
|
||||
}
|
||||
}()
|
||||
now := r.now().UTC()
|
||||
candidates, err := r.repository.ListDueReconcile(ctx, now, r.batchSize)
|
||||
token, err := newClaimToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list reconciliation candidates: %w", err)
|
||||
return err
|
||||
}
|
||||
candidates, err := r.repository.ClaimDueReconcile(ctx, store.ReconcileClaim{
|
||||
Owner: r.instanceID, Token: token, Now: now,
|
||||
LeaseDuration: r.leaseDuration, Limit: r.batchSize,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim reconciliation candidates: %w", err)
|
||||
}
|
||||
var runErrors []error
|
||||
for _, candidate := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.reconcileOne(ctx, candidate, now); err != nil {
|
||||
itemNow := r.now().UTC()
|
||||
renewed, err := r.repository.RenewReconcileLease(
|
||||
ctx, candidate.Device.ID, r.instanceID, token, itemNow, r.leaseDuration,
|
||||
)
|
||||
if err != nil {
|
||||
r.observeItem("error")
|
||||
runErrors = append(runErrors, fmt.Errorf("renew reconcile device lease: %w", err))
|
||||
continue
|
||||
}
|
||||
if !renewed {
|
||||
r.observeItem("lease_lost")
|
||||
continue
|
||||
}
|
||||
itemCtx, cancel := context.WithTimeout(ctx, r.operationTimeout)
|
||||
err = r.reconcileOne(itemCtx, ctx, candidate, itemNow, r.instanceID, token)
|
||||
cancel()
|
||||
if errors.Is(err, store.ErrReconcileLeaseLost) {
|
||||
r.observeItem("lease_lost")
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
r.observeItem("error")
|
||||
runErrors = append(runErrors, fmt.Errorf("reconcile device %s: %w", candidate.Device.ID, err))
|
||||
} else {
|
||||
r.observeItem("success")
|
||||
}
|
||||
}
|
||||
if r.metrics != nil {
|
||||
snapshot, err := r.repository.ConvergenceSnapshot(ctx)
|
||||
if err != nil {
|
||||
runErrors = append(runErrors, fmt.Errorf("read convergence metrics: %w", err))
|
||||
} else {
|
||||
r.metrics.SetConvergence(snapshot.Total, snapshot.Unconverged)
|
||||
}
|
||||
}
|
||||
return errors.Join(runErrors...)
|
||||
}
|
||||
|
||||
func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.ReconcileCandidate, now time.Time) error {
|
||||
func (r *Reconciler) reconcileOne(
|
||||
ctx context.Context,
|
||||
persistCtx context.Context,
|
||||
candidate store.ReconcileCandidate,
|
||||
now time.Time,
|
||||
owner, token string,
|
||||
) error {
|
||||
if candidate.Device.DesiredState == device.DesiredDisabled {
|
||||
if err := r.media.DeletePath(ctx, candidate.Device.PathName); err == nil {
|
||||
return r.repository.CompleteReconcile(
|
||||
persistCtx, candidate.Device.ID, candidate.Device.Generation, owner, token, now,
|
||||
)
|
||||
} else if persistCtx.Err() != nil {
|
||||
return persistCtx.Err()
|
||||
} else {
|
||||
return r.persistFailure(persistCtx, candidate, now, owner, token, err)
|
||||
}
|
||||
}
|
||||
result, err := r.discovery.Probe(ctx, onvif.Target{
|
||||
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
|
||||
})
|
||||
@@ -70,11 +172,23 @@ func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.Reconcile
|
||||
_, err = r.media.EnsurePath(ctx, candidate.Device.PathName, result.StreamURI)
|
||||
}
|
||||
if err == nil {
|
||||
return r.repository.MarkReconciled(ctx, candidate.Device.ID, candidate.Device.Generation, now)
|
||||
return r.repository.CompleteReconcile(
|
||||
persistCtx, candidate.Device.ID, candidate.Device.Generation, owner, token, now,
|
||||
)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
if persistCtx.Err() != nil {
|
||||
return persistCtx.Err()
|
||||
}
|
||||
return r.persistFailure(persistCtx, candidate, now, owner, token, err)
|
||||
}
|
||||
|
||||
func (r *Reconciler) persistFailure(
|
||||
ctx context.Context,
|
||||
candidate store.ReconcileCandidate,
|
||||
now time.Time,
|
||||
owner, token string,
|
||||
err error,
|
||||
) error {
|
||||
failureCount := candidate.FailureCount + 1
|
||||
nextAttempt := now.Add(r.backoff(failureCount))
|
||||
errorCode := string(onvif.CodeOf(err))
|
||||
@@ -82,14 +196,28 @@ func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.Reconcile
|
||||
if !errors.As(err, &onvifError) {
|
||||
errorCode = "media_error"
|
||||
}
|
||||
if markErr := r.repository.MarkReconcileFailure(
|
||||
ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, now,
|
||||
if markErr := r.repository.FailReconcile(
|
||||
ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, owner, token, now,
|
||||
); markErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("persist reconcile failure: %w", markErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Reconciler) observeItem(result string) {
|
||||
if r.metrics != nil {
|
||||
r.metrics.ObserveReconcileItem(result)
|
||||
}
|
||||
}
|
||||
|
||||
func newClaimToken() (string, error) {
|
||||
value := make([]byte, 18)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", errors.New("generate reconcile claim token")
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func validateStreamURI(value string) error {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "rtsp" && parsed.Scheme != "rtsps") {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type recordingMedia struct {
|
||||
calls int
|
||||
changed int
|
||||
deleted int
|
||||
paths map[string]string
|
||||
}
|
||||
|
||||
@@ -31,6 +32,15 @@ func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (boo
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *recordingMedia) DeletePath(_ context.Context, name string) error {
|
||||
m.calls++
|
||||
if m.paths != nil {
|
||||
delete(m.paths, name)
|
||||
}
|
||||
m.deleted++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
@@ -132,6 +142,106 @@ func TestCancellationDoesNotPersistFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledDeviceDeletesOnlyItsExactPath(t *testing.T) {
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
createReconcileDevice(t, repository)
|
||||
media := &recordingMedia{paths: map[string]string{"camera-1": "source", "other": "keep"}}
|
||||
if err := repository.SetDesiredState(context.Background(), "camera-1", device.DesiredDisabled); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconciler := New(repository, onvif.NewFake(nil), media)
|
||||
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.deleted != 1 || media.paths["other"] != "keep" {
|
||||
t.Fatalf("disabled convergence touched the wrong paths: %+v", media.paths)
|
||||
}
|
||||
value, err := repository.GetDevice(context.Background(), "camera-1")
|
||||
if err != nil || value.ActualState != device.ActualOffline {
|
||||
t.Fatalf("disabled device did not converge offline: %+v %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
type lostLeaseRepository struct {
|
||||
completed bool
|
||||
failed bool
|
||||
}
|
||||
|
||||
func (r *lostLeaseRepository) ClaimDueReconcile(
|
||||
context.Context,
|
||||
store.ReconcileClaim,
|
||||
) ([]store.ReconcileCandidate, error) {
|
||||
return []store.ReconcileCandidate{{Device: device.Device{
|
||||
ID: "camera-lost", DesiredState: device.DesiredEnabled,
|
||||
EndpointRef: "onvif://camera-lost", PathName: "camera-lost", Generation: 1,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (r *lostLeaseRepository) RenewReconcileLease(
|
||||
context.Context, string, string, string, time.Time, time.Duration,
|
||||
) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *lostLeaseRepository) CompleteReconcile(
|
||||
context.Context, string, int64, string, string, time.Time,
|
||||
) error {
|
||||
r.completed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *lostLeaseRepository) FailReconcile(
|
||||
context.Context, string, int, time.Time, string, string, string, time.Time,
|
||||
) error {
|
||||
r.failed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *lostLeaseRepository) ConvergenceSnapshot(context.Context) (store.ConvergenceSnapshot, error) {
|
||||
return store.ConvergenceSnapshot{}, nil
|
||||
}
|
||||
|
||||
func TestLostLeaseSkipsAllExternalAndStoreMutations(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := &lostLeaseRepository{}
|
||||
media := &recordingMedia{}
|
||||
reconciler := New(repository, onvif.NewFake(nil), media)
|
||||
if err := reconciler.RunOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.calls != 0 || repository.completed || repository.failed {
|
||||
t.Fatalf("lost lease performed a side effect: media=%d completed=%v failed=%v",
|
||||
media.calls, repository.completed, repository.failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerItemTimeoutPersistsRetryWhenParentIsAlive(t *testing.T) {
|
||||
t.Parallel()
|
||||
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
|
||||
createReconcileDevice(t, repository)
|
||||
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
|
||||
"onvif://camera-1": {
|
||||
DelayMillis: 100,
|
||||
Result: onvif.ProbeResult{StreamURI: "rtsp://media.invalid/camera-1"},
|
||||
},
|
||||
})
|
||||
reconciler := NewWithOptions(repository, discovery, &recordingMedia{}, Options{
|
||||
InstanceID: "ins_test", LeaseDuration: time.Second, OperationTimeout: 5 * time.Millisecond,
|
||||
})
|
||||
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||
reconciler.now = func() time.Time { return now }
|
||||
if err := reconciler.RunOnce(context.Background()); err == nil {
|
||||
t.Fatal("expected bounded operation timeout")
|
||||
}
|
||||
candidates, err := repository.ListDueReconcile(context.Background(), now.Add(time.Hour), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != 1 || candidates[0].FailureCount != 1 {
|
||||
t.Fatalf("operation timeout did not persist retry state: %+v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func openRepository(t *testing.T, path string) *store.SQLite {
|
||||
t.Helper()
|
||||
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuditActorType string
|
||||
|
||||
const (
|
||||
AuditActorUser AuditActorType = "user"
|
||||
AuditActorService AuditActorType = "service"
|
||||
AuditActorSystem AuditActorType = "system"
|
||||
)
|
||||
|
||||
// AuditContext is supplied by a verified caller boundary. Repository callers
|
||||
// without an HTTP principal use the explicit system/sense fallback.
|
||||
type AuditContext struct {
|
||||
ActorType AuditActorType
|
||||
ActorID string
|
||||
Reason string
|
||||
TraceID string
|
||||
}
|
||||
|
||||
type auditContextKey struct{}
|
||||
|
||||
func WithAuditContext(ctx context.Context, value AuditContext) context.Context {
|
||||
return context.WithValue(ctx, auditContextKey{}, value)
|
||||
}
|
||||
|
||||
func auditFromContext(ctx context.Context) AuditContext {
|
||||
value, ok := ctx.Value(auditContextKey{}).(AuditContext)
|
||||
if !ok {
|
||||
return AuditContext{ActorType: AuditActorSystem, ActorID: "sense"}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func validateAudit(value AuditContext) error {
|
||||
if value.ActorType != AuditActorUser && value.ActorType != AuditActorService && value.ActorType != AuditActorSystem {
|
||||
return errors.New("invalid audit actor type")
|
||||
}
|
||||
if strings.TrimSpace(value.ActorID) == "" || len(value.ActorID) > 200 {
|
||||
return errors.New("invalid audit actor ID")
|
||||
}
|
||||
if len(value.Reason) > 500 {
|
||||
return errors.New("audit reason is too long")
|
||||
}
|
||||
if len(value.TraceID) > 128 {
|
||||
return errors.New("audit trace ID is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type postgresAuditEvent struct {
|
||||
EventType string
|
||||
TenantID string
|
||||
SiteID string
|
||||
DeviceID string
|
||||
Generation int64
|
||||
QuotaSourceVersion int64
|
||||
AreaPolicySourceVersion int64
|
||||
Payload any
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
func insertPostgresAudit(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
event postgresAuditEvent,
|
||||
) error {
|
||||
audit := auditFromContext(ctx)
|
||||
if err := validateAudit(audit); err != nil {
|
||||
return err
|
||||
}
|
||||
eventID, err := newAuditEventID()
|
||||
if err != nil {
|
||||
return errors.New("generate audit event ID")
|
||||
}
|
||||
payload, err := json.Marshal(event.Payload)
|
||||
if err != nil {
|
||||
return errors.New("encode audit payload")
|
||||
}
|
||||
if !json.Valid(payload) {
|
||||
return errors.New("invalid audit payload")
|
||||
}
|
||||
occurredAt := event.OccurredAt.UTC()
|
||||
if occurredAt.IsZero() {
|
||||
occurredAt = time.Now().UTC()
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense.device_operation_outbox(
|
||||
event_id, 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, available_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NULLIF($8, ''), NULLIF($9, ''),
|
||||
$10, $11, $12, $13, $14, $14)`,
|
||||
eventID, event.EventType, event.TenantID, event.SiteID, event.DeviceID,
|
||||
audit.ActorType, audit.ActorID, audit.Reason, audit.TraceID,
|
||||
event.Generation, nullableVersion(event.QuotaSourceVersion),
|
||||
nullableVersion(event.AreaPolicySourceVersion), payload, occurredAt)
|
||||
if err != nil {
|
||||
return errors.New("insert device operation audit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newAuditEventID() (string, error) {
|
||||
value := make([]byte, 16)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "audit_" + hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func nullableVersion(value int64) any {
|
||||
if value < 1 {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/auditrelay"
|
||||
)
|
||||
|
||||
var relayErrorCode = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
|
||||
func (s *Postgres) AuditRelayReady(ctx context.Context) error {
|
||||
var version int64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM sense.schema_migrations`).Scan(&version); err != nil || version < 6 {
|
||||
return errors.New("postgres Sense schema migration v6 is required for audit relay")
|
||||
}
|
||||
var canUseOutbox, canReadBell, canWriteBell bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'sense.device_operation_outbox', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'bell.audit_events', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.audit_events', 'INSERT,UPDATE,DELETE')`).Scan(
|
||||
&canUseOutbox, &canReadBell, &canWriteBell,
|
||||
); err != nil {
|
||||
return errors.New("verify audit relay privileges")
|
||||
}
|
||||
if !canUseOutbox || canReadBell || canWriteBell {
|
||||
return errors.New("Sense audit relay violates schema ownership boundary")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ClaimAuditRelayBatch(
|
||||
ctx context.Context,
|
||||
owner string,
|
||||
limit int,
|
||||
lease time.Duration,
|
||||
) ([]auditrelay.QueuedEvent, error) {
|
||||
if strings.TrimSpace(owner) == "" || limit < 1 || limit > auditrelay.MaxBatchSize || lease <= 0 {
|
||||
return nil, errors.New("invalid audit relay claim")
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `WITH due AS (
|
||||
SELECT event_id FROM sense.device_operation_outbox
|
||||
WHERE delivered_at IS NULL AND dead_lettered_at IS NULL
|
||||
AND COALESCE(next_attempt_at, available_at) <= clock_timestamp()
|
||||
AND (relay_lease_until IS NULL OR relay_lease_until <= clock_timestamp())
|
||||
ORDER BY COALESCE(next_attempt_at, available_at), event_id
|
||||
FOR UPDATE SKIP LOCKED LIMIT $1
|
||||
)
|
||||
UPDATE sense.device_operation_outbox AS outbox SET
|
||||
relay_lease_owner = $2,
|
||||
relay_lease_token = outbox.relay_lease_token + 1,
|
||||
relay_lease_until = clock_timestamp() + ($3 * interval '1 second'),
|
||||
attempt_count = outbox.attempt_count + 1,
|
||||
last_error_code = NULL
|
||||
FROM due WHERE outbox.event_id = due.event_id
|
||||
RETURNING outbox.event_id, outbox.event_type, outbox.tenant_id,
|
||||
outbox.site_id, outbox.device_id, outbox.actor_type, outbox.actor_id,
|
||||
outbox.reason, outbox.trace_id, outbox.aggregate_generation,
|
||||
outbox.quota_source_version, outbox.area_policy_source_version,
|
||||
outbox.payload, outbox.occurred_at, outbox.relay_lease_token,
|
||||
outbox.attempt_count`, limit, owner, lease.Seconds())
|
||||
if err != nil {
|
||||
return nil, errors.New("claim audit relay batch")
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]auditrelay.QueuedEvent, 0)
|
||||
for rows.Next() {
|
||||
var value auditrelay.QueuedEvent
|
||||
var reason, trace sql.NullString
|
||||
var quota, area sql.NullInt64
|
||||
var payload []byte
|
||||
if err := rows.Scan(
|
||||
&value.Event.EventID, &value.Event.EventType, &value.Event.TenantID,
|
||||
&value.Event.SiteID, &value.Event.DeviceID, &value.Event.Actor.Type,
|
||||
&value.Event.Actor.ID, &reason, &trace, &value.Event.AggregateGeneration,
|
||||
"a, &area, &payload, &value.Event.OccurredAt, &value.LeaseToken,
|
||||
&value.AttemptCount,
|
||||
); err != nil {
|
||||
return nil, errors.New("scan audit relay claim")
|
||||
}
|
||||
if reason.Valid {
|
||||
value.Event.Reason = &reason.String
|
||||
}
|
||||
if trace.Valid {
|
||||
value.Event.TraceID = &trace.String
|
||||
}
|
||||
if quota.Valid {
|
||||
value.Event.ProjectionVersions.QuotaSourceVersion = "a.Int64
|
||||
}
|
||||
if area.Valid {
|
||||
value.Event.ProjectionVersions.AreaPolicySourceVersion = &area.Int64
|
||||
}
|
||||
if !json.Valid(payload) {
|
||||
return nil, errors.New("invalid audit payload in outbox")
|
||||
}
|
||||
value.Event.Data = append(json.RawMessage(nil), payload...)
|
||||
value.SchemaVersion = 1
|
||||
if value.Event.EventType == "device.configuration.accepted" {
|
||||
value.SchemaVersion = 2
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.New("iterate audit relay claims")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) CompleteAuditRelayBatch(
|
||||
ctx context.Context,
|
||||
owner string,
|
||||
values []auditrelay.Completion,
|
||||
) error {
|
||||
if strings.TrimSpace(owner) == "" || len(values) == 0 || len(values) > auditrelay.MaxBatchSize {
|
||||
return errors.New("invalid audit relay completion")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin audit relay completion")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, value := range values {
|
||||
code := value.ErrorCode
|
||||
if code != "" && !relayErrorCode.MatchString(code) {
|
||||
code = "invalid_response"
|
||||
}
|
||||
var result sql.Result
|
||||
switch value.Disposition {
|
||||
case auditrelay.Delivered:
|
||||
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
|
||||
delivered_at = clock_timestamp(), last_error_code = NULL,
|
||||
relay_lease_owner = NULL, relay_lease_until = NULL
|
||||
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
|
||||
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken)
|
||||
case auditrelay.DeadLetter:
|
||||
if code == "" {
|
||||
return errors.New("dead-letter completion requires an error code")
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
|
||||
dead_lettered_at = clock_timestamp(), last_error_code = $4,
|
||||
relay_lease_owner = NULL, relay_lease_until = NULL
|
||||
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
|
||||
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken, code)
|
||||
case auditrelay.Retry:
|
||||
if code == "" || value.RetryAfter < time.Second || value.RetryAfter > 300*time.Second {
|
||||
return errors.New("invalid audit relay retry completion")
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET
|
||||
next_attempt_at = clock_timestamp() + ($4 * interval '1 second'),
|
||||
last_error_code = $5, relay_lease_owner = NULL, relay_lease_until = NULL
|
||||
WHERE event_id = $1 AND relay_lease_owner = $2 AND relay_lease_token = $3
|
||||
AND relay_lease_until > clock_timestamp()`, value.EventID, owner, value.LeaseToken, value.RetryAfter.Seconds(), code)
|
||||
default:
|
||||
return errors.New("invalid audit relay disposition")
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("persist audit relay completion")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.New("read audit relay completion")
|
||||
}
|
||||
if affected != 1 {
|
||||
return auditrelay.ErrLeaseLost
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit audit relay completion")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/auditrelay"
|
||||
)
|
||||
|
||||
func TestPostgresAuditRelayClaimUsesFencing(t *testing.T) {
|
||||
repository, admin := openPostgresTestStore(t)
|
||||
ctx := context.Background()
|
||||
if err := repository.AuditRelayReady(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertBellSite(t, admin, "relay-tenant", "relay-site", 1)
|
||||
device := videoDevice(9001, "relay-tenant", "relay-site")
|
||||
if err := repository.CreateDevice(ctx, device); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := repository.ClaimAuditRelayBatch(ctx, "worker-a", 100, 30*time.Second)
|
||||
if err != nil || len(first) != 1 || first[0].SchemaVersion != 1 || first[0].AttemptCount != 1 {
|
||||
t.Fatalf("first claim: %+v %v", first, err)
|
||||
}
|
||||
if _, err := admin.ExecContext(ctx, `UPDATE sense.device_operation_outbox SET relay_lease_until=clock_timestamp()-interval '1 second' WHERE event_id=$1`, first[0].Event.EventID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := repository.ClaimAuditRelayBatch(ctx, "worker-b", 100, 30*time.Second)
|
||||
if err != nil || len(second) != 1 || second[0].LeaseToken <= first[0].LeaseToken {
|
||||
t.Fatalf("reclaim: %+v %v", second, err)
|
||||
}
|
||||
err = repository.CompleteAuditRelayBatch(ctx, "worker-a", []auditrelay.Completion{{EventID: first[0].Event.EventID, LeaseToken: first[0].LeaseToken, Disposition: auditrelay.Delivered}})
|
||||
if !errors.Is(err, auditrelay.ErrLeaseLost) {
|
||||
t.Fatalf("stale worker completion returned %v", err)
|
||||
}
|
||||
if err := repository.CompleteAuditRelayBatch(ctx, "worker-b", []auditrelay.Completion{{EventID: second[0].Event.EventID, LeaseToken: second[0].LeaseToken, Disposition: auditrelay.Delivered}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var delivered bool
|
||||
if err := admin.QueryRowContext(ctx, `SELECT delivered_at IS NOT NULL FROM sense.device_operation_outbox WHERE event_id=$1`, first[0].Event.EventID).Scan(&delivered); err != nil || !delivered {
|
||||
t.Fatalf("delivered=%v err=%v", delivered, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrETagMismatch = errors.New("device ETag mismatch")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key body conflict")
|
||||
ErrDuplicateSerialNumber = errors.New("duplicate device serial number")
|
||||
)
|
||||
|
||||
type ControlProjectionVersions struct {
|
||||
QuotaSourceVersion *int64 `json:"quota_source_version"`
|
||||
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
}
|
||||
|
||||
// ControlDevice is deliberately safe to serialize. It contains configured
|
||||
// booleans, never endpoint, credential or profile-token values.
|
||||
type ControlDevice struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Name string `json:"name"`
|
||||
Modality device.Modality `json:"modality"`
|
||||
Capabilities []device.Capability `json:"capabilities"`
|
||||
AreaID string `json:"area_id"`
|
||||
DesiredState device.DesiredState `json:"desired_state"`
|
||||
ActualState device.ActualState `json:"actual_state"`
|
||||
AdapterStatus string `json:"adapter_status"`
|
||||
EndpointConfigured bool `json:"endpoint_configured"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
Generation int64 `json:"generation"`
|
||||
ObservedGeneration int64 `json:"observed_generation"`
|
||||
Converged bool `json:"converged"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
|
||||
LastErrorCode *string `json:"last_error_code,omitempty"`
|
||||
ProjectionVersions ControlProjectionVersions `json:"projection_versions"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ResourceVersion int64 `json:"-"`
|
||||
}
|
||||
|
||||
type ControlSiteQuota struct {
|
||||
Status string
|
||||
UsedVideoChannels int
|
||||
MaxVideoChannels *int
|
||||
AvailableVideoChannels *int
|
||||
OverLimit bool
|
||||
SourceVersion *int64
|
||||
SyncedAt *time.Time
|
||||
}
|
||||
|
||||
type ControlListFilter struct {
|
||||
Limit int
|
||||
AfterCreated *time.Time
|
||||
AfterDeviceID string
|
||||
Modality *device.Modality
|
||||
Capability *device.Capability
|
||||
DesiredState *device.DesiredState
|
||||
ActualState *device.ActualState
|
||||
}
|
||||
|
||||
type ControlDevicePage struct {
|
||||
Items []ControlDevice
|
||||
HasMore bool
|
||||
Quota ControlSiteQuota
|
||||
}
|
||||
|
||||
type IdempotencyScope struct {
|
||||
PrincipalID string
|
||||
TenantID string
|
||||
SiteID string
|
||||
Operation string
|
||||
Key string
|
||||
RequestHash [sha256.Size]byte
|
||||
TraceID string
|
||||
}
|
||||
|
||||
type ControlCreateRequest struct {
|
||||
Scope IdempotencyScope
|
||||
Device device.Device
|
||||
}
|
||||
|
||||
type ControlCreateResult struct {
|
||||
Device ControlDevice
|
||||
AcceptedAt time.Time
|
||||
TraceID string
|
||||
ETag string
|
||||
Location string
|
||||
Replay bool
|
||||
}
|
||||
|
||||
type ControlPatch struct {
|
||||
Name *string
|
||||
AreaID *string
|
||||
EndpointRef *string
|
||||
CredentialRef *string
|
||||
ProfileToken *string
|
||||
}
|
||||
|
||||
type ControlMutationResult struct {
|
||||
Device ControlDevice
|
||||
AcceptedAt time.Time
|
||||
TraceID string
|
||||
ETag string
|
||||
}
|
||||
|
||||
type ControlBatchItem struct {
|
||||
DeviceID string
|
||||
ETag string
|
||||
DesiredState device.DesiredState
|
||||
}
|
||||
|
||||
type ControlBatchRequest struct {
|
||||
Scope IdempotencyScope
|
||||
Reason string
|
||||
Items []ControlBatchItem
|
||||
}
|
||||
|
||||
type ControlBatchItemResult struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
Status string `json:"status"`
|
||||
ErrorCode *string `json:"error_code"`
|
||||
Message *string `json:"message"`
|
||||
Generation *int64 `json:"generation"`
|
||||
}
|
||||
|
||||
type ControlBatchOperation struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"-"`
|
||||
SiteID string `json:"-"`
|
||||
Status string `json:"status"`
|
||||
SubmittedAt time.Time `json:"submitted_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
Results []ControlBatchItemResult `json:"results"`
|
||||
TraceID string `json:"trace_id"`
|
||||
Replay bool `json:"-"`
|
||||
}
|
||||
|
||||
type ControlRepository interface {
|
||||
ListControlDevices(context.Context, string, string, ControlListFilter) (ControlDevicePage, error)
|
||||
CreateControlDevice(context.Context, ControlCreateRequest) (ControlCreateResult, error)
|
||||
GetControlDevice(context.Context, string, string, string) (ControlDevice, error)
|
||||
PatchControlDevice(context.Context, string, string, string, string, ControlPatch) (ControlMutationResult, error)
|
||||
SetControlDesiredState(context.Context, string, string, string, string, device.DesiredState) (ControlMutationResult, error)
|
||||
BatchSetControlDesiredState(context.Context, ControlBatchRequest) (ControlBatchOperation, error)
|
||||
GetControlOperation(context.Context, string, string) (ControlBatchOperation, error)
|
||||
}
|
||||
|
||||
func DeviceETag(deviceID string, resourceVersion int64) string {
|
||||
digest := sha256.Sum256([]byte(deviceID + "\x00" + strconv.FormatInt(resourceVersion, 10)))
|
||||
return `"` + base64.RawURLEncoding.EncodeToString(digest[:18]) + `"`
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
const controlReceiptTTL = 24 * time.Hour
|
||||
|
||||
const controlDeviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
|
||||
d.area_id, d.desired_state, d.actual_state,
|
||||
(d.endpoint_ref <> ''), (d.credential_ref <> ''),
|
||||
d.generation, d.resource_version,
|
||||
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code,
|
||||
d.quota_source_version, d.area_policy_source_version,
|
||||
GREATEST(
|
||||
(SELECT q.synced_at FROM sense.site_quota_projection_state q
|
||||
WHERE q.tenant_id = d.tenant_id AND q.site_id = d.site_id
|
||||
AND q.source_version = d.quota_source_version),
|
||||
(SELECT a.synced_at FROM sense.area_policy_projection_state a
|
||||
WHERE a.tenant_id = d.tenant_id AND a.site_id = d.site_id
|
||||
AND a.area_id = d.area_id AND a.source_version = d.area_policy_source_version)
|
||||
),
|
||||
d.created_at, d.updated_at,
|
||||
COALESCE((SELECT jsonb_agg(c.capability ORDER BY c.capability)
|
||||
FROM sense.device_capabilities c WHERE c.device_id = d.id), '[]'::jsonb)::text`
|
||||
|
||||
const controlDeviceSelect = `SELECT ` + controlDeviceColumns + `
|
||||
FROM sense.devices d JOIN sense.reconcile_state r ON r.device_id = d.id`
|
||||
|
||||
type controlScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanControlDevice(row controlScanner) (ControlDevice, error) {
|
||||
var value ControlDevice
|
||||
var nextAttempt, syncedAt sql.NullTime
|
||||
var lastError sql.NullString
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
var capabilitiesJSON string
|
||||
err := row.Scan(
|
||||
&value.ID, &value.TenantID, &value.SiteID, &value.SerialNumber, &value.Name,
|
||||
&value.Modality, &value.AreaID, &value.DesiredState, &value.ActualState,
|
||||
&value.EndpointConfigured, &value.CredentialConfigured,
|
||||
&value.Generation, &value.ResourceVersion, &value.ObservedGeneration,
|
||||
&value.FailureCount, &nextAttempt, &lastError, "aVersion, &areaVersion,
|
||||
&syncedAt, &value.CreatedAt, &value.UpdatedAt, &capabilitiesJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return ControlDevice{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(capabilitiesJSON), &value.Capabilities); err != nil {
|
||||
return ControlDevice{}, errors.New("decode postgres control device capabilities")
|
||||
}
|
||||
if value.Capabilities == nil {
|
||||
value.Capabilities = make([]device.Capability, 0)
|
||||
}
|
||||
if nextAttempt.Valid {
|
||||
point := nextAttempt.Time.UTC()
|
||||
value.NextAttemptAt = &point
|
||||
}
|
||||
if lastError.Valid {
|
||||
code := lastError.String
|
||||
value.LastErrorCode = &code
|
||||
}
|
||||
if quotaVersion.Valid {
|
||||
version := quotaVersion.Int64
|
||||
value.ProjectionVersions.QuotaSourceVersion = &version
|
||||
}
|
||||
if areaVersion.Valid {
|
||||
version := areaVersion.Int64
|
||||
value.ProjectionVersions.AreaPolicySourceVersion = &version
|
||||
}
|
||||
if syncedAt.Valid {
|
||||
point := syncedAt.Time.UTC()
|
||||
value.ProjectionVersions.SyncedAt = &point
|
||||
}
|
||||
value.Converged = value.ObservedGeneration >= value.Generation && value.FailureCount == 0
|
||||
value.AdapterStatus = controlAdapterStatus(value)
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func controlAdapterStatus(value ControlDevice) string {
|
||||
if value.LastErrorCode != nil {
|
||||
switch *value.LastErrorCode {
|
||||
case "authentication_failed":
|
||||
return "authentication_failed"
|
||||
case "adapter_not_ready":
|
||||
return "adapter_not_ready"
|
||||
default:
|
||||
return "unavailable"
|
||||
}
|
||||
}
|
||||
if value.Converged {
|
||||
return "ready"
|
||||
}
|
||||
if value.ActualState == device.ActualFailed || value.ActualState == device.ActualOffline {
|
||||
return "unavailable"
|
||||
}
|
||||
return "pending"
|
||||
}
|
||||
|
||||
func (s *Postgres) GetControlDevice(
|
||||
ctx context.Context, tenantID, siteID, deviceID string,
|
||||
) (ControlDevice, error) {
|
||||
value, err := scanControlDevice(s.db.QueryRowContext(ctx, controlDeviceSelect+`
|
||||
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ControlDevice{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ControlDevice{}, errors.New("get postgres control device")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ListControlDevices(
|
||||
ctx context.Context, tenantID, siteID string, filter ControlListFilter,
|
||||
) (ControlDevicePage, error) {
|
||||
quota, err := s.controlSiteQuota(ctx, tenantID, siteID)
|
||||
if err != nil {
|
||||
return ControlDevicePage{}, err
|
||||
}
|
||||
query := controlDeviceSelect + ` WHERE d.tenant_id = $1 AND d.site_id = $2`
|
||||
arguments := []any{tenantID, siteID}
|
||||
appendCondition := func(clause string, value any) {
|
||||
arguments = append(arguments, value)
|
||||
query += fmt.Sprintf(clause, len(arguments))
|
||||
}
|
||||
if filter.Modality != nil {
|
||||
appendCondition(` AND d.modality = $%d`, *filter.Modality)
|
||||
}
|
||||
if filter.Capability != nil {
|
||||
appendCondition(` AND EXISTS (SELECT 1 FROM sense.device_capabilities fc
|
||||
WHERE fc.device_id = d.id AND fc.capability = $%d)`, *filter.Capability)
|
||||
}
|
||||
if filter.DesiredState != nil {
|
||||
appendCondition(` AND d.desired_state = $%d`, *filter.DesiredState)
|
||||
}
|
||||
if filter.ActualState != nil {
|
||||
appendCondition(` AND d.actual_state = $%d`, *filter.ActualState)
|
||||
}
|
||||
if filter.AfterCreated != nil {
|
||||
arguments = append(arguments, filter.AfterCreated.UTC(), filter.AfterDeviceID)
|
||||
query += fmt.Sprintf(` AND (d.created_at, d.id) > ($%d, $%d)`, len(arguments)-1, len(arguments))
|
||||
}
|
||||
arguments = append(arguments, filter.Limit+1)
|
||||
query += fmt.Sprintf(` ORDER BY d.created_at ASC, d.id ASC LIMIT $%d`, len(arguments))
|
||||
rows, err := s.db.QueryContext(ctx, query, arguments...)
|
||||
if err != nil {
|
||||
return ControlDevicePage{}, errors.New("list postgres control devices")
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]ControlDevice, 0, filter.Limit+1)
|
||||
for rows.Next() {
|
||||
value, scanErr := scanControlDevice(rows)
|
||||
if scanErr != nil {
|
||||
return ControlDevicePage{}, errors.New("scan postgres control device page")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ControlDevicePage{}, errors.New("iterate postgres control device page")
|
||||
}
|
||||
hasMore := len(values) > filter.Limit
|
||||
if hasMore {
|
||||
values = values[:filter.Limit]
|
||||
}
|
||||
return ControlDevicePage{Items: values, HasMore: hasMore, Quota: quota}, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) controlSiteQuota(ctx context.Context, tenantID, siteID string) (ControlSiteQuota, error) {
|
||||
var maximum int
|
||||
var sourceVersion int64
|
||||
var syncedAt time.Time
|
||||
err := s.db.QueryRowContext(ctx, `SELECT max_video_channels, source_version, source_updated_at
|
||||
FROM bell.site_quota_v1 WHERE tenant_id = $1 AND site_id = $2`, tenantID, siteID).
|
||||
Scan(&maximum, &sourceVersion, &syncedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ControlSiteQuota{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ControlSiteQuota{}, errors.New("read postgres control site quota")
|
||||
}
|
||||
var used int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sense.devices d
|
||||
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.desired_state = 'enabled'
|
||||
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture')`, tenantID, siteID).Scan(&used); err != nil {
|
||||
return ControlSiteQuota{}, errors.New("count postgres control site video channels")
|
||||
}
|
||||
status := "current"
|
||||
if maximum < 1 || maximum > device.MaximumVideoChannels || sourceVersion < 1 || syncedAt.IsZero() {
|
||||
status = "invalid"
|
||||
return ControlSiteQuota{Status: status, UsedVideoChannels: used, OverLimit: false}, nil
|
||||
}
|
||||
available := maximum - used
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
point := syncedAt.UTC()
|
||||
return ControlSiteQuota{
|
||||
Status: status, UsedVideoChannels: used, MaxVideoChannels: &maximum,
|
||||
AvailableVideoChannels: &available, OverLimit: used > maximum,
|
||||
SourceVersion: &sourceVersion, SyncedAt: &point,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type controlReceipt struct {
|
||||
Status int
|
||||
Body []byte
|
||||
ETag string
|
||||
Location string
|
||||
TraceID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func controlScopeHash(scope IdempotencyScope) [sha256.Size]byte {
|
||||
encoded, _ := json.Marshal([]string{
|
||||
scope.PrincipalID, scope.TenantID, scope.SiteID, scope.Operation, scope.Key,
|
||||
})
|
||||
return sha256.Sum256(encoded)
|
||||
}
|
||||
|
||||
func readControlReceipt(
|
||||
ctx context.Context, tx *sql.Tx, scope IdempotencyScope, now time.Time,
|
||||
) (controlReceipt, bool, error) {
|
||||
var receipt controlReceipt
|
||||
scopeHash := controlScopeHash(scope)
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, hex.EncodeToString(scopeHash[:])); err != nil {
|
||||
return receipt, false, errors.New("lock postgres Control API idempotency scope")
|
||||
}
|
||||
var storedRequestHash []byte
|
||||
var etag, location sql.NullString
|
||||
var expiresAt time.Time
|
||||
err := tx.QueryRowContext(ctx, `SELECT request_hash, response_status, response_body::text,
|
||||
response_etag, response_location, trace_id, created_at, expires_at
|
||||
FROM sense.control_idempotency_receipts WHERE scope_hash = $1`, scopeHash[:]).
|
||||
Scan(&storedRequestHash, &receipt.Status, &receipt.Body, &etag, &location,
|
||||
&receipt.TraceID, &receipt.CreatedAt, &expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return receipt, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return receipt, false, errors.New("read postgres Control API idempotency receipt")
|
||||
}
|
||||
if !expiresAt.After(now) {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sense.control_idempotency_receipts
|
||||
WHERE scope_hash = $1`, scopeHash[:]); err != nil {
|
||||
return receipt, false, errors.New("expire postgres Control API idempotency receipt")
|
||||
}
|
||||
return controlReceipt{}, false, nil
|
||||
}
|
||||
if subtle.ConstantTimeCompare(storedRequestHash, scope.RequestHash[:]) != 1 {
|
||||
return receipt, false, ErrIdempotencyConflict
|
||||
}
|
||||
receipt.ETag = etag.String
|
||||
receipt.Location = location.String
|
||||
return receipt, true, nil
|
||||
}
|
||||
|
||||
func writeControlReceipt(
|
||||
ctx context.Context, tx *sql.Tx, scope IdempotencyScope, receipt controlReceipt,
|
||||
) error {
|
||||
scopeHash := controlScopeHash(scope)
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO sense.control_idempotency_receipts(
|
||||
scope_hash, request_hash, operation_name, principal_id, tenant_id, site_id,
|
||||
response_status, response_body, response_etag, response_location, trace_id,
|
||||
created_at, expires_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, NULLIF($9, ''), NULLIF($10, ''), $11, $12, $13)`,
|
||||
scopeHash[:], scope.RequestHash[:], scope.Operation, scope.PrincipalID,
|
||||
scope.TenantID, scope.SiteID, receipt.Status, string(receipt.Body),
|
||||
receipt.ETag, receipt.Location, receipt.TraceID, receipt.CreatedAt,
|
||||
receipt.CreatedAt.Add(controlReceiptTTL))
|
||||
if err != nil {
|
||||
return errors.New("write postgres Control API idempotency receipt")
|
||||
}
|
||||
// Bound opportunistic cleanup; never scans or deletes unexpired receipts.
|
||||
_, _ = tx.ExecContext(ctx, `DELETE FROM sense.control_idempotency_receipts
|
||||
WHERE scope_hash IN (SELECT scope_hash FROM sense.control_idempotency_receipts
|
||||
WHERE expires_at <= $1 ORDER BY expires_at LIMIT 32)`, receipt.CreatedAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) CreateControlDevice(
|
||||
ctx context.Context, request ControlCreateRequest,
|
||||
) (ControlCreateResult, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ControlCreateResult{}, errors.New("begin postgres Control API device create")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
receipt, found, err := readControlReceipt(ctx, tx, request.Scope, now)
|
||||
if err != nil {
|
||||
return ControlCreateResult{}, err
|
||||
}
|
||||
if found {
|
||||
var value ControlDevice
|
||||
if err := json.Unmarshal(receipt.Body, &value); err != nil {
|
||||
return ControlCreateResult{}, errors.New("decode postgres device creation receipt")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ControlCreateResult{}, errors.New("commit postgres device creation replay")
|
||||
}
|
||||
return ControlCreateResult{
|
||||
Device: value, AcceptedAt: receipt.CreatedAt, TraceID: receipt.TraceID,
|
||||
ETag: receipt.ETag, Location: receipt.Location, Replay: true,
|
||||
}, nil
|
||||
}
|
||||
value := request.Device
|
||||
if value.Generation == 0 {
|
||||
value.Generation = 1
|
||||
}
|
||||
if value.ResourceVersion == 0 {
|
||||
value.ResourceVersion = 1
|
||||
}
|
||||
if value.ActualState == "" {
|
||||
value.ActualState = device.ActualPending
|
||||
}
|
||||
value.CreatedAt = now
|
||||
value.UpdatedAt = now
|
||||
if err := createControlDeviceTx(ctx, tx, value, now); err != nil {
|
||||
return ControlCreateResult{}, err
|
||||
}
|
||||
created, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
|
||||
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, value.TenantID, value.SiteID, value.ID))
|
||||
if err != nil {
|
||||
return ControlCreateResult{}, errors.New("read created postgres control device")
|
||||
}
|
||||
responseBody, err := json.Marshal(created)
|
||||
if err != nil {
|
||||
return ControlCreateResult{}, errors.New("encode created postgres control device")
|
||||
}
|
||||
etag := DeviceETag(created.ID, created.ResourceVersion)
|
||||
location := "/api/v1/sites/" + url.PathEscape(created.SiteID) + "/devices/" + url.PathEscape(created.ID)
|
||||
receipt = controlReceipt{
|
||||
Status: 201, Body: responseBody, ETag: etag, Location: location,
|
||||
TraceID: request.Scope.TraceID, CreatedAt: now,
|
||||
}
|
||||
if err := writeControlReceipt(ctx, tx, request.Scope, receipt); err != nil {
|
||||
return ControlCreateResult{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ControlCreateResult{}, errors.New("commit postgres Control API device create")
|
||||
}
|
||||
return ControlCreateResult{
|
||||
Device: created, AcceptedAt: now, TraceID: receipt.TraceID,
|
||||
ETag: etag, Location: location,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createControlDeviceTx(ctx context.Context, tx *sql.Tx, value device.Device, now time.Time) error {
|
||||
if err := value.Validate(); err != nil {
|
||||
return fmt.Errorf("validate Control API device: %w", err)
|
||||
}
|
||||
areaVersion, err := checkPostgresAreaPolicy(
|
||||
ctx, tx, value.TenantID, value.SiteID, value.AreaID,
|
||||
value.HasCapability(device.CapabilityVideoCapture), now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var quotaVersion int64
|
||||
if value.ConsumesVideoChannel() {
|
||||
quotaVersion, err = checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
|
||||
id, tenant_id, site_id, area_id, serial_number, name, modality,
|
||||
desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
|
||||
path_name, generation, resource_version, quota_source_version,
|
||||
area_policy_source_version, created_at, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`,
|
||||
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
|
||||
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
||||
value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
|
||||
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
|
||||
value.CreatedAt, value.UpdatedAt)
|
||||
if err != nil {
|
||||
var postgresError *pgconn.PgError
|
||||
if errors.As(err, &postgresError) && postgresError.Code == "23505" &&
|
||||
strings.Contains(postgresError.ConstraintName, "serial_number") {
|
||||
return ErrDuplicateSerialNumber
|
||||
}
|
||||
return errors.New("insert postgres Control API device")
|
||||
}
|
||||
for _, capability := range sortedCapabilities(value.Capabilities) {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.device_capabilities(device_id, capability)
|
||||
VALUES ($1, $2)`, value.ID, capability); err != nil {
|
||||
return errors.New("insert postgres Control API device capability")
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.reconcile_state(device_id, updated_at)
|
||||
VALUES ($1, $2)`, value.ID, now); err != nil {
|
||||
return errors.New("insert postgres Control API reconcile state")
|
||||
}
|
||||
return insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.created", TenantID: value.TenantID, SiteID: value.SiteID,
|
||||
DeviceID: value.ID, Generation: value.Generation,
|
||||
QuotaSourceVersion: quotaVersion, AreaPolicySourceVersion: areaVersion,
|
||||
OccurredAt: now,
|
||||
Payload: map[string]any{
|
||||
"kind": "device_created", "area_id": value.AreaID, "modality": value.Modality,
|
||||
"capabilities": sortedCapabilities(value.Capabilities), "desired_state": value.DesiredState,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var _ ControlRepository = (*Postgres)(nil)
|
||||
|
||||
func (s *Postgres) PatchControlDevice(
|
||||
ctx context.Context, tenantID, siteID, deviceID, expectedETag string, patch ControlPatch,
|
||||
) (ControlMutationResult, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ControlMutationResult{}, errors.New("begin postgres Control API device patch")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var name, areaID, endpointRef, credentialRef, profileToken string
|
||||
var generation, resourceVersion int64
|
||||
var desired device.DesiredState
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
var hasVideo bool
|
||||
err = tx.QueryRowContext(ctx, `SELECT d.name, d.area_id, d.endpoint_ref,
|
||||
d.credential_ref, d.profile_token, d.generation, d.resource_version,
|
||||
d.desired_state, d.quota_source_version, d.area_policy_source_version,
|
||||
EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||
FROM sense.devices d
|
||||
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3 FOR UPDATE`,
|
||||
tenantID, siteID, deviceID).Scan(
|
||||
&name, &areaID, &endpointRef, &credentialRef, &profileToken,
|
||||
&generation, &resourceVersion, &desired, "aVersion, &areaVersion, &hasVideo,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ControlMutationResult{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ControlMutationResult{}, errors.New("read postgres Control API device patch state")
|
||||
}
|
||||
if DeviceETag(deviceID, resourceVersion) != expectedETag {
|
||||
return ControlMutationResult{}, ErrETagMismatch
|
||||
}
|
||||
changedFields := make([]string, 0, 5)
|
||||
reconcileChanged := false
|
||||
if patch.Name != nil && *patch.Name != name {
|
||||
name = *patch.Name
|
||||
changedFields = append(changedFields, "name")
|
||||
}
|
||||
if patch.AreaID != nil && *patch.AreaID != areaID {
|
||||
version, policyErr := checkPostgresAreaPolicy(
|
||||
ctx, tx, tenantID, siteID, *patch.AreaID, hasVideo, now,
|
||||
)
|
||||
if policyErr != nil {
|
||||
return ControlMutationResult{}, policyErr
|
||||
}
|
||||
areaID = *patch.AreaID
|
||||
areaVersion = sql.NullInt64{Int64: version, Valid: true}
|
||||
changedFields = append(changedFields, "area_id")
|
||||
}
|
||||
if patch.EndpointRef != nil && *patch.EndpointRef != endpointRef {
|
||||
endpointRef = *patch.EndpointRef
|
||||
changedFields = append(changedFields, "endpoint_ref")
|
||||
reconcileChanged = true
|
||||
}
|
||||
if patch.CredentialRef != nil && *patch.CredentialRef != credentialRef {
|
||||
credentialRef = *patch.CredentialRef
|
||||
changedFields = append(changedFields, "credential_ref")
|
||||
reconcileChanged = true
|
||||
}
|
||||
if patch.ProfileToken != nil && *patch.ProfileToken != profileToken {
|
||||
profileToken = *patch.ProfileToken
|
||||
changedFields = append(changedFields, "profile_token")
|
||||
reconcileChanged = true
|
||||
}
|
||||
if len(changedFields) > 0 {
|
||||
resourceVersion++
|
||||
if reconcileChanged {
|
||||
generation++
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE sense.devices SET
|
||||
name = $1, area_id = $2, endpoint_ref = $3, credential_ref = $4,
|
||||
profile_token = $5, generation = $6, resource_version = $7,
|
||||
area_policy_source_version = $8,
|
||||
actual_state = CASE WHEN $9 THEN 'pending' ELSE actual_state END,
|
||||
updated_at = $10
|
||||
WHERE tenant_id = $11 AND site_id = $12 AND id = $13`,
|
||||
name, areaID, endpointRef, credentialRef, profileToken, generation,
|
||||
resourceVersion, nullableVersion(areaVersion.Int64), reconcileChanged, now,
|
||||
tenantID, siteID, deviceID)
|
||||
if err != nil {
|
||||
return ControlMutationResult{}, errors.New("update postgres Control API device configuration")
|
||||
}
|
||||
if reconcileChanged {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
lease_owner = NULL, lease_token = NULL, lease_until = NULL,
|
||||
updated_at = $1 WHERE device_id = $2`, now, deviceID); err != nil {
|
||||
return ControlMutationResult{}, errors.New("reset postgres Control API reconcile state")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.configuration.accepted", TenantID: tenantID, SiteID: siteID,
|
||||
DeviceID: deviceID, Generation: generation,
|
||||
QuotaSourceVersion: quotaVersion.Int64, AreaPolicySourceVersion: areaVersion.Int64,
|
||||
OccurredAt: now,
|
||||
Payload: map[string]any{
|
||||
"kind": "configuration_accepted", "changed": len(changedFields) > 0,
|
||||
"changed_fields": changedFields, "area_id": areaID,
|
||||
},
|
||||
}); err != nil {
|
||||
return ControlMutationResult{}, err
|
||||
}
|
||||
updated, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
|
||||
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
|
||||
if err != nil {
|
||||
return ControlMutationResult{}, errors.New("read patched postgres control device")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ControlMutationResult{}, errors.New("commit postgres Control API device patch")
|
||||
}
|
||||
return ControlMutationResult{
|
||||
Device: updated, AcceptedAt: now, TraceID: auditFromContext(ctx).TraceID,
|
||||
ETag: DeviceETag(updated.ID, updated.ResourceVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) SetControlDesiredState(
|
||||
ctx context.Context, tenantID, siteID, deviceID, expectedETag string, desired device.DesiredState,
|
||||
) (ControlMutationResult, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ControlMutationResult{}, errors.New("begin postgres Control API desired-state update")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
updated, err := setControlDesiredStateTx(
|
||||
ctx, tx, tenantID, siteID, deviceID, expectedETag, desired, now,
|
||||
)
|
||||
if err != nil {
|
||||
return ControlMutationResult{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ControlMutationResult{}, errors.New("commit postgres Control API desired-state update")
|
||||
}
|
||||
return ControlMutationResult{
|
||||
Device: updated, AcceptedAt: now, TraceID: auditFromContext(ctx).TraceID,
|
||||
ETag: DeviceETag(updated.ID, updated.ResourceVersion),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setControlDesiredStateTx(
|
||||
ctx context.Context, tx *sql.Tx, tenantID, siteID, deviceID, expectedETag string,
|
||||
desired device.DesiredState, now time.Time,
|
||||
) (ControlDevice, error) {
|
||||
var areaID, endpointRef, pathName string
|
||||
var current device.DesiredState
|
||||
var generation, resourceVersion int64
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
var hasVideo bool
|
||||
err := tx.QueryRowContext(ctx, `SELECT d.area_id, d.desired_state, d.endpoint_ref,
|
||||
d.path_name, d.generation, d.resource_version, d.quota_source_version,
|
||||
d.area_policy_source_version,
|
||||
EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||
FROM sense.devices d
|
||||
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3 FOR UPDATE`,
|
||||
tenantID, siteID, deviceID).Scan(
|
||||
&areaID, ¤t, &endpointRef, &pathName, &generation, &resourceVersion,
|
||||
"aVersion, &areaVersion, &hasVideo,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ControlDevice{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ControlDevice{}, errors.New("read postgres Control API desired state")
|
||||
}
|
||||
if DeviceETag(deviceID, resourceVersion) != expectedETag {
|
||||
return ControlDevice{}, ErrETagMismatch
|
||||
}
|
||||
if current != desired {
|
||||
var admittedQuota, admittedArea int64
|
||||
if desired == device.DesiredEnabled && hasVideo {
|
||||
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
||||
return ControlDevice{}, errors.New("video adapter configuration is incomplete")
|
||||
}
|
||||
admittedArea, err = checkPostgresAreaPolicy(ctx, tx, tenantID, siteID, areaID, true, now)
|
||||
if err != nil {
|
||||
return ControlDevice{}, err
|
||||
}
|
||||
admittedQuota, err = checkPostgresVideoQuota(ctx, tx, tenantID, siteID, now)
|
||||
if err != nil {
|
||||
return ControlDevice{}, err
|
||||
}
|
||||
}
|
||||
generation++
|
||||
resourceVersion++
|
||||
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
|
||||
desired_state = $1, actual_state = 'pending', generation = $2,
|
||||
resource_version = $3,
|
||||
quota_source_version = COALESCE($4, quota_source_version),
|
||||
area_policy_source_version = COALESCE($5, area_policy_source_version),
|
||||
updated_at = $6
|
||||
WHERE tenant_id = $7 AND site_id = $8 AND id = $9
|
||||
RETURNING quota_source_version, area_policy_source_version`,
|
||||
desired, generation, resourceVersion, nullableVersion(admittedQuota),
|
||||
nullableVersion(admittedArea), now, tenantID, siteID, deviceID).
|
||||
Scan("aVersion, &areaVersion)
|
||||
if err != nil {
|
||||
return ControlDevice{}, errors.New("update postgres Control API desired state")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
lease_owner = NULL, lease_token = NULL, lease_until = NULL,
|
||||
updated_at = $1 WHERE device_id = $2`, now, deviceID); err != nil {
|
||||
return ControlDevice{}, errors.New("reset postgres Control API desired-state reconciliation")
|
||||
}
|
||||
}
|
||||
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
||||
DeviceID: deviceID, Generation: generation,
|
||||
QuotaSourceVersion: quotaVersion.Int64, AreaPolicySourceVersion: areaVersion.Int64,
|
||||
OccurredAt: now,
|
||||
Payload: map[string]any{
|
||||
"kind": "desired_state_accepted", "previous_desired_state": current,
|
||||
"desired_state": desired, "changed": current != desired,
|
||||
},
|
||||
}); err != nil {
|
||||
return ControlDevice{}, err
|
||||
}
|
||||
value, err := scanControlDevice(tx.QueryRowContext(ctx, controlDeviceSelect+`
|
||||
WHERE d.tenant_id = $1 AND d.site_id = $2 AND d.id = $3`, tenantID, siteID, deviceID))
|
||||
if err != nil {
|
||||
return ControlDevice{}, errors.New("read updated postgres control desired state")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) BatchSetControlDesiredState(
|
||||
ctx context.Context, request ControlBatchRequest,
|
||||
) (ControlBatchOperation, error) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ControlBatchOperation{}, errors.New("begin postgres Control API batch")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
receipt, found, err := readControlReceipt(ctx, tx, request.Scope, now)
|
||||
if err != nil {
|
||||
return ControlBatchOperation{}, err
|
||||
}
|
||||
if found {
|
||||
var operation ControlBatchOperation
|
||||
if err := json.Unmarshal(receipt.Body, &operation); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("decode postgres Control API batch receipt")
|
||||
}
|
||||
operation.TenantID = request.Scope.TenantID
|
||||
operation.SiteID = request.Scope.SiteID
|
||||
operation.Replay = true
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("commit postgres Control API batch replay")
|
||||
}
|
||||
return operation, nil
|
||||
}
|
||||
operationID, err := newControlOperationID(now)
|
||||
if err != nil {
|
||||
return ControlBatchOperation{}, err
|
||||
}
|
||||
counts := make(map[string]int, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
counts[item.DeviceID]++
|
||||
}
|
||||
if err := lockControlBatchDevices(
|
||||
ctx, tx, request.Scope.TenantID, request.Scope.SiteID, counts,
|
||||
); err != nil {
|
||||
return ControlBatchOperation{}, err
|
||||
}
|
||||
results := make([]ControlBatchItemResult, 0, len(request.Items))
|
||||
succeeded := 0
|
||||
for index, item := range request.Items {
|
||||
if counts[item.DeviceID] > 1 {
|
||||
code, message := "invalid_request", "device_id is duplicated in this request"
|
||||
results = append(results, ControlBatchItemResult{
|
||||
DeviceID: item.DeviceID, Status: "rejected", ErrorCode: &code, Message: &message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
savepoint := fmt.Sprintf("control_batch_%d", index)
|
||||
if _, err := tx.ExecContext(ctx, "SAVEPOINT "+savepoint); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("create postgres Control API batch savepoint")
|
||||
}
|
||||
updated, itemErr := setControlDesiredStateTx(
|
||||
ctx, tx, request.Scope.TenantID, request.Scope.SiteID,
|
||||
item.DeviceID, item.ETag, item.DesiredState, now,
|
||||
)
|
||||
if itemErr != nil {
|
||||
if _, rollbackErr := tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT "+savepoint); rollbackErr != nil {
|
||||
return ControlBatchOperation{}, errors.New("rollback postgres Control API batch item")
|
||||
}
|
||||
code, status, message := controlBatchError(itemErr)
|
||||
results = append(results, ControlBatchItemResult{
|
||||
DeviceID: item.DeviceID, Status: status, ErrorCode: &code, Message: &message,
|
||||
})
|
||||
} else {
|
||||
generation := updated.Generation
|
||||
results = append(results, ControlBatchItemResult{
|
||||
DeviceID: item.DeviceID, Status: "succeeded", Generation: &generation,
|
||||
})
|
||||
succeeded++
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT "+savepoint); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("release postgres Control API batch savepoint")
|
||||
}
|
||||
}
|
||||
status := "partially_succeeded"
|
||||
if succeeded == len(results) {
|
||||
status = "succeeded"
|
||||
} else if succeeded == 0 {
|
||||
status = "failed"
|
||||
}
|
||||
completedAt := now
|
||||
operation := ControlBatchOperation{
|
||||
ID: operationID, TenantID: request.Scope.TenantID, SiteID: request.Scope.SiteID,
|
||||
Status: status, SubmittedAt: now, CompletedAt: &completedAt,
|
||||
Results: results, TraceID: request.Scope.TraceID,
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.batch_operations(
|
||||
id, tenant_id, site_id, principal_id, status, trace_id, submitted_at, completed_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, operation.ID, operation.TenantID,
|
||||
operation.SiteID, request.Scope.PrincipalID, operation.Status, operation.TraceID,
|
||||
operation.SubmittedAt, operation.CompletedAt); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("insert postgres Control API batch operation")
|
||||
}
|
||||
for index, result := range results {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.batch_operation_items(
|
||||
operation_id, ordinal, device_id, status, error_code, message, generation
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7)`, operation.ID, index, result.DeviceID,
|
||||
result.Status, result.ErrorCode, result.Message, result.Generation); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("insert postgres Control API batch item")
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(operation)
|
||||
if err != nil {
|
||||
return ControlBatchOperation{}, errors.New("encode postgres Control API batch operation")
|
||||
}
|
||||
receipt = controlReceipt{
|
||||
Status: 202, Body: body,
|
||||
Location: "/api/v1/operations/" + url.PathEscape(operation.ID),
|
||||
TraceID: operation.TraceID, CreatedAt: now,
|
||||
}
|
||||
if err := writeControlReceipt(ctx, tx, request.Scope, receipt); err != nil {
|
||||
return ControlBatchOperation{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("commit postgres Control API batch")
|
||||
}
|
||||
return operation, nil
|
||||
}
|
||||
|
||||
func lockControlBatchDevices(
|
||||
ctx context.Context, tx *sql.Tx, tenantID, siteID string, deviceIDs map[string]int,
|
||||
) error {
|
||||
ordered := make([]string, 0, len(deviceIDs))
|
||||
for deviceID := range deviceIDs {
|
||||
ordered = append(ordered, deviceID)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
for _, deviceID := range ordered {
|
||||
var locked string
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM sense.devices
|
||||
WHERE tenant_id = $1 AND site_id = $2 AND id = $3 FOR UPDATE`,
|
||||
tenantID, siteID, deviceID).Scan(&locked)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return errors.New("lock postgres Control API batch devices")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func controlBatchError(err error) (code, status, message string) {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return "not_found", "rejected", "device was not found"
|
||||
case errors.Is(err, ErrETagMismatch):
|
||||
return "etag_mismatch", "rejected", "device ETag does not match"
|
||||
case errors.Is(err, ErrAreaPolicyDenied):
|
||||
return "area_policy_denied", "rejected", "Area policy denies this change"
|
||||
case errors.Is(err, ErrAreaPolicyUnavailable), errors.Is(err, ErrAreaPolicyInvalid):
|
||||
return "area_policy_unavailable", "failed", "Area policy is unavailable"
|
||||
case errors.Is(err, ErrQuotaProjectionUnavailable):
|
||||
return "quota_projection_unavailable", "failed", "Site quota is unavailable"
|
||||
case errors.Is(err, ErrQuotaProjectionInvalid):
|
||||
return "quota_projection_invalid", "failed", "Site quota is invalid"
|
||||
}
|
||||
var quotaError *device.QuotaExceededError
|
||||
if errors.As(err, "aError) {
|
||||
return "quota_exceeded", "rejected", "Site video channel quota is exceeded"
|
||||
}
|
||||
return "service_unavailable", "failed", "device change could not be accepted"
|
||||
}
|
||||
|
||||
func (s *Postgres) GetControlOperation(
|
||||
ctx context.Context, tenantID, operationID string,
|
||||
) (ControlBatchOperation, error) {
|
||||
var value ControlBatchOperation
|
||||
var completedAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id, tenant_id, site_id, status,
|
||||
submitted_at, completed_at, trace_id
|
||||
FROM sense.batch_operations WHERE tenant_id = $1 AND id = $2`, tenantID, operationID).
|
||||
Scan(&value.ID, &value.TenantID, &value.SiteID, &value.Status,
|
||||
&value.SubmittedAt, &completedAt, &value.TraceID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ControlBatchOperation{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ControlBatchOperation{}, errors.New("read postgres Control API batch operation")
|
||||
}
|
||||
if completedAt.Valid {
|
||||
point := completedAt.Time.UTC()
|
||||
value.CompletedAt = &point
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT device_id, status, error_code, message, generation
|
||||
FROM sense.batch_operation_items WHERE operation_id = $1 ORDER BY ordinal`, operationID)
|
||||
if err != nil {
|
||||
return ControlBatchOperation{}, errors.New("list postgres Control API batch results")
|
||||
}
|
||||
defer rows.Close()
|
||||
value.Results = make([]ControlBatchItemResult, 0)
|
||||
for rows.Next() {
|
||||
var item ControlBatchItemResult
|
||||
var code, message sql.NullString
|
||||
var generation sql.NullInt64
|
||||
if err := rows.Scan(&item.DeviceID, &item.Status, &code, &message, &generation); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("scan postgres Control API batch result")
|
||||
}
|
||||
if code.Valid {
|
||||
item.ErrorCode = &code.String
|
||||
}
|
||||
if message.Valid {
|
||||
item.Message = &message.String
|
||||
}
|
||||
if generation.Valid {
|
||||
item.Generation = &generation.Int64
|
||||
}
|
||||
value.Results = append(value.Results, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ControlBatchOperation{}, errors.New("iterate postgres Control API batch results")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func newControlOperationID(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 Control API operation ID")
|
||||
}
|
||||
number := new(big.Int).SetBytes(value)
|
||||
base, remainder := big.NewInt(32), new(big.Int)
|
||||
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
encoded := make([]byte, 26)
|
||||
for index := len(encoded) - 1; index >= 0; index-- {
|
||||
number.QuoRem(number, base, remainder)
|
||||
encoded[index] = alphabet[remainder.Int64()]
|
||||
}
|
||||
return "op_" + string(encoded), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
OperationalLeaseOrphanScan = "mediamtx-orphan-scan"
|
||||
OperationalLeaseOrphanCleanup = "mediamtx-orphan-cleanup"
|
||||
|
||||
OrphanOwnedStale = "owned_stale"
|
||||
OrphanUnowned = "unowned"
|
||||
)
|
||||
|
||||
var ErrOrphanScanNotFound = errors.New("orphan scan not found")
|
||||
|
||||
type MediaPathOwnership struct {
|
||||
PathName string
|
||||
DeviceID string
|
||||
CurrentClaim bool
|
||||
}
|
||||
|
||||
type OrphanFinding struct {
|
||||
PathName string
|
||||
Classification string
|
||||
DeviceID string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
type OrphanScan struct {
|
||||
ID string
|
||||
InstanceID string
|
||||
ObservedCount int
|
||||
OwnedStaleCount int
|
||||
UnownedCount int
|
||||
SafetyAllowed bool
|
||||
SafetyReason string
|
||||
CompletedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Findings []OrphanFinding
|
||||
}
|
||||
|
||||
type OrphanRepository interface {
|
||||
AcquireOperationalLease(
|
||||
context.Context, string, string, string, time.Time, time.Duration,
|
||||
) (bool, error)
|
||||
ReleaseOperationalLease(context.Context, string, string, string, time.Time) error
|
||||
ListMediaPathOwnership(context.Context) ([]MediaPathOwnership, error)
|
||||
SaveOrphanScan(context.Context, OrphanScan, string, string) error
|
||||
GetOrphanScan(context.Context, string) (OrphanScan, error)
|
||||
RecordOrphanCleanup(
|
||||
context.Context, string, string, string, string, string, time.Time,
|
||||
) error
|
||||
}
|
||||
|
||||
var _ OrphanRepository = (*Postgres)(nil)
|
||||
@@ -0,0 +1,268 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const orphanReportRetention = 7 * 24 * time.Hour
|
||||
|
||||
func (s *Postgres) AcquireOperationalLease(
|
||||
ctx context.Context,
|
||||
name, owner, token string,
|
||||
_ time.Time,
|
||||
duration time.Duration,
|
||||
) (bool, error) {
|
||||
if strings.TrimSpace(name) == "" || strings.TrimSpace(owner) == "" ||
|
||||
strings.TrimSpace(token) == "" || duration <= 0 {
|
||||
return false, errors.New("invalid operational lease")
|
||||
}
|
||||
var acquired int
|
||||
err := s.db.QueryRowContext(ctx, `INSERT INTO sense.operational_leases(
|
||||
lease_name, owner_id, fencing_token, lease_until, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3,
|
||||
clock_timestamp() + ($4 * interval '1 second'), clock_timestamp()
|
||||
)
|
||||
ON CONFLICT (lease_name) DO UPDATE SET
|
||||
owner_id = EXCLUDED.owner_id,
|
||||
fencing_token = EXCLUDED.fencing_token,
|
||||
lease_until = EXCLUDED.lease_until,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
WHERE sense.operational_leases.lease_until <= clock_timestamp()
|
||||
OR (sense.operational_leases.owner_id = $2
|
||||
AND sense.operational_leases.fencing_token = $3)
|
||||
RETURNING 1`, name, owner, token, duration.Seconds()).Scan(&acquired)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.New("acquire postgres operational lease")
|
||||
}
|
||||
return acquired == 1, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ReleaseOperationalLease(
|
||||
ctx context.Context,
|
||||
name, owner, token string,
|
||||
_ time.Time,
|
||||
) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE sense.operational_leases
|
||||
SET lease_until = clock_timestamp(), updated_at = clock_timestamp()
|
||||
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3`,
|
||||
name, owner, token)
|
||||
if err != nil {
|
||||
return errors.New("release postgres operational lease")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ListMediaPathOwnership(ctx context.Context) ([]MediaPathOwnership, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
o.path_name, o.device_id,
|
||||
EXISTS (
|
||||
SELECT 1 FROM sense.devices d
|
||||
WHERE d.id = o.device_id AND d.path_name = o.path_name
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture'
|
||||
)
|
||||
) AS current_claim
|
||||
FROM sense.media_path_ownership o
|
||||
ORDER BY o.path_name`)
|
||||
if err != nil {
|
||||
return nil, errors.New("list postgres MediaMTX path ownership")
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]MediaPathOwnership, 0)
|
||||
for rows.Next() {
|
||||
var value MediaPathOwnership
|
||||
if err := rows.Scan(&value.PathName, &value.DeviceID, &value.CurrentClaim); err != nil {
|
||||
return nil, errors.New("scan postgres MediaMTX path ownership")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.New("iterate postgres MediaMTX path ownership")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) SaveOrphanScan(
|
||||
ctx context.Context,
|
||||
scan OrphanScan,
|
||||
owner, token string,
|
||||
) error {
|
||||
if err := validateOrphanScan(scan); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres orphan scan save")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var lease int
|
||||
err = tx.QueryRowContext(ctx, `SELECT 1 FROM sense.operational_leases
|
||||
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3
|
||||
AND lease_until > clock_timestamp()
|
||||
FOR UPDATE`, OperationalLeaseOrphanScan, owner, token).Scan(&lease)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrOperationalLeaseLost
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("verify postgres orphan scan lease")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.orphan_scan_runs(
|
||||
id, instance_id, observed_count, owned_stale_count, unowned_count,
|
||||
safety_allowed, safety_reason, completed_at, expires_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
scan.ID, scan.InstanceID, scan.ObservedCount, scan.OwnedStaleCount, scan.UnownedCount,
|
||||
scan.SafetyAllowed, scan.SafetyReason, scan.CompletedAt, scan.ExpiresAt,
|
||||
); err != nil {
|
||||
return errors.New("insert postgres orphan scan")
|
||||
}
|
||||
for _, finding := range scan.Findings {
|
||||
var deviceID any
|
||||
if finding.DeviceID != "" {
|
||||
deviceID = finding.DeviceID
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.orphan_scan_findings(
|
||||
scan_id, path_name, classification, device_id
|
||||
) VALUES ($1,$2,$3,$4)`, scan.ID, finding.PathName, finding.Classification, deviceID); err != nil {
|
||||
return errors.New("insert postgres orphan scan finding")
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.operational_leases
|
||||
SET lease_until = clock_timestamp(), updated_at = clock_timestamp()
|
||||
WHERE lease_name = $1 AND owner_id = $2 AND fencing_token = $3`,
|
||||
OperationalLeaseOrphanScan, owner, token); err != nil {
|
||||
return errors.New("release postgres orphan scan lease")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM sense.orphan_scan_runs r
|
||||
WHERE r.completed_at < $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sense.orphan_cleanup_actions a WHERE a.scan_id = r.id
|
||||
)`, scan.CompletedAt.Add(-orphanReportRetention)); err != nil {
|
||||
return errors.New("expire postgres orphan scan reports")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres orphan scan")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOrphanScan(scan OrphanScan) error {
|
||||
if strings.TrimSpace(scan.ID) == "" || strings.TrimSpace(scan.InstanceID) == "" ||
|
||||
scan.ObservedCount < 0 || scan.OwnedStaleCount < 0 || scan.UnownedCount < 0 ||
|
||||
scan.OwnedStaleCount+scan.UnownedCount > scan.ObservedCount ||
|
||||
strings.TrimSpace(scan.SafetyReason) == "" || !scan.ExpiresAt.After(scan.CompletedAt) {
|
||||
return errors.New("invalid orphan scan")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(scan.Findings))
|
||||
staleCount, unownedCount := 0, 0
|
||||
for _, finding := range scan.Findings {
|
||||
if strings.TrimSpace(finding.PathName) == "" {
|
||||
return errors.New("invalid orphan finding path")
|
||||
}
|
||||
if _, duplicate := seen[finding.PathName]; duplicate {
|
||||
return errors.New("duplicate orphan finding path")
|
||||
}
|
||||
seen[finding.PathName] = struct{}{}
|
||||
switch finding.Classification {
|
||||
case OrphanOwnedStale:
|
||||
staleCount++
|
||||
if strings.TrimSpace(finding.DeviceID) == "" {
|
||||
return errors.New("owned stale finding lacks device")
|
||||
}
|
||||
case OrphanUnowned:
|
||||
unownedCount++
|
||||
if finding.DeviceID != "" {
|
||||
return errors.New("unowned finding has device")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid orphan finding classification %q", finding.Classification)
|
||||
}
|
||||
}
|
||||
if staleCount != scan.OwnedStaleCount || unownedCount != scan.UnownedCount {
|
||||
return errors.New("orphan scan counts do not match findings")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) GetOrphanScan(ctx context.Context, id string) (OrphanScan, error) {
|
||||
var scan OrphanScan
|
||||
err := s.db.QueryRowContext(ctx, `SELECT
|
||||
id, instance_id, observed_count, owned_stale_count, unowned_count,
|
||||
safety_allowed, safety_reason, completed_at, expires_at
|
||||
FROM sense.orphan_scan_runs WHERE id = $1`, id).Scan(
|
||||
&scan.ID, &scan.InstanceID, &scan.ObservedCount, &scan.OwnedStaleCount,
|
||||
&scan.UnownedCount, &scan.SafetyAllowed, &scan.SafetyReason,
|
||||
&scan.CompletedAt, &scan.ExpiresAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return OrphanScan{}, ErrOrphanScanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return OrphanScan{}, errors.New("read postgres orphan scan")
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
f.path_name, f.classification, COALESCE(f.device_id, ''),
|
||||
COALESCE(a.status = 'deleted', false)
|
||||
FROM sense.orphan_scan_findings f
|
||||
LEFT JOIN sense.orphan_cleanup_actions a
|
||||
ON a.scan_id = f.scan_id AND a.path_name = f.path_name
|
||||
WHERE f.scan_id = $1 ORDER BY f.path_name`, id)
|
||||
if err != nil {
|
||||
return OrphanScan{}, errors.New("list postgres orphan scan findings")
|
||||
}
|
||||
defer rows.Close()
|
||||
scan.Findings = make([]OrphanFinding, 0)
|
||||
for rows.Next() {
|
||||
var finding OrphanFinding
|
||||
if err := rows.Scan(
|
||||
&finding.PathName, &finding.Classification, &finding.DeviceID, &finding.Deleted,
|
||||
); err != nil {
|
||||
return OrphanScan{}, errors.New("scan postgres orphan finding")
|
||||
}
|
||||
scan.Findings = append(scan.Findings, finding)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return OrphanScan{}, errors.New("iterate postgres orphan findings")
|
||||
}
|
||||
return scan, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) RecordOrphanCleanup(
|
||||
ctx context.Context,
|
||||
scanID, pathName, actorID, status, errorCode string,
|
||||
now time.Time,
|
||||
) error {
|
||||
if status != "deleted" && status != "failed" {
|
||||
return errors.New("invalid orphan cleanup status")
|
||||
}
|
||||
var storedError any
|
||||
if status == "failed" {
|
||||
if strings.TrimSpace(errorCode) == "" {
|
||||
return errors.New("failed orphan cleanup requires an error code")
|
||||
}
|
||||
storedError = errorCode
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sense.orphan_cleanup_actions(
|
||||
scan_id, path_name, classification, actor_id, status, error_code, attempted_at
|
||||
) VALUES ($1,$2,'owned_stale',$3,$4,$5,$6)
|
||||
ON CONFLICT (scan_id, path_name) DO UPDATE SET
|
||||
actor_id = EXCLUDED.actor_id,
|
||||
status = EXCLUDED.status,
|
||||
error_code = EXCLUDED.error_code,
|
||||
attempted_at = EXCLUDED.attempted_at
|
||||
WHERE sense.orphan_cleanup_actions.status <> 'deleted'`,
|
||||
scanID, pathName, actorID, status, storedError, now)
|
||||
if err != nil {
|
||||
return errors.New("record postgres orphan cleanup result")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
|
||||
"yovision/sense/internal/device"
|
||||
)
|
||||
|
||||
// Postgres persists Sense state in the sense schema and consumes only Bell's
|
||||
// versioned quota and Area views. Migrations are installed out of process.
|
||||
type Postgres struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func OpenPostgres(ctx context.Context, dsn string) (*Postgres, error) {
|
||||
configuration, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid postgres DSN")
|
||||
}
|
||||
if configuration.RuntimeParams == nil {
|
||||
configuration.RuntimeParams = make(map[string]string)
|
||||
}
|
||||
configuration.RuntimeParams["application_name"] = "yovision-sense"
|
||||
db := stdlib.OpenDB(*configuration)
|
||||
db.SetMaxOpenConns(16)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, errors.New("connect postgres database")
|
||||
}
|
||||
store := &Postgres{db: db}
|
||||
if err := store.verifySchemaAndPrivileges(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Postgres) verifySchemaAndPrivileges(ctx context.Context) error {
|
||||
var version sql.NullInt64
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT MAX(version) FROM sense.schema_migrations`).Scan(&version); err != nil || !version.Valid || version.Int64 < 5 {
|
||||
return errors.New("postgres sense schema migration v5 is required")
|
||||
}
|
||||
var canReadQuotaView, canWriteQuotaView, canReadSiteSource, canWriteSiteSource bool
|
||||
var canReadAreaView, canWriteAreaView, canReadAreaSource, canWriteAreaSource bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'bell.site_quota_v1', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.site_quota_v1', 'INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'bell.sites', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.sites', 'INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'bell.area_policy_v1', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.area_policy_v1', 'INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'bell.areas', 'SELECT'),
|
||||
has_table_privilege(current_user, 'bell.areas', 'INSERT,UPDATE,DELETE')`).
|
||||
Scan(
|
||||
&canReadQuotaView, &canWriteQuotaView, &canReadSiteSource, &canWriteSiteSource,
|
||||
&canReadAreaView, &canWriteAreaView, &canReadAreaSource, &canWriteAreaSource,
|
||||
); err != nil {
|
||||
return errors.New("verify postgres Bell projection privileges")
|
||||
}
|
||||
if !canReadQuotaView || canWriteQuotaView || canReadSiteSource || canWriteSiteSource ||
|
||||
!canReadAreaView || canWriteAreaView || canReadAreaSource || canWriteAreaSource {
|
||||
return errors.New("postgres role violates Bell projection privilege boundary")
|
||||
}
|
||||
var canUseReceipts, canUseOperations, canUseOperationItems bool
|
||||
var publicReceipts, publicOperations, publicOperationItems bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.control_idempotency_receipts', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.batch_operations', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.batch_operation_items', 'SELECT,INSERT,UPDATE,DELETE')`).Scan(
|
||||
&canUseReceipts, &canUseOperations, &canUseOperationItems,
|
||||
&publicReceipts, &publicOperations, &publicOperationItems,
|
||||
); err != nil {
|
||||
return errors.New("verify postgres Control API state privileges")
|
||||
}
|
||||
if !canUseReceipts || !canUseOperations || !canUseOperationItems ||
|
||||
publicReceipts || publicOperations || publicOperationItems {
|
||||
return errors.New("postgres role violates Control API state privilege boundary")
|
||||
}
|
||||
var canUseOwnership, canUseLeases, canUseScans, canUseFindings, canUseActions bool
|
||||
var publicOwnership, publicLeases, publicScans, publicFindings, publicActions bool
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
has_table_privilege(current_user, 'sense.media_path_ownership', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.operational_leases', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.orphan_scan_runs', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.orphan_scan_findings', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege(current_user, 'sense.orphan_cleanup_actions', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.media_path_ownership', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.operational_leases', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.orphan_scan_runs', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.orphan_scan_findings', 'SELECT,INSERT,UPDATE,DELETE'),
|
||||
has_table_privilege('public', 'sense.orphan_cleanup_actions', 'SELECT,INSERT,UPDATE,DELETE')`).Scan(
|
||||
&canUseOwnership, &canUseLeases, &canUseScans, &canUseFindings, &canUseActions,
|
||||
&publicOwnership, &publicLeases, &publicScans, &publicFindings, &publicActions,
|
||||
); err != nil {
|
||||
return errors.New("verify postgres reconciliation safety privileges")
|
||||
}
|
||||
if !canUseOwnership || !canUseLeases || !canUseScans || !canUseFindings || !canUseActions ||
|
||||
publicOwnership || publicLeases || publicScans || publicFindings || publicActions {
|
||||
return errors.New("postgres role violates reconciliation safety privilege boundary")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) CreateDevice(ctx context.Context, value device.Device) error {
|
||||
if value.Generation == 0 {
|
||||
value.Generation = 1
|
||||
}
|
||||
if value.ActualState == "" {
|
||||
value.ActualState = device.ActualPending
|
||||
}
|
||||
if value.ResourceVersion == 0 {
|
||||
value.ResourceVersion = 1
|
||||
}
|
||||
if err := value.Validate(); err != nil {
|
||||
return fmt.Errorf("validate device: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if value.CreatedAt.IsZero() {
|
||||
value.CreatedAt = now
|
||||
}
|
||||
value.UpdatedAt = now
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres create device")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
areaVersion, err := checkPostgresAreaPolicy(
|
||||
ctx, tx, value.TenantID, value.SiteID, value.AreaID,
|
||||
value.HasCapability(device.CapabilityVideoCapture), now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var quotaVersion int64
|
||||
if value.ConsumesVideoChannel() {
|
||||
version, quotaErr := checkPostgresVideoQuota(ctx, tx, value.TenantID, value.SiteID, now)
|
||||
if quotaErr != nil {
|
||||
return quotaErr
|
||||
}
|
||||
quotaVersion = version
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense.devices(
|
||||
id, tenant_id, site_id, area_id, serial_number, name, modality,
|
||||
desired_state, actual_state, endpoint_ref, credential_ref, profile_token,
|
||||
path_name, generation, resource_version, quota_source_version, area_policy_source_version,
|
||||
created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
|
||||
value.ID, value.TenantID, value.SiteID, value.AreaID, value.SerialNumber, value.Name,
|
||||
value.Modality, value.DesiredState, value.ActualState, value.EndpointRef,
|
||||
value.CredentialRef, value.ProfileToken, value.PathName, value.Generation,
|
||||
value.ResourceVersion, nullableVersion(quotaVersion), areaVersion,
|
||||
value.CreatedAt, value.UpdatedAt)
|
||||
if err != nil {
|
||||
return errors.New("insert postgres device")
|
||||
}
|
||||
for _, capability := range sortedCapabilities(value.Capabilities) {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO sense.device_capabilities(device_id, capability) VALUES ($1, $2)`,
|
||||
value.ID, capability); err != nil {
|
||||
return errors.New("insert postgres device capability")
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.reconcile_state(device_id, updated_at)
|
||||
VALUES ($1, $2)`, value.ID, now); err != nil {
|
||||
return errors.New("insert postgres reconcile state")
|
||||
}
|
||||
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.created", TenantID: value.TenantID, SiteID: value.SiteID,
|
||||
DeviceID: value.ID, Generation: value.Generation,
|
||||
QuotaSourceVersion: quotaVersion, AreaPolicySourceVersion: areaVersion,
|
||||
OccurredAt: now,
|
||||
Payload: map[string]any{
|
||||
"kind": "device_created", "area_id": value.AreaID,
|
||||
"modality": value.Modality, "capabilities": sortedCapabilities(value.Capabilities),
|
||||
"desired_state": value.DesiredState,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres create device")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPostgresAreaPolicy(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
tenantID, siteID, areaID string,
|
||||
imaging bool,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
if strings.TrimSpace(areaID) == "" {
|
||||
return 0, areaPolicyUnavailable()
|
||||
}
|
||||
// Area projection observation is serialized before the Site quota lock.
|
||||
// No admission path acquires these locks in the opposite order.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext('area:' || $2))`, tenantID, areaID); err != nil {
|
||||
return 0, errors.New("lock postgres Area admission")
|
||||
}
|
||||
var capturePolicy string
|
||||
var sourceVersion int64
|
||||
var sourceUpdatedAt time.Time
|
||||
err := tx.QueryRowContext(ctx, `SELECT capture_policy, source_version, source_updated_at
|
||||
FROM bell.area_policy_v1
|
||||
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
|
||||
Scan(&capturePolicy, &sourceVersion, &sourceUpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, areaPolicyUnavailable()
|
||||
}
|
||||
if err != nil {
|
||||
return 0, areaPolicyUnavailable()
|
||||
}
|
||||
if (capturePolicy != "video_allowed" && capturePolicy != "non_imaging_only") ||
|
||||
sourceVersion < 1 || sourceUpdatedAt.IsZero() {
|
||||
return 0, areaPolicyInvalid()
|
||||
}
|
||||
var previous sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `SELECT source_version
|
||||
FROM sense.area_policy_projection_state
|
||||
WHERE tenant_id = $1 AND site_id = $2 AND area_id = $3`, tenantID, siteID, areaID).
|
||||
Scan(&previous)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, errors.New("read postgres Area projection state")
|
||||
}
|
||||
if previous.Valid && sourceVersion < previous.Int64 {
|
||||
return 0, areaPolicyInvalid()
|
||||
}
|
||||
if imaging && capturePolicy == "non_imaging_only" {
|
||||
return 0, areaPolicyDenied()
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.area_policy_projection_state(
|
||||
tenant_id, site_id, area_id, source_version, synced_at
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (tenant_id, site_id, area_id) DO UPDATE SET
|
||||
source_version = EXCLUDED.source_version,
|
||||
synced_at = EXCLUDED.synced_at`, tenantID, siteID, areaID, sourceVersion, now); err != nil {
|
||||
return 0, errors.New("record postgres Area projection state")
|
||||
}
|
||||
return sourceVersion, nil
|
||||
}
|
||||
|
||||
func checkPostgresVideoQuota(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
tenantID, siteID string,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
// A transaction-scoped lock shared by all Sense instances makes count +
|
||||
// write atomic per logical site without locking Bell-owned rows.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))`, tenantID, siteID); err != nil {
|
||||
return 0, errors.New("lock postgres site quota admission")
|
||||
}
|
||||
var limit int
|
||||
var sourceVersion int64
|
||||
var sourceUpdatedAt time.Time
|
||||
err := tx.QueryRowContext(ctx, `SELECT max_video_channels, source_version, source_updated_at
|
||||
FROM bell.site_quota_v1 WHERE tenant_id = $1 AND site_id = $2`, tenantID, siteID).
|
||||
Scan(&limit, &sourceVersion, &sourceUpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, projectionUnavailable()
|
||||
}
|
||||
if err != nil {
|
||||
return 0, projectionUnavailable()
|
||||
}
|
||||
if limit < 1 || limit > device.MaximumVideoChannels || sourceVersion < 1 || sourceUpdatedAt.IsZero() {
|
||||
return 0, projectionInvalid()
|
||||
}
|
||||
var previous sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `SELECT source_version
|
||||
FROM sense.site_quota_projection_state WHERE tenant_id = $1 AND site_id = $2`,
|
||||
tenantID, siteID).Scan(&previous)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, errors.New("read postgres quota projection state")
|
||||
}
|
||||
if previous.Valid && sourceVersion < previous.Int64 {
|
||||
return 0, projectionInvalid()
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.site_quota_projection_state(
|
||||
tenant_id, site_id, source_version, synced_at
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (tenant_id, site_id) DO UPDATE SET
|
||||
source_version = EXCLUDED.source_version,
|
||||
synced_at = EXCLUDED.synced_at`, tenantID, siteID, sourceVersion, now); err != nil {
|
||||
return 0, errors.New("record postgres quota projection state")
|
||||
}
|
||||
var current int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*)
|
||||
FROM sense.devices d
|
||||
JOIN sense.device_capabilities c ON c.device_id = d.id
|
||||
WHERE d.tenant_id = $1 AND d.site_id = $2
|
||||
AND d.desired_state = 'enabled'
|
||||
AND c.capability = 'video_capture'`, tenantID, siteID).Scan(¤t); err != nil {
|
||||
return 0, errors.New("count postgres site video channels")
|
||||
}
|
||||
if current >= limit {
|
||||
return 0, &device.QuotaExceededError{TenantID: tenantID, SiteID: siteID, Limit: limit}
|
||||
}
|
||||
return sourceVersion, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) SetDesiredState(ctx context.Context, id string, desired device.DesiredState) error {
|
||||
if desired != device.DesiredEnabled && desired != device.DesiredDisabled {
|
||||
return fmt.Errorf("invalid desired state %q", desired)
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres desired-state update")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var tenantID, siteID, endpointRef, pathName string
|
||||
var areaID sql.NullString
|
||||
var current device.DesiredState
|
||||
var generation int64
|
||||
var storedQuotaVersion, storedAreaVersion sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `SELECT tenant_id, site_id, area_id, desired_state,
|
||||
endpoint_ref, path_name, generation, quota_source_version, area_policy_source_version
|
||||
FROM sense.devices WHERE id = $1 FOR UPDATE`, id).
|
||||
Scan(
|
||||
&tenantID, &siteID, &areaID, ¤t, &endpointRef, &pathName,
|
||||
&generation, &storedQuotaVersion, &storedAreaVersion,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("read postgres device desired state")
|
||||
}
|
||||
if current == desired {
|
||||
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
||||
DeviceID: id, Generation: generation,
|
||||
QuotaSourceVersion: storedQuotaVersion.Int64,
|
||||
AreaPolicySourceVersion: storedAreaVersion.Int64,
|
||||
OccurredAt: time.Now().UTC(),
|
||||
Payload: map[string]any{
|
||||
"kind": "desired_state_accepted", "previous_desired_state": current,
|
||||
"desired_state": desired, "changed": false,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres no-op desired-state audit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var quotaVersion, areaVersion int64
|
||||
if desired == device.DesiredEnabled {
|
||||
var hasVideo bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM sense.device_capabilities
|
||||
WHERE device_id = $1 AND capability = 'video_capture'
|
||||
)`, id).Scan(&hasVideo); err != nil {
|
||||
return errors.New("read postgres video capability")
|
||||
}
|
||||
if hasVideo {
|
||||
if strings.TrimSpace(endpointRef) == "" || strings.TrimSpace(pathName) == "" {
|
||||
return errors.New("enabled video devices require endpoint ref and path name")
|
||||
}
|
||||
version, areaErr := checkPostgresAreaPolicy(
|
||||
ctx, tx, tenantID, siteID, areaID.String, true, time.Now().UTC(),
|
||||
)
|
||||
if areaErr != nil {
|
||||
return areaErr
|
||||
}
|
||||
areaVersion = version
|
||||
version, quotaErr := checkPostgresVideoQuota(ctx, tx, tenantID, siteID, time.Now().UTC())
|
||||
if quotaErr != nil {
|
||||
return quotaErr
|
||||
}
|
||||
quotaVersion = version
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var updatedQuotaVersion, updatedAreaVersion sql.NullInt64
|
||||
err = tx.QueryRowContext(ctx, `UPDATE sense.devices SET
|
||||
desired_state = $1, actual_state = 'pending', generation = generation + 1,
|
||||
resource_version = resource_version + 1,
|
||||
quota_source_version = COALESCE($2, quota_source_version),
|
||||
area_policy_source_version = COALESCE($3, area_policy_source_version),
|
||||
updated_at = $4
|
||||
WHERE id = $5
|
||||
RETURNING generation, quota_source_version, area_policy_source_version`,
|
||||
desired, nullableVersion(quotaVersion), nullableVersion(areaVersion), now, id).
|
||||
Scan(&generation, &updatedQuotaVersion, &updatedAreaVersion)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("update postgres desired state")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
lease_owner = NULL, lease_token = NULL, lease_until = NULL, updated_at = $1
|
||||
WHERE device_id = $2`, now, id); err != nil {
|
||||
return errors.New("reset postgres reconcile state")
|
||||
}
|
||||
if err := insertPostgresAudit(ctx, tx, postgresAuditEvent{
|
||||
EventType: "device.desired_state.accepted", TenantID: tenantID, SiteID: siteID,
|
||||
DeviceID: id, Generation: generation,
|
||||
QuotaSourceVersion: updatedQuotaVersion.Int64,
|
||||
AreaPolicySourceVersion: updatedAreaVersion.Int64,
|
||||
OccurredAt: now,
|
||||
Payload: map[string]any{
|
||||
"kind": "desired_state_accepted", "previous_desired_state": current,
|
||||
"desired_state": desired, "changed": true,
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres desired-state update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) GetDevice(ctx context.Context, id string) (device.Device, error) {
|
||||
value, err := scanPostgresDevice(s.db.QueryRowContext(ctx, postgresDeviceSelect+` WHERE d.id = $1`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return device.Device{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return device.Device{}, errors.New("get postgres device")
|
||||
}
|
||||
value.Capabilities, err = s.capabilities(ctx, value.ID)
|
||||
if err != nil {
|
||||
return device.Device{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]ReconcileCandidate, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
|
||||
FROM sense.devices d
|
||||
JOIN sense.reconcile_state r ON r.device_id = d.id
|
||||
WHERE EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||
AND (r.observed_generation < d.generation
|
||||
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
|
||||
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= $1)
|
||||
ORDER BY d.updated_at, d.id LIMIT $2`, now, limit)
|
||||
if err != nil {
|
||||
return nil, errors.New("list postgres due reconcile devices")
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]ReconcileCandidate, 0)
|
||||
for rows.Next() {
|
||||
var candidate ReconcileCandidate
|
||||
var areaID sql.NullString
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
var nextAttempt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||
&candidate.Device.ProfileToken, &candidate.Device.PathName,
|
||||
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
|
||||
"aVersion, &areaVersion,
|
||||
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
|
||||
&candidate.FailureCount, &nextAttempt,
|
||||
); err != nil {
|
||||
return nil, errors.New("scan postgres due reconcile device")
|
||||
}
|
||||
candidate.Device.AreaID = areaID.String
|
||||
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
|
||||
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
|
||||
if nextAttempt.Valid {
|
||||
value := nextAttempt.Time
|
||||
candidate.NextAttempt = &value
|
||||
}
|
||||
values = append(values, candidate)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.New("iterate postgres due reconcile devices")
|
||||
}
|
||||
for index := range values {
|
||||
values[index].Device.Capabilities, err = s.capabilities(ctx, values[index].Device.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ClaimDueReconcile(
|
||||
ctx context.Context,
|
||||
claim ReconcileClaim,
|
||||
) ([]ReconcileCandidate, error) {
|
||||
if claim.Limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(claim.Owner) == "" || strings.TrimSpace(claim.Token) == "" ||
|
||||
claim.LeaseDuration <= 0 {
|
||||
return nil, errors.New("invalid postgres reconcile claim")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("begin postgres reconcile claim")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.QueryContext(ctx, `SELECT `+postgresDeviceColumns+`, r.failure_count, r.next_attempt_at
|
||||
FROM sense.devices d
|
||||
JOIN sense.reconcile_state r ON r.device_id = d.id
|
||||
WHERE EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||
AND (r.observed_generation < d.generation
|
||||
OR (d.desired_state = 'enabled' AND r.failure_count > 0))
|
||||
AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= clock_timestamp())
|
||||
AND (r.lease_until IS NULL OR r.lease_until <= clock_timestamp())
|
||||
ORDER BY d.updated_at, d.id
|
||||
FOR UPDATE OF r SKIP LOCKED
|
||||
LIMIT $1`, claim.Limit)
|
||||
if err != nil {
|
||||
return nil, errors.New("select postgres reconcile claims")
|
||||
}
|
||||
values := make([]ReconcileCandidate, 0)
|
||||
for rows.Next() {
|
||||
var candidate ReconcileCandidate
|
||||
var areaID sql.NullString
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
var nextAttempt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&candidate.Device.ID, &candidate.Device.TenantID, &candidate.Device.SiteID,
|
||||
&areaID, &candidate.Device.SerialNumber, &candidate.Device.Name, &candidate.Device.Modality,
|
||||
&candidate.Device.DesiredState, &candidate.Device.ActualState,
|
||||
&candidate.Device.EndpointRef, &candidate.Device.CredentialRef,
|
||||
&candidate.Device.ProfileToken, &candidate.Device.PathName,
|
||||
&candidate.Device.Generation, &candidate.Device.ResourceVersion,
|
||||
"aVersion, &areaVersion,
|
||||
&candidate.Device.CreatedAt, &candidate.Device.UpdatedAt,
|
||||
&candidate.FailureCount, &nextAttempt,
|
||||
); err != nil {
|
||||
rows.Close()
|
||||
return nil, errors.New("scan postgres reconcile claim")
|
||||
}
|
||||
candidate.Device.AreaID = areaID.String
|
||||
candidate.Device.QuotaSourceVersion = quotaVersion.Int64
|
||||
candidate.Device.AreaPolicySourceVersion = areaVersion.Int64
|
||||
if nextAttempt.Valid {
|
||||
value := nextAttempt.Time
|
||||
candidate.NextAttempt = &value
|
||||
}
|
||||
values = append(values, candidate)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, errors.New("close postgres reconcile claims")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.New("iterate postgres reconcile claims")
|
||||
}
|
||||
for _, candidate := range values {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
lease_owner = $1, lease_token = $2,
|
||||
lease_until = clock_timestamp() + ($3 * interval '1 second'),
|
||||
updated_at = clock_timestamp()
|
||||
WHERE device_id = $4`,
|
||||
claim.Owner, claim.Token, claim.LeaseDuration.Seconds(), candidate.Device.ID,
|
||||
); err != nil {
|
||||
return nil, errors.New("persist postgres reconcile claim")
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, errors.New("commit postgres reconcile claim")
|
||||
}
|
||||
for index := range values {
|
||||
values[index].Device.Capabilities, err = s.capabilities(ctx, values[index].Device.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) RenewReconcileLease(
|
||||
ctx context.Context,
|
||||
id, owner, token string,
|
||||
now time.Time,
|
||||
duration time.Duration,
|
||||
) (bool, error) {
|
||||
if strings.TrimSpace(owner) == "" || strings.TrimSpace(token) == "" || duration <= 0 {
|
||||
return false, errors.New("invalid postgres reconcile lease renewal")
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
lease_until = clock_timestamp() + ($1 * interval '1 second'),
|
||||
updated_at = clock_timestamp()
|
||||
WHERE device_id = $2 AND lease_owner = $3 AND lease_token = $4
|
||||
AND lease_until > clock_timestamp()`, duration.Seconds(), id, owner, token)
|
||||
if err != nil {
|
||||
return false, errors.New("renew postgres reconcile lease")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return false, errors.New("read postgres reconcile lease renewal")
|
||||
}
|
||||
return affected == 1, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) CompleteReconcile(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
generation int64,
|
||||
owner, token string,
|
||||
now time.Time,
|
||||
) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres fenced reconciliation completion")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
observed_generation = $1, lease_owner = NULL, lease_token = NULL,
|
||||
lease_until = NULL, updated_at = $2
|
||||
WHERE device_id = $3 AND lease_owner = $4 AND lease_token = $5
|
||||
AND lease_until > clock_timestamp()`, generation, now, id, owner, token)
|
||||
if err != nil {
|
||||
return errors.New("complete postgres fenced reconciliation")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.New("read postgres fenced reconciliation completion")
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrReconcileLeaseLost
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO sense.media_path_ownership(
|
||||
path_name, device_id, tenant_id, site_id, first_claimed_at, last_confirmed_at
|
||||
)
|
||||
SELECT path_name, id, tenant_id, site_id, $1, $1
|
||||
FROM sense.devices
|
||||
WHERE id = $2 AND desired_state = 'enabled' AND btrim(path_name) <> ''
|
||||
ON CONFLICT (path_name) DO UPDATE SET
|
||||
device_id = EXCLUDED.device_id,
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
site_id = EXCLUDED.site_id,
|
||||
last_confirmed_at = EXCLUDED.last_confirmed_at`, now, id); err != nil {
|
||||
return errors.New("record postgres MediaMTX path ownership")
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `UPDATE sense.devices
|
||||
SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
|
||||
updated_at = $1 WHERE id = $2`, now, id)
|
||||
if err != nil {
|
||||
return errors.New("mark postgres fenced device state")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres fenced reconciliation completion")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) FailReconcile(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
failureCount int,
|
||||
nextAttempt time.Time,
|
||||
errorCode, owner, token string,
|
||||
now time.Time,
|
||||
) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres fenced reconciliation failure")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = $1, next_attempt_at = $2, last_error_code = $3,
|
||||
lease_owner = NULL, lease_token = NULL, lease_until = NULL, updated_at = $4
|
||||
WHERE device_id = $5 AND lease_owner = $6 AND lease_token = $7
|
||||
AND lease_until > clock_timestamp()`, failureCount, nextAttempt, errorCode, now, id, owner, token)
|
||||
if err != nil {
|
||||
return errors.New("persist postgres fenced reconciliation failure")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.New("read postgres fenced reconciliation failure")
|
||||
}
|
||||
if affected != 1 {
|
||||
return ErrReconcileLeaseLost
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `UPDATE sense.devices
|
||||
SET actual_state = 'failed', updated_at = $1 WHERE id = $2`, now, id)
|
||||
if err != nil {
|
||||
return errors.New("mark postgres fenced failed device")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres fenced reconciliation failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ListEnabledVideoDevices(ctx context.Context, limit int) ([]device.Device, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, postgresDeviceSelect+`
|
||||
WHERE d.desired_state = 'enabled'
|
||||
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||
ORDER BY d.id LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, errors.New("list postgres enabled video devices")
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]device.Device, 0)
|
||||
for rows.Next() {
|
||||
value, scanErr := scanPostgresDevice(rows)
|
||||
if scanErr != nil {
|
||||
return nil, errors.New("scan postgres enabled video device")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.New("iterate postgres enabled video devices")
|
||||
}
|
||||
for index := range values {
|
||||
values[index].Capabilities, err = s.capabilities(ctx, values[index].ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (s *Postgres) MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres reconciled update")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = 0, next_attempt_at = NULL, last_error_code = NULL,
|
||||
observed_generation = $1, updated_at = $2 WHERE device_id = $3`, generation, now, id)
|
||||
if err != nil {
|
||||
return errors.New("mark postgres device reconciled")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
|
||||
SET actual_state = CASE WHEN desired_state = 'disabled' THEN 'offline' ELSE 'pending' END,
|
||||
updated_at = $1 WHERE id = $2`, now, id); err != nil {
|
||||
return errors.New("mark postgres reconciled device pending")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres reconciled update")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) MarkReconcileFailure(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
failureCount int,
|
||||
nextAttempt time.Time,
|
||||
errorCode string,
|
||||
now time.Time,
|
||||
) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return errors.New("begin postgres reconcile failure update")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `UPDATE sense.reconcile_state SET
|
||||
failure_count = $1, next_attempt_at = $2, last_error_code = $3, updated_at = $4
|
||||
WHERE device_id = $5`, failureCount, nextAttempt, errorCode, now, id)
|
||||
if err != nil {
|
||||
return errors.New("mark postgres reconcile failure")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE sense.devices
|
||||
SET actual_state = 'failed', updated_at = $1 WHERE id = $2`, now, id); err != nil {
|
||||
return errors.New("mark postgres failed device state")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return errors.New("commit postgres reconcile failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) UpdateActualState(ctx context.Context, id string, state device.ActualState, now time.Time) error {
|
||||
if state != device.ActualPending && state != device.ActualOnline &&
|
||||
state != device.ActualOffline && state != device.ActualFailed {
|
||||
return fmt.Errorf("invalid actual state %q", state)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE sense.devices
|
||||
SET actual_state = $1, updated_at = $2 WHERE id = $3`, state, now, id)
|
||||
if err != nil {
|
||||
return errors.New("update postgres actual state")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) RequestReconcile(ctx context.Context, id string, now time.Time) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE sense.reconcile_state
|
||||
SET observed_generation = 0, updated_at = $1 WHERE device_id = $2`, now, id)
|
||||
if err != nil {
|
||||
return errors.New("request postgres device reconciliation")
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Postgres) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
d.id, d.path_name, d.desired_state, d.actual_state, d.generation,
|
||||
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code
|
||||
FROM sense.devices d
|
||||
JOIN sense.reconcile_state r ON r.device_id = d.id
|
||||
WHERE d.desired_state = 'enabled'
|
||||
AND EXISTS (SELECT 1 FROM sense.device_capabilities c
|
||||
WHERE c.device_id = d.id AND c.capability = 'video_capture')
|
||||
ORDER BY d.id`)
|
||||
if err != nil {
|
||||
return ConvergenceSnapshot{}, errors.New("query postgres convergence snapshot")
|
||||
}
|
||||
defer rows.Close()
|
||||
snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)}
|
||||
for rows.Next() {
|
||||
var value DeviceConvergence
|
||||
var nextAttempt sql.NullTime
|
||||
var lastError sql.NullString
|
||||
if err := rows.Scan(
|
||||
&value.ID, &value.PathName, &value.DesiredState, &value.ActualState,
|
||||
&value.Generation, &value.ObservedGeneration, &value.FailureCount,
|
||||
&nextAttempt, &lastError,
|
||||
); err != nil {
|
||||
return ConvergenceSnapshot{}, errors.New("scan postgres convergence snapshot")
|
||||
}
|
||||
if nextAttempt.Valid {
|
||||
point := nextAttempt.Time
|
||||
value.NextAttemptAt = &point
|
||||
}
|
||||
if lastError.Valid {
|
||||
value.LastErrorCode = lastError.String
|
||||
}
|
||||
value.Converged = value.ObservedGeneration == value.Generation &&
|
||||
value.FailureCount == 0 && value.ActualState == device.ActualOnline
|
||||
if !value.Converged {
|
||||
snapshot.Unconverged++
|
||||
}
|
||||
snapshot.Devices = append(snapshot.Devices, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ConvergenceSnapshot{}, errors.New("iterate postgres convergence snapshot")
|
||||
}
|
||||
snapshot.Total = len(snapshot.Devices)
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
const postgresDeviceColumns = `d.id, d.tenant_id, d.site_id, d.area_id, d.serial_number, d.name, d.modality,
|
||||
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref, d.profile_token,
|
||||
d.path_name, d.generation, d.resource_version, d.quota_source_version, d.area_policy_source_version,
|
||||
d.created_at, d.updated_at`
|
||||
|
||||
const postgresDeviceSelect = `SELECT ` + postgresDeviceColumns + ` FROM sense.devices d`
|
||||
|
||||
func scanPostgresDevice(row scanner) (device.Device, error) {
|
||||
var value device.Device
|
||||
var areaID sql.NullString
|
||||
var quotaVersion, areaVersion sql.NullInt64
|
||||
err := row.Scan(
|
||||
&value.ID, &value.TenantID, &value.SiteID, &areaID, &value.SerialNumber,
|
||||
&value.Name, &value.Modality, &value.DesiredState, &value.ActualState,
|
||||
&value.EndpointRef, &value.CredentialRef, &value.ProfileToken, &value.PathName,
|
||||
&value.Generation, &value.ResourceVersion, "aVersion, &areaVersion,
|
||||
&value.CreatedAt, &value.UpdatedAt,
|
||||
)
|
||||
value.AreaID = areaID.String
|
||||
value.QuotaSourceVersion = quotaVersion.Int64
|
||||
value.AreaPolicySourceVersion = areaVersion.Int64
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *Postgres) capabilities(ctx context.Context, id string) ([]device.Capability, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT capability
|
||||
FROM sense.device_capabilities WHERE device_id = $1 ORDER BY capability`, id)
|
||||
if err != nil {
|
||||
return nil, errors.New("list postgres device capabilities")
|
||||
}
|
||||
defer rows.Close()
|
||||
values := make([]device.Capability, 0)
|
||||
for rows.Next() {
|
||||
var value device.Capability
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
return nil, errors.New("scan postgres device capability")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, errors.New("iterate postgres device capabilities")
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
|
||||
return values, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user