Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"8080"
],
"envFile": "${workspaceFolder}/machinery/.env.local",
"buildFlags": "--tags dynamic",
"buildFlags": "--tags dynamic,moq",
"env": {
"GOWORK": "off"
},
Expand Down
23 changes: 13 additions & 10 deletions README-RTSPS-TLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 \
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"upload_url":"https://vault.kerberos.io/api/storage/tus/19e42fbc666a38064904caf8c46d182a","vault_uri":"https://vault.kerberos.io/api/storage/tus/","size":1591581}
45 changes: 38 additions & 7 deletions machinery/src/capture/gortsplib.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import "C"
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"image"
Expand Down Expand Up @@ -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, error) {
if os.Getenv(rtspsInsecureEnv) == "true" {
return &tls.Config{InsecureSkipVerify: true}, nil // #nosec G402 -- explicit opt-in for cameras with self-signed certificates
}

func rtspsTLSConfig() *tls.Config {
if os.Getenv(rtspsInsecureEnv) != "true" {
return nil
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{InsecureSkipVerify: true} // #nosec G402 -- explicit opt-in for cameras with self-signed certificates
return &tls.Config{RootCAs: rootCAs}, nil
}

// Implements the RTSPClient interface.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
63 changes: 60 additions & 3 deletions machinery/src/capture/gortsplib_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
68 changes: 61 additions & 7 deletions machinery/src/cloud/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,54 @@ func rawJSONOrEmptyArray(b []byte) json.RawMessage {
return json.RawMessage(b)
}

const heartbeatResponseBodyLogLimit = 4 * 1024

func readHeartbeatResponseBody(response *http.Response) (string, bool, error) {
if response == nil || response.Body == nil {
return "", false, nil
}
defer response.Body.Close()

body, err := io.ReadAll(io.LimitReader(response.Body, heartbeatResponseBodyLogLimit+1))
if err != nil {
return "", false, err
}

truncated := len(body) > heartbeatResponseBodyLogLimit
if truncated {
body = body[:heartbeatResponseBodyLogLimit]
}

return strings.TrimSpace(string(body)), truncated, nil
}

func formatHeartbeatFailureLog(response *http.Response, requestErr error, responseBody string, responseBodyTruncated bool, responseBodyErr error, elapsed time.Duration) string {
details := make([]string, 0, 6)
if response != nil {
details = append(details, "status_code="+strconv.Itoa(response.StatusCode))
if response.Status != "" {
details = append(details, "status="+strconv.Quote(response.Status))
}
} else {
details = append(details, "status_code=none")
}
details = append(details, "duration="+elapsed.Round(time.Millisecond).String())
if requestErr != nil {
details = append(details, "request_error="+strconv.Quote(requestErr.Error()))
}
if responseBody != "" {
details = append(details, "response_body="+strconv.Quote(responseBody))
}
if responseBodyTruncated {
details = append(details, "response_body_truncated=true")
}
if responseBodyErr != nil {
details = append(details, "response_body_error="+strconv.Quote(responseBodyErr.Error()))
}

return "cloud.HandleHeartBeat(): heartbeat request to Kerberos Hub failed: " + strings.Join(details, ", ")
}

func HandleHeartBeat(configuration *models.Configuration, communication *models.Communication, uptimeStart time.Time) {
log.Log.Debug("cloud.HandleHeartBeat(): started")

Expand Down Expand Up @@ -644,20 +692,26 @@ loop:

var jsonStr = []byte(object)
buffy := bytes.NewBuffer(jsonStr)
req, _ := http.NewRequest("POST", hubURI, buffy)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if resp != nil {
resp.Body.Close()
requestStarted := time.Now()
req, requestErr := http.NewRequest("POST", hubURI, buffy)
var resp *http.Response
if requestErr == nil {
req.Header.Set("Content-Type", "application/json")
resp, requestErr = client.Do(req)
}
if err == nil && resp.StatusCode == 200 {
if requestErr == nil && resp != nil && resp.StatusCode == http.StatusOK {
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
communication.CloudTimestamp.Store(time.Now().Unix())
log.Log.Info("cloud.HandleHeartBeat(): (200) Heartbeat received by Kerberos Hub.")
} else {
responseBody, responseBodyTruncated, responseBodyErr := readHeartbeatResponseBody(resp)
if communication.CloudTimestamp != nil && communication.CloudTimestamp.Load() != nil {
communication.CloudTimestamp.Store(int64(0))
}
log.Log.Error("cloud.HandleHeartBeat(): (400) Something went wrong while sending to Kerberos Hub.")
log.Log.Error(formatHeartbeatFailureLog(resp, requestErr, responseBody, responseBodyTruncated, responseBodyErr, time.Since(requestStarted)))
}
} else {
log.Log.Error("cloud.HandleHeartBeat(): Disabled as we do not have a public key defined.")
Expand Down
51 changes: 51 additions & 0 deletions machinery/src/cloud/cloud_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cloud

import (
"io"
"net/http"
"strings"
"testing"
"time"
)

func TestHeartbeatFailureLogIncludesHubResponse(t *testing.T) {
response := &http.Response{
StatusCode: http.StatusBadRequest,
Status: "400 Bad Request",
Body: io.NopCloser(strings.NewReader(`{"error":"invalid heartbeat"}`)),
}

responseBody, truncated, err := readHeartbeatResponseBody(response)
if err != nil {
t.Fatalf("readHeartbeatResponseBody() error = %v", err)
}
message := formatHeartbeatFailureLog(response, nil, responseBody, truncated, nil, 125*time.Millisecond)

for _, expected := range []string{
"status_code=400",
`status="400 Bad Request"`,
"duration=125ms",
`response_body="{\"error\":\"invalid heartbeat\"}"`,
} {
if !strings.Contains(message, expected) {
t.Errorf("formatHeartbeatFailureLog() = %q, want it to contain %q", message, expected)
}
}
}

func TestReadHeartbeatResponseBodyTruncatesLargeBody(t *testing.T) {
response := &http.Response{
Body: io.NopCloser(strings.NewReader(strings.Repeat("x", heartbeatResponseBodyLogLimit+1))),
}

body, truncated, err := readHeartbeatResponseBody(response)
if err != nil {
t.Fatalf("readHeartbeatResponseBody() error = %v", err)
}
if !truncated {
t.Fatal("readHeartbeatResponseBody() truncated = false, want true")
}
if len(body) != heartbeatResponseBodyLogLimit {
t.Fatalf("len(body) = %d, want %d", len(body), heartbeatResponseBodyLogLimit)
}
}
Loading
Loading