update to AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE

This commit is contained in:
Cédric Verstraeten
2026-08-14 15:38:34 +02:00
parent 29e7f26c0e
commit 51a11edb71
3 changed files with 111 additions and 20 deletions

View File

@@ -377,7 +377,7 @@ For a process running directly in the same environment:
AGENT_CAPTURE_IPCAMERA_RTSP="rtsps://<user>:<password>@10.0.30.11:9554/?inst=1"
AGENT_CAPTURE_IPCAMERA_SUB_RTSP="rtsps://<user>:<password>@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://<user>:<password>@10.0.30.11:9554/?inst=1' \
-e 'AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsps://<user>:<password>@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.

View File

@@ -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)

View File

@@ -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")
}
})
}