From 51a11edb710e7af58400e3ff4cd709f97e1d8223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Verstraeten?= Date: Fri, 14 Aug 2026 15:38:34 +0200 Subject: [PATCH] update to AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE --- README-RTSPS-TLS.md | 23 +++++---- machinery/src/capture/gortsplib.go | 45 +++++++++++++++--- machinery/src/capture/gortsplib_test.go | 63 +++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/README-RTSPS-TLS.md b/README-RTSPS-TLS.md index aec391d..242eb74 100644 --- a/README-RTSPS-TLS.md +++ b/README-RTSPS-TLS.md @@ -377,7 +377,7 @@ For a process running directly in the same environment: AGENT_CAPTURE_IPCAMERA_RTSP="rtsps://:@10.0.30.11:9554/?inst=1" AGENT_CAPTURE_IPCAMERA_SUB_RTSP="rtsps://:@10.0.30.11:9554/?inst=2" AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false -SSL_CERT_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem +AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem ``` For a container, mount the public bundle read-only at the exact path visible @@ -387,20 +387,22 @@ includes Debian's `ca-certificates` package: ```bash docker run \ -v /secure/config/uug-camera-trust-bundle.pem:/home/agent/data/config/uug-camera-trust-bundle.pem:ro \ - -e SSL_CERT_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem \ + -e AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem \ -e AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \ -e 'AGENT_CAPTURE_IPCAMERA_RTSP=rtsps://:@10.0.30.11:9554/?inst=1' \ -e 'AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsps://:@10.0.30.11:9554/?inst=2' \ kerberos/agent:latest ``` -Restart the Agent after changing trust files. Go can cache the process system -certificate pool after its first use, so editing a file does not guarantee that -an already-running process reloads it. +`AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` starts with the operating system's roots +and appends the camera bundle only to the gortsplib TLS configuration. Other +clients, including MoQ, Hub, and Vault, retain the normal public CA chain. +Restart the Agent after changing trust files. -The default production mode retains the image's normal public roots in addition -to the private camera CA. For a deliberately private-CA-only deployment, mount -an empty directory and set `SSL_CERT_DIR` to its path: +Do not set `SSL_CERT_FILE` or `SSL_CERT_DIR` in production solely for camera +trust. They are process-wide and can prevent other clients from validating +public services. For a deliberate process-wide isolation test, mount an empty +directory and set `SSL_CERT_DIR` to its path: ```bash -v /secure/config/empty-ca-dir:/home/agent/data/config/empty-ca-dir:ro \ @@ -523,9 +525,10 @@ go run -tags moq . -action run -port 8080 That fresh process must fail with `x509: certificate signed by unknown authority`. -Use exactly one trust-distribution approach when possible: +Use exactly one camera trust-distribution approach when possible: -1. Mount a private trust bundle and set `SSL_CERT_FILE`; or +1. Mount a private trust bundle and set + `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE`; or 2. Install the CA certificates into the operating-system trust store. Using both is valid, but makes isolation tests less obvious. diff --git a/machinery/src/capture/gortsplib.go b/machinery/src/capture/gortsplib.go index 3a6b43e..163d6d1 100644 --- a/machinery/src/capture/gortsplib.go +++ b/machinery/src/capture/gortsplib.go @@ -9,6 +9,7 @@ import "C" import ( "context" "crypto/tls" + "crypto/x509" "errors" "fmt" "image" @@ -40,14 +41,34 @@ import ( var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/capture") -const rtspsInsecureEnv = "AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE" +const ( + rtspsCAFileEnv = "AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE" + rtspsInsecureEnv = "AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE" +) -func rtspsTLSConfig() *tls.Config { - if os.Getenv(rtspsInsecureEnv) != "true" { - return nil +func rtspsTLSConfig() (*tls.Config, error) { + if os.Getenv(rtspsInsecureEnv) == "true" { + return &tls.Config{InsecureSkipVerify: true}, nil // #nosec G402 -- explicit opt-in for cameras with self-signed certificates } - return &tls.Config{InsecureSkipVerify: true} // #nosec G402 -- explicit opt-in for cameras with self-signed certificates + caFile := os.Getenv(rtspsCAFileEnv) + if caFile == "" { + return nil, nil + } + + rootCAs, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("load system CA pool: %w", err) + } + caPEM, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read RTSPS CA file %q: %w", caFile, err) + } + if !rootCAs.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("RTSPS CA file %q contains no certificates", caFile) + } + + return &tls.Config{RootCAs: rootCAs}, nil } // Implements the RTSPClient interface. @@ -329,12 +350,17 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e _, span := tracer.Start(ctxOtel, "Connect") defer span.End() + tlsConfig, err := rtspsTLSConfig() + if err != nil { + return fmt.Errorf("configure RTSPS TLS: %w", err) + } + protocol := gortsplib.ProtocolTCP g.health = newStreamHealth() g.Client = gortsplib.Client{ RequestBackChannels: false, Protocol: &protocol, - TLSConfig: rtspsTLSConfig(), + TLSConfig: tlsConfig, // Route gortsplib's packet-loss / decode-error reporting through our // structured logger with stream context (replaces its plain stdout // logging). These hooks are what let us tell whether the camera is @@ -607,12 +633,17 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context. _, span := tracer.Start(ctxRunAgent, "ConnectBackChannel") defer span.End() + tlsConfig, err := rtspsTLSConfig() + if err != nil { + return fmt.Errorf("configure RTSPS TLS: %w", err) + } + // Transport TCP protocol := gortsplib.ProtocolTCP g.Client = gortsplib.Client{ RequestBackChannels: true, Protocol: &protocol, - TLSConfig: rtspsTLSConfig(), + TLSConfig: tlsConfig, } // parse URL u, err := base.ParseURL(g.Url) diff --git a/machinery/src/capture/gortsplib_test.go b/machinery/src/capture/gortsplib_test.go index adfbfb2..9ff5d6e 100644 --- a/machinery/src/capture/gortsplib_test.go +++ b/machinery/src/capture/gortsplib_test.go @@ -1,22 +1,79 @@ package capture -import "testing" +import ( + "bytes" + "encoding/pem" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) func TestRTSPSTLSConfig(t *testing.T) { t.Run("verifies certificates by default", func(t *testing.T) { + t.Setenv(rtspsCAFileEnv, "") t.Setenv(rtspsInsecureEnv, "") - if got := rtspsTLSConfig(); got != nil { + got, err := rtspsTLSConfig() + if err != nil { + t.Fatalf("rtspsTLSConfig() error = %v", err) + } + if got != nil { t.Fatalf("rtspsTLSConfig() = %#v, want nil", got) } }) t.Run("allows explicit insecure mode", func(t *testing.T) { + t.Setenv(rtspsCAFileEnv, "/missing/ignored-in-insecure-mode.pem") t.Setenv(rtspsInsecureEnv, "true") - got := rtspsTLSConfig() + got, err := rtspsTLSConfig() + if err != nil { + t.Fatalf("rtspsTLSConfig() error = %v", err) + } if got == nil || !got.InsecureSkipVerify { t.Fatalf("rtspsTLSConfig() = %#v, want InsecureSkipVerify enabled", got) } }) + + t.Run("adds a camera CA to system roots", func(t *testing.T) { + t.Setenv(rtspsInsecureEnv, "") + server := httptest.NewTLSServer(nil) + defer server.Close() + + certificate := server.Certificate() + caFile := filepath.Join(t.TempDir(), "camera-ca.pem") + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw}) + if err := os.WriteFile(caFile, caPEM, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(rtspsCAFileEnv, caFile) + + got, err := rtspsTLSConfig() + if err != nil { + t.Fatalf("rtspsTLSConfig() error = %v", err) + } + if got == nil || got.RootCAs == nil { + t.Fatalf("rtspsTLSConfig() = %#v, want custom RootCAs", got) + } + for _, subject := range got.RootCAs.Subjects() { + if bytes.Equal(subject, certificate.RawSubject) { + return + } + } + t.Fatal("camera CA was not added to RootCAs") + }) + + t.Run("rejects an invalid camera CA file", func(t *testing.T) { + t.Setenv(rtspsInsecureEnv, "") + caFile := filepath.Join(t.TempDir(), "camera-ca.pem") + if err := os.WriteFile(caFile, []byte("not a certificate"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(rtspsCAFileEnv, caFile) + + if _, err := rtspsTLSConfig(); err == nil { + t.Fatal("rtspsTLSConfig() error = nil, want invalid CA error") + } + }) }