169 lines
4.7 KiB
Go
169 lines
4.7 KiB
Go
// Package config loads and validates the Sense process configuration.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultHTTPAddress = "127.0.0.1:8080"
|
|
defaultDatabaseDriver = "sqlite"
|
|
defaultDatabaseDSN = "file:data/sense.db"
|
|
defaultMediaMTXURL = "http://127.0.0.1:9997"
|
|
defaultReconcilePeriod = 5 * time.Second
|
|
defaultProbePeriod = 10 * time.Second
|
|
defaultONVIFMode = "disabled"
|
|
)
|
|
|
|
type Config struct {
|
|
HTTPAddress string
|
|
AllowNonLoopback bool
|
|
DatabaseDriver string
|
|
DatabaseDSN string
|
|
MediaMTXURL string
|
|
ReconcileInterval time.Duration
|
|
ProbeInterval time.Duration
|
|
ONVIFMode string
|
|
RTSPRewriteHost string
|
|
RTSPRewritePort int
|
|
RTSPStripQuery bool
|
|
}
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
cfg := Config{
|
|
HTTPAddress: stringEnv("SENSE_HTTP_ADDR", defaultHTTPAddress),
|
|
AllowNonLoopback: allow,
|
|
DatabaseDriver: stringEnv("SENSE_DB_DRIVER", defaultDatabaseDriver),
|
|
DatabaseDSN: stringEnv("SENSE_DB_DSN", defaultDatabaseDSN),
|
|
MediaMTXURL: stringEnv("SENSE_MEDIAMTX_URL", defaultMediaMTXURL),
|
|
ReconcileInterval: reconcilePeriod,
|
|
ProbeInterval: probePeriod,
|
|
ONVIFMode: stringEnv("SENSE_ONVIF_MODE", defaultONVIFMode),
|
|
RTSPRewriteHost: stringEnv("SENSE_ONVIF_RTSP_REWRITE_HOST", ""),
|
|
RTSPRewritePort: rewritePort,
|
|
RTSPStripQuery: stripQuery,
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c Config) Validate() error {
|
|
host, _, err := net.SplitHostPort(c.HTTPAddress)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid SENSE_HTTP_ADDR: %w", err)
|
|
}
|
|
ip := net.ParseIP(host)
|
|
isLoopback := host == "localhost" || (ip != nil && ip.IsLoopback())
|
|
if !isLoopback && !c.AllowNonLoopback {
|
|
return fmt.Errorf("non-loopback HTTP bind requires SENSE_ALLOW_NON_LOOPBACK=true")
|
|
}
|
|
if c.DatabaseDSN == "" {
|
|
return fmt.Errorf("SENSE_DB_DSN must not be empty")
|
|
}
|
|
databaseDriver := strings.ToLower(strings.TrimSpace(c.DatabaseDriver))
|
|
if databaseDriver == "" {
|
|
databaseDriver = defaultDatabaseDriver
|
|
}
|
|
if databaseDriver != "sqlite" && databaseDriver != "postgres" {
|
|
return fmt.Errorf("SENSE_DB_DRIVER must be sqlite or postgres")
|
|
}
|
|
if databaseDriver == "postgres" && strings.HasPrefix(c.DatabaseDSN, "file:") {
|
|
return fmt.Errorf("postgres SENSE_DB_DRIVER requires an explicit PostgreSQL SENSE_DB_DSN")
|
|
}
|
|
mediaURL, err := url.Parse(c.MediaMTXURL)
|
|
if err != nil || mediaURL.Scheme == "" || mediaURL.Host == "" {
|
|
return fmt.Errorf("invalid SENSE_MEDIAMTX_URL")
|
|
}
|
|
if mediaURL.User != nil {
|
|
return fmt.Errorf("SENSE_MEDIAMTX_URL must not contain credentials")
|
|
}
|
|
if c.ReconcileInterval <= 0 || c.ProbeInterval <= 0 {
|
|
return fmt.Errorf("loop intervals must be positive")
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stringEnv(name, fallback string) string {
|
|
if value, ok := os.LookupEnv(name); ok {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func boolEnv(name string, fallback bool) (bool, error) {
|
|
value, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid %s: %w", name, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
|
value, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid %s: %w", name, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func intEnv(name string, fallback int) (int, error) {
|
|
value, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid %s: %w", name, err)
|
|
}
|
|
return parsed, nil
|
|
}
|