2026-08-04 15:45:50 +08:00
|
|
|
// Package config loads and validates the Sense process configuration.
|
|
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"net"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
2026-08-07 20:54:41 +08:00
|
|
|
"path/filepath"
|
2026-08-07 23:00:03 +08:00
|
|
|
"regexp"
|
2026-08-04 15:45:50 +08:00
|
|
|
"strconv"
|
2026-08-07 16:33:35 +08:00
|
|
|
"strings"
|
2026-08-04 15:45:50 +08:00
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-08-07 23:00:03 +08:00
|
|
|
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"
|
2026-08-11 00:24:32 +08:00
|
|
|
defaultAuditRelayPeriod = time.Second
|
2026-08-04 15:45:50 +08:00
|
|
|
)
|
|
|
|
|
|
2026-08-07 23:00:03 +08:00
|
|
|
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
|
|
|
|
|
2026-08-04 15:45:50 +08:00
|
|
|
type Config struct {
|
2026-08-07 23:00:03 +08:00
|
|
|
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
|
2026-08-11 00:24:32 +08:00
|
|
|
AuditRelayEnabled bool
|
|
|
|
|
AuditRelayURL string
|
|
|
|
|
AuditRelayKeyFile string
|
|
|
|
|
AuditRelayKeyID string
|
|
|
|
|
AuditRelayInterval time.Duration
|
2026-08-04 15:45:50 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func Load() (Config, error) {
|
|
|
|
|
allow, err := boolEnv("SENSE_ALLOW_NON_LOOPBACK", false)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Config{}, err
|
|
|
|
|
}
|
|
|
|
|
reconcilePeriod, err := durationEnv("SENSE_RECONCILE_INTERVAL", defaultReconcilePeriod)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Config{}, err
|
|
|
|
|
}
|
2026-08-07 23:00:03 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-04 15:45:50 +08:00
|
|
|
probePeriod, err := durationEnv("SENSE_PROBE_INTERVAL", defaultProbePeriod)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Config{}, err
|
|
|
|
|
}
|
2026-08-07 16:33:35 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-07 20:54:41 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-11 00:24:32 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-07 23:00:03 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-04 15:45:50 +08:00
|
|
|
|
|
|
|
|
cfg := Config{
|
2026-08-07 23:00:03 +08:00
|
|
|
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,
|
2026-08-11 00:24:32 +08:00
|
|
|
AuditRelayEnabled: auditRelayEnabled,
|
|
|
|
|
AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""),
|
|
|
|
|
AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""),
|
|
|
|
|
AuditRelayKeyID: stringEnv("SENSE_AUDIT_RELAY_KEY_ID", ""),
|
|
|
|
|
AuditRelayInterval: auditRelayInterval,
|
2026-08-04 15:45:50 +08:00
|
|
|
}
|
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
|
|
|
return Config{}, err
|
|
|
|
|
}
|
|
|
|
|
return cfg, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c Config) Validate() error {
|
|
|
|
|
host, _, err := net.SplitHostPort(c.HTTPAddress)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("invalid SENSE_HTTP_ADDR: %w", err)
|
|
|
|
|
}
|
|
|
|
|
ip := net.ParseIP(host)
|
|
|
|
|
isLoopback := host == "localhost" || (ip != nil && ip.IsLoopback())
|
|
|
|
|
if !isLoopback && !c.AllowNonLoopback {
|
|
|
|
|
return fmt.Errorf("non-loopback HTTP bind requires SENSE_ALLOW_NON_LOOPBACK=true")
|
|
|
|
|
}
|
|
|
|
|
if c.DatabaseDSN == "" {
|
|
|
|
|
return fmt.Errorf("SENSE_DB_DSN must not be empty")
|
|
|
|
|
}
|
2026-08-07 17:52:50 +08:00
|
|
|
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")
|
|
|
|
|
}
|
2026-08-04 15:45:50 +08:00
|
|
|
mediaURL, err := url.Parse(c.MediaMTXURL)
|
|
|
|
|
if err != nil || mediaURL.Scheme == "" || mediaURL.Host == "" {
|
|
|
|
|
return fmt.Errorf("invalid SENSE_MEDIAMTX_URL")
|
|
|
|
|
}
|
|
|
|
|
if mediaURL.User != nil {
|
|
|
|
|
return fmt.Errorf("SENSE_MEDIAMTX_URL must not contain credentials")
|
|
|
|
|
}
|
|
|
|
|
if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 {
|
|
|
|
|
return fmt.Errorf("loop intervals must be positive")
|
|
|
|
|
}
|
2026-08-07 23:00:03 +08:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-07 16:33:35 +08:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-07 20:54:41 +08:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-11 00:24:32 +08:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-04 15:45:50 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func stringEnv(name, fallback string) string {
|
|
|
|
|
if value, ok := os.LookupEnv(name); ok {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
return fallback
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func boolEnv(name string, fallback bool) (bool, error) {
|
|
|
|
|
value, ok := os.LookupEnv(name)
|
|
|
|
|
if !ok {
|
|
|
|
|
return fallback, nil
|
|
|
|
|
}
|
|
|
|
|
parsed, err := strconv.ParseBool(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, fmt.Errorf("invalid %s: %w", name, err)
|
|
|
|
|
}
|
|
|
|
|
return parsed, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
|
|
|
|
value, ok := os.LookupEnv(name)
|
|
|
|
|
if !ok {
|
|
|
|
|
return fallback, nil
|
|
|
|
|
}
|
|
|
|
|
parsed, err := time.ParseDuration(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0, fmt.Errorf("invalid %s: %w", name, err)
|
|
|
|
|
}
|
|
|
|
|
return parsed, nil
|
|
|
|
|
}
|
2026-08-07 16:33:35 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|