mirror of
https://github.com/kerberos-io/agent.git
synced 2026-08-23 15:08:32 +00:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92d3311192 | ||
|
|
97a8c1fcaf | ||
|
|
de5b0666bd | ||
|
|
ffdb8b6f22 | ||
|
|
250e3b0b20 | ||
|
|
2bb8144e79 | ||
|
|
72d4fca63c | ||
|
|
81cd95379b | ||
|
|
cd50f58138 | ||
|
|
b2f029117e | ||
|
|
db135acea9 | ||
|
|
7b589b53f9 | ||
|
|
c1740c752e | ||
|
|
5862786381 | ||
|
|
e8dd64f54b | ||
|
|
ba96b63002 | ||
|
|
c7c6bcbdf2 | ||
|
|
faa3b4eabb | ||
|
|
f0a6eb7d98 | ||
|
|
33a58cddf7 | ||
|
|
fea6d81246 | ||
|
|
dbff9fbc8e | ||
|
|
63b352b5e2 | ||
|
|
2ffb210ccb | ||
|
|
ff643d21ef | ||
|
|
8f04a6d42f | ||
|
|
4395fe2417 | ||
|
|
18392e136e | ||
|
|
ed916eb042 | ||
|
|
72b8160dc4 | ||
|
|
4f41786038 | ||
|
|
8fb186fd6d | ||
|
|
420b8b8a01 | ||
|
|
5a13416bed | ||
|
|
704011c20b | ||
|
|
6683c9b994 | ||
|
|
2092f3e49d | ||
|
|
d815e39e1d | ||
|
|
e2e1f8cfa8 | ||
|
|
ba5992378e | ||
|
|
2163a8e146 | ||
|
|
1fec49500e | ||
|
|
8175908073 | ||
|
|
1bcce4694d | ||
|
|
b26f0190c6 | ||
|
|
91194f5c1a | ||
|
|
57cfc90c4b | ||
|
|
357cc719a5 | ||
|
|
4f2a96b5e1 | ||
|
|
ed85261c8e | ||
|
|
5a58808f20 | ||
|
|
94b26cf096 | ||
|
|
57ef7ebaaf | ||
|
|
ddf58fe633 | ||
|
|
6f2d35cdf1 | ||
|
|
c836cef28d | ||
|
|
c97bb70cb5 | ||
|
|
96b145b046 | ||
|
|
09a697e00b | ||
|
|
1d0714f199 | ||
|
|
42e91867ec | ||
|
|
155c4a7e44 | ||
|
|
4fe4977559 | ||
|
|
67e66e863a | ||
|
|
bd34e9d836 | ||
|
|
1a0e6bf153 | ||
|
|
52aef0870e | ||
|
|
012ed3b658 | ||
|
|
7ced8a3044 | ||
|
|
f043be5371 | ||
|
|
b85d9858d1 | ||
|
|
434730b970 | ||
|
|
94df7298e3 | ||
|
|
0f76baec1f | ||
|
|
6ae61ea046 | ||
|
|
93e17ac73e | ||
|
|
0037f5a0ab | ||
|
|
79f225ad3c |
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/devcontainers/go:1.24-bookworm
|
||||
FROM mcr.microsoft.com/devcontainers/go:1.25-trixie
|
||||
|
||||
# Install node environment
|
||||
RUN apt-get update && \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "go:1.24-bookworm",
|
||||
"name": "go:1.24-trixie",
|
||||
"runArgs": [
|
||||
"--name=agent",
|
||||
"--network=host"
|
||||
@@ -20,5 +20,5 @@
|
||||
3000,
|
||||
8080
|
||||
],
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download"
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
|
||||
}
|
||||
4
.github/workflows/pr-build.yml
vendored
4
.github/workflows/pr-build.yml
vendored
@@ -16,10 +16,8 @@ jobs:
|
||||
include:
|
||||
- architecture: amd64
|
||||
runner: ubuntu-24.04
|
||||
dockerfile: Dockerfile
|
||||
- architecture: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
dockerfile: Dockerfile.arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -34,7 +32,7 @@ jobs:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build -t ${{ matrix.architecture }} -f ${{ matrix.dockerfile }} .
|
||||
docker build -t ${{ matrix.architecture }} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
|
||||
2
.github/workflows/release-bump.yml
vendored
2
.github/workflows/release-bump.yml
vendored
@@ -108,7 +108,7 @@ jobs:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} -f Dockerfile.arm64 .
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
|
||||
2
.github/workflows/release-create.yml
vendored
2
.github/workflows/release-create.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} -f Dockerfile.arm64 .
|
||||
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
|
||||
14
.vscode/tasks.json
vendored
Normal file
14
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "agent: moq verify",
|
||||
"type": "shell",
|
||||
"command": "bash ./verify-moq-devcontainer.sh",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/machinery"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
27
Dockerfile
27
Dockerfile
@@ -1,13 +1,15 @@
|
||||
|
||||
ARG BASE_IMAGE_VERSION=amd64-ddbe40e
|
||||
ARG GO_IMAGE=golang:1.25-trixie
|
||||
ARG RUNTIME_IMAGE=debian:trixie-slim
|
||||
ARG VERSION=0.0.0
|
||||
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
|
||||
FROM ${GO_IMAGE} AS build-machinery
|
||||
LABEL AUTHOR=uug.ai
|
||||
|
||||
# Re-declare VERSION inside this stage so the value passed via
|
||||
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
|
||||
# ARGs declared before the first FROM are not visible inside build stages.
|
||||
ARG VERSION
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV GOROOT=/usr/local/go
|
||||
ENV GOPATH=/go
|
||||
@@ -17,9 +19,10 @@ ENV GOSUMDB=off
|
||||
##########################################
|
||||
# Installing some additional dependencies.
|
||||
|
||||
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
git build-essential cmake pkg-config unzip libgtk2.0-dev \
|
||||
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
|
||||
curl ca-certificates libavcodec-dev libavutil-dev libcurl4-openssl-dev \
|
||||
libssl-dev libjpeg62-turbo-dev libswscale-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
##############################################################################
|
||||
@@ -43,7 +46,9 @@ RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
|
||||
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
|
||||
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
|
||||
fi && \
|
||||
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
|
||||
BUILD_TAGS=timetzdata,netgo,osusergo && \
|
||||
case "${TARGETARCH:-$(go env GOARCH)}" in amd64|arm64) BUILD_TAGS="moq,${BUILD_TAGS}" ;; esac && \
|
||||
go build -tags "${BUILD_TAGS}" --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION}" main.go && \
|
||||
mkdir -p /agent && \
|
||||
mv main /agent && \
|
||||
mv version /agent && \
|
||||
@@ -89,12 +94,16 @@ RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www
|
||||
############################################
|
||||
# Publish main binary to GitHub release
|
||||
|
||||
FROM alpine:latest
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
############################
|
||||
# Protect by non-root user.
|
||||
|
||||
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl ffmpeg libatomic1 libcap2-bin libstdc++6 && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd --system kerberosio && \
|
||||
useradd --system --gid kerberosio --groups video --create-home agent
|
||||
|
||||
#################################
|
||||
# Copy files from previous images
|
||||
@@ -102,8 +111,6 @@ RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent v
|
||||
COPY --chown=0:0 --from=build-machinery /dist /
|
||||
COPY --chown=0:0 --from=build-ui /dist /
|
||||
|
||||
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
|
||||
|
||||
##################
|
||||
# Try running agent
|
||||
|
||||
@@ -123,7 +130,7 @@ RUN chown -R agent:kerberosio /home/agent/www
|
||||
|
||||
###########################
|
||||
# Grant the necessary root capabilities to the process trying to bind to the privileged port
|
||||
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main
|
||||
RUN setcap 'cap_net_bind_service=+ep' /home/agent/main
|
||||
|
||||
###################
|
||||
# Run non-root user
|
||||
|
||||
147
Dockerfile.arm64
147
Dockerfile.arm64
@@ -1,147 +0,0 @@
|
||||
|
||||
ARG BASE_IMAGE_VERSION=arm64-ddbe40e
|
||||
ARG VERSION=0.0.0
|
||||
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
|
||||
LABEL AUTHOR=uug.ai
|
||||
|
||||
# Re-declare VERSION inside this stage so the value passed via
|
||||
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
|
||||
# ARGs declared before the first FROM are not visible inside build stages.
|
||||
ARG VERSION
|
||||
|
||||
ENV GOROOT=/usr/local/go
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
|
||||
ENV GOSUMDB=off
|
||||
|
||||
##########################################
|
||||
# Installing some additional dependencies.
|
||||
|
||||
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
git build-essential cmake pkg-config unzip libgtk2.0-dev \
|
||||
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
##############################################################################
|
||||
# Copy all the relevant source code in the Docker image, so we can build this.
|
||||
|
||||
RUN mkdir -p /go/src/github.com/kerberos-io/agent
|
||||
COPY machinery /go/src/github.com/kerberos-io/agent/machinery
|
||||
RUN rm -rf /go/src/github.com/kerberos-io/agent/machinery/.env
|
||||
|
||||
##################################################################
|
||||
# Get the latest commit hash, so we know which version we're running
|
||||
COPY .git /go/src/github.com/kerberos-io/agent/.git
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/.git && git log --format="%H" -n 1 | head -c7 > /go/src/github.com/kerberos-io/agent/machinery/version
|
||||
RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
|
||||
|
||||
##################
|
||||
# Build Machinery
|
||||
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
|
||||
go mod download && \
|
||||
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
|
||||
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
|
||||
fi && \
|
||||
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
|
||||
mkdir -p /agent && \
|
||||
mv main /agent && \
|
||||
mv version /agent && \
|
||||
mv data /agent && \
|
||||
mkdir -p /agent/data/cloud && \
|
||||
mkdir -p /agent/data/snapshots && \
|
||||
mkdir -p /agent/data/log && \
|
||||
mkdir -p /agent/data/recordings && \
|
||||
mkdir -p /agent/data/capture-test && \
|
||||
mkdir -p /agent/data/config
|
||||
|
||||
####################################
|
||||
# Let's create a /dist folder containing just the files necessary for runtime.
|
||||
# Later, it will be copied as the / (root) of the output image.
|
||||
|
||||
WORKDIR /dist
|
||||
RUN cp -r /agent ./
|
||||
|
||||
####################################################################################
|
||||
# This will collect dependent libraries so they're later copied to the final image.
|
||||
|
||||
RUN /dist/agent/main version
|
||||
|
||||
FROM node:22-alpine AS build-ui
|
||||
|
||||
RUN apk update && apk upgrade --available && sync
|
||||
|
||||
########################
|
||||
# Build Web (React app)
|
||||
|
||||
RUN mkdir -p /go/src/github.com/kerberos-io/agent/machinery/www
|
||||
COPY ui /go/src/github.com/kerberos-io/agent/ui
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/ui && rm -rf yarn.lock && yarn config set network-timeout 300000 && \
|
||||
yarn && yarn build
|
||||
|
||||
####################################
|
||||
# Let's create a /dist folder containing just the files necessary for runtime.
|
||||
# Later, it will be copied as the / (root) of the output image.
|
||||
|
||||
WORKDIR /dist
|
||||
RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www ./agent/
|
||||
|
||||
############################################
|
||||
# Publish main binary to GitHub release
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
############################
|
||||
# Protect by non-root user.
|
||||
|
||||
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
|
||||
|
||||
#################################
|
||||
# Copy files from previous images
|
||||
|
||||
COPY --chown=0:0 --from=build-machinery /dist /
|
||||
COPY --chown=0:0 --from=build-ui /dist /
|
||||
|
||||
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
|
||||
|
||||
##################
|
||||
# Try running agent
|
||||
|
||||
RUN mv /agent/* /home/agent/
|
||||
RUN /home/agent/main version
|
||||
|
||||
#######################
|
||||
# Make template config
|
||||
|
||||
RUN cp /home/agent/data/config/config.json /home/agent/data/config.template.json
|
||||
|
||||
###########################
|
||||
# Set permissions correctly
|
||||
|
||||
RUN chown -R agent:kerberosio /home/agent/data
|
||||
RUN chown -R agent:kerberosio /home/agent/www
|
||||
|
||||
###########################
|
||||
# Grant the necessary root capabilities to the process trying to bind to the privileged port
|
||||
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main
|
||||
|
||||
###################
|
||||
# Run non-root user
|
||||
|
||||
USER agent
|
||||
|
||||
######################################
|
||||
# By default the app runs on port 80
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
######################################
|
||||
# Check if agent is still running
|
||||
|
||||
HEALTHCHECK CMD curl --fail http://localhost:80 || exit 1
|
||||
|
||||
###################################################
|
||||
# Leeeeettttt'ssss goooooo!!!
|
||||
# Run the shizzle from the right working directory.
|
||||
WORKDIR /home/agent
|
||||
CMD ["./main", "-action", "run", "-port", "80"]
|
||||
639
README-RTSPS-TLS.md
Normal file
639
README-RTSPS-TLS.md
Normal file
@@ -0,0 +1,639 @@
|
||||
# RTSPS and TLS certificates
|
||||
|
||||
This guide explains how Kerberos Agent connects to an IP camera over RTSPS,
|
||||
how to issue a camera certificate with a private CA, and how to validate the
|
||||
complete trust path. It also explains why some apparently corrupted trust
|
||||
bundles can still allow a connection.
|
||||
|
||||
The camera-specific steps were verified with a Bosch FLEXIDOME micro 3100i.
|
||||
Other Bosch firmware versions may use different labels or ports.
|
||||
|
||||
The commands were tested with Smallstep CLI `0.30.6` and OpenSSL `3.5.6` on
|
||||
Debian. Check `step certificate sign --help` when using an older Smallstep CLI.
|
||||
The OpenSSL isolation flags `-no-CApath` and `-no-CAstore` require a version that
|
||||
lists them in `openssl s_client -help`.
|
||||
|
||||
## Tested configuration
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Camera | Bosch FLEXIDOME micro 3100i |
|
||||
| Example camera address | `10.0.30.11` |
|
||||
| RTSPS port | `9554` |
|
||||
| Main stream | `rtsps://<user>:<password>@10.0.30.11:9554/?inst=1` |
|
||||
| Sub stream | `rtsps://<user>:<password>@10.0.30.11:9554/?inst=2` |
|
||||
| Certificate SAN | `IP Address:10.0.30.11` |
|
||||
| Bosch certificate usage | `HTTPS` |
|
||||
| Agent trust input | Issuing intermediate plus root CA |
|
||||
|
||||
Replace the example address and certificate names throughout this guide. Keep
|
||||
camera credentials out of source control and percent-encode reserved URL
|
||||
characters in usernames and passwords.
|
||||
|
||||
## Mental model
|
||||
|
||||
### RTSPS, SRTSP, TLS, and SRTP
|
||||
|
||||
- The standard URL scheme is `rtsps://`. Do not use `srtsp://`.
|
||||
- Bosch interfaces and documentation may use SRTSP or Secure RTSP as product
|
||||
terminology.
|
||||
- RTSPS carries the RTSP control connection over TLS. With gortsplib, media is
|
||||
normally interleaved over the same TCP/TLS connection for this camera.
|
||||
- SRTP is a separate media protection mechanism and is negotiated only when the
|
||||
camera advertises an appropriate secure RTP profile.
|
||||
|
||||
Encryption alone does not prove which camera the Agent reached. Verified TLS
|
||||
also checks that:
|
||||
|
||||
1. The camera certificate is signed by a trusted authority.
|
||||
2. The certificate is valid at the current time.
|
||||
3. The URL host matches a certificate Subject Alternative Name (SAN).
|
||||
|
||||
Modern Go verification uses SANs for identity. A Common Name alone is not
|
||||
sufficient. Connecting to `10.0.30.11` requires an IP SAN with that exact value,
|
||||
not `DNS:10.0.30.11` and not only a device-name DNS SAN.
|
||||
|
||||
### Agent behavior
|
||||
|
||||
Kerberos Agent uses gortsplib for RTSP and RTSPS. With the normal configuration,
|
||||
gortsplib receives a nil custom TLS configuration and Go performs standard
|
||||
certificate and hostname verification with the process trust pool.
|
||||
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` is an explicit escape hatch that
|
||||
sets `InsecureSkipVerify` for camera clients. It should be false in a verified
|
||||
deployment.
|
||||
|
||||
## Communication and certificate flow
|
||||
|
||||
The certificate is used during the TLS handshake, before the first RTSP command
|
||||
is exchanged. It is not attached to `DESCRIBE`, `SETUP`, or `PLAY`, and the CA
|
||||
trust bundle is never sent to the camera.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Agent as Kerberos Agent
|
||||
participant Trust as Go trust pool
|
||||
participant Camera as Camera RTSPS :9554
|
||||
|
||||
Agent->>Trust: Load trusted CAs from SSL_CERT_FILE and CA directories
|
||||
Agent->>Camera: Open TCP connection
|
||||
Agent->>Camera: Send TLS ClientHello
|
||||
Camera-->>Agent: Send TLS ServerHello and camera certificate
|
||||
Agent->>Trust: Verify chain, validity, serverAuth, and URL host against SAN
|
||||
Trust-->>Agent: Accept or reject the camera identity
|
||||
Agent->>Camera: Complete TLS handshake
|
||||
Note over Agent,Camera: All following traffic is encrypted by TLS
|
||||
Agent->>Camera: DESCRIBE with RTSP authentication
|
||||
Camera-->>Agent: Return SDP and available media tracks
|
||||
Agent->>Camera: SETUP selected video and audio tracks over TCP
|
||||
Agent->>Camera: PLAY
|
||||
Camera-->>Agent: Send interleaved RTP and RTCP media over TLS
|
||||
```
|
||||
|
||||
The files and keys have distinct roles:
|
||||
|
||||
| Material | Location | Purpose | Sent over the connection |
|
||||
| --- | --- | --- | --- |
|
||||
| Camera leaf certificate | Camera | Identifies the camera and binds its public key to its SAN | Yes, by the camera during the TLS handshake |
|
||||
| Camera private key | Camera | Proves that the camera owns the presented certificate | No |
|
||||
| Intermediate and root CA PEM bundle | Agent | Lets Go build and trust the camera certificate chain | No |
|
||||
| RTSP username and password | Agent configuration or URL | Authenticates the Agent to the RTSP service after TLS succeeds | An authentication response is sent inside TLS; its form depends on the RTSP authentication method |
|
||||
|
||||
For an `rtsps://` URL, the Agent parses the URL and gives gortsplib the host and
|
||||
TLS settings. gortsplib opens the TCP connection and starts TLS. Go compares the
|
||||
certificate presented by the camera with the local trust pool, checks its
|
||||
validity period and server usage, and matches the URL hostname or IP address to
|
||||
the certificate SAN. Only a successful handshake creates the encrypted channel
|
||||
needed for the RTSP exchange.
|
||||
|
||||
The Agent then sends `DESCRIBE`, selects the advertised video and audio tracks,
|
||||
sends `SETUP`, and starts delivery with `PLAY`. For the tested camera, gortsplib
|
||||
uses interleaved TCP, so the RTSP control messages and RTP/RTCP media remain
|
||||
inside the same encrypted TLS connection. Main stream, sub stream, and enabled
|
||||
audio backchannel clients each establish and verify their own connection.
|
||||
|
||||
If certificate verification fails, the TLS handshake does not complete and no
|
||||
usable RTSP session is established. Setting
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` keeps traffic encrypted but skips
|
||||
certificate-chain and hostname verification, so an attacker could impersonate
|
||||
the camera. It is not equivalent to trusting the camera certificate.
|
||||
|
||||
## Decide the certificate identity first
|
||||
|
||||
Choose the stable name used in every Agent URL before creating the certificate:
|
||||
|
||||
- For an IP URL, add that address as an IP SAN.
|
||||
- For a DNS URL, add the exact hostname as a DNS SAN.
|
||||
- Add both when clients legitimately use both forms.
|
||||
|
||||
A certificate stops matching if the camera IP changes. Use a static address,
|
||||
DHCP reservation, or stable DNS name.
|
||||
|
||||
## Configure RTSPS in the Bosch UI
|
||||
|
||||
1. Sign in to the camera as an administrator.
|
||||
2. Open **Configuration**.
|
||||
3. Open **Network > Network Services**.
|
||||
4. Enable **RTSPS**.
|
||||
5. Confirm port `9554`, or record the configured alternative.
|
||||
6. Click **Set**.
|
||||
|
||||
RTSP on port `554` and RTSPS on port `9554` are separate services. Enabling
|
||||
RTSPS does not make an `rtsp://` URL secure.
|
||||
|
||||
## Generate the private key and CSR on the camera
|
||||
|
||||
Keeping the TLS private key on the camera avoids exporting it to an operator
|
||||
workstation or deployment system.
|
||||
|
||||
1. Open **Service > Certificates**.
|
||||
2. Click **Add**.
|
||||
3. Select **Generate signing request**.
|
||||
4. Select `RSA 2048bit` or the stronger option supported by all clients.
|
||||
5. Enter a unique file name, such as `agent-rtsps`.
|
||||
6. Enter a descriptive Common Name and any required organization fields.
|
||||
7. Click **Generate**.
|
||||
8. Download the resulting CSR from the certificate table.
|
||||
|
||||
On the tested firmware, this form contains no SAN field. The downloaded CSR
|
||||
therefore has no IP SAN. The CA must add the SAN while signing.
|
||||
|
||||
Inspect the CSR before signing:
|
||||
|
||||
```bash
|
||||
openssl req -in camera.csr.pem -noout -verify -subject
|
||||
openssl req -in camera.csr.pem -noout -text
|
||||
```
|
||||
|
||||
The first command must report `Certificate request self-signature verify OK`.
|
||||
An absent `Subject Alternative Name` section is expected for this firmware.
|
||||
|
||||
## Prepare Smallstep
|
||||
|
||||
Use an existing organizational CA when one is available. Creating a new CA
|
||||
creates a new long-lived trust domain that must be distributed, protected,
|
||||
backed up, and eventually rotated.
|
||||
|
||||
### Install the CLI on Debian amd64
|
||||
|
||||
```bash
|
||||
curl -fsSL \
|
||||
https://dl.smallstep.com/cli/docs-ca-install/latest/step-cli_amd64.deb \
|
||||
-o /tmp/step-cli_amd64.deb
|
||||
sudo dpkg -i /tmp/step-cli_amd64.deb
|
||||
rm /tmp/step-cli_amd64.deb
|
||||
step version
|
||||
```
|
||||
|
||||
Use the official package matching the host architecture on other systems.
|
||||
|
||||
### Create a dedicated offline CA
|
||||
|
||||
Skip this section when using an existing CA.
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.step/secrets" "$HOME/.step/camera"
|
||||
|
||||
openssl rand -base64 48 > "$HOME/.step/secrets/camera_ca_password"
|
||||
chmod 600 "$HOME/.step/secrets/camera_ca_password"
|
||||
|
||||
step ca init \
|
||||
--pki \
|
||||
--name "UUG Camera CA" \
|
||||
--password-file "$HOME/.step/secrets/camera_ca_password"
|
||||
```
|
||||
|
||||
This produces:
|
||||
|
||||
```text
|
||||
$HOME/.step/certs/root_ca.crt
|
||||
$HOME/.step/certs/intermediate_ca.crt
|
||||
$HOME/.step/secrets/root_ca_key
|
||||
$HOME/.step/secrets/intermediate_ca_key
|
||||
$HOME/.step/secrets/camera_ca_password
|
||||
```
|
||||
|
||||
The files under `secrets/` are sensitive. Keep them mode `600`, never commit
|
||||
them, and back them up to encrypted persistent storage. A devcontainer can be
|
||||
rebuilt or deleted; it is not sufficient as the only CA backup.
|
||||
|
||||
## Add the SAN while signing
|
||||
|
||||
Copy the camera CSR into a protected working directory:
|
||||
|
||||
```bash
|
||||
cp camera.csr.pem "$HOME/.step/camera/camera.csr.pem"
|
||||
```
|
||||
|
||||
Create `$HOME/.step/camera/bosch-rtsps.tpl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": {
|
||||
"commonName": {{ toJson .Insecure.CR.Subject.CommonName }}
|
||||
},
|
||||
"ipAddresses": ["10.0.30.11"],
|
||||
"keyUsage": ["keyEncipherment", "digitalSignature"],
|
||||
"extKeyUsage": ["serverAuth", "clientAuth"]
|
||||
}
|
||||
```
|
||||
|
||||
The template preserves the camera CSR public key, sets the IP identity, and
|
||||
creates a TLS leaf rather than a CA certificate.
|
||||
|
||||
Sign it with a validity period that ends before the intermediate CA expires.
|
||||
A one-year leaf is preferable to a ten-year leaf when automated renewal is
|
||||
available:
|
||||
|
||||
```bash
|
||||
step certificate sign \
|
||||
--template "$HOME/.step/camera/bosch-rtsps.tpl" \
|
||||
--bundle \
|
||||
--not-after 8760h \
|
||||
--password-file "$HOME/.step/secrets/camera_ca_password" \
|
||||
"$HOME/.step/camera/camera.csr.pem" \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/secrets/intermediate_ca_key" \
|
||||
> "$HOME/.step/camera/bosch-rtsps-chain.pem"
|
||||
```
|
||||
|
||||
For an online `step-ca`, do not assume `step ca sign` accepts a `--san` flag. It
|
||||
does not. Authorize SANs in the one-time token or configure a provisioner
|
||||
template that produces the required SANs.
|
||||
|
||||
## Validate before upload
|
||||
|
||||
Inspect the leaf certificate, which is the first PEM block in the chain file:
|
||||
|
||||
```bash
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -subject -issuer -dates -ext subjectAltName -ext extendedKeyUsage
|
||||
```
|
||||
|
||||
Confirm the SAN separately because some OpenSSL versions display only the last
|
||||
requested extension:
|
||||
|
||||
```bash
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -ext subjectAltName
|
||||
```
|
||||
|
||||
Verify the path and IP identity:
|
||||
|
||||
```bash
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-untrusted "$HOME/.step/certs/intermediate_ca.crt" \
|
||||
-verify_ip 10.0.30.11 \
|
||||
"$HOME/.step/camera/bosch-rtsps-chain.pem"
|
||||
```
|
||||
|
||||
Confirm that the signed leaf uses the exact public key from the camera CSR:
|
||||
|
||||
```bash
|
||||
csr_key=$(
|
||||
openssl req -in "$HOME/.step/camera/camera.csr.pem" -pubkey -noout |
|
||||
openssl pkey -pubin -outform DER 2>/dev/null |
|
||||
sha256sum | cut -d' ' -f1
|
||||
)
|
||||
|
||||
cert_key=$(
|
||||
openssl x509 -in "$HOME/.step/camera/bosch-rtsps-chain.pem" -pubkey -noout |
|
||||
openssl pkey -pubin -outform DER 2>/dev/null |
|
||||
sha256sum | cut -d' ' -f1
|
||||
)
|
||||
|
||||
test "$csr_key" = "$cert_key"
|
||||
```
|
||||
|
||||
Do not upload a certificate when any of these checks fail.
|
||||
|
||||
## Upload and assign the certificate
|
||||
|
||||
1. Return to **Service > Certificates**.
|
||||
2. Click **Add > Upload certificate**.
|
||||
3. Select the leaf-plus-intermediate PEM chain.
|
||||
4. Click **Upload** and wait for `100%`.
|
||||
5. Confirm that the former CSR row is now a `Certificate`.
|
||||
6. Confirm that the key icon is present. It proves that the camera associated
|
||||
the certificate with its retained private key.
|
||||
7. Open the new certificate's **Usage** selector.
|
||||
8. Select only **HTTPS**.
|
||||
9. Leave **CBS client** assigned to the original Bosch `DeviceCertificate`.
|
||||
10. Click **Set** and wait for the table to reload.
|
||||
|
||||
On the tested firmware, there is no separate SRTSP usage. RTSPS presents the
|
||||
certificate assigned to HTTPS. Reassigning HTTPS therefore changes both the
|
||||
web interface and RTSPS certificate.
|
||||
|
||||
After saving, the expected split is:
|
||||
|
||||
| Certificate | Usage |
|
||||
| --- | --- |
|
||||
| Private-CA camera certificate | `HTTPS` |
|
||||
| Bosch `DeviceCertificate` | `CBS client` |
|
||||
|
||||
The browser may warn about the new HTTPS certificate until the private root CA
|
||||
is trusted by the workstation.
|
||||
|
||||
## Account for the Bosch chain behavior
|
||||
|
||||
The tested firmware served only the leaf certificate on ports `443` and `9554`,
|
||||
even when the uploaded file contained the leaf and intermediate. Uploading the
|
||||
intermediate separately as a trusted camera certificate did not change the
|
||||
served chain.
|
||||
|
||||
Confirm the behavior:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-showcerts </dev/null 2>/dev/null |
|
||||
grep -c '^-----BEGIN CERTIFICATE-----$'
|
||||
```
|
||||
|
||||
A result of `1` means the client must already have the issuing intermediate.
|
||||
Create a portable trust bundle containing the intermediate and root:
|
||||
|
||||
```bash
|
||||
step certificate bundle \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
"$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
|
||||
chmod 644 "$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
```
|
||||
|
||||
The trust bundle is public material. The CA private keys and password are not.
|
||||
|
||||
## Configure Kerberos Agent
|
||||
|
||||
For a process running directly in the same environment:
|
||||
|
||||
```dotenv
|
||||
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
|
||||
```
|
||||
|
||||
For a container, mount the public bundle read-only at the exact path visible
|
||||
inside the container. The Agent image creates `/home/agent/data/config` and
|
||||
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_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.
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
-v /secure/config/empty-ca-dir:/home/agent/data/config/empty-ca-dir:ro \
|
||||
-e SSL_CERT_DIR=/home/agent/data/config/empty-ca-dir
|
||||
```
|
||||
|
||||
Do not use `SSL_CERT_DIR=`. Go treats an empty value as unset and scans its
|
||||
default certificate directories.
|
||||
|
||||
Only use that mode when the Agent does not need public roots for other TLS
|
||||
connections.
|
||||
|
||||
## Validate the live endpoints
|
||||
|
||||
### Strict TLS and identity check
|
||||
|
||||
Use only the specified bundle, without OpenSSL's default CA locations:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-brief \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-verify_ip 10.0.30.11 \
|
||||
-verify_return_error \
|
||||
-CAfile "$HOME/.step/camera/uug-camera-trust-bundle.pem" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
</dev/null
|
||||
```
|
||||
|
||||
Repeat with port `443`. Both must report `Verification: OK`.
|
||||
|
||||
Confirm that identity checking is active by repeating the command with a wrong
|
||||
address, such as `-verify_ip 10.0.30.12`. It must fail with an IP address
|
||||
mismatch.
|
||||
|
||||
### Confirm the live leaf is the generated leaf
|
||||
|
||||
```bash
|
||||
live_fingerprint=$(
|
||||
openssl s_client -connect 10.0.30.11:9554 -showcerts </dev/null 2>/dev/null |
|
||||
openssl x509 -noout -fingerprint -sha256 |
|
||||
cut -d= -f2
|
||||
)
|
||||
|
||||
local_fingerprint=$(
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -fingerprint -sha256 |
|
||||
cut -d= -f2
|
||||
)
|
||||
|
||||
test "$live_fingerprint" = "$local_fingerprint"
|
||||
```
|
||||
|
||||
### Validate the media path
|
||||
|
||||
A successful TLS handshake does not prove that RTSP authentication, DESCRIBE,
|
||||
SETUP, PLAY, and RTP delivery work. Start a fresh Agent with verified TLS and
|
||||
confirm that it connects without an x509 error and receives frames. During the
|
||||
verified setup described here, a gortsplib probe completed all RTSP operations
|
||||
and received an RTP packet over TCP.
|
||||
|
||||
## Why a tampered bundle may still connect
|
||||
|
||||
Editing PEM text is not always a useful negative TLS test.
|
||||
|
||||
### A certificate can still parse after a byte change
|
||||
|
||||
Base64 can remain syntactically valid when one character changes. OpenSSL may
|
||||
still list the certificate subject and issuer even though a signature is now
|
||||
invalid. Parsing and signature verification are different operations.
|
||||
|
||||
### Trust anchors are not validated through a parent
|
||||
|
||||
Every certificate loaded into Go's root pool is a trust anchor, including a
|
||||
non-self-signed intermediate CA. Verification can terminate at that certificate.
|
||||
|
||||
If tampering changes only the intermediate's signature from its parent root,
|
||||
but does not change its public key, that intermediate can still validate the
|
||||
camera leaf when it is trusted directly. Its now-invalid parent signature is
|
||||
not consulted at the trust boundary.
|
||||
|
||||
This is equivalent to OpenSSL's partial-chain behavior:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-verify_ip 10.0.30.11 \
|
||||
-verify_return_error \
|
||||
-partial_chain \
|
||||
-CAfile tampered-bundle.pem \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
</dev/null
|
||||
```
|
||||
|
||||
### `SSL_CERT_FILE` does not isolate Go from CA directories
|
||||
|
||||
On Unix, Go uses `SSL_CERT_FILE` instead of its default aggregate CA file, but it
|
||||
still scans default certificate directories such as `/etc/ssl/certs`. Setting
|
||||
`SSL_CERT_FILE` alone therefore does not remove CA certificates installed with
|
||||
`update-ca-certificates`.
|
||||
|
||||
Blank values do not select empty trust sources. Both `SSL_CERT_FILE=` and
|
||||
`SSL_CERT_DIR=` are treated as unset, so Go falls back to its default aggregate
|
||||
CA file and certificate directories. To test with no trusted certificates on
|
||||
Linux, use a non-empty file path that contains no certificates and a non-empty
|
||||
directory path that contains no certificates:
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/empty-ca-dir
|
||||
SSL_CERT_FILE=/dev/null \
|
||||
SSL_CERT_DIR=/tmp/empty-ca-dir \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
|
||||
GOWORK=off \
|
||||
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:
|
||||
|
||||
1. Mount a private trust bundle and set `SSL_CERT_FILE`; or
|
||||
2. Install the CA certificates into the operating-system trust store.
|
||||
|
||||
Using both is valid, but makes isolation tests less obvious.
|
||||
|
||||
### Running processes can retain old roots
|
||||
|
||||
A long-running Go process may already have loaded and cached the trust pool.
|
||||
Always start a new process after changing trust configuration during a negative
|
||||
test.
|
||||
|
||||
## Perform a meaningful negative test
|
||||
|
||||
Do not corrupt only the root or intermediate signature. Instead, give a fresh
|
||||
Agent process a completely unrelated CA and hide the default CA directories.
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/empty-ca-dir
|
||||
|
||||
openssl req \
|
||||
-x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-subj '/CN=Unrelated Test Root' \
|
||||
-keyout /tmp/unrelated-test-root.key \
|
||||
-out /tmp/unrelated-test-root.crt
|
||||
|
||||
SSL_CERT_FILE=/tmp/unrelated-test-root.crt \
|
||||
SSL_CERT_DIR=/tmp/empty-ca-dir \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
|
||||
GOWORK=off \
|
||||
go run -tags moq . -action run -port 8080
|
||||
```
|
||||
|
||||
The connection must fail with an unknown-authority or chain-building error.
|
||||
Delete the temporary test key and certificate afterward.
|
||||
|
||||
To test bundle integrity rather than client distrust, validate the intermediate
|
||||
against the root explicitly:
|
||||
|
||||
```bash
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
"$HOME/.step/certs/intermediate_ca.crt"
|
||||
```
|
||||
|
||||
Store and compare approved SHA-256 fingerprints when detecting unauthorized
|
||||
certificate-file changes is a requirement.
|
||||
|
||||
Restore an accidentally edited bundle from the protected CA certificates, then
|
||||
restart the Agent:
|
||||
|
||||
```bash
|
||||
step certificate bundle -f \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
"$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
"$HOME/.step/certs/intermediate_ca.crt"
|
||||
```
|
||||
|
||||
## Optional system trust installation
|
||||
|
||||
On Debian, install both public CA certificates when every process in the system
|
||||
should trust this camera PKI:
|
||||
|
||||
```bash
|
||||
sudo install -m 0644 \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
/usr/local/share/ca-certificates/uug-camera-ca.crt
|
||||
|
||||
sudo install -m 0644 \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
/usr/local/share/ca-certificates/uug-camera-intermediate-ca.crt
|
||||
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
This creates links below `/etc/ssl/certs`. Remove those files and rerun
|
||||
`update-ca-certificates` before attempting an isolated trust-bundle test.
|
||||
|
||||
## Renewal and recovery
|
||||
|
||||
- Renew before the leaf or intermediate expires.
|
||||
- Generate a new camera CSR if the firmware cannot renew the existing key.
|
||||
- Sign the new CSR with all required SANs.
|
||||
- Upload and validate the new certificate before deleting the old one.
|
||||
- Preserve an alternate administrative access path while changing HTTPS usage.
|
||||
- Back up the CA certificates, encrypted CA keys, and password separately.
|
||||
- If the CA private keys are lost, create a new CA and redistribute its trust
|
||||
before replacing camera certificates.
|
||||
|
||||
## Production checklist
|
||||
|
||||
- [ ] The Agent URL uses `rtsps://`, not `rtsp://` or `srtsp://`.
|
||||
- [ ] RTSPS is enabled on the camera and the configured port is reachable.
|
||||
- [ ] The certificate SAN exactly matches the Agent URL host.
|
||||
- [ ] The leaf public key matches the camera-generated CSR.
|
||||
- [ ] The certificate has `serverAuth` extended key usage.
|
||||
- [ ] The certificate expires before its issuer.
|
||||
- [ ] HTTPS is assigned to the private-CA certificate.
|
||||
- [ ] CBS client remains assigned to the Bosch device certificate.
|
||||
- [ ] The Agent has the intermediate and root CA certificates it needs.
|
||||
- [ ] `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false`.
|
||||
- [ ] The Agent was restarted after trust changes.
|
||||
- [ ] A strict TLS check reports `Verification: OK`.
|
||||
- [ ] A real Agent connection receives RTP packets.
|
||||
- [ ] CA private keys and passwords are backed up outside the devcontainer.
|
||||
67
README.md
67
README.md
@@ -189,6 +189,21 @@ Next to attaching the configuration file, it is also possible to override the co
|
||||
-e AGENT_CAPTURE_CONTINUOUS=true \
|
||||
-d --restart=always kerberos/agent:latest
|
||||
|
||||
### Secure camera streams (RTSPS)
|
||||
|
||||
The Agent accepts `rtsps://` camera URLs. Do not use `srtsp://`; RTSPS is RTSP over TLS. For a Bosch FLEXIDOME micro 3100i, enable **Secure RTSP** under **Network > Network Services** and use port `9554`:
|
||||
|
||||
```bash
|
||||
AGENT_CAPTURE_IPCAMERA_RTSP='rtsps://username:password@camera.example:9554/?inst=1'
|
||||
AGENT_CAPTURE_IPCAMERA_SUB_RTSP='rtsps://username:password@camera.example:9554/?inst=2'
|
||||
```
|
||||
|
||||
Certificate verification is enabled by default. The URL hostname or IP address must match the camera certificate SAN. On this Bosch firmware, RTSPS presents the certificate assigned to **HTTPS**; there is no separate SRTSP certificate usage. Leave **CBS client** assigned to the Bosch device certificate.
|
||||
|
||||
For a private CA, mount a PEM trust bundle containing every CA certificate needed to build the camera certificate chain and set `SSL_CERT_FILE` to that file. This Bosch firmware presents only its leaf certificate, so include both the issuing intermediate and root certificates in the bundle. As a temporary fallback, `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` disables certificate verification for camera streams only.
|
||||
|
||||
See [RTSPS and TLS certificates](README-RTSPS-TLS.md) for the complete Bosch UI, private-CA, deployment, validation, and troubleshooting procedure.
|
||||
|
||||
| Name | Description | Default Value |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
|
||||
@@ -203,12 +218,14 @@ Next to attaching the configuration file, it is also possible to override the co
|
||||
| `AGENT_REMOVE_AFTER_UPLOAD` | When enabled, recordings uploaded successfully to a storage will be removed from disk. | "true" |
|
||||
| `AGENT_OFFLINE` | Makes sure no external connection is made. | "false" |
|
||||
| `AGENT_AUTO_CLEAN` | Cleans up the recordings directory. | "true" |
|
||||
| `AGENT_AUTO_CLEAN_MAX_SIZE` | If `AUTO_CLEAN` enabled, set the max size of the recordings directory (in MB). | "100" |
|
||||
| `AGENT_AUTO_CLEAN_MAX_SIZE` | If `AUTO_CLEAN` enabled, cap the recordings directory at this size (in MB). When unset/0, recordings may use the whole disk instead (see `AGENT_AUTO_CLEAN_MIN_FREE_SPACE`). | "100" |
|
||||
| `AGENT_AUTO_CLEAN_MIN_FREE_SPACE` | When `AUTO_CLEAN` is enabled and no `MAX_SIZE` is set, keep at least this much free space (in MB) on the recordings disk before deleting the oldest (already-uploaded first) recordings. Defaults to 5% of the disk. | "" |
|
||||
| `AGENT_TIME` | Enable the timetable for Kerberos Agent | "false" |
|
||||
| `AGENT_TIMETABLE` | A (weekly) time table to specify when to make recordings "start1,end1,start2,end2;start1.. | "" |
|
||||
| `AGENT_REGION_POLYGON` | A single polygon set for motion detection: "x1,y1;x2,y2;x3,y3;... | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP endpoint to the camera you're targetting. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | Sub-stream RTSP endpoint used for livestreaming (WebRTC). | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP or RTSPS endpoint for the target camera. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | RTSP or RTSPS sub-stream endpoint used for livestreaming (WebRTC). | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE` | Disable RTSPS camera certificate verification; use only when a trusted CA cannot be installed. | "false" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_BASE_WIDTH` | Force a specific width resolution for live view processing. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_BASE_HEIGHT` | Force a specific height resolution for live view processing. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF` | Mark as a compliant ONVIF device. | "" |
|
||||
@@ -411,13 +428,53 @@ Remember the build step of the `web` part, during build time we move the build d
|
||||
|
||||
## Building for Docker
|
||||
|
||||
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping a **Kerberos Agent**. Important to note is that you start from a prebuilt base image, `kerberos/base:xxx`.
|
||||
This base image already contains a couple of tools, such as Golang, FFmpeg and OpenCV. We do this for faster compilation times.
|
||||
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping a **Kerberos Agent**. It uses Debian Trixie to support the native dependencies used by the Agent, including Media over QUIC.
|
||||
|
||||
By running the `docker build` command, you will create the Kerberos Agent Docker image. After building you can simply run the image as a Docker container.
|
||||
|
||||
docker build -t kerberos/agent .
|
||||
|
||||
### Media over QUIC
|
||||
|
||||
The standard AMD64 and ARM64 images include the optional MoQ publisher. Its Rust
|
||||
FFI archive requires CGO and glibc 2.38 or newer, which is why the standard image
|
||||
uses Debian Trixie. The publisher is disabled unless explicitly enabled at runtime:
|
||||
|
||||
docker run --rm -p 80:80 \
|
||||
-e AGENT_LIVE_MOQ_ENABLED=true \
|
||||
-e AGENT_LIVE_MOQ_URL=https://relay.uug.ai/anon \
|
||||
kerberos/agent
|
||||
|
||||
`AGENT_LIVE_MOQ_BROADCAST_PREFIX` defaults to `devices`. MoQ viewers subscribe to
|
||||
a relay and never negotiate with the Agent, so every quality tier is published as
|
||||
its own broadcast and switching quality is simply a resubscribe:
|
||||
|
||||
| Tier | Broadcast | Source |
|
||||
| ------ | ------------------------------------- | ------------------------------------------ |
|
||||
| `high` | `devices/<agent-key>/live.hang` | highest-resolution camera stream |
|
||||
| `low` | `devices/<agent-key>/live-low.hang` | sub stream (main stream when none is set) |
|
||||
|
||||
Each tier only uploads while it has at least one subscriber, so the tier nobody
|
||||
watches costs virtually no bandwidth. `AGENT_LIVE_MOQ_QUALITY` accepts `high` or
|
||||
`low` to pin the Agent to a single tier; viewers requesting the other tier then
|
||||
find no broadcast. Any other value (including the default) publishes both. The
|
||||
initial implementation publishes H.264 video only.
|
||||
|
||||
The `/anon` relay route is intended for interoperability testing. Production
|
||||
deployments must set `AGENT_LIVE_MOQ_URL` to a short-lived, device-scoped
|
||||
publisher URL issued by Hub API.
|
||||
|
||||
To verify the native SDK in a development container, rebuild the Agent or shared
|
||||
monorepo devcontainer so it uses the Trixie base, then run the VS Code task
|
||||
`agent: moq verify`. The same check is available from a terminal:
|
||||
|
||||
cd machinery
|
||||
bash ./verify-moq-devcontainer.sh
|
||||
|
||||
The check requires glibc 2.38 or newer, runs the tagged package tests, links the
|
||||
complete Agent with `-tags moq`, and executes the resulting binary's version
|
||||
command. Both devcontainers also run this check during their post-create setup.
|
||||
|
||||
## What is new?
|
||||
|
||||
This repository contains the next generation of Kerberos.io, **Kerberos Agent (v3)**, and is the successor of the machinery and web repositories. A switch in technologies and architecture has been made. This version is still under active development and can be followed on the [develop branch](https://github.com/kerberos-io/agent/tree/develop) and [project overview](https://github.com/kerberos-io/agent/projects/1).
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"offline": "false",
|
||||
"auto_clean": "true",
|
||||
"remove_after_upload": "true",
|
||||
"max_directory_size": 100,
|
||||
"max_directory_size": 0,
|
||||
"timezone": "Africa/Ceuta",
|
||||
"capture": {
|
||||
"name": "",
|
||||
@@ -122,4 +122,4 @@
|
||||
"signing": {},
|
||||
"realtimeprocessing": "false",
|
||||
"realtimeprocessing_topic": ""
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/kerberos-io/agent/machinery
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237
|
||||
|
||||
@@ -8,7 +8,7 @@ require (
|
||||
github.com/Eyevinn/mp4ff v0.48.0
|
||||
github.com/InVisionApp/conjungo v1.1.0
|
||||
github.com/appleboy/gin-jwt/v2 v2.10.3
|
||||
github.com/bluenviron/gortsplib/v4 v4.14.1
|
||||
github.com/bluenviron/gortsplib/v5 v5.6.3
|
||||
github.com/bluenviron/mediacommon v1.14.0
|
||||
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6
|
||||
github.com/dromara/carbon/v2 v2.6.8
|
||||
@@ -24,14 +24,16 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/kellydunn/golang-geo v0.7.0
|
||||
github.com/kerberos-io/joy4 v1.0.64
|
||||
github.com/kerberos-io/onvif v1.0.0
|
||||
github.com/kerberos-io/onvif v1.2.2
|
||||
github.com/minio/minio-go/v6 v6.0.57
|
||||
github.com/moq-dev/moq-go v0.5.7
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/pion/interceptor v0.1.40
|
||||
github.com/pion/rtp v1.8.19
|
||||
github.com/pion/webrtc/v4 v4.1.2
|
||||
github.com/pion/interceptor v0.1.47
|
||||
github.com/pion/rtp v1.10.5
|
||||
github.com/pion/webrtc/v4 v4.2.18
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.4
|
||||
@@ -51,13 +53,14 @@ require (
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/beevik/etree v1.2.0 // indirect
|
||||
github.com/bluenviron/mediacommon/v2 v2.2.0 // indirect
|
||||
github.com/bluenviron/mediacommon/v2 v2.9.2 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elgs/gostrgen v0.0.0-20161222160715-9d61ae07eeae // indirect
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
|
||||
@@ -76,7 +79,6 @@ require (
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||
github.com/icholy/digest v0.1.23 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/juju/errors v1.0.0 // indirect
|
||||
@@ -94,21 +96,23 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/moq-dev/moq-go-ffi v0.3.7 // indirect
|
||||
github.com/nxadm/tail v1.4.11 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/logging v0.2.3 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/datachannel v1.6.2 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.5 // indirect
|
||||
github.com/pion/ice/v4 v4.4.0 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.13 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.5 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/pion/rtcp v1.2.17 // indirect
|
||||
github.com/pion/sctp v1.11.1 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.19 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.12 // indirect
|
||||
github.com/pion/stun/v3 v3.1.6 // indirect
|
||||
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/pion/turn/v5 v5.0.12 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
@@ -122,13 +126,14 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.30.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/grpc v1.72.1 // indirect
|
||||
|
||||
111
machinery/go.sum
111
machinery/go.sum
@@ -393,12 +393,12 @@ github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhpl
|
||||
github.com/bazelbuild/rules_go v0.49.0/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs=
|
||||
github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw=
|
||||
github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc=
|
||||
github.com/bluenviron/gortsplib/v4 v4.14.1 h1:v99NmXeeJFfbrO+ipPzPxYGibQaR5ZOUESOA9UQZhsI=
|
||||
github.com/bluenviron/gortsplib/v4 v4.14.1/go.mod h1:3LaEcg0d47+kfXju5KSlsSxCiZ3IKBI/sqIrBPcsS64=
|
||||
github.com/bluenviron/gortsplib/v5 v5.6.3 h1:OXvHthQZ9fZbLh6r3Go2wuF4XQ4/QW4WTIM2f4bv/W4=
|
||||
github.com/bluenviron/gortsplib/v5 v5.6.3/go.mod h1:kzHgUtvl8NWNsQ5Vsez6Vuugk6ItFT4ByCPm8J/kbSQ=
|
||||
github.com/bluenviron/mediacommon v1.14.0 h1:lWCwOBKNKgqmspRpwpvvg3CidYm+XOc2+z/Jw7LM5dQ=
|
||||
github.com/bluenviron/mediacommon v1.14.0/go.mod h1:z5LP9Tm1ZNfQV5Co54PyOzaIhGMusDfRKmh42nQSnyo=
|
||||
github.com/bluenviron/mediacommon/v2 v2.2.0 h1:fGXEX0OEvv5VhGHOv3Q2ABzOtSkIpl9UbwOHrnKWNTk=
|
||||
github.com/bluenviron/mediacommon/v2 v2.2.0/go.mod h1:a6MbPmXtYda9mKibKVMZlW20GYLLrX2R7ZkUE+1pwV0=
|
||||
github.com/bluenviron/mediacommon/v2 v2.9.2 h1:jvYeBjvhHKFOBRMTMm4hvrSjyOlCelkOkx6708DidQM=
|
||||
github.com/bluenviron/mediacommon/v2 v2.9.2/go.mod h1:jMf/OJDaJl02xRgkLM2zbidUHnDYqLnO1dMMveCmyyU=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
@@ -755,8 +755,6 @@ github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
||||
github.com/icholy/digest v0.1.23 h1:4hX2pIloP0aDx7RJW0JewhPPy3R8kU+vWKdxPsCCGtY=
|
||||
github.com/icholy/digest v0.1.23/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
@@ -776,8 +774,8 @@ github.com/kellydunn/golang-geo v0.7.0 h1:A5j0/BvNgGwY6Yb6inXQxzYwlPHc6WVZR+Mrar
|
||||
github.com/kellydunn/golang-geo v0.7.0/go.mod h1:YYlQPJ+DPEzrHx8kT3oPHC/NjyvCCXE+IuKGKdrjrcU=
|
||||
github.com/kerberos-io/joy4 v1.0.64 h1:gTUSotHSOhp9mNqEecgq88tQHvpj7TjmrvPUsPm0idg=
|
||||
github.com/kerberos-io/joy4 v1.0.64/go.mod h1:nZp4AjvKvTOXRrmDyAIOw+Da+JA5OcSo/JundGfOlFU=
|
||||
github.com/kerberos-io/onvif v1.0.0 h1:pLJrK6skPkK+5Bj4XfqHUkQ2I+p5pwELnp+kQTJWXiQ=
|
||||
github.com/kerberos-io/onvif v1.0.0/go.mod h1:P1kUcCfeotJSlL1jwGseH6NSnCwWiuJLl3gAzafnLbA=
|
||||
github.com/kerberos-io/onvif v1.2.2 h1:QnxITps7xvAVD2abWRsa3+p9QexjKESQQldUFMx/mYA=
|
||||
github.com/kerberos-io/onvif v1.2.2/go.mod h1:XSgEQXmEDjUQTbdXvsaRJt6Az8YPGj7L+j5iXKEGijU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
@@ -847,6 +845,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/moq-dev/moq-go v0.5.7 h1:LfFpgAU8FRMcnU85L5Lb03HzxhYbQ2+BhT/pJQdjy5U=
|
||||
github.com/moq-dev/moq-go v0.5.7/go.mod h1:5K8zjKKjWe5lzfCtlfxoAfdCr3KX6EBuTOZsz0WBnzw=
|
||||
github.com/moq-dev/moq-go-ffi v0.3.7 h1:+xwPOzTJHvB0tuTnW6znbvvOgT4yLnN7sMqXXZYh02M=
|
||||
github.com/moq-dev/moq-go-ffi v0.3.7/go.mod h1:zxpOlUetvaoxWBnbXTdILtLLUytaoVuLvO36lftwWO0=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
@@ -864,38 +866,40 @@ github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2
|
||||
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
|
||||
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
|
||||
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
|
||||
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
|
||||
github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4=
|
||||
github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
|
||||
github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo=
|
||||
github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c=
|
||||
github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
|
||||
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
|
||||
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54=
|
||||
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
|
||||
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
|
||||
github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
|
||||
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
|
||||
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA=
|
||||
github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
|
||||
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
|
||||
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
|
||||
github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
|
||||
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
|
||||
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
|
||||
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
|
||||
github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
|
||||
github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI=
|
||||
github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -955,8 +959,9 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
@@ -1164,8 +1169,8 @@ golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -1234,8 +1239,9 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1323,8 +1329,8 @@ golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1403,8 +1409,9 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -1513,8 +1520,8 @@ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -1581,8 +1588,8 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1594,6 +1601,8 @@ golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -1667,8 +1676,9 @@ golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -2109,7 +2119,6 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
@@ -76,12 +77,14 @@ func main() {
|
||||
var name string
|
||||
var port string
|
||||
var timeout string
|
||||
var subnet string
|
||||
|
||||
flag.StringVar(&action, "action", "version", "Tell us what you want do 'run' or 'version'")
|
||||
flag.StringVar(&configDirectory, "config", ".", "Where is the configuration stored")
|
||||
flag.StringVar(&name, "name", "agent", "Provide a name for the agent")
|
||||
flag.StringVar(&port, "port", "80", "On which port should the agent run")
|
||||
flag.StringVar(&timeout, "timeout", "2000", "Number of milliseconds to wait for the ONVIF discovery to complete")
|
||||
flag.StringVar(&subnet, "subnet", "", "Optional subnet(s) to scan for discovery, e.g. '192.168.1.0/24' (comma-separated). Defaults to the local interfaces.")
|
||||
flag.Parse()
|
||||
|
||||
// Specify the level of loggin: "info", "warning", "debug", "error" or "fatal."
|
||||
@@ -112,7 +115,13 @@ func main() {
|
||||
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
|
||||
return
|
||||
}
|
||||
onvif.Discover(timeout)
|
||||
var subnets []string
|
||||
for _, part := range strings.Split(subnet, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
subnets = append(subnets, trimmed)
|
||||
}
|
||||
}
|
||||
onvif.Discover(timeout, subnets...)
|
||||
}
|
||||
case "decrypt":
|
||||
{
|
||||
|
||||
238
machinery/src/capture/cleanup_test.go
Normal file
238
machinery/src/capture/cleanup_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// writeRecording creates a file under recordingsDir and sets its modtime so the
|
||||
// tests can control the "oldest" ordering deterministically.
|
||||
func writeRecording(t *testing.T, recordingsDir, name string, ageMinutes int) {
|
||||
t.Helper()
|
||||
full := filepath.Join(recordingsDir, name)
|
||||
if err := os.WriteFile(full, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write recording %s: %v", name, err)
|
||||
}
|
||||
mod := time.Now().Add(-time.Duration(ageMinutes) * time.Minute)
|
||||
if err := os.Chtimes(full, mod, mod); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// markPending creates the upload marker in cloudDir for the given recording,
|
||||
// marking it as still queued for upload.
|
||||
func markPending(t *testing.T, cloudDir, name string) {
|
||||
t.Helper()
|
||||
markerName := models.RecordingUploadMetadataFileName(name)
|
||||
if err := os.WriteFile(filepath.Join(cloudDir, markerName), nil, 0o644); err != nil {
|
||||
t.Fatalf("write marker %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCleanupDirs(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
recordingsDir := filepath.Join(base, "data", "recordings")
|
||||
cloudDir := filepath.Join(base, "data", "cloud")
|
||||
if err := os.MkdirAll(recordingsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir recordings: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cloudDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud: %v", err)
|
||||
}
|
||||
return recordingsDir, cloudDir
|
||||
}
|
||||
|
||||
// The core regression: when the oldest recording is still pending upload but a
|
||||
// newer one has already been uploaded, cleanup must delete the uploaded (safe)
|
||||
// one and leave the pending recording on disk so it can still be uploaded.
|
||||
func TestPickRecordingToCleanup_PrefersUploaded(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
// oldest is still pending upload (marker present).
|
||||
writeRecording(t, recordingsDir, "oldest_pending.mp4", 30)
|
||||
markPending(t, cloudDir, "oldest_pending.mp4")
|
||||
// newer one has already been uploaded (no marker).
|
||||
writeRecording(t, recordingsDir, "newer_uploaded.mp4", 10)
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending {
|
||||
t.Fatalf("expected a safe (already-uploaded) deletion, got pending=true")
|
||||
}
|
||||
if name != "newer_uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want the uploaded recording newer_uploaded.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRecordingToCleanup_RecognizesLegacyMarkerName(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "legacy_pending.mp4", 30)
|
||||
if err := os.WriteFile(filepath.Join(cloudDir, "legacy_pending.mp4"), nil, 0o644); err != nil {
|
||||
t.Fatalf("write legacy marker: %v", err)
|
||||
}
|
||||
writeRecording(t, recordingsDir, "uploaded.mp4", 10)
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending || name != "uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked name=%q pending=%v, want uploaded.mp4 pending=false", name, pending)
|
||||
}
|
||||
}
|
||||
|
||||
// Among several already-uploaded recordings, the oldest uploaded one is chosen.
|
||||
func TestPickRecordingToCleanup_OldestUploadedFirst(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "old_uploaded.mp4", 40)
|
||||
writeRecording(t, recordingsDir, "mid_uploaded.mp4", 20)
|
||||
// pending one must be ignored even though it is not the oldest.
|
||||
writeRecording(t, recordingsDir, "pending.mp4", 30)
|
||||
markPending(t, cloudDir, "pending.mp4")
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending {
|
||||
t.Fatalf("expected pending=false, got true")
|
||||
}
|
||||
if name != "old_uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want old_uploaded.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: when every recording is still pending upload, cleanup returns the
|
||||
// oldest one with pending=true so the caller can drop it (and its marker) to keep
|
||||
// the disk bounded.
|
||||
func TestPickRecordingToCleanup_AllPendingFallsBackToOldest(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "a_old.mp4", 50)
|
||||
markPending(t, cloudDir, "a_old.mp4")
|
||||
writeRecording(t, recordingsDir, "b_new.mp4", 5)
|
||||
markPending(t, cloudDir, "b_new.mp4")
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !pending {
|
||||
t.Fatalf("expected pending=true when every recording is queued for upload")
|
||||
}
|
||||
if name != "a_old.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want the oldest pending a_old.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty recordings directory yields os.ErrNotExist so the caller does nothing.
|
||||
func TestPickRecordingToCleanup_Empty(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
if _, _, err := pickRecordingToCleanup(recordingsDir, cloudDir); err != os.ErrNotExist {
|
||||
t.Fatalf("expected os.ErrNotExist for an empty directory, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSizedRecording writes a recording of an exact byte size so tests can
|
||||
// exercise the megabyte-based directory-cap threshold.
|
||||
func writeSizedRecording(t *testing.T, dir, name string, size int) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o644); err != nil {
|
||||
t.Fatalf("write sized recording %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// When AGENT_AUTO_CLEAN_MAX_SIZE (MaxDirectorySize) is set, cleanup triggers once
|
||||
// the recordings directory grows past that many megabytes.
|
||||
func TestRecordingsNeedCleanup_FixedCap(t *testing.T) {
|
||||
recordingsDir, _ := newCleanupDirs(t)
|
||||
// ~2 MB of recordings on disk.
|
||||
writeSizedRecording(t, recordingsDir, "big.mp4", 2*1000*1000)
|
||||
|
||||
over := &models.Configuration{Config: models.Config{MaxDirectorySize: 1}}
|
||||
need, err := recordingsNeedCleanup(recordingsDir, over)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !need {
|
||||
t.Fatalf("expected cleanup when 2MB of recordings exceed the 1MB cap")
|
||||
}
|
||||
|
||||
under := &models.Configuration{Config: models.Config{MaxDirectorySize: 100}}
|
||||
need, err = recordingsNeedCleanup(recordingsDir, under)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if need {
|
||||
t.Fatalf("expected no cleanup when 2MB of recordings stay under the 100MB cap")
|
||||
}
|
||||
}
|
||||
|
||||
// With no fixed cap (the default), cleanup is driven by the free space left on
|
||||
// the recordings filesystem versus the reserve.
|
||||
func TestRecordingsNeedCleanup_DefaultDiskReserve(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("disk usage stats are only implemented on linux")
|
||||
}
|
||||
recordingsDir, _ := newCleanupDirs(t)
|
||||
|
||||
totalMB, availableMB, err := diskUsageMB(recordingsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("diskUsageMB: %v", err)
|
||||
}
|
||||
if totalMB <= 0 || availableMB <= 0 {
|
||||
t.Skipf("unexpected disk stats total=%dMB available=%dMB", totalMB, availableMB)
|
||||
}
|
||||
|
||||
// A reserve larger than the whole disk means free space is always below it.
|
||||
over := &models.Configuration{Config: models.Config{MinFreeSpace: totalMB + availableMB}}
|
||||
need, err := recordingsNeedCleanup(recordingsDir, over)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !need {
|
||||
t.Fatalf("expected cleanup when free space (%dMB) is below the reserve", availableMB)
|
||||
}
|
||||
|
||||
// A 1 MB reserve leaves plenty of free space, so nothing should be cleaned.
|
||||
under := &models.Configuration{Config: models.Config{MinFreeSpace: 1}}
|
||||
need, err = recordingsNeedCleanup(recordingsDir, under)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if need {
|
||||
t.Fatalf("expected no cleanup when free space (%dMB) exceeds the 1MB reserve", availableMB)
|
||||
}
|
||||
}
|
||||
|
||||
// The default 5% reserve must never truncate to 0MB on small disks, otherwise
|
||||
// cleanup would only trigger once the disk is completely full.
|
||||
func TestDefaultReserveMB(t *testing.T) {
|
||||
cases := []struct {
|
||||
totalMB int64
|
||||
want int64
|
||||
}{
|
||||
{totalMB: 0, want: 1}, // no/unknown disk size still reserves 1MB
|
||||
{totalMB: 10, want: 1}, // 5% = 0MB -> floored to 1MB
|
||||
{totalMB: 19, want: 1}, // 5% = 0MB -> floored to 1MB
|
||||
{totalMB: 20, want: 1}, // 5% = exactly 1MB
|
||||
{totalMB: 100, want: 5}, // 5% = 5MB
|
||||
{totalMB: 1000, want: 50},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := defaultReserveMB(c.totalMB); got != c.want {
|
||||
t.Errorf("defaultReserveMB(%d) = %d, want %d", c.totalMB, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
machinery/src/capture/disk_linux.go
Normal file
23
machinery/src/capture/disk_linux.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build linux
|
||||
|
||||
package capture
|
||||
|
||||
import "syscall"
|
||||
|
||||
// diskUsageMB returns the total capacity and the currently available space (both
|
||||
// in megabytes, decimal) of the filesystem that contains path. Auto-clean uses
|
||||
// it to default its cleanup threshold to the real disk capacity instead of a
|
||||
// fixed size, so recordings can grow to fill the disk while keeping a reserve
|
||||
// free. Linux is the agent's deployment target (amd64/arm64 containers).
|
||||
func diskUsageMB(path string) (totalMB int64, availableMB int64, err error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err = syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
blockSize := int64(stat.Bsize)
|
||||
totalMB = int64(stat.Blocks) * blockSize / 1000 / 1000
|
||||
// Bavail is the free space available to unprivileged users, which is the
|
||||
// space we can actually keep writing recordings into.
|
||||
availableMB = int64(stat.Bavail) * blockSize / 1000 / 1000
|
||||
return totalMB, availableMB, nil
|
||||
}
|
||||
13
machinery/src/capture/disk_other.go
Normal file
13
machinery/src/capture/disk_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package capture
|
||||
|
||||
import "errors"
|
||||
|
||||
// diskUsageMB is only implemented on Linux (the agent's deployment target). On
|
||||
// other platforms (e.g. local macOS/Windows dev builds) auto-clean falls back to
|
||||
// its historical fixed-size directory cap, so this reports the capability as
|
||||
// unavailable.
|
||||
func diskUsageMB(path string) (totalMB int64, availableMB int64, err error) {
|
||||
return 0, 0, errors.New("disk usage stats are not supported on this platform")
|
||||
}
|
||||
@@ -8,24 +8,26 @@ import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/bluenviron/gortsplib/v4"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/base"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtph264"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtph265"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtplpcm"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtpmpeg4audio"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtpsimpleaudio"
|
||||
"github.com/bluenviron/gortsplib/v5"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph264"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph265"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtplpcm"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpmpeg4audio"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpsimpleaudio"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h265"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
|
||||
@@ -38,6 +40,16 @@ import (
|
||||
|
||||
var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/capture")
|
||||
|
||||
const rtspsInsecureEnv = "AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE"
|
||||
|
||||
func rtspsTLSConfig() *tls.Config {
|
||||
if os.Getenv(rtspsInsecureEnv) != "true" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &tls.Config{InsecureSkipVerify: true} // #nosec G402 -- explicit opt-in for cameras with self-signed certificates
|
||||
}
|
||||
|
||||
// Implements the RTSPClient interface.
|
||||
type Golibrtsp struct {
|
||||
RTSPClient
|
||||
@@ -317,11 +329,12 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
|
||||
_, span := tracer.Start(ctxOtel, "Connect")
|
||||
defer span.End()
|
||||
|
||||
transport := gortsplib.TransportTCP
|
||||
protocol := gortsplib.ProtocolTCP
|
||||
g.health = newStreamHealth()
|
||||
g.Client = gortsplib.Client{
|
||||
RequestBackChannels: false,
|
||||
Transport: &transport,
|
||||
Protocol: &protocol,
|
||||
TLSConfig: rtspsTLSConfig(),
|
||||
// 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
|
||||
@@ -342,7 +355,9 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
|
||||
}
|
||||
|
||||
// connect to the server
|
||||
err = g.Client.Start(u.Scheme, u.Host)
|
||||
g.Client.Scheme = u.Scheme
|
||||
g.Client.Host = u.Host
|
||||
err = g.Client.Start()
|
||||
if err != nil {
|
||||
log.Log.Debug("capture.golibrtsp.Connect(Start): " + err.Error())
|
||||
}
|
||||
@@ -593,10 +608,11 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
|
||||
defer span.End()
|
||||
|
||||
// Transport TCP
|
||||
transport := gortsplib.TransportTCP
|
||||
protocol := gortsplib.ProtocolTCP
|
||||
g.Client = gortsplib.Client{
|
||||
RequestBackChannels: true,
|
||||
Transport: &transport,
|
||||
Protocol: &protocol,
|
||||
TLSConfig: rtspsTLSConfig(),
|
||||
}
|
||||
// parse URL
|
||||
u, err := base.ParseURL(g.Url)
|
||||
@@ -606,7 +622,9 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
|
||||
}
|
||||
|
||||
// connect to the server
|
||||
err = g.Client.Start(u.Scheme, u.Host)
|
||||
g.Client.Scheme = u.Scheme
|
||||
g.Client.Host = u.Host
|
||||
err = g.Client.Start()
|
||||
if err != nil {
|
||||
log.Log.Error("capture.golibrtsp.ConnectBackChannel(): " + err.Error())
|
||||
}
|
||||
@@ -680,6 +698,12 @@ func compositionOffsetMs(ext dtsExtractor, au [][]byte, pts int64, clockRate int
|
||||
return offset * 1000 / int64(clockRate)
|
||||
}
|
||||
|
||||
func ptsToDuration(pts int64, clockRate int) time.Duration {
|
||||
rate := int64(clockRate)
|
||||
return time.Duration(pts/rate)*time.Second +
|
||||
time.Duration(pts%rate)*time.Second/time.Duration(rate)
|
||||
}
|
||||
|
||||
// Start the RTSP client, and start reading packets.
|
||||
func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets.Queue, configuration *models.Configuration, communication *models.Communication) (err error) {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): started")
|
||||
@@ -693,13 +717,13 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// called when a MULAW audio RTP packet arrives
|
||||
if g.AudioG711Media != nil && g.AudioG711Forma != nil {
|
||||
g.Client.OnPacketRTP(g.AudioG711Media, g.AudioG711Forma, func(rtppkt *rtp.Packet) {
|
||||
pts, ok := g.Client.PacketPTS(g.AudioG711Media, rtppkt)
|
||||
// decode timestamp
|
||||
pts2, ok := g.Client.PacketPTS2(g.AudioG711Media, rtppkt)
|
||||
pts2, ok := g.Client.PacketPTS(g.AudioG711Media, rtppkt)
|
||||
if !ok {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): " + "unable to get PTS")
|
||||
return
|
||||
}
|
||||
pts := ptsToDuration(pts2, g.AudioG711Forma.ClockRate())
|
||||
|
||||
// extract LPCM samples from RTP packets
|
||||
op, err := g.AudioG711Decoder.Decode(rtppkt)
|
||||
@@ -729,12 +753,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
if g.AudioMPEG4Media != nil && g.AudioMPEG4Forma != nil {
|
||||
g.Client.OnPacketRTP(g.AudioMPEG4Media, g.AudioMPEG4Forma, func(rtppkt *rtp.Packet) {
|
||||
// decode timestamp
|
||||
pts, ok := g.Client.PacketPTS(g.AudioMPEG4Media, rtppkt)
|
||||
pts2, ok := g.Client.PacketPTS2(g.AudioMPEG4Media, rtppkt)
|
||||
pts2, ok := g.Client.PacketPTS(g.AudioMPEG4Media, rtppkt)
|
||||
if !ok {
|
||||
log.Log.Error("capture.golibrtsp.Start(): " + "unable to get PTS")
|
||||
return
|
||||
}
|
||||
pts := ptsToDuration(pts2, g.AudioMPEG4Forma.ClockRate())
|
||||
|
||||
// Encode the AAC samples from RTP packets
|
||||
// extract access units from RTP packets
|
||||
@@ -788,12 +812,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// decode timestamps — validate each call separately
|
||||
pts, okPTS := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
|
||||
pts2, okPTS2 := g.Client.PacketPTS2(g.VideoH264Media, rtppkt)
|
||||
pts2, okPTS2 := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
|
||||
if !okPTS2 {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS2 from PacketPTS2")
|
||||
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS")
|
||||
return
|
||||
}
|
||||
pts := ptsToDuration(pts2, g.VideoH264Forma.ClockRate())
|
||||
|
||||
// Extract access units from RTP packets.
|
||||
// We need a complete access unit to determine whether
|
||||
@@ -807,15 +831,13 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
}
|
||||
|
||||
// Frame is complete — update per-stream FPS from PTS.
|
||||
if okPTS {
|
||||
ft := g.fpsTrackers[g.VideoH264Index]
|
||||
if ft == nil {
|
||||
ft = newFPSTracker(30)
|
||||
g.fpsTrackers[g.VideoH264Index] = ft
|
||||
}
|
||||
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
|
||||
g.Streams[g.VideoH264Index].FPS = ptsFPS
|
||||
}
|
||||
ft := g.fpsTrackers[g.VideoH264Index]
|
||||
if ft == nil {
|
||||
ft = newFPSTracker(30)
|
||||
g.fpsTrackers[g.VideoH264Index] = ft
|
||||
}
|
||||
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
|
||||
g.Streams[g.VideoH264Index].FPS = ptsFPS
|
||||
}
|
||||
|
||||
// We'll need to read out a few things.
|
||||
@@ -864,6 +886,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// Get FPS using enhanced method
|
||||
fps := g.getEnhancedFPS(&sps, g.VideoH264Index)
|
||||
g.Streams[g.VideoH264Index].FPS = fps
|
||||
g.persistStreamFPS(configuration, streamType, fps)
|
||||
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Final FPS=%.2f", streamType, fps))
|
||||
g.VideoH264Forma.SPS = nalu
|
||||
if streamType == "main" && len(nalu) > 0 {
|
||||
@@ -1034,12 +1057,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// decode timestamps — validate each call separately
|
||||
pts, okPTS := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
|
||||
pts2, okPTS2 := g.Client.PacketPTS2(g.VideoH265Media, rtppkt)
|
||||
pts2, okPTS2 := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
|
||||
if !okPTS2 {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS")
|
||||
return
|
||||
}
|
||||
pts := ptsToDuration(pts2, g.VideoH265Forma.ClockRate())
|
||||
|
||||
// Extract access units from RTP packets.
|
||||
// We need a complete access unit to determine whether
|
||||
@@ -1053,15 +1076,14 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
}
|
||||
|
||||
// Frame is complete — update per-stream FPS from PTS.
|
||||
if okPTS {
|
||||
ft := g.fpsTrackers[g.VideoH265Index]
|
||||
if ft == nil {
|
||||
ft = newFPSTracker(30)
|
||||
g.fpsTrackers[g.VideoH265Index] = ft
|
||||
}
|
||||
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
|
||||
g.Streams[g.VideoH265Index].FPS = ptsFPS
|
||||
}
|
||||
ft := g.fpsTrackers[g.VideoH265Index]
|
||||
if ft == nil {
|
||||
ft = newFPSTracker(30)
|
||||
g.fpsTrackers[g.VideoH265Index] = ft
|
||||
}
|
||||
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
|
||||
g.Streams[g.VideoH265Index].FPS = ptsFPS
|
||||
g.persistStreamFPS(configuration, streamType, ptsFPS)
|
||||
}
|
||||
|
||||
// Preserve the decoded access unit (in decode order) for DTS
|
||||
@@ -1537,6 +1559,21 @@ func (g *Golibrtsp) initFPSCalculation() {
|
||||
}
|
||||
|
||||
// Get enhanced FPS information from SPS with fallback to PTS-based calculation.
|
||||
// persistStreamFPS stores the computed frame rate into the shared config so it
|
||||
// is reported to the hub/UI (mirrors how width/height are persisted). The value
|
||||
// is rounded to 2 decimals with trailing zeros trimmed (e.g. "25", "29.97").
|
||||
func (g *Golibrtsp) persistStreamFPS(configuration *models.Configuration, streamType string, fps float64) {
|
||||
if fps <= 0 {
|
||||
return
|
||||
}
|
||||
fpsStr := strconv.FormatFloat(float64(int(fps*100+0.5))/100, 'f', -1, 64)
|
||||
if streamType == "main" {
|
||||
configuration.Config.Capture.IPCamera.FPS = fpsStr
|
||||
} else if streamType == "sub" {
|
||||
configuration.Config.Capture.IPCamera.SubFPS = fpsStr
|
||||
}
|
||||
}
|
||||
|
||||
// The PTS-based FPS is computed per completed frame via fpsTracker.update(),
|
||||
// so by the time this is called we already have a good estimate.
|
||||
func (g *Golibrtsp) getEnhancedFPS(sps *h264.SPS, streamIndex int8) float64 {
|
||||
|
||||
22
machinery/src/capture/gortsplib_test.go
Normal file
22
machinery/src/capture/gortsplib_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package capture
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRTSPSTLSConfig(t *testing.T) {
|
||||
t.Run("verifies certificates by default", func(t *testing.T) {
|
||||
t.Setenv(rtspsInsecureEnv, "")
|
||||
|
||||
if got := rtspsTLSConfig(); got != nil {
|
||||
t.Fatalf("rtspsTLSConfig() = %#v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows explicit insecure mode", func(t *testing.T) {
|
||||
t.Setenv(rtspsInsecureEnv, "true")
|
||||
|
||||
got := rtspsTLSConfig()
|
||||
if got == nil || !got.InsecureSkipVerify {
|
||||
t.Fatalf("rtspsTLSConfig() = %#v, want InsecureSkipVerify enabled", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,15 @@ package capture
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/conditions"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
@@ -20,45 +24,326 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
|
||||
autoClean := configuration.Config.AutoClean
|
||||
if autoClean == "true" {
|
||||
maxSize := configuration.Config.MaxDirectorySize
|
||||
if maxSize == 0 {
|
||||
maxSize = 300
|
||||
}
|
||||
// Total size of the recording directory.
|
||||
recordingsDirectory := configDirectory + "/data/recordings"
|
||||
size, err := utils.DirSize(recordingsDirectory)
|
||||
if err == nil {
|
||||
sizeInMB := size / 1000 / 1000
|
||||
if sizeInMB >= maxSize {
|
||||
// Remove the oldest recording
|
||||
oldestFile, err := utils.FindOldestFile(recordingsDirectory)
|
||||
if err == nil {
|
||||
err := os.Remove(recordingsDirectory + "/" + oldestFile.Name())
|
||||
log.Log.Info("HandleRecordStream: removed oldest file as part of cleanup - " + recordingsDirectory + "/" + oldestFile.Name())
|
||||
if err != nil {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
}
|
||||
|
||||
// publishRecordingState notifies the hub (and ultimately the live-view UI) that
|
||||
// this camera started ("recording": true) or stopped ("recording": false)
|
||||
// recording, so the frontend can show a "recording" indicator while the agent
|
||||
// is recording (e.g. a motion clip triggered manually from the live view or by
|
||||
// motion detection). It is a best-effort broadcast: when no hub/MQTT is
|
||||
// configured (or the agent is offline) it is a no-op, and a missed message is
|
||||
// self-healed by the frontend's safety timeout.
|
||||
func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration *models.Configuration, recording bool) {
|
||||
if mqttClient == nil || hubKey == "" || configuration.Config.Offline == "true" {
|
||||
return
|
||||
}
|
||||
message := models.Message{
|
||||
Payload: models.Payload{
|
||||
Action: "recording",
|
||||
DeviceId: configuration.Config.Key,
|
||||
Value: map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
"recording": recording,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, err := models.PackageMQTTMessage(configuration, message)
|
||||
if err == nil {
|
||||
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
|
||||
} else {
|
||||
log.Log.Info("HandleRecordStream: Autoclean disabled, nothing to do here.")
|
||||
log.Log.Error("capture.main.publishRecordingState(): failed to package MQTT message: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRecordStream(queue *packets.Queue, configDirectory string, configuration *models.Configuration, communication *models.Communication, rtspClient RTSPClient) {
|
||||
func recordingUploadMetadata(name, deviceKey string, timestamp int64, mp4Video *video.MP4) models.RecordingUploadMetadata {
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: filepath.Base(name),
|
||||
DeviceKey: deviceKey,
|
||||
Timestamp: timestamp,
|
||||
Duration: mp4Video.VideoTotalDuration,
|
||||
}
|
||||
value := mp4Video.AverageFPS()
|
||||
if value > 0 && value <= 240 && !math.IsInf(value, 0) && !math.IsNaN(value) {
|
||||
metadata.FPS = value
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// queueRecordingForUpload creates the marker consumed by the upload worker and
|
||||
// stores metadata captured from the finalized recording.
|
||||
func queueRecordingForUpload(configDirectory string, metadata models.RecordingUploadMetadata) {
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
log.Log.Error("capture.main.queueRecordingForUpload(): " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Publish the marker with a same-filesystem rename. Writing directly to the
|
||||
// watched directory would briefly expose an empty file to the upload poller.
|
||||
marker, err := os.CreateTemp(filepath.Join(configDirectory, "data"), ".upload-marker-*")
|
||||
if err == nil {
|
||||
_, err = marker.Write(payload)
|
||||
}
|
||||
if err == nil {
|
||||
err = marker.Chmod(0644)
|
||||
}
|
||||
if marker != nil {
|
||||
if closeErr := marker.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
defer os.Remove(marker.Name())
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Rename(marker.Name(), filepath.Join(configDirectory, "data", "cloud", models.RecordingUploadMetadataFileName(metadata.FileName)))
|
||||
}
|
||||
if err != nil {
|
||||
log.Log.Error("capture.main.queueRecordingForUpload(): " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// manualRecordingHeartbeatTimeout is how long the agent keeps a manual
|
||||
// (live-view / remote) recording alive after the LAST viewer heartbeat. The
|
||||
// frontend re-sends the record command every ~15s while the user stays on the
|
||||
// page; if several heartbeats are missed (the viewer closed the tab, went idle
|
||||
// or lost connectivity) the recorder auto-stops the recording so the camera
|
||||
// doesn't record forever when the "stop" message never arrives.
|
||||
manualRecordingHeartbeatTimeout = 45 * time.Second
|
||||
// manualRecordingMaxDuration caps a single manual recording so a forgotten
|
||||
// record button can't record indefinitely even while the viewer keeps sending
|
||||
// heartbeats. After this the recording auto-stops and the viewer must press
|
||||
// record again to continue.
|
||||
manualRecordingMaxDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
// manualRecordingExpired reports whether an active manual (live-view) recording
|
||||
// has outlived its viewer heartbeat window or the maximum duration cap. When it
|
||||
// has, it clears the manual-recording state (so the motion recorder lets the
|
||||
// current clip close normally and broadcasts recording:false) and returns true.
|
||||
// It is a no-op returning false when no manual recording is active.
|
||||
func manualRecordingExpired(communication *models.Communication, now int64) bool {
|
||||
if communication.IsRecordingManual.IsNotSet() {
|
||||
return false
|
||||
}
|
||||
manualStart := communication.RecordingManualStart.Load()
|
||||
maxDurationReached := manualStart > 0 && now-manualStart > manualRecordingMaxDuration.Milliseconds()
|
||||
// The heartbeat timeout only applies once the viewer has proven it supports
|
||||
// heartbeats (an older frontend that starts a recording but never heartbeats
|
||||
// still records up to the max-duration cap instead of being cut off early).
|
||||
heartbeatExpired := false
|
||||
if communication.RecordingManualHeartbeatSeen.IsSet() {
|
||||
lastHeartbeat := communication.RecordingManualHeartbeat.Load()
|
||||
heartbeatExpired = lastHeartbeat > 0 && now-lastHeartbeat > manualRecordingHeartbeatTimeout.Milliseconds()
|
||||
}
|
||||
if !heartbeatExpired && !maxDurationReached {
|
||||
return false
|
||||
}
|
||||
if heartbeatExpired {
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, no viewer heartbeat within timeout.")
|
||||
} else {
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, maximum duration reached.")
|
||||
}
|
||||
communication.IsRecordingManual.UnSet()
|
||||
communication.RecordingManualHeartbeat.Store(0)
|
||||
communication.RecordingManualStart.Store(0)
|
||||
communication.RecordingManualHeartbeatSeen.UnSet()
|
||||
return true
|
||||
}
|
||||
|
||||
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
|
||||
autoClean := configuration.Config.AutoClean
|
||||
if autoClean != "true" {
|
||||
log.Log.Info("HandleRecordStream: Autoclean disabled, nothing to do here.")
|
||||
return
|
||||
}
|
||||
|
||||
recordingsDirectory := configDirectory + "/data/recordings"
|
||||
cloudDirectory := configDirectory + "/data/cloud"
|
||||
|
||||
// Decide whether we still need to free up space. See recordingsNeedCleanup
|
||||
// for the two modes: an explicit fixed directory cap
|
||||
// (AGENT_AUTO_CLEAN_MAX_SIZE) or, by default, letting recordings use the whole
|
||||
// disk while keeping a free-space reserve.
|
||||
needsCleanup, err := recordingsNeedCleanup(recordingsDirectory, configuration)
|
||||
if err != nil {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
return
|
||||
}
|
||||
if !needsCleanup {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the oldest recording, but PREFER recordings that have already been
|
||||
// uploaded (i.e. no longer have a pending marker in data/cloud). This stops
|
||||
// auto-clean from deleting recordings that are still queued for upload. That
|
||||
// previously caused silent data loss: during a network outage the upload
|
||||
// backlog grows, cleanup deletes the oldest (still un-uploaded) recording to
|
||||
// stay under MaxDirectorySize, and when connectivity returns the upload loop
|
||||
// finds the marker but the file is gone -> the recording is dropped and never
|
||||
// reaches the vault.
|
||||
//
|
||||
// Only when EVERY recording on disk is still pending upload do we fall back to
|
||||
// deleting the oldest pending one, as a last resort to keep the disk bounded
|
||||
// (otherwise a long outage would fill the disk and stop new recordings).
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDirectory, cloudDirectory)
|
||||
if err != nil {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(recordingsDirectory + "/" + name); err != nil {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if pending {
|
||||
// Data-loss event: the whole recordings directory is an un-uploaded
|
||||
// backlog (e.g. a prolonged network outage), so we had to drop a recording
|
||||
// that was never uploaded to keep recording new footage. Also remove the
|
||||
// now-dangling upload marker so the upload loop doesn't keep trying to
|
||||
// upload a file that no longer exists.
|
||||
log.Log.Warning("HandleRecordStream: removed oldest recording as part of cleanup, but it was STILL PENDING UPLOAD (disk full of un-uploaded recordings) - " + recordingsDirectory + "/" + name)
|
||||
for _, markerName := range uploadMarkerNames(name) {
|
||||
if err := os.Remove(filepath.Join(cloudDirectory, markerName)); err != nil && !os.IsNotExist(err) {
|
||||
log.Log.Info("HandleRecordStream: could not remove dangling upload marker " + markerName + ", " + err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("HandleRecordStream: removed oldest file as part of cleanup - " + recordingsDirectory + "/" + name)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingsNeedCleanup reports whether auto-clean should free up space in the
|
||||
// recordings directory. There are two modes:
|
||||
//
|
||||
// - AGENT_AUTO_CLEAN_MAX_SIZE (MaxDirectorySize, MB) set: cap the size of the
|
||||
// recordings directory itself (the historical behaviour).
|
||||
// - MaxDirectorySize == 0 (the default): recordings may use the WHOLE disk.
|
||||
// Cleanup only triggers once the free space on the recordings filesystem
|
||||
// drops to/below a reserve. The reserve is AGENT_AUTO_CLEAN_MIN_FREE_SPACE
|
||||
// (MinFreeSpace, MB) when set, otherwise 5% of the disk's total capacity.
|
||||
//
|
||||
// If disk stats can't be read (e.g. non-Linux dev builds) it falls back to the
|
||||
// historical fixed 300 MB directory cap so behaviour stays bounded.
|
||||
func recordingsNeedCleanup(recordingsDirectory string, configuration *models.Configuration) (bool, error) {
|
||||
maxSize := configuration.Config.MaxDirectorySize
|
||||
|
||||
// Explicit fixed cap on the recordings directory size.
|
||||
if maxSize > 0 {
|
||||
size, err := utils.DirSize(recordingsDirectory)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return size/1000/1000 >= maxSize, nil
|
||||
}
|
||||
|
||||
// Default: allow recordings to use the full disk, keeping a reserve free.
|
||||
totalMB, availableMB, err := diskUsageMB(recordingsDirectory)
|
||||
if err != nil {
|
||||
// Disk stats unavailable: fall back to the historical 300 MB cap.
|
||||
size, derr := utils.DirSize(recordingsDirectory)
|
||||
if derr != nil {
|
||||
return false, derr
|
||||
}
|
||||
return size/1000/1000 >= 300, nil
|
||||
}
|
||||
|
||||
reserveMB := configuration.Config.MinFreeSpace
|
||||
if reserveMB <= 0 {
|
||||
reserveMB = defaultReserveMB(totalMB)
|
||||
}
|
||||
|
||||
return availableMB <= reserveMB, nil
|
||||
}
|
||||
|
||||
// defaultReserveMB returns the free-space reserve (MB) to keep on the recordings
|
||||
// disk when AGENT_AUTO_CLEAN_MIN_FREE_SPACE is not set: 5% of the disk total,
|
||||
// but never below 1MB. On very small disks 5% truncates to 0MB, which would
|
||||
// disable the reserve entirely (cleanup only once availableMB <= 0), so we floor
|
||||
// it at 1MB to preserve the intended "keep some space free" behaviour.
|
||||
func defaultReserveMB(totalMB int64) int64 {
|
||||
reserveMB := totalMB * 5 / 100
|
||||
if reserveMB < 1 {
|
||||
reserveMB = 1
|
||||
}
|
||||
return reserveMB
|
||||
}
|
||||
|
||||
// pickRecordingToCleanup chooses which recording to delete to free space in the
|
||||
// recordings directory. It returns the oldest recording that has already been
|
||||
// uploaded (no pending marker with the same name in cloudDirectory). Only when
|
||||
// every recording is still pending upload does it return the oldest recording
|
||||
// overall with pending=true, signalling the caller that it is about to drop an
|
||||
// un-uploaded recording as a last resort.
|
||||
func pickRecordingToCleanup(recordingsDirectory, cloudDirectory string) (string, bool, error) {
|
||||
entries, err := os.ReadDir(recordingsDirectory)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var oldestSafeName, oldestAnyName string
|
||||
var oldestSafeTime, oldestAnyTime time.Time
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
modTime := info.ModTime()
|
||||
|
||||
if oldestAnyName == "" || modTime.Before(oldestAnyTime) {
|
||||
oldestAnyName = entry.Name()
|
||||
oldestAnyTime = modTime
|
||||
}
|
||||
|
||||
// A recording is still pending upload if either its current .metadata
|
||||
// marker or a marker created by an older agent exists.
|
||||
if recordingPendingUpload(cloudDirectory, entry.Name()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if oldestSafeName == "" || modTime.Before(oldestSafeTime) {
|
||||
oldestSafeName = entry.Name()
|
||||
oldestSafeTime = modTime
|
||||
}
|
||||
}
|
||||
|
||||
if oldestSafeName != "" {
|
||||
return oldestSafeName, false, nil
|
||||
}
|
||||
if oldestAnyName != "" {
|
||||
return oldestAnyName, true, nil
|
||||
}
|
||||
return "", false, os.ErrNotExist
|
||||
}
|
||||
|
||||
func uploadMarkerNames(recordingName string) []string {
|
||||
return []string{models.RecordingUploadMetadataFileName(recordingName), filepath.Base(recordingName)}
|
||||
}
|
||||
|
||||
func recordingPendingUpload(cloudDirectory, recordingName string) bool {
|
||||
for _, markerName := range uploadMarkerNames(recordingName) {
|
||||
if _, err := os.Stat(filepath.Join(cloudDirectory, markerName)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func HandleRecordStream(queue *packets.Queue, configDirectory string, configuration *models.Configuration, communication *models.Communication, rtspClient RTSPClient, mqttClient mqtt.Client) {
|
||||
|
||||
config := configuration.Config
|
||||
hubKey := config.HubKey
|
||||
loc, _ := time.LoadLocation(config.Timezone)
|
||||
|
||||
// Start each capture session with manual recording off, so a leftover
|
||||
// request from before a restart/reconnect doesn't silently persist.
|
||||
communication.IsRecordingManual.UnSet()
|
||||
communication.RecordingManualHeartbeat.Store(0)
|
||||
communication.RecordingManualStart.Store(0)
|
||||
communication.RecordingManualHeartbeatSeen.UnSet()
|
||||
|
||||
if config.Capture.Recording == "false" {
|
||||
log.Log.Info("capture.main.HandleRecordStream(): disabled, we will not record anything.")
|
||||
} else {
|
||||
@@ -217,12 +502,13 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
// Create a symbol link.
|
||||
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
|
||||
fc.Close()
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video))
|
||||
|
||||
recordingStatus = "idle"
|
||||
|
||||
// Notify the hub / live-view UI that this camera stopped recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, false)
|
||||
|
||||
// Clean up the recording directory if necessary.
|
||||
CleanupRecordingDirectory(configDirectory, configuration)
|
||||
}
|
||||
@@ -299,6 +585,9 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
recordingStatus = "started"
|
||||
|
||||
// Notify the hub / live-view UI that this camera started recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, true)
|
||||
|
||||
} else if start {
|
||||
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
@@ -370,12 +659,13 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
// Create a symbol link.
|
||||
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
|
||||
fc.Close()
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video))
|
||||
|
||||
recordingStatus = "idle"
|
||||
|
||||
// Notify the hub / live-view UI that this camera stopped recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, false)
|
||||
|
||||
// Clean up the recording directory if necessary.
|
||||
CleanupRecordingDirectory(configDirectory, configuration)
|
||||
}
|
||||
@@ -492,6 +782,17 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
default:
|
||||
}
|
||||
|
||||
// While a manual recording is active, keep it alive: refresh the
|
||||
// motion timestamp every iteration so the post-recording timeout
|
||||
// never fires. The clip still rolls over at maxRecordingPeriod and
|
||||
// is restarted below, until the viewer stops the manual recording.
|
||||
// It also auto-stops when the viewer's heartbeat lapses (closed page
|
||||
// or idle) or the max remote-recording duration is reached, so a
|
||||
// missed "stop" message can't keep the camera recording forever.
|
||||
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, now) {
|
||||
motionTimestamp = now
|
||||
}
|
||||
|
||||
if start && (motionTimestamp+postRecording-now < 0 || now-startRecording > maxRecordingPeriod-500) && nextPkt.IsKeyFrame {
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): timestamp+postRecording-now < 0 - " + strconv.FormatInt(motionTimestamp+postRecording-now, 10) + " < 0")
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): now-startRecording > maxRecordingPeriod-500 - " + strconv.FormatInt(now-startRecording, 10) + " > " + strconv.FormatInt(maxRecordingPeriod-500, 10))
|
||||
@@ -523,6 +824,9 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
start = true
|
||||
|
||||
// Notify the hub / live-view UI that this camera started recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, true)
|
||||
}
|
||||
if start {
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
@@ -557,6 +861,21 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
mp4Video.Close(&config)
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): file save: " + name)
|
||||
|
||||
// Notify the hub / live-view UI that this camera stopped recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, false)
|
||||
|
||||
// If the viewer still has a manual recording running, this clip just
|
||||
// rolled over at the max length — immediately kick off the next
|
||||
// segment so recording stays continuous until they stop it. Skip the
|
||||
// restart when the recording has expired (heartbeat lapsed or max
|
||||
// duration reached), so it ends here instead of recording forever.
|
||||
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, time.Now().UnixMilli()) {
|
||||
select {
|
||||
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Update the name of the recording with the duration.
|
||||
// We will update the name of the recording with the duration in milliseconds.
|
||||
if mp4Video.VideoTotalDuration > 0 {
|
||||
@@ -610,9 +929,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
// Create a symbol linc.
|
||||
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
|
||||
fc.Close()
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, displayTime, mp4Video))
|
||||
|
||||
// Clean up the recording directory if necessary.
|
||||
CleanupRecordingDirectory(configDirectory, configuration)
|
||||
|
||||
88
machinery/src/capture/main_test.go
Normal file
88
machinery/src/capture/main_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
func TestPTSToDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pts int64
|
||||
clockRate int
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "one video second", pts: 90_000, clockRate: 90_000, want: time.Second},
|
||||
{name: "one audio frame", pts: 1_024, clockRate: 8_000, want: 128 * time.Millisecond},
|
||||
{name: "fractional millisecond", pts: 45_045, clockRate: 90_000, want: 500*time.Millisecond + 500*time.Microsecond},
|
||||
{name: "negative timestamp", pts: -45_045, clockRate: 90_000, want: -500*time.Millisecond - 500*time.Microsecond},
|
||||
{name: "large timestamp", pts: 90_000 * 60 * 60 * 24, clockRate: 90_000, want: 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := ptsToDuration(test.pts, test.clockRate); got != test.want {
|
||||
t.Fatalf("ptsToDuration(%d, %d) = %s, want %s", test.pts, test.clockRate, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
|
||||
configDirectory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(configDirectory, "data", "cloud"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
|
||||
mp4Video := &video.MP4{VideoTotalDuration: 20452, SampleCount: 613}
|
||||
metadata := recordingUploadMetadata("recording.mp4", "device-key", 1785934709414, mp4Video)
|
||||
queueRecordingForUpload(configDirectory, metadata)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upload marker: %v", err)
|
||||
}
|
||||
var stored models.RecordingUploadMetadata
|
||||
if err := json.Unmarshal(got, &stored); err != nil {
|
||||
t.Fatalf("decode upload marker: %v", err)
|
||||
}
|
||||
expectedFPS := mp4Video.AverageFPS()
|
||||
if stored.FileName != "recording.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 {
|
||||
t.Fatalf("upload marker = %+v", stored)
|
||||
}
|
||||
if stored.FPS == math.Floor(stored.FPS) {
|
||||
t.Fatalf("upload marker FPS = %v, want fractional precision", stored.FPS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueRecordingForUploadKeepsUnknownFPSCompatible(t *testing.T) {
|
||||
for _, fps := range []float64{0, 0.99, -1, math.NaN(), math.Inf(1), 241} {
|
||||
t.Run("invalid FPS", func(t *testing.T) {
|
||||
configDirectory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(configDirectory, "data", "cloud"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
|
||||
metadata := models.RecordingUploadMetadata{FileName: "recording.mp4"}
|
||||
if fps >= 1 && fps <= 240 && !math.IsNaN(fps) && !math.IsInf(fps, 0) {
|
||||
metadata.FPS = fps
|
||||
}
|
||||
queueRecordingForUpload(configDirectory, metadata)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upload marker: %v", err)
|
||||
}
|
||||
if string(got) != `{"filename":"recording.mp4","device_key":"","timestamp":0,"duration":0}` {
|
||||
t.Fatalf("upload marker = %q, want metadata without FPS", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -79,7 +78,8 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
default:
|
||||
}
|
||||
|
||||
fileName := f.Name()
|
||||
markerFileName := f.Name()
|
||||
fileName := models.RecordingFileNameFromUploadMarker(markerFileName)
|
||||
uploaded := false
|
||||
configured := false
|
||||
err = nil
|
||||
@@ -114,7 +114,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
// Check if the file is uploaded, if so, remove it.
|
||||
if uploaded {
|
||||
delay = 500 * time.Millisecond // reset
|
||||
err := os.Remove(watchDirectory + fileName)
|
||||
err := os.Remove(watchDirectory + markerFileName)
|
||||
if err != nil {
|
||||
log.Log.Error("HandleUpload: " + err.Error())
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
}
|
||||
}
|
||||
} else if !configured {
|
||||
err := os.Remove(watchDirectory + fileName)
|
||||
err := os.Remove(watchDirectory + markerFileName)
|
||||
if err != nil {
|
||||
log.Log.Error("HandleUpload: " + err.Error())
|
||||
}
|
||||
@@ -219,17 +219,33 @@ func GetSystemInfo() (models.System, error) {
|
||||
return system, nil
|
||||
}
|
||||
|
||||
// rawJSONOrEmptyArray returns pre-marshalled JSON bytes as a json.RawMessage.
|
||||
// When the input is empty it falls back to an empty JSON array so the
|
||||
// surrounding payload always stays valid JSON.
|
||||
func rawJSONOrEmptyArray(b []byte) json.RawMessage {
|
||||
if len(b) == 0 {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
|
||||
func HandleHeartBeat(configuration *models.Configuration, communication *models.Communication, uptimeStart time.Time) {
|
||||
log.Log.Debug("cloud.HandleHeartBeat(): started")
|
||||
|
||||
// Bound every heartbeat POST so a stalled connection (e.g. a saturated uplink
|
||||
// or an unresponsive Hub/Vault) fails fast on this cycle instead of blocking
|
||||
// the whole heartbeat loop indefinitely. A hung POST would otherwise stop all
|
||||
// further heartbeats, and Hub marks a camera offline once its last heartbeat is
|
||||
// older than 180s even while capture is healthy.
|
||||
const heartbeatHTTPTimeout = 30 * time.Second
|
||||
var client *http.Client
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
client = &http.Client{Transport: tr, Timeout: heartbeatHTTPTimeout}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
client = &http.Client{Timeout: heartbeatHTTPTimeout}
|
||||
}
|
||||
|
||||
kerberosAgentVersion := utils.VERSION
|
||||
@@ -472,6 +488,14 @@ loop:
|
||||
hasBackChannel = "true"
|
||||
}
|
||||
|
||||
// Whether this camera records continuously (24/7) rather than on
|
||||
// motion. The Hub live view uses this to disable the manual record
|
||||
// button, which is a no-op in continuous mode (already recording).
|
||||
continuousRecording := "false"
|
||||
if config.Capture.Continuous == "true" {
|
||||
continuousRecording = "true"
|
||||
}
|
||||
|
||||
hub_encryption := "false"
|
||||
if config.HubEncryption == "true" {
|
||||
hub_encryption = "true"
|
||||
@@ -496,48 +520,98 @@ loop:
|
||||
// We need a hub URI and hub public key before we will send a heartbeat
|
||||
if hubURI != "" && key != "" {
|
||||
|
||||
var object = fmt.Sprintf(`{
|
||||
"key" : "%s",
|
||||
"version" : "%s",
|
||||
"hub_encryption": "%s",
|
||||
"e2e_encryption": "%s",
|
||||
"release" : "%s",
|
||||
"cpuid" : "%s",
|
||||
"clouduser" : "%s",
|
||||
"cloudpublickey" : "%s",
|
||||
"cameraname" : "%s",
|
||||
"enterprise" : %t,
|
||||
"hostname" : "%s",
|
||||
"architecture" : "%s",
|
||||
"totalMemory" : "%d",
|
||||
"usedMemory" : "%d",
|
||||
"freeMemory" : "%d",
|
||||
"processMemory" : "%d",
|
||||
"mac_list" : %s,
|
||||
"ip_list" : %s,
|
||||
"board" : "",
|
||||
"disk1size" : "%s",
|
||||
"disk3size" : "%s",
|
||||
"diskvdasize" : "%s",
|
||||
"uptime" : "%s",
|
||||
"boot_time" : "%s",
|
||||
"siteID" : "%s",
|
||||
"onvif" : "%s",
|
||||
"onvif_zoom" : "%s",
|
||||
"onvif_pantilt" : "%s",
|
||||
"onvif_presets": "%s",
|
||||
"onvif_presets_list": %s,
|
||||
"onvif_events_list": %s,
|
||||
"cameraConnected": "%s",
|
||||
"hasBackChannel": "%s",
|
||||
"livePreviewHttp": true,
|
||||
"numberoffiles" : "33",
|
||||
"timestamp" : 1564747908,
|
||||
"cameratype" : "IPCamera",
|
||||
"docker" : true,
|
||||
"kios" : false,
|
||||
"raspberrypi" : false
|
||||
}`, config.Key, kerberosAgentVersion, hub_encryption, e2e_encryption, system.Version, system.CPUId, username, key, name, isEnterprise, system.Hostname, system.Architecture, system.TotalMemory, system.UsedMemory, system.FreeMemory, system.ProcessUsedMemory, macs, ips, "0", "0", "0", uptimeString, boottimeString, config.HubSite, onvifEnabled, onvifZoom, onvifPanTilt, onvifPresets, onvifPresetsList, onvifEventsList, cameraConnected, hasBackChannel)
|
||||
heartbeat := struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
HubEncryption string `json:"hub_encryption"`
|
||||
E2EEncryption string `json:"e2e_encryption"`
|
||||
Release string `json:"release"`
|
||||
CPUId string `json:"cpuid"`
|
||||
CloudUser string `json:"clouduser"`
|
||||
CloudPublicKey string `json:"cloudpublickey"`
|
||||
CameraName string `json:"cameraname"`
|
||||
Enterprise bool `json:"enterprise"`
|
||||
Hostname string `json:"hostname"`
|
||||
Architecture string `json:"architecture"`
|
||||
TotalMemory string `json:"totalMemory"`
|
||||
UsedMemory string `json:"usedMemory"`
|
||||
FreeMemory string `json:"freeMemory"`
|
||||
ProcessMemory string `json:"processMemory"`
|
||||
MacList json.RawMessage `json:"mac_list"`
|
||||
IPList json.RawMessage `json:"ip_list"`
|
||||
Board string `json:"board"`
|
||||
Disk1Size string `json:"disk1size"`
|
||||
Disk3Size string `json:"disk3size"`
|
||||
DiskVdaSize string `json:"diskvdasize"`
|
||||
Uptime string `json:"uptime"`
|
||||
BootTime string `json:"boot_time"`
|
||||
SiteID string `json:"siteID"`
|
||||
Onvif string `json:"onvif"`
|
||||
OnvifZoom string `json:"onvif_zoom"`
|
||||
OnvifPanTilt string `json:"onvif_pantilt"`
|
||||
OnvifPresets string `json:"onvif_presets"`
|
||||
OnvifPresetsList json.RawMessage `json:"onvif_presets_list"`
|
||||
OnvifEventsList json.RawMessage `json:"onvif_events_list"`
|
||||
CameraConnected string `json:"cameraConnected"`
|
||||
HasBackChannel string `json:"hasBackChannel"`
|
||||
ContinuousRecording string `json:"continuousRecording"`
|
||||
LivePreviewHTTP bool `json:"livePreviewHttp"`
|
||||
NumberOfFiles string `json:"numberoffiles"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CameraType string `json:"cameratype"`
|
||||
Docker bool `json:"docker"`
|
||||
Kios bool `json:"kios"`
|
||||
RaspberryPi bool `json:"raspberrypi"`
|
||||
}{
|
||||
Key: config.Key,
|
||||
Version: kerberosAgentVersion,
|
||||
HubEncryption: hub_encryption,
|
||||
E2EEncryption: e2e_encryption,
|
||||
Release: system.Version,
|
||||
CPUId: system.CPUId,
|
||||
CloudUser: username,
|
||||
CloudPublicKey: key,
|
||||
CameraName: name,
|
||||
Enterprise: isEnterprise,
|
||||
Hostname: system.Hostname,
|
||||
Architecture: system.Architecture,
|
||||
TotalMemory: strconv.FormatUint(system.TotalMemory, 10),
|
||||
UsedMemory: strconv.FormatUint(system.UsedMemory, 10),
|
||||
FreeMemory: strconv.FormatUint(system.FreeMemory, 10),
|
||||
ProcessMemory: strconv.FormatUint(system.ProcessUsedMemory, 10),
|
||||
MacList: rawJSONOrEmptyArray(macs),
|
||||
IPList: rawJSONOrEmptyArray(ips),
|
||||
Board: "",
|
||||
Disk1Size: "0",
|
||||
Disk3Size: "0",
|
||||
DiskVdaSize: "0",
|
||||
Uptime: uptimeString,
|
||||
BootTime: boottimeString,
|
||||
SiteID: config.HubSite,
|
||||
Onvif: onvifEnabled,
|
||||
OnvifZoom: onvifZoom,
|
||||
OnvifPanTilt: onvifPanTilt,
|
||||
OnvifPresets: onvifPresets,
|
||||
OnvifPresetsList: rawJSONOrEmptyArray(onvifPresetsList),
|
||||
OnvifEventsList: rawJSONOrEmptyArray(onvifEventsList),
|
||||
CameraConnected: cameraConnected,
|
||||
HasBackChannel: hasBackChannel,
|
||||
ContinuousRecording: continuousRecording,
|
||||
LivePreviewHTTP: true,
|
||||
NumberOfFiles: "33",
|
||||
Timestamp: 1564747908,
|
||||
CameraType: "IPCamera",
|
||||
Docker: true,
|
||||
Kios: false,
|
||||
RaspberryPi: false,
|
||||
}
|
||||
|
||||
objectBytes, err := json.Marshal(heartbeat)
|
||||
if err != nil {
|
||||
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling heartbeat: " + err.Error())
|
||||
objectBytes = []byte("{}")
|
||||
}
|
||||
object := string(objectBytes)
|
||||
|
||||
// Get the private key to encrypt the data using symmetric encryption: AES.
|
||||
privateKey := config.HubPrivateKey
|
||||
@@ -551,11 +625,21 @@ loop:
|
||||
|
||||
// Base64 encode the encrypted data.
|
||||
encryptedBase64 := base64.StdEncoding.EncodeToString(encrypted)
|
||||
object = fmt.Sprintf(`{
|
||||
"cloudpublicKey": "%s",
|
||||
"encrypted" : %t,
|
||||
"encryptedData" : "%s"
|
||||
}`, config.HubKey, true, encryptedBase64)
|
||||
encryptedPayload := struct {
|
||||
CloudPublicKey string `json:"cloudpublicKey"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
EncryptedData string `json:"encryptedData"`
|
||||
}{
|
||||
CloudPublicKey: config.HubKey,
|
||||
Encrypted: true,
|
||||
EncryptedData: encryptedBase64,
|
||||
}
|
||||
encryptedBytes, err := json.Marshal(encryptedPayload)
|
||||
if err != nil {
|
||||
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling encrypted heartbeat: " + err.Error())
|
||||
encryptedBytes = []byte("{}")
|
||||
}
|
||||
object = string(encryptedBytes)
|
||||
}
|
||||
|
||||
var jsonStr = []byte(object)
|
||||
@@ -586,43 +670,86 @@ loop:
|
||||
secretAccessKey := config.KStorage.SecretAccessKey
|
||||
if vaultURI != "" && accessKey != "" && secretAccessKey != "" {
|
||||
|
||||
var object = fmt.Sprintf(`{
|
||||
"key" : "%s",
|
||||
"version" : "%s",
|
||||
"release" : "%s",
|
||||
"cpuid" : "%s",
|
||||
"clouduser" : "%s",
|
||||
"cloudpublickey" : "%s",
|
||||
"cameraname" : "%s",
|
||||
"enterprise" : %t,
|
||||
"hostname" : "%s",
|
||||
"architecture" : "%s",
|
||||
"totalMemory" : "%d",
|
||||
"usedMemory" : "%d",
|
||||
"freeMemory" : "%d",
|
||||
"processMemory" : "%d",
|
||||
"mac_list" : %s,
|
||||
"ip_list" : %s,
|
||||
"board" : "",
|
||||
"disk1size" : "%s",
|
||||
"disk3size" : "%s",
|
||||
"diskvdasize" : "%s",
|
||||
"uptime" : "%s",
|
||||
"boot_time" : "%s",
|
||||
"siteID" : "%s",
|
||||
"onvif" : "%s",
|
||||
"onvif_zoom" : "%s",
|
||||
"onvif_pantilt" : "%s",
|
||||
"onvif_presets": "%s",
|
||||
"onvif_presets_list": %s,
|
||||
"cameraConnected": "%s",
|
||||
"numberoffiles" : "33",
|
||||
"timestamp" : 1564747908,
|
||||
"cameratype" : "IPCamera",
|
||||
"docker" : true,
|
||||
"kios" : false,
|
||||
"raspberrypi" : false
|
||||
}`, config.Key, kerberosAgentVersion, system.Version, system.CPUId, username, key, name, isEnterprise, system.Hostname, system.Architecture, system.TotalMemory, system.UsedMemory, system.FreeMemory, system.ProcessUsedMemory, macs, ips, "0", "0", "0", uptimeString, boottimeString, config.HubSite, onvifEnabled, onvifZoom, onvifPanTilt, onvifPresets, onvifPresetsList, cameraConnected)
|
||||
heartbeat := struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
Release string `json:"release"`
|
||||
CPUId string `json:"cpuid"`
|
||||
CloudUser string `json:"clouduser"`
|
||||
CloudPublicKey string `json:"cloudpublickey"`
|
||||
CameraName string `json:"cameraname"`
|
||||
Enterprise bool `json:"enterprise"`
|
||||
Hostname string `json:"hostname"`
|
||||
Architecture string `json:"architecture"`
|
||||
TotalMemory string `json:"totalMemory"`
|
||||
UsedMemory string `json:"usedMemory"`
|
||||
FreeMemory string `json:"freeMemory"`
|
||||
ProcessMemory string `json:"processMemory"`
|
||||
MacList json.RawMessage `json:"mac_list"`
|
||||
IPList json.RawMessage `json:"ip_list"`
|
||||
Board string `json:"board"`
|
||||
Disk1Size string `json:"disk1size"`
|
||||
Disk3Size string `json:"disk3size"`
|
||||
DiskVdaSize string `json:"diskvdasize"`
|
||||
Uptime string `json:"uptime"`
|
||||
BootTime string `json:"boot_time"`
|
||||
SiteID string `json:"siteID"`
|
||||
Onvif string `json:"onvif"`
|
||||
OnvifZoom string `json:"onvif_zoom"`
|
||||
OnvifPanTilt string `json:"onvif_pantilt"`
|
||||
OnvifPresets string `json:"onvif_presets"`
|
||||
OnvifPresetsList json.RawMessage `json:"onvif_presets_list"`
|
||||
CameraConnected string `json:"cameraConnected"`
|
||||
NumberOfFiles string `json:"numberoffiles"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CameraType string `json:"cameratype"`
|
||||
Docker bool `json:"docker"`
|
||||
Kios bool `json:"kios"`
|
||||
RaspberryPi bool `json:"raspberrypi"`
|
||||
}{
|
||||
Key: config.Key,
|
||||
Version: kerberosAgentVersion,
|
||||
Release: system.Version,
|
||||
CPUId: system.CPUId,
|
||||
CloudUser: username,
|
||||
CloudPublicKey: key,
|
||||
CameraName: name,
|
||||
Enterprise: isEnterprise,
|
||||
Hostname: system.Hostname,
|
||||
Architecture: system.Architecture,
|
||||
TotalMemory: strconv.FormatUint(system.TotalMemory, 10),
|
||||
UsedMemory: strconv.FormatUint(system.UsedMemory, 10),
|
||||
FreeMemory: strconv.FormatUint(system.FreeMemory, 10),
|
||||
ProcessMemory: strconv.FormatUint(system.ProcessUsedMemory, 10),
|
||||
MacList: rawJSONOrEmptyArray(macs),
|
||||
IPList: rawJSONOrEmptyArray(ips),
|
||||
Board: "",
|
||||
Disk1Size: "0",
|
||||
Disk3Size: "0",
|
||||
DiskVdaSize: "0",
|
||||
Uptime: uptimeString,
|
||||
BootTime: boottimeString,
|
||||
SiteID: config.HubSite,
|
||||
Onvif: onvifEnabled,
|
||||
OnvifZoom: onvifZoom,
|
||||
OnvifPanTilt: onvifPanTilt,
|
||||
OnvifPresets: onvifPresets,
|
||||
OnvifPresetsList: rawJSONOrEmptyArray(onvifPresetsList),
|
||||
CameraConnected: cameraConnected,
|
||||
NumberOfFiles: "33",
|
||||
Timestamp: 1564747908,
|
||||
CameraType: "IPCamera",
|
||||
Docker: true,
|
||||
Kios: false,
|
||||
RaspberryPi: false,
|
||||
}
|
||||
|
||||
objectBytes, err := json.Marshal(heartbeat)
|
||||
if err != nil {
|
||||
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling vault heartbeat: " + err.Error())
|
||||
objectBytes = []byte("{}")
|
||||
}
|
||||
object := string(objectBytes)
|
||||
|
||||
var jsonStr = []byte(object)
|
||||
buffy := bytes.NewBuffer(jsonStr)
|
||||
|
||||
@@ -85,6 +85,7 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
req.Header.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
req.Header.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
req.Header.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
|
||||
var client *http.Client
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
@@ -128,6 +129,7 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
req.Header.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
req.Header.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
req.Header.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
resp, err = client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
@@ -164,6 +165,7 @@ func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, la
|
||||
}
|
||||
req.Header.Set("Content-Type", "video/mp4")
|
||||
setVaultHeaders(req.Header, vault, publicKey, deviceKey, fileName)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
|
||||
client := newVaultHTTPClient(0)
|
||||
resp, err := client.Do(req)
|
||||
@@ -199,17 +201,59 @@ func setVaultHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey,
|
||||
}
|
||||
|
||||
// newVaultHTTPClient builds an HTTP client honouring the AGENT_TLS_INSECURE
|
||||
// escape hatch. A timeout of 0 disables the client-level timeout, which is
|
||||
// required for streaming large upload bodies.
|
||||
// escape hatch. A timeout of 0 disables the *overall* client timeout, which is
|
||||
// required for streaming large upload bodies without capping the total transfer
|
||||
// time. Transport-level timeouts are still applied so that a lost network
|
||||
// connection (for example the internet being disconnected) fails reasonably
|
||||
// fast and the upload is retried, instead of the request hanging until the OS
|
||||
// TCP timeout (which can be many minutes) and blocking the whole upload loop.
|
||||
func newVaultHTTPClient(timeout time.Duration) *http.Client {
|
||||
client := &http.Client{}
|
||||
// Start from a clone of the default transport so we keep its sane dial and
|
||||
// TLS-handshake timeouts, connection pooling and HTTP/2 support even when the
|
||||
// AGENT_TLS_INSECURE escape hatch is enabled (a bare http.Transport would have
|
||||
// no dial/handshake timeouts at all).
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// ResponseHeaderTimeout bounds how long we wait for the vault's response
|
||||
// headers *after* the request body has been fully written. It does not limit
|
||||
// the time spent streaming the (potentially large) upload body, so big
|
||||
// recordings still upload fine, but a vault/network that disappears while we
|
||||
// wait for the acknowledgement is detected and the upload is retried instead
|
||||
// of hanging indefinitely.
|
||||
transport.ResponseHeaderTimeout = vaultResponseHeaderTimeout()
|
||||
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
if transport.TLSClientConfig == nil {
|
||||
transport.TLSClientConfig = &tls.Config{}
|
||||
}
|
||||
transport.TLSClientConfig.InsecureSkipVerify = true
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
if timeout > 0 {
|
||||
client.Timeout = timeout
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// vaultResponseHeaderTimeout returns the maximum time to wait for a vault's
|
||||
// response headers after the request body has been written. It defaults to 5
|
||||
// minutes — generous enough for the vault to persist/finalize a chunk or a full
|
||||
// recording to its storage provider — and can be tuned with the
|
||||
// AGENT_VAULT_RESPONSE_HEADER_TIMEOUT_SECONDS environment variable. A value of 0
|
||||
// (or a negative/invalid value) disables the timeout.
|
||||
func vaultResponseHeaderTimeout() time.Duration {
|
||||
const def = 5 * time.Minute
|
||||
v := os.Getenv("AGENT_VAULT_RESPONSE_HEADER_TIMEOUT_SECONDS")
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
|
||||
103
machinery/src/cloud/livemoq/annexb.go
Normal file
103
machinery/src/cloud/livemoq/annexb.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
var annexBStartCode = []byte{0x00, 0x00, 0x00, 0x01}
|
||||
|
||||
// H264NormalizationStats reports malformed duplication removed from an access unit.
|
||||
type H264NormalizationStats struct {
|
||||
DuplicateIDRNALUs int
|
||||
}
|
||||
|
||||
// EnsureAnnexB restores the start code stripped by the Agent capture queue.
|
||||
func EnsureAnnexB(payload []byte) []byte {
|
||||
if hasAnnexBStartCode(payload) {
|
||||
return payload
|
||||
}
|
||||
|
||||
framed := make([]byte, 0, len(annexBStartCode)+len(payload))
|
||||
framed = append(framed, annexBStartCode...)
|
||||
return append(framed, payload...)
|
||||
}
|
||||
|
||||
// NormalizeH264AccessUnit removes delimiters and exact duplicate parameter-set
|
||||
// or IDR NALUs that can confuse older MoQ splitters and decoders.
|
||||
func NormalizeH264AccessUnit(payload []byte) ([]byte, error) {
|
||||
normalized, _, err := NormalizeH264AccessUnitWithStats(payload)
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
// NormalizeH264AccessUnitWithStats also reports exact duplicate IDR NALUs.
|
||||
func NormalizeH264AccessUnitWithStats(payload []byte) ([]byte, H264NormalizationStats, error) {
|
||||
nalus, err := h264.AnnexBUnmarshal(EnsureAnnexB(payload))
|
||||
if err != nil {
|
||||
return nil, H264NormalizationStats{}, err
|
||||
}
|
||||
|
||||
stats := H264NormalizationStats{}
|
||||
normalized := make([][]byte, 0, len(nalus))
|
||||
for _, nalu := range nalus {
|
||||
if len(nalu) == 0 || nalu[0]&0x1f == 9 {
|
||||
continue
|
||||
}
|
||||
naluType := nalu[0] & 0x1f
|
||||
if naluType == 7 || naluType == 8 || naluType == 5 {
|
||||
duplicate := false
|
||||
for _, existing := range normalized {
|
||||
if bytes.Equal(existing, nalu) {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if duplicate {
|
||||
if naluType == 5 {
|
||||
stats.DuplicateIDRNALUs++
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
normalized = append(normalized, nalu)
|
||||
}
|
||||
|
||||
result, err := h264.AnnexBMarshal(normalized)
|
||||
return result, stats, err
|
||||
}
|
||||
|
||||
// BroadcastPath returns the relay path a quality tier is published on. Every
|
||||
// tier gets its own broadcast so a viewer switches between the camera's main and
|
||||
// sub stream by resubscribing to another path, without any control channel back
|
||||
// to the Agent. The high tier keeps the historical ".../live.hang" path so
|
||||
// existing viewers keep working; the low tier lives next to it on
|
||||
// ".../live-low.hang".
|
||||
func BroadcastPath(prefix string, deviceKey string, quality string) string {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix == "" {
|
||||
prefix = "devices"
|
||||
}
|
||||
name := "live.hang"
|
||||
if quality == models.StreamQualityLow {
|
||||
name = "live-low.hang"
|
||||
}
|
||||
return prefix + "/" + strings.Trim(deviceKey, "/") + "/" + name
|
||||
}
|
||||
|
||||
// TimestampUs converts the capture presentation timestamp from milliseconds.
|
||||
// CompositionTime must not be added: it is already represented in the PTS and
|
||||
// is only used by muxers to derive DTS for streams containing B-frames.
|
||||
func TimestampUs(presentationTimeMs int64) uint64 {
|
||||
if presentationTimeMs < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(presentationTimeMs) * 1000
|
||||
}
|
||||
|
||||
func hasAnnexBStartCode(payload []byte) bool {
|
||||
return len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 &&
|
||||
((payload[2] == 0 && payload[3] == 1) || payload[2] == 1)
|
||||
}
|
||||
116
machinery/src/cloud/livemoq/annexb_test.go
Normal file
116
machinery/src/cloud/livemoq/annexb_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestEnsureAnnexB(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
want []byte
|
||||
}{
|
||||
{
|
||||
name: "missing start code",
|
||||
payload: []byte{0x41, 0x01},
|
||||
want: []byte{0x00, 0x00, 0x00, 0x01, 0x41, 0x01},
|
||||
},
|
||||
{
|
||||
name: "four byte start code",
|
||||
payload: []byte{0x00, 0x00, 0x00, 0x01, 0x65},
|
||||
want: []byte{0x00, 0x00, 0x00, 0x01, 0x65},
|
||||
},
|
||||
{
|
||||
name: "three byte start code",
|
||||
payload: []byte{0x00, 0x00, 0x01, 0x41},
|
||||
want: []byte{0x00, 0x00, 0x01, 0x41},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := EnsureAnnexB(test.payload); !bytes.Equal(got, test.want) {
|
||||
t.Fatalf("EnsureAnnexB() = %x, want %x", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeH264AccessUnit(t *testing.T) {
|
||||
startCode := []byte{0x00, 0x00, 0x00, 0x01}
|
||||
sps := []byte{0x67, 0x42, 0x00, 0x1f}
|
||||
pps := []byte{0x68, 0xce, 0x06, 0xe2}
|
||||
aud := []byte{0x09, 0xf0}
|
||||
idr := []byte{0x65, 0x88, 0x84}
|
||||
|
||||
payload := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{sps, pps, aud, sps, pps, idr} {
|
||||
payload = append(payload, startCode...)
|
||||
payload = append(payload, nalu...)
|
||||
}
|
||||
|
||||
got, err := NormalizeH264AccessUnit(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{sps, pps, idr} {
|
||||
want = append(want, startCode...)
|
||||
want = append(want, nalu...)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("NormalizeH264AccessUnit() = %x, want %x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeH264AccessUnitRemovesOnlyExactDuplicateIDRSlices(t *testing.T) {
|
||||
startCode := []byte{0x00, 0x00, 0x00, 0x01}
|
||||
idrSlice1 := []byte{0x65, 0x88, 0x84}
|
||||
idrSlice2 := []byte{0x65, 0x44, 0x22}
|
||||
|
||||
payload := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{idrSlice1, idrSlice1, idrSlice2} {
|
||||
payload = append(payload, startCode...)
|
||||
payload = append(payload, nalu...)
|
||||
}
|
||||
|
||||
got, stats, err := NormalizeH264AccessUnitWithStats(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{idrSlice1, idrSlice2} {
|
||||
want = append(want, startCode...)
|
||||
want = append(want, nalu...)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("NormalizeH264AccessUnitWithStats() = %x, want %x", got, want)
|
||||
}
|
||||
if stats.DuplicateIDRNALUs != 1 {
|
||||
t.Fatalf("DuplicateIDRNALUs = %d, want 1", stats.DuplicateIDRNALUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastPath(t *testing.T) {
|
||||
if got := BroadcastPath("/devices/", "/camera-1/", models.StreamQualityHigh); got != "devices/camera-1/live.hang" {
|
||||
t.Fatalf("BroadcastPath() high = %q", got)
|
||||
}
|
||||
if got := BroadcastPath("", "camera-1", models.StreamQualityHigh); got != "devices/camera-1/live.hang" {
|
||||
t.Fatalf("BroadcastPath() default = %q", got)
|
||||
}
|
||||
if got := BroadcastPath("", "camera-1", models.StreamQualityLow); got != "devices/camera-1/live-low.hang" {
|
||||
t.Fatalf("BroadcastPath() low = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampUs(t *testing.T) {
|
||||
if got := TimestampUs(1234); got != 1_234_000 {
|
||||
t.Fatalf("TimestampUs() = %d, want 1234000", got)
|
||||
}
|
||||
if got := TimestampUs(-1); got != 0 {
|
||||
t.Fatalf("TimestampUs() negative = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
41
machinery/src/cloud/livemoq/dedup.go
Normal file
41
machinery/src/cloud/livemoq/dedup.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KeyframeDeduplicator struct {
|
||||
hasPrevious bool
|
||||
timestampMs int64
|
||||
capturedAtMs int64
|
||||
observedAt time.Time
|
||||
digest [sha256.Size]byte
|
||||
}
|
||||
|
||||
func (d *KeyframeDeduplicator) Reset() {
|
||||
*d = KeyframeDeduplicator{}
|
||||
}
|
||||
|
||||
// IsDuplicate reports exact repeated keyframe access units observed close
|
||||
// together. Distinct IDR slices within one access unit remain untouched.
|
||||
func (d *KeyframeDeduplicator) IsDuplicate(timestampMs int64, capturedAtMs int64, payload []byte, observedAt time.Time, window time.Duration) bool {
|
||||
digest := sha256.Sum256(payload)
|
||||
duplicate := d.hasPrevious && d.timestampMs == timestampMs && d.digest == digest
|
||||
if duplicate {
|
||||
if capturedAtMs > 0 && d.capturedAtMs > 0 {
|
||||
gap := time.Duration(capturedAtMs-d.capturedAtMs) * time.Millisecond
|
||||
duplicate = gap >= 0 && gap <= window
|
||||
} else {
|
||||
gap := observedAt.Sub(d.observedAt)
|
||||
duplicate = gap >= 0 && gap <= window
|
||||
}
|
||||
}
|
||||
|
||||
d.hasPrevious = true
|
||||
d.timestampMs = timestampMs
|
||||
d.capturedAtMs = capturedAtMs
|
||||
d.observedAt = observedAt
|
||||
d.digest = digest
|
||||
return duplicate
|
||||
}
|
||||
60
machinery/src/cloud/livemoq/dedup_test.go
Normal file
60
machinery/src/cloud/livemoq/dedup_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKeyframeDeduplicator(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
window := 500 * time.Millisecond
|
||||
payload := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
if deduplicator.IsDuplicate(1_000, 10_000, payload, now, window) {
|
||||
t.Fatal("first keyframe reported as duplicate")
|
||||
}
|
||||
if !deduplicator.IsDuplicate(1_000, 10_020, payload, now.Add(20*time.Millisecond), window) {
|
||||
t.Fatal("exact repeated keyframe was not reported as duplicate")
|
||||
}
|
||||
if deduplicator.IsDuplicate(2_000, 11_000, payload, now.Add(time.Second), window) {
|
||||
t.Fatal("same payload with a new timestamp reported as duplicate")
|
||||
}
|
||||
if deduplicator.IsDuplicate(2_000, 11_020, append(payload, 0x01), now.Add(1020*time.Millisecond), window) {
|
||||
t.Fatal("different payload with the same timestamp reported as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorAllowsTimestampReuseOutsideWindow(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 10_000, payload, now, 500*time.Millisecond)
|
||||
if deduplicator.IsDuplicate(1_000, 20_000, payload, now.Add(10*time.Second), 500*time.Millisecond) {
|
||||
t.Fatal("later keyframe after timestamp reset reported as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorFallsBackToObservationTime(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 0, payload, now, 500*time.Millisecond)
|
||||
if !deduplicator.IsDuplicate(1_000, 0, payload, now.Add(20*time.Millisecond), 500*time.Millisecond) {
|
||||
t.Fatal("duplicate without capture time was not reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorReset(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 10_000, payload, now, 500*time.Millisecond)
|
||||
deduplicator.Reset()
|
||||
if deduplicator.IsDuplicate(1_000, 10_020, payload, now.Add(20*time.Millisecond), 500*time.Millisecond) {
|
||||
t.Fatal("first keyframe after reset reported as duplicate")
|
||||
}
|
||||
}
|
||||
55
machinery/src/cloud/livemoq/recovery.go
Normal file
55
machinery/src/cloud/livemoq/recovery.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package livemoq
|
||||
|
||||
import "time"
|
||||
|
||||
type FrameGateEvent uint8
|
||||
|
||||
const (
|
||||
FrameGateEventNone FrameGateEvent = iota
|
||||
FrameGateEventStarted
|
||||
FrameGateEventLagging
|
||||
FrameGateEventRecovered
|
||||
)
|
||||
|
||||
// FrameGate keeps publication on a decodable, recent GOP.
|
||||
type FrameGate struct {
|
||||
started bool
|
||||
recovering bool
|
||||
}
|
||||
|
||||
// Reset closes the gate so publication resumes on the next keyframe. It is used
|
||||
// when the publisher stopped writing for a reason unrelated to the stream health
|
||||
// (no subscribers), so the next viewer never receives a partial GOP.
|
||||
func (g *FrameGate) Reset() {
|
||||
g.started = false
|
||||
g.recovering = false
|
||||
}
|
||||
|
||||
// Allow rejects stale frames and waits for a fresh keyframe before reopening.
|
||||
func (g *FrameGate) Allow(isKeyFrame bool, capturedAtMs int64, now time.Time, maxAge time.Duration) (bool, FrameGateEvent) {
|
||||
if capturedAtMs > 0 && now.Sub(time.UnixMilli(capturedAtMs)) > maxAge {
|
||||
event := FrameGateEventNone
|
||||
if g.started {
|
||||
if !g.recovering {
|
||||
event = FrameGateEventLagging
|
||||
}
|
||||
g.started = false
|
||||
g.recovering = true
|
||||
}
|
||||
return false, event
|
||||
}
|
||||
|
||||
if !g.started {
|
||||
if !isKeyFrame {
|
||||
return false, FrameGateEventNone
|
||||
}
|
||||
g.started = true
|
||||
if g.recovering {
|
||||
g.recovering = false
|
||||
return true, FrameGateEventRecovered
|
||||
}
|
||||
return true, FrameGateEventStarted
|
||||
}
|
||||
|
||||
return true, FrameGateEventNone
|
||||
}
|
||||
49
machinery/src/cloud/livemoq/recovery_test.go
Normal file
49
machinery/src/cloud/livemoq/recovery_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFrameGateRecoversAtFreshKeyframe(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
maxAge := 1500 * time.Millisecond
|
||||
gate := FrameGate{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isKeyFrame bool
|
||||
capturedAtMs int64
|
||||
wantAllowed bool
|
||||
wantEvent FrameGateEvent
|
||||
}{
|
||||
{name: "waits for initial keyframe", capturedAtMs: 10_000},
|
||||
{name: "starts at initial keyframe", isKeyFrame: true, capturedAtMs: 10_000, wantAllowed: true, wantEvent: FrameGateEventStarted},
|
||||
{name: "publishes fresh delta", capturedAtMs: 10_020, wantAllowed: true},
|
||||
{name: "detects stale packet", capturedAtMs: 8_000, wantEvent: FrameGateEventLagging},
|
||||
{name: "rejects fresh delta while recovering", capturedAtMs: 10_040},
|
||||
{name: "rejects stale keyframe without duplicate event", isKeyFrame: true, capturedAtMs: 8_000},
|
||||
{name: "recovers at fresh keyframe", isKeyFrame: true, capturedAtMs: 10_060, wantAllowed: true, wantEvent: FrameGateEventRecovered},
|
||||
{name: "publishes delta after recovery", capturedAtMs: 10_080, wantAllowed: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
allowed, event := gate.Allow(test.isKeyFrame, test.capturedAtMs, now, maxAge)
|
||||
if allowed != test.wantAllowed {
|
||||
t.Fatalf("Allow() allowed = %t, want %t", allowed, test.wantAllowed)
|
||||
}
|
||||
if event != test.wantEvent {
|
||||
t.Fatalf("Allow() event = %d, want %d", event, test.wantEvent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameGateAllowsMissingCaptureTime(t *testing.T) {
|
||||
gate := FrameGate{}
|
||||
allowed, event := gate.Allow(true, 0, time.Now(), time.Second)
|
||||
if !allowed || event != FrameGateEventStarted {
|
||||
t.Fatalf("Allow() = (%t, %d), want (true, %d)", allowed, event, FrameGateEventStarted)
|
||||
}
|
||||
}
|
||||
8
machinery/src/cloud/livemoq_disabled.go
Normal file
8
machinery/src/cloud/livemoq_disabled.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !moq
|
||||
|
||||
package cloud
|
||||
|
||||
import "github.com/kerberos-io/agent/machinery/src/models"
|
||||
|
||||
// StartLiveStreamMoQ is disabled in the standard Agent build.
|
||||
func StartLiveStreamMoQ(_ *models.Configuration, _ *models.Communication, _ bool) {}
|
||||
285
machinery/src/cloud/livemoq_enabled.go
Normal file
285
machinery/src/cloud/livemoq_enabled.go
Normal file
@@ -0,0 +1,285 @@
|
||||
//go:build moq
|
||||
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/cloud/livemoq"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/moq-dev/moq-go/moq"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMoQRelayURL = "https://relay.uug.ai/anon"
|
||||
minMoQRetryDelay = time.Second
|
||||
maxMoQRetryDelay = 30 * time.Second
|
||||
maxMoQLivePacketAge = 1500 * time.Millisecond
|
||||
slowMoQWriteThreshold = 100 * time.Millisecond
|
||||
moQWriteWarningInterval = 10 * time.Second
|
||||
duplicateKeyframeWindow = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type liveMoQConfig struct {
|
||||
relayURL string
|
||||
broadcast string
|
||||
quality string
|
||||
sourceLabel string
|
||||
queue *packets.Queue
|
||||
}
|
||||
|
||||
// label identifies the tier in log lines, since one Agent runs a publisher per
|
||||
// quality tier.
|
||||
func (c liveMoQConfig) label() string {
|
||||
return c.quality + " (" + c.sourceLabel + " stream)"
|
||||
}
|
||||
|
||||
// StartLiveStreamMoQ starts the publisher only in the dedicated MoQ build and
|
||||
// only when explicitly enabled by the deployment.
|
||||
//
|
||||
// Unlike WebRTC and HLS — where a viewer negotiates a session with the Agent and
|
||||
// can therefore ask for another quality on the fly — MoQ viewers subscribe to a
|
||||
// relay and never talk to the Agent. The quality selector is honoured by
|
||||
// publishing each tier as its OWN broadcast (see livemoq.BroadcastPath): the
|
||||
// high tier from the camera's highest-resolution stream and the low tier from
|
||||
// its sub stream, so switching quality in the frontend is a resubscribe to the
|
||||
// other path. Each tier only uploads while it actually has subscribers, so the
|
||||
// second broadcast is close to free when nobody watches it.
|
||||
func StartLiveStreamMoQ(configuration *models.Configuration, communication *models.Communication, subStreamEnabled bool) {
|
||||
if os.Getenv("AGENT_LIVE_MOQ_ENABLED") != "true" {
|
||||
return
|
||||
}
|
||||
|
||||
config := configuration.Config
|
||||
if config.Offline == "true" || config.Capture.Liveview == "false" {
|
||||
log.Log.Info("cloud.StartLiveStreamMoQ(): disabled by Agent live-view configuration")
|
||||
return
|
||||
}
|
||||
if config.Key == "" {
|
||||
log.Log.Warning("cloud.StartLiveStreamMoQ(): AGENT_KEY is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Both tiers are published by default. AGENT_LIVE_MOQ_QUALITY pins the Agent
|
||||
// to a single tier for deployments that must never publish the other one
|
||||
// (viewers asking for the pinned-away tier then find no broadcast).
|
||||
qualities := []string{models.StreamQualityHigh, models.StreamQualityLow}
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("AGENT_LIVE_MOQ_QUALITY"))) {
|
||||
case models.StreamQualityHigh:
|
||||
qualities = []string{models.StreamQualityHigh}
|
||||
case models.StreamQualityLow:
|
||||
qualities = []string{models.StreamQualityLow}
|
||||
}
|
||||
|
||||
relayURL := os.Getenv("AGENT_LIVE_MOQ_URL")
|
||||
if relayURL == "" {
|
||||
relayURL = defaultMoQRelayURL
|
||||
}
|
||||
broadcastPrefix := os.Getenv("AGENT_LIVE_MOQ_BROADCAST_PREFIX")
|
||||
|
||||
ctx := context.Background()
|
||||
if communication.Context != nil {
|
||||
ctx = *communication.Context
|
||||
}
|
||||
|
||||
for _, quality := range qualities {
|
||||
queue := communication.Queue
|
||||
sourceLabel := "main"
|
||||
if models.SelectSubStreamForQuality(config, quality, subStreamEnabled) && communication.SubQueue != nil {
|
||||
queue = communication.SubQueue
|
||||
sourceLabel = "sub"
|
||||
}
|
||||
if queue == nil {
|
||||
log.Log.Warning("cloud.StartLiveStreamMoQ(): packet queue for the " + quality + " tier is unavailable")
|
||||
continue
|
||||
}
|
||||
go runLiveStreamMoQ(ctx, liveMoQConfig{
|
||||
relayURL: relayURL,
|
||||
broadcast: livemoq.BroadcastPath(broadcastPrefix, config.Key, quality),
|
||||
quality: quality,
|
||||
sourceLabel: sourceLabel,
|
||||
queue: queue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runLiveStreamMoQ(ctx context.Context, config liveMoQConfig) {
|
||||
log.Log.Info(fmt.Sprintf(
|
||||
"cloud.runLiveStreamMoQ(): publishing %s stream (quality=%s) to %s/%s",
|
||||
config.sourceLabel, config.quality, strings.TrimRight(config.relayURL, "/"), config.broadcast,
|
||||
))
|
||||
|
||||
retryDelay := minMoQRetryDelay
|
||||
for ctx.Err() == nil {
|
||||
connectedAt := time.Now()
|
||||
err := publishLiveStreamMoQ(ctx, config)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Log.Warning("cloud.runLiveStreamMoQ(): publisher stopped: " + err.Error())
|
||||
if time.Since(connectedAt) >= time.Minute {
|
||||
retryDelay = minMoQRetryDelay
|
||||
}
|
||||
|
||||
timer := time.NewTimer(retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if retryDelay < maxMoQRetryDelay {
|
||||
retryDelay *= 2
|
||||
if retryDelay > maxMoQRetryDelay {
|
||||
retryDelay = maxMoQRetryDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishLiveStreamMoQ(ctx context.Context, config liveMoQConfig) error {
|
||||
client, err := moq.Dial(ctx, config.relayURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to relay: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
broadcast, err := client.CreateBroadcast(config.broadcast)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create broadcast: %w", err)
|
||||
}
|
||||
defer broadcast.Finish()
|
||||
|
||||
stream, err := broadcast.PublishMedia("avc3", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create H.264 media stream: %w", err)
|
||||
}
|
||||
defer stream.Finish()
|
||||
|
||||
// Only upload while this tier is actually being watched. `publishing` starts
|
||||
// true so the track becomes discoverable on the relay even before the first
|
||||
// subscriber ever arrives; from the moment a viewer has attached once, the
|
||||
// subscriber watcher takes over and idles the tier again when everybody left.
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
publishing := &atomic.Bool{}
|
||||
publishing.Store(true)
|
||||
go watchLiveStreamMoQSubscribers(watchCtx, stream, publishing, config)
|
||||
|
||||
cursor := config.queue.Latest()
|
||||
gate := livemoq.FrameGate{}
|
||||
deduplicator := livemoq.KeyframeDeduplicator{}
|
||||
var lastSlowWriteWarning time.Time
|
||||
var lastDuplicateKeyframeWarning time.Time
|
||||
idle := false
|
||||
for {
|
||||
packet, err := cursor.ReadPacket()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read packet: %w", err)
|
||||
}
|
||||
if !publishing.Load() {
|
||||
// Keep draining the cursor so we stay at the live edge, but publish
|
||||
// nothing. The gate is closed so the next viewer resumes on a keyframe.
|
||||
if !idle {
|
||||
gate.Reset()
|
||||
deduplicator.Reset()
|
||||
idle = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
idle = false
|
||||
if !packet.IsVideo || len(packet.Data) == 0 || !strings.EqualFold(packet.Codec, "H264") {
|
||||
continue
|
||||
}
|
||||
allowed, event := gate.Allow(packet.IsKeyFrame, packet.CurrentTime, time.Now(), maxMoQLivePacketAge)
|
||||
switch event {
|
||||
case livemoq.FrameGateEventStarted:
|
||||
log.Log.Info("cloud.publishLiveStreamMoQ(): first H.264 keyframe received; " + config.label() + " broadcast is live")
|
||||
case livemoq.FrameGateEventLagging:
|
||||
log.Log.Warning("cloud.publishLiveStreamMoQ(): " + config.label() + " stream is lagging; dropping packets until a recent keyframe")
|
||||
case livemoq.FrameGateEventRecovered:
|
||||
log.Log.Info("cloud.publishLiveStreamMoQ(): caught up with the " + config.label() + " live stream at a recent keyframe")
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
payload, normalizationStats, err := livemoq.NormalizeH264AccessUnitWithStats(packet.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("normalize H.264 access unit: %w", err)
|
||||
}
|
||||
if normalizationStats.DuplicateIDRNALUs > 0 && time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s removed %d duplicate IDR NALU(s) from H.264 keyframe (timestamp_ms=%d)",
|
||||
config.label(), normalizationStats.DuplicateIDRNALUs, packet.Time,
|
||||
))
|
||||
lastDuplicateKeyframeWarning = time.Now()
|
||||
}
|
||||
if packet.IsKeyFrame && deduplicator.IsDuplicate(packet.Time, packet.CurrentTime, payload, time.Now(), duplicateKeyframeWindow) {
|
||||
if time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s dropping duplicate H.264 keyframe (timestamp_ms=%d)",
|
||||
config.label(), packet.Time,
|
||||
))
|
||||
lastDuplicateKeyframeWarning = time.Now()
|
||||
}
|
||||
continue
|
||||
}
|
||||
frame := moq.Frame{
|
||||
Payload: payload,
|
||||
TimestampUs: livemoq.TimestampUs(packet.Time),
|
||||
}
|
||||
writeStartedAt := time.Now()
|
||||
if err := stream.WriteFrame(frame); err != nil {
|
||||
return fmt.Errorf("write H.264 access unit: %w", err)
|
||||
}
|
||||
writeDuration := time.Since(writeStartedAt)
|
||||
if writeDuration >= slowMoQWriteThreshold && time.Since(lastSlowWriteWarning) >= moQWriteWarningInterval {
|
||||
packetAge := time.Duration(0)
|
||||
if packet.CurrentTime > 0 {
|
||||
packetAge = time.Since(time.UnixMilli(packet.CurrentTime))
|
||||
if packetAge < 0 {
|
||||
packetAge = 0
|
||||
}
|
||||
}
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s WriteFrame blocked for %s (packet_age=%s keyframe=%t)",
|
||||
config.label(), writeDuration.Round(time.Millisecond), packetAge.Round(time.Millisecond), packet.IsKeyFrame,
|
||||
))
|
||||
lastSlowWriteWarning = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchLiveStreamMoQSubscribers flips the publisher between uploading and idling
|
||||
// as viewers subscribe to and leave this tier's broadcast. Used and Unused both
|
||||
// block, so they are followed from their own goroutine.
|
||||
//
|
||||
// It deliberately never turns publishing off before the first subscriber has
|
||||
// been observed: the relay catalog is only complete once media has flowed, so
|
||||
// going idle up front could keep the tier undiscoverable. On any error it fails
|
||||
// open (keeps publishing) — a stalled watcher must never take the live view down.
|
||||
func watchLiveStreamMoQSubscribers(ctx context.Context, stream *moq.MediaProducer, publishing *atomic.Bool, config liveMoQConfig) {
|
||||
for ctx.Err() == nil {
|
||||
if err := stream.Used(ctx); err != nil {
|
||||
publishing.Store(true)
|
||||
return
|
||||
}
|
||||
if publishing.CompareAndSwap(false, true) {
|
||||
log.Log.Info("cloud.watchLiveStreamMoQSubscribers(): viewer subscribed, resuming the " + config.label() + " broadcast")
|
||||
}
|
||||
|
||||
if err := stream.Unused(ctx); err != nil {
|
||||
publishing.Store(true)
|
||||
return
|
||||
}
|
||||
publishing.Store(false)
|
||||
log.Log.Info("cloud.watchLiveStreamMoQSubscribers(): no viewers left, idling the " + config.label() + " broadcast")
|
||||
}
|
||||
}
|
||||
88
machinery/src/cloud/recording_metadata.go
Normal file
88
machinery/src/cloud/recording_metadata.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
const recordingFPSHeader = "X-Kerberos-Storage-Fps"
|
||||
const recordingDurationHeader = "X-Kerberos-Storage-Duration"
|
||||
const recordingTimestampHeader = "X-Kerberos-Storage-Timestamp"
|
||||
|
||||
// queuedRecordingFPS reads the FPS snapshot written into the upload marker
|
||||
// when the recording was finalized. Historical empty markers intentionally
|
||||
// return no value so receivers can retain their existing MP4-derived fallback.
|
||||
func queuedRecordingFPS(fileName string) string {
|
||||
value, ok := readRecordingUploadMetadata(fileName)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
marker := strings.TrimSpace(string(value))
|
||||
if strings.HasPrefix(marker, "{") {
|
||||
metadata, ok := decodeRecordingUploadMetadata(value)
|
||||
if !ok || metadata.FPS <= 0 || metadata.FPS > 240 || math.IsInf(metadata.FPS, 0) || math.IsNaN(metadata.FPS) {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(metadata.FPS, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Compatibility with markers created before upload metadata used JSON.
|
||||
fps := marker
|
||||
parsed, err := strconv.ParseFloat(fps, 64)
|
||||
if err != nil || parsed <= 0 || parsed > 240 || math.IsInf(parsed, 0) || math.IsNaN(parsed) {
|
||||
return ""
|
||||
}
|
||||
return fps
|
||||
}
|
||||
|
||||
func queuedRecordingMetadata(fileName string) (models.RecordingUploadMetadata, bool) {
|
||||
value, ok := readRecordingUploadMetadata(fileName)
|
||||
if !ok || !strings.HasPrefix(strings.TrimSpace(string(value)), "{") {
|
||||
return models.RecordingUploadMetadata{}, false
|
||||
}
|
||||
return decodeRecordingUploadMetadata(value)
|
||||
}
|
||||
|
||||
func decodeRecordingUploadMetadata(value []byte) (models.RecordingUploadMetadata, bool) {
|
||||
var metadata models.RecordingUploadMetadata
|
||||
if err := json.Unmarshal(value, &metadata); err != nil {
|
||||
return models.RecordingUploadMetadata{}, false
|
||||
}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
|
||||
markerNames := []string{
|
||||
models.RecordingUploadMetadataFileName(fileName),
|
||||
filepath.Base(fileName),
|
||||
}
|
||||
for _, markerName := range markerNames {
|
||||
value, err := os.ReadFile(filepath.Join("data", "cloud", markerName))
|
||||
if err == nil {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
|
||||
if fps := queuedRecordingFPS(fileName); fps != "" {
|
||||
header.Set(recordingFPSHeader, fps)
|
||||
}
|
||||
if metadata, ok := queuedRecordingMetadata(fileName); ok {
|
||||
if metadata.Duration > 0 {
|
||||
header.Set(recordingDurationHeader, strconv.FormatUint(metadata.Duration, 10))
|
||||
}
|
||||
if metadata.Timestamp > 0 {
|
||||
header.Set(recordingTimestampHeader, strconv.FormatInt(metadata.Timestamp, 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,10 +154,29 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
const maxAttempts = 4
|
||||
restartedAfterComplete := false
|
||||
|
||||
// lastStatus holds the HTTP status code of the most recent tus request. A
|
||||
// value of 0 means the request failed at the transport level (no HTTP
|
||||
// response at all, e.g. the vault was unreachable or the connection dropped
|
||||
// because the internet went down). It lets the final "gave up" return report
|
||||
// whether the vault actually answered, so the caller only advances its
|
||||
// retry/back-off policy on a definitive response and transient network errors
|
||||
// never consume the retry budget (matching the legacy single-POST behaviour).
|
||||
lastStatus := 0
|
||||
|
||||
// highWaterOffset is the furthest server-acknowledged offset observed across
|
||||
// all attempts (via HEAD or PATCH). It lets the retry budget be refreshed only
|
||||
// on GENUINE net forward progress. Without it, a server that keeps resetting the
|
||||
// offset — e.g. a persistent 409 ERR_MISMATCHED_OFFSET where HEAD reports 0 again
|
||||
// while the first chunk still "succeeds" — would refresh the budget every attempt
|
||||
// and loop forever, wedging the upload worker on one recording and saturating the
|
||||
// uplink.
|
||||
highWaterOffset := int64(0)
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// (1) Ensure we have an active upload URL, creating one if needed.
|
||||
if uploadURL == "" {
|
||||
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
|
||||
lastStatus = status
|
||||
if cerr != nil {
|
||||
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
|
||||
// The vault does not implement tus; let the caller fall back.
|
||||
@@ -173,6 +192,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
|
||||
// (2) Query the current server-side offset.
|
||||
offset, status, herr := tusHead(client, uploadURL, setHeaders)
|
||||
lastStatus = status
|
||||
if herr != nil {
|
||||
if status == http.StatusNotFound || status == http.StatusGone {
|
||||
// The upload expired/was removed server-side; start over.
|
||||
@@ -185,6 +205,15 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
continue
|
||||
}
|
||||
|
||||
// The furthest offset any previous attempt reached. If this attempt pushes
|
||||
// past it (via HEAD showing server-side progress or a successful PATCH) we made
|
||||
// genuine net progress and may refresh the retry budget; if not, a repeated
|
||||
// failure at the same spot must count against maxAttempts.
|
||||
startHighWater := highWaterOffset
|
||||
if offset > highWaterOffset {
|
||||
highWaterOffset = offset
|
||||
}
|
||||
|
||||
// (3) All bytes are present but the upload was not finalized (e.g. the
|
||||
// completion hook failed). A completed tus upload cannot be re-finalized
|
||||
// with another PATCH, so delete it and re-upload to force a clean finalize.
|
||||
@@ -205,7 +234,6 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
// checkpointing the offset after each one so an interruption resumes from the
|
||||
// last completed chunk instead of re-uploading everything.
|
||||
chunkSize := tusChunkSize()
|
||||
progressed := false
|
||||
patchFailed := false
|
||||
var lastBody string
|
||||
loggedProgressBucket := tusProgressBucket(offset, size)
|
||||
@@ -220,6 +248,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
patchLen = chunkSize
|
||||
}
|
||||
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
|
||||
lastStatus = status
|
||||
if perr != nil {
|
||||
if status >= 400 {
|
||||
// Definitive rejection (e.g. provider push failed during finalize).
|
||||
@@ -232,10 +261,10 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
patchFailed = true
|
||||
break
|
||||
}
|
||||
if newOffset > offset {
|
||||
progressed = true
|
||||
}
|
||||
offset = newOffset
|
||||
if offset > highWaterOffset {
|
||||
highWaterOffset = offset
|
||||
}
|
||||
lastBody = respBody
|
||||
logTusUploadProgress(label, offset, size, &loggedProgressBucket)
|
||||
if offset < size {
|
||||
@@ -244,10 +273,13 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
}
|
||||
}
|
||||
if patchFailed {
|
||||
if progressed {
|
||||
// Forward progress refreshes the retry budget: maxAttempts bounds the
|
||||
// number of consecutive failures, not the number of chunks needed for
|
||||
// a large recording.
|
||||
if highWaterOffset > startHighWater {
|
||||
// Genuine net progress (we advanced past the furthest point any previous
|
||||
// attempt reached) refreshes the retry budget: maxAttempts bounds the
|
||||
// number of consecutive *non-progressing* failures, not the number of
|
||||
// chunks needed for a large recording. A server that keeps rejecting the
|
||||
// same offset (no net progress, e.g. a persistent ERR_MISMATCHED_OFFSET)
|
||||
// therefore gives up after maxAttempts instead of retrying forever.
|
||||
attempt = -1
|
||||
}
|
||||
continue
|
||||
@@ -258,7 +290,13 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
return true, true, true, lastBody, nil
|
||||
}
|
||||
|
||||
return false, true, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
|
||||
// Every attempt failed. Only report responded=true when the vault actually
|
||||
// answered on the last attempt (lastStatus > 0). If every attempt failed at
|
||||
// the transport level (lastStatus == 0, e.g. the internet was disconnected),
|
||||
// report responded=false so the caller keeps the recording queued and retries
|
||||
// later instead of consuming its retry budget and entering the long back-off
|
||||
// timeout.
|
||||
return false, lastStatus > 0, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
|
||||
}
|
||||
|
||||
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
|
||||
@@ -267,14 +305,17 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
|
||||
// is additionally carried in the tus Upload-Metadata.
|
||||
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, bool, string, error) {
|
||||
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
|
||||
metadata := encodeTusMetadata(map[string]string{
|
||||
metadataValues := map[string]string{
|
||||
"filename": fileName,
|
||||
"device": deviceKey,
|
||||
"directory": vault.Directory,
|
||||
"provider": vault.Provider,
|
||||
"capture": "IPCamera",
|
||||
"cloudkey": publicKey,
|
||||
})
|
||||
"fps": queuedRecordingFPS(fileName),
|
||||
}
|
||||
addRecordingTusMetadata(metadataValues, fileName)
|
||||
metadata := encodeTusMetadata(metadataValues)
|
||||
setHeaders := func(h http.Header, fn string) {
|
||||
setVaultTusHeaders(h, vault, publicKey, deviceKey, fn)
|
||||
}
|
||||
@@ -288,17 +329,33 @@ func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName,
|
||||
// intentionally omitted from the metadata here.
|
||||
func uploadHubResumable(config *models.Config, fileName, label, slot string) (bool, bool, bool, string, error) {
|
||||
baseURL := strings.TrimRight(config.HubURI, "/") + tusUploadPath
|
||||
metadata := encodeTusMetadata(map[string]string{
|
||||
metadataValues := map[string]string{
|
||||
"filename": fileName,
|
||||
"device": config.Key,
|
||||
"capture": "IPCamera",
|
||||
})
|
||||
"fps": queuedRecordingFPS(fileName),
|
||||
}
|
||||
addRecordingTusMetadata(metadataValues, fileName)
|
||||
metadata := encodeTusMetadata(metadataValues)
|
||||
setHeaders := func(h http.Header, fn string) {
|
||||
setHubTusHeaders(h, config, fn)
|
||||
}
|
||||
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
|
||||
}
|
||||
|
||||
func addRecordingTusMetadata(values map[string]string, fileName string) {
|
||||
metadata, ok := queuedRecordingMetadata(fileName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if metadata.Duration > 0 {
|
||||
values["duration"] = strconv.FormatUint(metadata.Duration, 10)
|
||||
}
|
||||
if metadata.Timestamp > 0 {
|
||||
values["timestamp"] = strconv.FormatInt(metadata.Timestamp, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// tusCreate performs the tus "creation" request (POST). On success it returns
|
||||
// the resolved upload URL the agent should use for subsequent HEAD/PATCH calls.
|
||||
func tusCreate(client *http.Client, baseURL string, size int64, metadata string, setHeaders tusHeaderFunc, fileName string) (string, int, error) {
|
||||
@@ -535,10 +592,15 @@ func removeTusResumeState(path string) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
// tusBackoffBaseDelay is the base delay used by tusBackoff for the exponential
|
||||
// back-off between resume attempts. It is a package variable (rather than a
|
||||
// constant) so tests can shrink it to keep them fast.
|
||||
var tusBackoffBaseDelay = 500 * time.Millisecond
|
||||
|
||||
// tusBackoff sleeps for an exponentially increasing duration (capped) between
|
||||
// resume attempts to avoid hammering a temporarily unavailable vault.
|
||||
func tusBackoff(attempt int) {
|
||||
delay := time.Duration(500*(1<<uint(attempt))) * time.Millisecond
|
||||
delay := tusBackoffBaseDelay * time.Duration(1<<uint(attempt))
|
||||
if delay > 3*time.Second {
|
||||
delay = 3 * time.Second
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
@@ -47,6 +49,14 @@ type fakeTus struct {
|
||||
// after storing the bytes, simulating a failed completion hook.
|
||||
failFinalize int
|
||||
|
||||
// loseProgress simulates a vault that never durably retains the in-progress
|
||||
// upload: every PATCH is acknowledged (the response advertises the advanced
|
||||
// offset) but the stored offset is immediately reset to 0. HEAD therefore
|
||||
// keeps reporting 0 and the next chunk — sent at the advanced offset — is
|
||||
// rejected with 409, reproducing the cross-replica ERR_MISMATCHED_OFFSET
|
||||
// loop that previously wedged the agent's upload worker forever.
|
||||
loseProgress bool
|
||||
|
||||
// requests records the headers of every received request (in order) so
|
||||
// tests can assert which auth/routing headers the client sent per method.
|
||||
requests []recordedRequest
|
||||
@@ -153,6 +163,31 @@ func (s *fakeTus) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if s.loseProgress {
|
||||
reqOffset, _ := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
|
||||
s.mu.Lock()
|
||||
cur := u.offset
|
||||
if reqOffset != cur {
|
||||
// The offset the client resumes from no longer matches what this
|
||||
// "replica" retained, so reject like a vault returning
|
||||
// ERR_MISMATCHED_OFFSET.
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(cur, 10))
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, r.Body)
|
||||
s.lastPatchBytes = n
|
||||
s.patchSizes = append(s.patchSizes, n)
|
||||
// Advertise progress to the client, then immediately forget it so the
|
||||
// next chunk (sent at the advanced offset) mismatches again.
|
||||
reported := cur + n
|
||||
u.offset = 0
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(reported, 10))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, r.Body)
|
||||
s.mu.Lock()
|
||||
u.offset += n
|
||||
@@ -208,6 +243,17 @@ func withRecording(t *testing.T, fileName string, payload []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func withQueuedRecordingFPS(t *testing.T, fileName, fps string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll("data/cloud", 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
markerName := models.RecordingUploadMetadataFileName(fileName)
|
||||
if err := os.WriteFile(filepath.Join("data/cloud", markerName), []byte(fps), 0o644); err != nil {
|
||||
t.Fatalf("write cloud queue marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testVault(uri string) models.KStorage {
|
||||
return models.KStorage{
|
||||
URI: uri,
|
||||
@@ -226,6 +272,7 @@ func TestUploadVaultResumable_HappyPath(t *testing.T) {
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("x"), 4096)
|
||||
withRecording(t, fileName, payload)
|
||||
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":29.97}`)
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
@@ -240,6 +287,103 @@ func TestUploadVaultResumable_HappyPath(t *testing.T) {
|
||||
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected sidecar to be removed after success, stat err = %v", err)
|
||||
}
|
||||
posts := srv.requestsForMethod(http.MethodPost)
|
||||
metadata := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
|
||||
if got := metadata["fps"]; got != "29.97" {
|
||||
t.Fatalf("POST metadata fps = %q, want %q", got, "29.97")
|
||||
}
|
||||
if got := metadata["duration"]; got != "20452" {
|
||||
t.Fatalf("POST metadata duration = %q, want %q", got, "20452")
|
||||
}
|
||||
if got := metadata["timestamp"]; got != "1785934709414" {
|
||||
t.Fatalf("POST metadata timestamp = %q, want %q", got, "1785934709414")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
fps string
|
||||
want string
|
||||
}{
|
||||
{name: "json", fps: `{"fps":29}`, want: "29"},
|
||||
{name: "json fractional", fps: `{"fps":17.35}`, want: "17.35"},
|
||||
{name: "json with future field", fps: `{"fps":29,"codec":"h264"}`, want: "29"},
|
||||
{name: "json without fps", fps: `{}`},
|
||||
{name: "json invalid fps", fps: `{"fps":241}`},
|
||||
{name: "legacy fractional", fps: "29.97", want: "29.97"},
|
||||
{name: "legacy trimmed", fps: " 25 \n", want: "25"},
|
||||
{name: "empty"},
|
||||
{name: "invalid", fps: "invalid"},
|
||||
{name: "zero", fps: "0"},
|
||||
{name: "negative", fps: "-1"},
|
||||
{name: "nan", fps: "NaN"},
|
||||
{name: "infinite", fps: "+Inf"},
|
||||
{name: "unreasonable", fps: "241"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
withQueuedRecordingFPS(t, fileName, test.fps)
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != test.want {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want %q", got, test.want)
|
||||
}
|
||||
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != test.want {
|
||||
t.Fatalf("legacy FPS header = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSAllowsMissingHistoricalMarker(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != "" {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want empty for missing marker", got)
|
||||
}
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != "" {
|
||||
t.Fatalf("legacy FPS header = %q, want empty for missing marker", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingMetadataHeaders(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":25}`)
|
||||
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != "25" {
|
||||
t.Fatalf("FPS header = %q", got)
|
||||
}
|
||||
if got := header.Get(recordingDurationHeader); got != "20452" {
|
||||
t.Fatalf("duration header = %q", got)
|
||||
}
|
||||
if got := header.Get(recordingTimestampHeader); got != "1785934709414" {
|
||||
t.Fatalf("timestamp header = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSAllowsLegacyMarkerFileName(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
if err := os.MkdirAll("data/cloud", 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("data/cloud", fileName), []byte("25"), 0o644); err != nil {
|
||||
t.Fatalf("write legacy cloud queue marker: %v", err)
|
||||
}
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != "25" {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want legacy marker FPS", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_Chunked(t *testing.T) {
|
||||
@@ -350,6 +494,102 @@ func TestUploadVaultResumable_Unsupported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadVaultResumable_NetworkErrorKeepsRetryBudget verifies that when the
|
||||
// vault is unreachable (mimicking the internet being disconnected) the resumable
|
||||
// upload reports responded=false. That is what stops the caller
|
||||
// (UploadKerberosVault) from consuming its retry budget and entering the long
|
||||
// back-off timeout on a transient network outage, so the recording keeps being
|
||||
// retried until connectivity returns.
|
||||
func TestUploadVaultResumable_NetworkErrorKeepsRetryBudget(t *testing.T) {
|
||||
// Bind then immediately release a loopback port so every connection to it is
|
||||
// refused, producing a transport-level error (no HTTP response).
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
if cerr := ln.Close(); cerr != nil {
|
||||
t.Fatalf("close listener: %v", cerr)
|
||||
}
|
||||
|
||||
// Keep the between-attempt back-off tiny so the test stays fast.
|
||||
oldDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = time.Millisecond
|
||||
defer func() { tusBackoffBaseDelay = oldDelay }()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
withRecording(t, fileName, bytes.Repeat([]byte("n"), 2048))
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault("http://"+addr), "pk", "dev", fileName, "test", "primary")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false when the vault is unreachable")
|
||||
}
|
||||
if !supported {
|
||||
t.Fatal("a transport error is not a missing tus endpoint; expected supported=true")
|
||||
}
|
||||
if responded {
|
||||
t.Fatal("expected responded=false for a pure network error so the retry budget is preserved")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the vault is unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_MismatchedOffsetGivesUp(t *testing.T) {
|
||||
// A vault that never durably retains the in-progress upload (offset resets to
|
||||
// 0 between chunks) makes every resume "progress" by one chunk and then fail
|
||||
// the next chunk with 409. Before the high-water gating fix this refreshed the
|
||||
// retry budget every attempt and looped forever, wedging the upload worker and
|
||||
// saturating the uplink (which starved heartbeats and reported the camera
|
||||
// offline). The loop must now be bounded: give up after a fixed number of
|
||||
// non-progressing attempts and report responded=true so the caller re-queues.
|
||||
srv := newFakeTus()
|
||||
srv.loseProgress = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
// Keep the between-attempt back-off tiny so the test stays fast.
|
||||
oldDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = time.Millisecond
|
||||
defer func() { tusBackoffBaseDelay = oldDelay }()
|
||||
|
||||
// Force multiple chunks so there is always a second chunk to be rejected.
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
withRecording(t, fileName, bytes.Repeat([]byte("m"), 12288))
|
||||
|
||||
done := make(chan struct{})
|
||||
var uploaded, responded bool
|
||||
var upErr error
|
||||
go func() {
|
||||
uploaded, responded, _, _, upErr = uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("resumable upload did not terminate: the retry loop is unbounded on a persistent mismatched offset")
|
||||
}
|
||||
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false when the vault never retains the offset")
|
||||
}
|
||||
if !responded {
|
||||
t.Fatal("expected responded=true (the vault answered) so the caller re-queues the recording")
|
||||
}
|
||||
if upErr == nil {
|
||||
t.Fatal("expected an error when the upload cannot complete")
|
||||
}
|
||||
|
||||
// The bounded retry budget must cap the number of PATCH requests. Two PATCHes
|
||||
// per attempt across a handful of attempts stays comfortably below this.
|
||||
if count, _ := srv.patchCounts(); count > 50 {
|
||||
t.Fatalf("expected a bounded number of PATCH requests, got %d (retry loop not bounded)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_FinalizeRetry(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.failFinalize = 1
|
||||
@@ -448,6 +688,7 @@ func TestUploadHubResumable_HappyPath(t *testing.T) {
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("h"), 4096)
|
||||
withRecording(t, fileName, payload)
|
||||
withQueuedRecordingFPS(t, fileName, "29.97")
|
||||
|
||||
uploaded, _, supported, _, err := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
|
||||
if err != nil {
|
||||
@@ -518,6 +759,9 @@ func TestUploadHubResumable_HappyPath(t *testing.T) {
|
||||
if meta["capture"] != "IPCamera" {
|
||||
t.Errorf("hub metadata capture = %q, want %q", meta["capture"], "IPCamera")
|
||||
}
|
||||
if meta["fps"] != "29.97" {
|
||||
t.Errorf("hub metadata fps = %q, want %q", meta["fps"], "29.97")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadHubResumable_Unsupported(t *testing.T) {
|
||||
|
||||
@@ -2,7 +2,9 @@ package components
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,62 @@ import (
|
||||
"github.com/zaf/g711"
|
||||
)
|
||||
|
||||
const (
|
||||
backchannelSampleRate = 8000
|
||||
backchannelTalkspurtGap = 500 * time.Millisecond
|
||||
backchannelReconnectInitial = time.Second
|
||||
backchannelReconnectMax = 30 * time.Second
|
||||
)
|
||||
|
||||
type backchannelClient interface {
|
||||
ConnectBackChannel(ctx context.Context, otelContext context.Context) error
|
||||
StartBackChannel(ctx context.Context, otelContext context.Context) error
|
||||
WritePacket(pkt packets.Packet) error
|
||||
Close(otelContext context.Context) error
|
||||
}
|
||||
|
||||
type backchannelPacketizer struct {
|
||||
sequenceNumber uint16
|
||||
timestamp uint32
|
||||
ssrc uint32
|
||||
lastPacketAt time.Time
|
||||
}
|
||||
|
||||
func newBackchannelPacketizer() backchannelPacketizer {
|
||||
return backchannelPacketizer{
|
||||
sequenceNumber: uint16(rand.Uint32()),
|
||||
timestamp: rand.Uint32(),
|
||||
ssrc: rand.Uint32(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *backchannelPacketizer) packet(audio models.AudioDataPartial, now time.Time) packets.Packet {
|
||||
bufferUlaw := make([]byte, len(audio.Data))
|
||||
for index, sample := range audio.Data {
|
||||
bufferUlaw[index] = g711.EncodeUlawFrame(sample)
|
||||
}
|
||||
|
||||
pkt := packets.Packet{
|
||||
Packet: &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: p.lastPacketAt.IsZero() || now.Sub(p.lastPacketAt) >= backchannelTalkspurtGap,
|
||||
PayloadType: 0,
|
||||
SequenceNumber: p.sequenceNumber,
|
||||
Timestamp: p.timestamp,
|
||||
SSRC: p.ssrc,
|
||||
},
|
||||
Payload: bufferUlaw,
|
||||
},
|
||||
}
|
||||
|
||||
p.timestamp += uint32(len(bufferUlaw))
|
||||
p.sequenceNumber++
|
||||
p.lastPacketAt = now
|
||||
|
||||
return pkt
|
||||
}
|
||||
|
||||
func GetBackChannelAudioCodec(streams []av.CodecData, communication *models.Communication) av.AudioCodecData {
|
||||
for _, stream := range streams {
|
||||
if stream.Type().IsAudio() {
|
||||
@@ -31,41 +89,115 @@ func GetBackChannelAudioCodec(streams []av.CodecData, communication *models.Comm
|
||||
}
|
||||
|
||||
func WriteAudioToBackchannel(communication *models.Communication, rtspClient capture.RTSPClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): writing to backchannel audio codec")
|
||||
length := uint32(0)
|
||||
sequenceNumber := uint16(0)
|
||||
for audio := range communication.HandleAudio {
|
||||
// Encode PCM to MULAW
|
||||
var bufferUlaw []byte
|
||||
for _, v := range audio.Data {
|
||||
b := g711.EncodeUlawFrame(v)
|
||||
bufferUlaw = append(bufferUlaw, b)
|
||||
}
|
||||
|
||||
pkt := packets.Packet{
|
||||
Packet: &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true, // should be true
|
||||
PayloadType: 0, //packet.PayloadType, // will be owerwriten
|
||||
SequenceNumber: sequenceNumber,
|
||||
Timestamp: uint32(length),
|
||||
SSRC: 1293847657,
|
||||
},
|
||||
Payload: bufferUlaw,
|
||||
},
|
||||
}
|
||||
err := rtspClient.WritePacket(pkt)
|
||||
if err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error writing packet to backchannel")
|
||||
}
|
||||
|
||||
length = (length + uint32(len(bufferUlaw))) % 65536
|
||||
sequenceNumber = (sequenceNumber + 1) % 65535
|
||||
time.Sleep(128 * time.Millisecond)
|
||||
ctx := context.Background()
|
||||
if communication.Context != nil {
|
||||
ctx = *communication.Context
|
||||
}
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): finished")
|
||||
|
||||
writeAudioToBackchannel(ctx, ctx, communication.HandleAudio, rtspClient)
|
||||
}
|
||||
|
||||
func writeAudioToBackchannel(ctx context.Context, otelContext context.Context, audioChannel <-chan models.AudioDataPartial, rtspClient backchannelClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): writing to backchannel audio codec")
|
||||
|
||||
if err := rtspClient.StartBackChannel(ctx, otelContext); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error starting backchannel: " + err.Error())
|
||||
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
packetizer := newBackchannelPacketizer()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped")
|
||||
return
|
||||
case audio, ok := <-audioChannel:
|
||||
if !ok {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): finished")
|
||||
return
|
||||
}
|
||||
|
||||
audio = latestBackchannelAudio(audio, audioChannel)
|
||||
if len(audio.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
pkt := packetizer.packet(audio, time.Now())
|
||||
if err := rtspClient.WritePacket(pkt); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error writing packet to backchannel: " + err.Error())
|
||||
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
|
||||
return
|
||||
}
|
||||
packetizer = newBackchannelPacketizer()
|
||||
continue
|
||||
}
|
||||
|
||||
if !waitForBackchannel(ctx, time.Duration(len(audio.Data))*time.Second/backchannelSampleRate) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func latestBackchannelAudio(audio models.AudioDataPartial, audioChannel <-chan models.AudioDataPartial) models.AudioDataPartial {
|
||||
for {
|
||||
select {
|
||||
case next, ok := <-audioChannel:
|
||||
if !ok {
|
||||
return audio
|
||||
}
|
||||
audio = next
|
||||
default:
|
||||
return audio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reconnectBackchannel(ctx context.Context, otelContext context.Context, rtspClient backchannelClient) bool {
|
||||
backoff := backchannelReconnectInitial
|
||||
for {
|
||||
if err := rtspClient.Close(otelContext); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error closing failed backchannel: " + err.Error())
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err := rtspClient.ConnectBackChannel(ctx, otelContext)
|
||||
if err == nil {
|
||||
err = rtspClient.StartBackChannel(ctx, otelContext)
|
||||
}
|
||||
if err == nil {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): reconnected backchannel")
|
||||
return true
|
||||
}
|
||||
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error reconnecting backchannel: " + err.Error())
|
||||
if !waitForBackchannel(ctx, backoff) {
|
||||
return false
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > backchannelReconnectMax {
|
||||
backoff = backchannelReconnectMax
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForBackchannel(ctx context.Context, duration time.Duration) bool {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func WriteFileToBackChannel(infile av.DemuxCloser) {
|
||||
|
||||
209
machinery/src/components/backchannel_test.go
Normal file
209
machinery/src/components/backchannel_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
)
|
||||
|
||||
type fakeBackchannelClient struct {
|
||||
mutex sync.Mutex
|
||||
startErrors []error
|
||||
connectError error
|
||||
writeErrors []error
|
||||
startCalls int
|
||||
connectCalls int
|
||||
closeCalls int
|
||||
writeCalls int
|
||||
connectAttempt chan struct{}
|
||||
successfulWrite chan packets.Packet
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) ConnectBackChannel(context.Context, context.Context) error {
|
||||
f.mutex.Lock()
|
||||
f.connectCalls++
|
||||
err := f.connectError
|
||||
f.mutex.Unlock()
|
||||
|
||||
select {
|
||||
case f.connectAttempt <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) StartBackChannel(context.Context, context.Context) error {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
f.startCalls++
|
||||
if len(f.startErrors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := f.startErrors[0]
|
||||
f.startErrors = f.startErrors[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) WritePacket(pkt packets.Packet) error {
|
||||
f.mutex.Lock()
|
||||
f.writeCalls++
|
||||
var err error
|
||||
if len(f.writeErrors) != 0 {
|
||||
err = f.writeErrors[0]
|
||||
f.writeErrors = f.writeErrors[1:]
|
||||
}
|
||||
f.mutex.Unlock()
|
||||
|
||||
if err == nil {
|
||||
select {
|
||||
case f.successfulWrite <- pkt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) Close(context.Context) error {
|
||||
f.mutex.Lock()
|
||||
f.closeCalls++
|
||||
f.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) callCounts() (start, connect, close, write int) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
return f.startCalls, f.connectCalls, f.closeCalls, f.writeCalls
|
||||
}
|
||||
|
||||
func TestBackchannelPacketizerUsesFullRTPClock(t *testing.T) {
|
||||
packetizer := backchannelPacketizer{ssrc: 1}
|
||||
audio := models.AudioDataPartial{Data: make([]int16, 1024)}
|
||||
startedAt := time.Unix(1, 0)
|
||||
|
||||
var timestamp uint32
|
||||
for index := 0; index <= 64; index++ {
|
||||
pkt := packetizer.packet(audio, startedAt.Add(time.Duration(index)*128*time.Millisecond))
|
||||
timestamp = pkt.Packet.Timestamp
|
||||
}
|
||||
|
||||
if timestamp != 65536 {
|
||||
t.Fatalf("timestamp after 64 frames = %d, want 65536", timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackchannelPacketizerUsesNaturalSequenceRollover(t *testing.T) {
|
||||
packetizer := backchannelPacketizer{sequenceNumber: ^uint16(0), ssrc: 1}
|
||||
audio := models.AudioDataPartial{Data: []int16{0}}
|
||||
startedAt := time.Unix(1, 0)
|
||||
|
||||
last := packetizer.packet(audio, startedAt)
|
||||
firstAfterRollover := packetizer.packet(audio, startedAt.Add(time.Millisecond))
|
||||
|
||||
if last.Packet.SequenceNumber != ^uint16(0) {
|
||||
t.Fatalf("last sequence number = %d, want %d", last.Packet.SequenceNumber, ^uint16(0))
|
||||
}
|
||||
if firstAfterRollover.Packet.SequenceNumber != 0 {
|
||||
t.Fatalf("first sequence number after rollover = %d, want 0", firstAfterRollover.Packet.SequenceNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackchannelPacketizerMarksTalkspurtStart(t *testing.T) {
|
||||
packetizer := backchannelPacketizer{ssrc: 1}
|
||||
audio := models.AudioDataPartial{Data: []int16{0}}
|
||||
startedAt := time.Unix(1, 0)
|
||||
|
||||
first := packetizer.packet(audio, startedAt)
|
||||
continuous := packetizer.packet(audio, startedAt.Add(128*time.Millisecond))
|
||||
afterGap := packetizer.packet(audio, startedAt.Add(backchannelTalkspurtGap+128*time.Millisecond))
|
||||
|
||||
if !first.Packet.Marker {
|
||||
t.Fatal("first packet must mark the start of a talkspurt")
|
||||
}
|
||||
if continuous.Packet.Marker {
|
||||
t.Fatal("continuous packet must not carry the marker bit")
|
||||
}
|
||||
if !afterGap.Packet.Marker {
|
||||
t.Fatal("packet after an audio gap must mark a new talkspurt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAudioToBackchannelReconnectsAfterWriteFailure(t *testing.T) {
|
||||
writeFailure := errors.New("EOF")
|
||||
client := &fakeBackchannelClient{
|
||||
writeErrors: []error{writeFailure, nil},
|
||||
connectAttempt: make(chan struct{}, 1),
|
||||
successfulWrite: make(chan packets.Packet, 1),
|
||||
}
|
||||
audioChannel := make(chan models.AudioDataPartial, 2)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
writeAudioToBackchannel(ctx, ctx, audioChannel, client)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
|
||||
select {
|
||||
case <-client.connectAttempt:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backchannel was not reconnected after the write failure")
|
||||
}
|
||||
|
||||
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
|
||||
select {
|
||||
case pkt := <-client.successfulWrite:
|
||||
if !pkt.Packet.Marker {
|
||||
t.Fatal("first packet after reconnect must mark a new talkspurt")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("fresh audio was not written after reconnect")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backchannel writer did not stop after cancellation")
|
||||
}
|
||||
|
||||
startCalls, connectCalls, closeCalls, writeCalls := client.callCounts()
|
||||
if startCalls != 2 || connectCalls != 1 || closeCalls != 1 || writeCalls != 2 {
|
||||
t.Fatalf("calls (start, connect, close, write) = (%d, %d, %d, %d), want (2, 1, 1, 2)", startCalls, connectCalls, closeCalls, writeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAudioToBackchannelCancellationStopsReconnect(t *testing.T) {
|
||||
client := &fakeBackchannelClient{
|
||||
connectError: errors.New("camera unavailable"),
|
||||
writeErrors: []error{errors.New("EOF")},
|
||||
connectAttempt: make(chan struct{}, 1),
|
||||
successfulWrite: make(chan packets.Packet, 1),
|
||||
}
|
||||
audioChannel := make(chan models.AudioDataPartial, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
writeAudioToBackchannel(ctx, ctx, audioChannel, client)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
|
||||
select {
|
||||
case <-client.connectAttempt:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected a reconnect attempt")
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
t.Fatal("cancellation did not interrupt reconnect backoff")
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,12 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
|
||||
communication.HandleLiveHDKeepalive = make(chan string, 1)
|
||||
communication.HandleLiveHDPeers = make(chan string, 1)
|
||||
communication.HandleLiveHLS = make(chan string, 1)
|
||||
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
|
||||
communication.IsConfiguring = abool.New()
|
||||
communication.IsRecordingManual = abool.New()
|
||||
communication.RecordingManualHeartbeat = &atomic.Int64{}
|
||||
communication.RecordingManualStart = &atomic.Int64{}
|
||||
communication.RecordingManualHeartbeatSeen = abool.New()
|
||||
|
||||
cameraSettings := &models.Camera{}
|
||||
|
||||
@@ -265,11 +270,11 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
communication.MainStreamConnected = true
|
||||
|
||||
// Try to create backchannel
|
||||
communication.HasBackChannel = false
|
||||
rtspBackChannelClient := captureDevice.SetBackChannelClient(rtspUrl)
|
||||
err = rtspBackChannelClient.ConnectBackChannel(ctx, ctxRunAgent)
|
||||
if err == nil {
|
||||
log.Log.Info("components.Kerberos.RunAgent(): opened RTSP backchannel stream: " + rtspUrl)
|
||||
go rtspBackChannelClient.StartBackChannel(ctx, ctxRunAgent)
|
||||
}
|
||||
|
||||
rtspSubClient := captureDevice.RTSPSubClient
|
||||
@@ -301,6 +306,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
// watching.
|
||||
go cloud.HandleLiveStreamHLS(configuration, communication, mqttClient, subStreamEnabled)
|
||||
|
||||
// MoQ is available only in the dedicated CGO/glibc build. The standard
|
||||
// static Alpine build resolves this hook to a no-op.
|
||||
cloud.StartLiveStreamMoQ(configuration, communication, subStreamEnabled)
|
||||
|
||||
// Handle livestream HD (high resolution over WEBRTC). Both the main and sub
|
||||
// stream are exposed as separate broadcasters so a viewer can request the
|
||||
// high (main) or low (sub) resolution per peer connection; "auto" prefers the
|
||||
@@ -309,7 +318,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
go cloud.HandleLiveStreamHD(configuration, communication, mqttClient, rtspClient, rtspSubClient, subStreamEnabled)
|
||||
|
||||
// Handle recording, will write an mp4 to disk.
|
||||
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient)
|
||||
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient, mqttClient)
|
||||
|
||||
// Handle processing of motion
|
||||
communication.HandleMotion = make(chan models.MotionDataPartial, 10)
|
||||
@@ -337,7 +346,11 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
communication.HandleONVIF = make(chan models.OnvifAction, 10)
|
||||
go onvif.HandleONVIFActions(configuration, communication)
|
||||
|
||||
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
|
||||
// Handle ONVIF event stream — opt-in via Capture.ONVIFMotion="true".
|
||||
// Stops when the agent's shared context is cancelled. The function
|
||||
// is a no-op if ONVIFMotion is not enabled.
|
||||
go onvif.HandleONVIFEventStream(*communication.Context, configuration, communication)
|
||||
|
||||
if rtspBackChannelClient.HasBackChannel {
|
||||
communication.HasBackChannel = true
|
||||
go WriteAudioToBackchannel(communication, rtspBackChannelClient)
|
||||
@@ -428,9 +441,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
close(communication.HandleMotion)
|
||||
communication.HandleMotion = nil
|
||||
|
||||
close(communication.HandleAudio)
|
||||
communication.HandleAudio = nil
|
||||
|
||||
close(communication.HandleONVIF)
|
||||
communication.HandleONVIF = nil
|
||||
|
||||
|
||||
@@ -22,20 +22,35 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
var isPixelChangeThresholdReached = false
|
||||
var changesToReturn = 0
|
||||
var motionRectangle models.MotionRectangle
|
||||
var motionRectangles []models.MotionRectangle
|
||||
|
||||
pixelThreshold := config.Capture.PixelChangeThreshold
|
||||
// Might not be set in the config file, so set it to 150
|
||||
if pixelThreshold == 0 {
|
||||
pixelThreshold = 150
|
||||
// Resolve the motion sensitivity (pixel-change threshold). Nil, zero, and
|
||||
// negative values use the historical default so older configurations keep
|
||||
// recording after an upgrade.
|
||||
pixelThreshold := 150
|
||||
if config.Capture.PixelChangeThreshold != nil && *config.Capture.PixelChangeThreshold > 0 {
|
||||
pixelThreshold = *config.Capture.PixelChangeThreshold
|
||||
}
|
||||
// In motion mode we always run detection. In CONTINUOUS mode recording is
|
||||
// 24/7 so motion detection is normally skipped, BUT if a motion region is
|
||||
// configured we still run it so the live view can visualise the motion boxes
|
||||
// + region. In that case we only emit the motion EVENT — no motion-triggered
|
||||
// recording (continuous already records, and the recorder's motion branch
|
||||
// isn't draining HandleMotion in continuous mode).
|
||||
continuousMode := config.Capture.Continuous == "true"
|
||||
hasMotionRegion := config.Region != nil && len(config.Region.Polygon) > 0
|
||||
|
||||
if config.Capture.Continuous == "true" {
|
||||
if continuousMode && !hasMotionRegion {
|
||||
|
||||
log.Log.Info("computervision.main.ProcessMotion(): you've enabled continuous recording, so no motion detection required.")
|
||||
log.Log.Info("computervision.main.ProcessMotion(): continuous recording enabled and no motion region configured, so no motion detection required.")
|
||||
|
||||
} else {
|
||||
|
||||
log.Log.Info("computervision.main.ProcessMotion(): motion detected is enabled, so starting the motion detection.")
|
||||
if continuousMode {
|
||||
log.Log.Info("computervision.main.ProcessMotion(): continuous recording enabled with a motion region, running motion detection for live-view visualisation only (no motion-triggered recording).")
|
||||
} else {
|
||||
log.Log.Info("computervision.main.ProcessMotion(): motion detected is enabled, so starting the motion detection.")
|
||||
}
|
||||
|
||||
hubKey := config.HubKey
|
||||
deviceKey := config.Key
|
||||
@@ -100,20 +115,47 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
}
|
||||
}
|
||||
|
||||
// Frame dimensions + the motion region polygon(s) in image space, shipped
|
||||
// with each motion event so the live view can draw a motion-debug overlay
|
||||
// (the boxes below + the detection region).
|
||||
var imageCols, imageRows int
|
||||
var regionPolygons [][]map[string]int
|
||||
if config.Region != nil {
|
||||
for _, polygon := range config.Region.Polygon {
|
||||
var pts []map[string]int
|
||||
for _, c := range polygon.Coordinates {
|
||||
pts = append(pts, map[string]int{
|
||||
"x": int(c.X * baseWidthRatio),
|
||||
"y": int(c.Y * baseHeightRatio),
|
||||
})
|
||||
}
|
||||
if len(pts) > 0 {
|
||||
regionPolygons = append(regionPolygons, pts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img := imageArray[0]
|
||||
var coordinatesToCheck []int
|
||||
var coordinatesPerRegion [][]int
|
||||
totalCoordinates := 0
|
||||
if img != nil {
|
||||
bounds := img.Bounds()
|
||||
rows := bounds.Dy()
|
||||
cols := bounds.Dx()
|
||||
imageCols = cols
|
||||
imageRows = rows
|
||||
|
||||
// Make fixed size array of uinty8
|
||||
// Build a SEPARATE coordinate list per region. Motion is evaluated
|
||||
// independently per region: pixels are NOT shared between regions, so
|
||||
// the threshold must be exceeded within a single region to trigger.
|
||||
coordinatesPerRegion = make([][]int, len(polyObjects))
|
||||
for y := 0; y < rows; y++ {
|
||||
for x := 0; x < cols; x++ {
|
||||
for _, poly := range polyObjects {
|
||||
point := geo.NewPoint(float64(x), float64(y))
|
||||
point := geo.NewPoint(float64(x), float64(y))
|
||||
for idx, poly := range polyObjects {
|
||||
if poly.Contains(point) {
|
||||
coordinatesToCheck = append(coordinatesToCheck, y*cols+x)
|
||||
coordinatesPerRegion[idx] = append(coordinatesPerRegion[idx], y*cols+x)
|
||||
totalCoordinates++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +163,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
}
|
||||
|
||||
// If no region is set, we'll skip the motion detection
|
||||
if len(coordinatesToCheck) > 0 {
|
||||
if totalCoordinates > 0 {
|
||||
|
||||
// Start the motion detection
|
||||
i := 0
|
||||
@@ -146,12 +188,16 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
log.Log.Debug("computervision.main.ProcessMotion(): " + err.Error() + ".")
|
||||
}
|
||||
|
||||
if config.Capture.Motion != "false" {
|
||||
// Run detection when motion is enabled, OR when we're in continuous
|
||||
// mode with a region: there config.Capture.Motion (the motion-RECORDING
|
||||
// switch) is irrelevant, so the configured region alone is enough to
|
||||
// emit motion events for the live-view overlay.
|
||||
if config.Capture.Motion != "false" || continuousMode {
|
||||
|
||||
if detectMotion {
|
||||
|
||||
// Remember additional information about the result of findmotion
|
||||
isPixelChangeThresholdReached, changesToReturn, motionRectangle = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
|
||||
isPixelChangeThresholdReached, changesToReturn, motionRectangle, motionRectangles = FindMotion(imageArray, coordinatesPerRegion, pixelThreshold)
|
||||
if isPixelChangeThresholdReached {
|
||||
|
||||
// If offline mode is disabled, send a message to the hub
|
||||
@@ -164,6 +210,24 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
DeviceId: configuration.Config.Key,
|
||||
Value: map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
// Live-view motion-debug overlay data. The boxes/region
|
||||
// are in the MOTION frame's pixel space (width/height =
|
||||
// the stream motion ran on, i.e. the sub stream when
|
||||
// set). mainWidth/mainHeight are the MAIN stream's
|
||||
// dimensions so the live view can extrapolate the
|
||||
// boxes/region onto the high-res main view it shows —
|
||||
// we know both, so no guessing from the <video> element.
|
||||
"width": imageCols,
|
||||
"height": imageRows,
|
||||
"mainWidth": configuration.Config.Capture.IPCamera.Width,
|
||||
"mainHeight": configuration.Config.Capture.IPCamera.Height,
|
||||
"regions": motionRectangles,
|
||||
"polygon": regionPolygons, // Motion sensitivity = the pixel-change threshold that must
|
||||
// be exceeded before motion triggers. The live view renders
|
||||
// a reference square of sqrt(threshold) px (in this MOTION
|
||||
// frame's pixel space) so the user can visually gauge how
|
||||
// large a moving object must be before it is detected.
|
||||
"pixelChangeThreshold": pixelThreshold,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -179,7 +243,12 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
}
|
||||
}
|
||||
|
||||
if config.Capture.Recording != "false" {
|
||||
// Trigger motion-based recording — but NOT in continuous mode:
|
||||
// there the recorder runs the continuous branch and does not
|
||||
// drain HandleMotion, so a (blocking) send would hang the motion
|
||||
// loop. In continuous mode we only publish the motion event above
|
||||
// for the live-view overlay.
|
||||
if config.Capture.Recording != "false" && !continuousMode {
|
||||
dataToPass := models.MotionDataPartial{
|
||||
Timestamp: time.Now().Unix(),
|
||||
NumberOfChanges: changesToReturn,
|
||||
@@ -205,17 +274,73 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
log.Log.Debug("computervision.main.ProcessMotion(): stop the motion detection.")
|
||||
}
|
||||
|
||||
func FindMotion(imageArray [3]*image.Gray, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle) {
|
||||
func FindMotion(imageArray [3]*image.Gray, coordinatesPerRegion [][]int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle, motionRectangles []models.MotionRectangle) {
|
||||
image1 := imageArray[0]
|
||||
image2 := imageArray[1]
|
||||
image3 := imageArray[2]
|
||||
threshold := 60
|
||||
changes, motionRectangle := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
|
||||
return changes > pixelChangeThreshold, changes, motionRectangle
|
||||
|
||||
// Evaluate each region INDEPENDENTLY — pixels are not shared between regions,
|
||||
// so the threshold must be exceeded within a single region to trigger. The
|
||||
// overall rectangle (recording metadata) and the per-cluster rectangles
|
||||
// (live-view overlay) are aggregated across all regions.
|
||||
var combinedRectangles []models.MotionRectangle
|
||||
var overall models.MotionRectangle
|
||||
haveOverall := false
|
||||
totalChanges := 0
|
||||
for _, coordinatesToCheck := range coordinatesPerRegion {
|
||||
if len(coordinatesToCheck) == 0 {
|
||||
continue
|
||||
}
|
||||
changes, rect, rects := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
|
||||
totalChanges += changes
|
||||
if changes > pixelChangeThreshold {
|
||||
thresholdReached = true
|
||||
}
|
||||
combinedRectangles = append(combinedRectangles, rects...)
|
||||
if changes > 0 {
|
||||
if !haveOverall {
|
||||
overall = rect
|
||||
haveOverall = true
|
||||
} else {
|
||||
overall = unionMotionRectangle(overall, rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return thresholdReached, totalChanges, overall, combinedRectangles
|
||||
}
|
||||
|
||||
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle) {
|
||||
// unionMotionRectangle returns the smallest rectangle that contains both a and b.
|
||||
func unionMotionRectangle(a, b models.MotionRectangle) models.MotionRectangle {
|
||||
minX := a.X
|
||||
if b.X < minX {
|
||||
minX = b.X
|
||||
}
|
||||
minY := a.Y
|
||||
if b.Y < minY {
|
||||
minY = b.Y
|
||||
}
|
||||
maxX := a.X + a.Width
|
||||
if b.X+b.Width > maxX {
|
||||
maxX = b.X + b.Width
|
||||
}
|
||||
maxY := a.Y + a.Height
|
||||
if b.Y+b.Height > maxY {
|
||||
maxY = b.Y + b.Height
|
||||
}
|
||||
return models.MotionRectangle{
|
||||
X: minX,
|
||||
Y: minY,
|
||||
Width: maxX - minX,
|
||||
Height: maxY - minY,
|
||||
}
|
||||
}
|
||||
|
||||
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle, []models.MotionRectangle) {
|
||||
changes := 0
|
||||
cols := img1.Bounds().Dx()
|
||||
rows := img1.Bounds().Dy()
|
||||
var pixelList [][]int
|
||||
for i := 0; i < len(coordinatesToCheck); i++ {
|
||||
pixel := coordinatesToCheck[i]
|
||||
@@ -224,7 +349,7 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
|
||||
if (diff > threshold || diff < -threshold) && (diff2 > threshold || diff2 < -threshold) {
|
||||
changes++
|
||||
// Store the pixel coordinates where the change is detected
|
||||
pixelList = append(pixelList, []int{pixel % img1.Bounds().Dx(), pixel / img1.Bounds().Dx()})
|
||||
pixelList = append(pixelList, []int{pixel % cols, pixel / cols})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,5 +383,118 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
|
||||
}
|
||||
log.Log.Debugf("Motion rectangle: %+v", motionRectangle)
|
||||
}
|
||||
return changes, motionRectangle
|
||||
|
||||
// Cluster the changed pixels into separate bounding boxes so the live view can
|
||||
// visualise WHERE motion happened (a single overall rectangle is useless when
|
||||
// two objects move in opposite corners). Cheap grid-based connected components.
|
||||
motionRectangles := clusterMotionRectangles(pixelList, cols, rows)
|
||||
|
||||
return changes, motionRectangle, motionRectangles
|
||||
}
|
||||
|
||||
// clusterMotionRectangles groups the changed-pixel coordinates into a handful of
|
||||
// bounding boxes using connected-components on a coarse grid (8-connectivity).
|
||||
// It is intentionally lightweight — it runs only when the motion threshold is
|
||||
// reached and the boxes are meant for a debug overlay, not precise detection.
|
||||
func clusterMotionRectangles(pixelList [][]int, cols, rows int) []models.MotionRectangle {
|
||||
if len(pixelList) == 0 || cols <= 0 || rows <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ~40 cells across the longest side keeps the grid small (cheap to cluster)
|
||||
// while still separating distinct motion blobs.
|
||||
const gridDim = 40
|
||||
cellW := cols / gridDim
|
||||
if cellW < 1 {
|
||||
cellW = 1
|
||||
}
|
||||
cellH := rows / gridDim
|
||||
if cellH < 1 {
|
||||
cellH = 1
|
||||
}
|
||||
gCols := (cols + cellW - 1) / cellW
|
||||
gRows := (rows + cellH - 1) / cellH
|
||||
|
||||
grid := make([]bool, gCols*gRows)
|
||||
for _, p := range pixelList {
|
||||
cx := p[0] / cellW
|
||||
cy := p[1] / cellH
|
||||
if cx >= 0 && cx < gCols && cy >= 0 && cy < gRows {
|
||||
grid[cy*gCols+cx] = true
|
||||
}
|
||||
}
|
||||
|
||||
visited := make([]bool, gCols*gRows)
|
||||
var rectangles []models.MotionRectangle
|
||||
const maxBoxes = 12
|
||||
stack := make([][2]int, 0, 64)
|
||||
|
||||
for cy := 0; cy < gRows; cy++ {
|
||||
for cx := 0; cx < gCols; cx++ {
|
||||
idx := cy*gCols + cx
|
||||
if !grid[idx] || visited[idx] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Flood-fill this component (8-connectivity) and track its extent.
|
||||
minX, minY, maxX, maxY := cx, cy, cx, cy
|
||||
cellCount := 0
|
||||
stack = stack[:0]
|
||||
stack = append(stack, [2]int{cx, cy})
|
||||
visited[idx] = true
|
||||
for len(stack) > 0 {
|
||||
cur := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
ccx, ccy := cur[0], cur[1]
|
||||
cellCount++
|
||||
if ccx < minX {
|
||||
minX = ccx
|
||||
}
|
||||
if ccy < minY {
|
||||
minY = ccy
|
||||
}
|
||||
if ccx > maxX {
|
||||
maxX = ccx
|
||||
}
|
||||
if ccy > maxY {
|
||||
maxY = ccy
|
||||
}
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
nx, ny := ccx+dx, ccy+dy
|
||||
if nx < 0 || ny < 0 || nx >= gCols || ny >= gRows {
|
||||
continue
|
||||
}
|
||||
nIdx := ny*gCols + nx
|
||||
if grid[nIdx] && !visited[nIdx] {
|
||||
visited[nIdx] = true
|
||||
stack = append(stack, [2]int{nx, ny})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip single-cell specks (sensor noise) unless it's the only motion.
|
||||
if cellCount < 2 && len(pixelList) > 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
x := minX * cellW
|
||||
y := minY * cellH
|
||||
w := (maxX - minX + 1) * cellW
|
||||
h := (maxY - minY + 1) * cellH
|
||||
if x+w > cols {
|
||||
w = cols - x
|
||||
}
|
||||
if y+h > rows {
|
||||
h = rows - y
|
||||
}
|
||||
rectangles = append(rectangles, models.MotionRectangle{X: x, Y: y, Width: w, Height: h})
|
||||
if len(rectangles) >= maxBoxes {
|
||||
return rectangles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rectangles
|
||||
}
|
||||
|
||||
@@ -327,6 +327,12 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
|
||||
configuration.Config.MaxDirectorySize = size
|
||||
}
|
||||
break
|
||||
case "AGENT_AUTO_CLEAN_MIN_FREE_SPACE":
|
||||
size, err := strconv.ParseInt(value, 10, 64)
|
||||
if err == nil {
|
||||
configuration.Config.MinFreeSpace = size
|
||||
}
|
||||
break
|
||||
|
||||
/* Camera configuration */
|
||||
case "AGENT_CAPTURE_IPCAMERA_RTSP":
|
||||
@@ -371,6 +377,9 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
|
||||
case "AGENT_CAPTURE_MOTION":
|
||||
configuration.Config.Capture.Motion = value
|
||||
break
|
||||
case "AGENT_CAPTURE_ONVIF_MOTION":
|
||||
configuration.Config.Capture.ONVIFMotion = value
|
||||
break
|
||||
case "AGENT_CAPTURE_SNAPSHOTS":
|
||||
configuration.Config.Capture.Snapshots = value
|
||||
break
|
||||
@@ -395,7 +404,7 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
|
||||
case "AGENT_CAPTURE_PIXEL_CHANGE":
|
||||
count, err := strconv.Atoi(value)
|
||||
if err == nil {
|
||||
configuration.Config.Capture.PixelChangeThreshold = count
|
||||
configuration.Config.Capture.PixelChangeThreshold = &count
|
||||
}
|
||||
break
|
||||
case "AGENT_CAPTURE_FRAGMENTED":
|
||||
@@ -642,6 +651,16 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
|
||||
}
|
||||
}
|
||||
|
||||
// Motion sensitivity historically used 0 to mean "use the default". Preserve
|
||||
// that behaviour for configurations created before this field became a
|
||||
// pointer, and also recover invalid negative values. Only apply this to the
|
||||
// effective configuration so missing values can still be inherited between
|
||||
// the separate global and custom layers.
|
||||
if applyDefaults && (configuration.Config.Capture.PixelChangeThreshold == nil || *configuration.Config.Capture.PixelChangeThreshold <= 0) {
|
||||
defaultPixelChangeThreshold := 150
|
||||
configuration.Config.Capture.PixelChangeThreshold = &defaultPixelChangeThreshold
|
||||
}
|
||||
|
||||
// Signing is a new feature, so if empty we set default values. Only applied
|
||||
// for the effective configuration (applyDefaults), not for the separate
|
||||
// global/custom views.
|
||||
|
||||
40
machinery/src/config/main_test.go
Normal file
40
machinery/src/config/main_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestApplyAgentEnvVarsPixelChangeThresholdDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
threshold *int
|
||||
want int
|
||||
}{
|
||||
{name: "missing", want: 150},
|
||||
{name: "legacy zero", threshold: intPointer(0), want: 150},
|
||||
{name: "negative", threshold: intPointer(-1), want: 150},
|
||||
{name: "positive", threshold: intPointer(275), want: 275},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configuration := &models.Configuration{}
|
||||
configuration.Config.Capture.PixelChangeThreshold = test.threshold
|
||||
|
||||
applyAgentEnvVars(configuration, "TEST_", true)
|
||||
|
||||
if configuration.Config.Capture.PixelChangeThreshold == nil {
|
||||
t.Fatal("PixelChangeThreshold is nil after applying defaults")
|
||||
}
|
||||
if got := *configuration.Config.Capture.PixelChangeThreshold; got != test.want {
|
||||
t.Fatalf("PixelChangeThreshold = %d, want %d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int {
|
||||
return &value
|
||||
}
|
||||
@@ -19,6 +19,44 @@ type CameraStreams struct {
|
||||
SubRTSP string `json:"sub_rtsp"`
|
||||
}
|
||||
|
||||
// DiscoveredDevice describes a device found on the local network during a
|
||||
// discovery scan (fing/wifiman-style). It combines ONVIF WS-Discovery results
|
||||
// with an active port scan and MAC/vendor lookup so cameras can be
|
||||
// auto-detected and pre-filled in the configuration UI.
|
||||
type DiscoveredDevice struct {
|
||||
IP string `json:"ip" bson:"ip"`
|
||||
Hostname string `json:"hostname,omitempty" bson:"hostname"`
|
||||
MAC string `json:"mac,omitempty" bson:"mac"`
|
||||
Vendor string `json:"vendor,omitempty" bson:"vendor"`
|
||||
Manufacturer string `json:"manufacturer,omitempty" bson:"manufacturer"`
|
||||
Model string `json:"model,omitempty" bson:"model"`
|
||||
Type string `json:"type,omitempty" bson:"type"`
|
||||
Server string `json:"server,omitempty" bson:"server"`
|
||||
OpenPorts []int `json:"open_ports,omitempty" bson:"open_ports"`
|
||||
Services []string `json:"services,omitempty" bson:"services"`
|
||||
ONVIF bool `json:"onvif" bson:"onvif"`
|
||||
ONVIFXAddr string `json:"onvif_xaddr,omitempty" bson:"onvif_xaddr"`
|
||||
RTSPURL string `json:"rtsp_url,omitempty" bson:"rtsp_url"`
|
||||
RTSPStreams []RTSPStream `json:"rtsp_streams,omitempty" bson:"rtsp_streams"`
|
||||
IsCamera bool `json:"is_camera" bson:"is_camera"`
|
||||
// IsAudio marks audio-only devices (e.g. IP speakers / intercoms such as
|
||||
// TOA) that expose RTSP to receive/stream audio rather than video.
|
||||
IsAudio bool `json:"is_audio" bson:"is_audio"`
|
||||
}
|
||||
|
||||
// RTSPStream is a candidate RTSP stream URL for a discovered camera, derived
|
||||
// from a built-in brand -> RTSP path mapping. When Verified is true the path was
|
||||
// confirmed to exist on the device via an unauthenticated RTSP DESCRIBE probe
|
||||
// (a 200 OK or a 401/403 "auth required" both prove the path is valid).
|
||||
type RTSPStream struct {
|
||||
Brand string `json:"brand,omitempty" bson:"brand"`
|
||||
Stream string `json:"stream,omitempty" bson:"stream"` // "main" or "sub"
|
||||
Path string `json:"path" bson:"path"`
|
||||
URL string `json:"url" bson:"url"`
|
||||
Verified bool `json:"verified" bson:"verified"`
|
||||
RequiresAuth bool `json:"requires_auth,omitempty" bson:"requires_auth"`
|
||||
}
|
||||
|
||||
type OnvifPanTilt struct {
|
||||
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
|
||||
Pan float64 `json:"pan,omitempty" bson:"pan"`
|
||||
|
||||
@@ -44,14 +44,38 @@ type Communication struct {
|
||||
// HandleLiveHLS is the live HLS viewer keepalive. It carries the requested
|
||||
// quality tier ("auto"|"high"|"low"; empty => auto) so the producer can switch
|
||||
// the live session between the main and sub stream on demand.
|
||||
HandleLiveHLS chan string
|
||||
HandleONVIF chan OnvifAction
|
||||
IsConfiguring *abool.AtomicBool
|
||||
Queue *packets.Queue
|
||||
SubQueue *packets.Queue
|
||||
Image string
|
||||
CameraConnected bool
|
||||
MainStreamConnected bool
|
||||
SubStreamConnected bool
|
||||
HasBackChannel bool
|
||||
HandleLiveHLS chan string
|
||||
HandleONVIF chan OnvifAction
|
||||
IsConfiguring *abool.AtomicBool
|
||||
// IsRecordingManual is set while a viewer has requested a manual recording
|
||||
// from the live view (the record button). While set, the motion-based
|
||||
// recorder keeps recording (it does not auto-close on the post-recording
|
||||
// timeout) until the viewer stops it again. It is independent of motion
|
||||
// detection so it also works when nothing is moving.
|
||||
IsRecordingManual *abool.AtomicBool
|
||||
// RecordingManualHeartbeat holds the unix-milliseconds timestamp of the last
|
||||
// heartbeat received from the live view while a manual recording is active.
|
||||
// The frontend re-sends the record command every few seconds while the user
|
||||
// stays on the page; if the heartbeats stop (the viewer closed the tab, went
|
||||
// idle or lost connectivity) the recorder auto-stops the manual recording so
|
||||
// it can't record forever when the "stop" message never arrives.
|
||||
RecordingManualHeartbeat *atomic.Int64
|
||||
// RecordingManualStart holds the unix-milliseconds timestamp at which the
|
||||
// current manual recording started. It bounds a manual recording to a maximum
|
||||
// duration (see capture.manualRecordingMaxDuration) so a forgotten record
|
||||
// button can't record indefinitely even while the viewer stays active.
|
||||
RecordingManualStart *atomic.Int64
|
||||
// RecordingManualHeartbeatSeen is set once the current manual recording has
|
||||
// received at least one heartbeat, i.e. the viewer proved it supports
|
||||
// heartbeating. Only then does the recorder enforce the heartbeat timeout; a
|
||||
// viewer that starts a recording but never heartbeats (an older frontend)
|
||||
// still records up to the max-duration cap instead of being cut off early.
|
||||
RecordingManualHeartbeatSeen *abool.AtomicBool
|
||||
Queue *packets.Queue
|
||||
SubQueue *packets.Queue
|
||||
Image string
|
||||
CameraConnected bool
|
||||
MainStreamConnected bool
|
||||
SubStreamConnected bool
|
||||
HasBackChannel bool
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Config struct {
|
||||
AutoClean string `json:"auto_clean"`
|
||||
RemoveAfterUpload string `json:"remove_after_upload"`
|
||||
MaxDirectorySize int64 `json:"max_directory_size"`
|
||||
MinFreeSpace int64 `json:"min_free_space,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
Capture Capture `json:"capture"`
|
||||
Timetable []*Timetable `json:"timetable"`
|
||||
@@ -73,7 +74,15 @@ type Capture struct {
|
||||
ForwardWebRTC string `json:"forwardwebrtc"`
|
||||
Fragmented string `json:"fragmented,omitempty" bson:"fragmented,omitempty"`
|
||||
FragmentedDuration int64 `json:"fragmentedduration,omitempty" bson:"fragmentedduration,omitempty"`
|
||||
PixelChangeThreshold int `json:"pixelChangeThreshold,omitempty"`
|
||||
PixelChangeThreshold *int `json:"pixelChangeThreshold,omitempty"`
|
||||
// ONVIFMotion routes the camera's ONVIF motion events into the
|
||||
// agent's motion-triggered recording pipeline. When "true" the
|
||||
// agent opens an event/stream against the configured ONVIF
|
||||
// endpoint and forwards Motion+Active events to HandleMotion.
|
||||
// Requires Capture.IPCamera.ONVIFXAddr / ONVIFUsername /
|
||||
// ONVIFPassword to be set. Default empty (disabled) keeps the
|
||||
// existing pixel-diff motion detection as the only source.
|
||||
ONVIFMotion string `json:"onvif_motion,omitempty" bson:"onvif_motion,omitempty"`
|
||||
}
|
||||
|
||||
// IPCamera configuration, such as the RTSP url of the IPCamera and the FPS.
|
||||
|
||||
@@ -150,6 +150,19 @@ type AudioPayload struct {
|
||||
// We received a recording request, we'll send it to the motion handler.
|
||||
type RecordPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp of the recording request.
|
||||
// Recording toggles a manual recording from the live view: true starts a
|
||||
// recording (and keeps it running), false stops it. Older clients that only
|
||||
// send a timestamp default to false; the live view always sets it explicitly.
|
||||
Recording bool `json:"recording"`
|
||||
// Heartbeat marks a keep-alive re-send (with Recording=true) from a viewer
|
||||
// that supports heartbeating, as opposed to the initial start (the record
|
||||
// button). While a user stays on the page the live view re-sends the record
|
||||
// command every few seconds; the agent uses this flag to (a) refresh the
|
||||
// recording's keep-alive without restarting an already auto-stopped clip from
|
||||
// a stray heartbeat, and (b) only enable the heartbeat-timeout auto-stop once
|
||||
// it has actually seen a heartbeat — so older viewers that never heartbeat
|
||||
// still record up to the max-duration cap instead of being cut off early.
|
||||
Heartbeat bool `json:"heartbeat"`
|
||||
}
|
||||
|
||||
// We received a preset position request, we'll request it through onvif and send it back.
|
||||
@@ -162,6 +175,15 @@ type RequestConfigPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp of the preset request.
|
||||
}
|
||||
|
||||
// We received a verify-stream request: probe the given (or configured) RTSP
|
||||
// stream and report whether it can be connected/decoded, along with the
|
||||
// discovered codec/resolution/fps. Responds with action "verify-stream-result".
|
||||
type VerifyStreamPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp of the verify request.
|
||||
Stream string `json:"stream"` // "main" or "sub".
|
||||
RTSP string `json:"rtsp"` // optional RTSP url to verify; falls back to the configured one.
|
||||
}
|
||||
|
||||
// We received a update config request, we'll update the current config and send a confirmation back.
|
||||
type UpdateConfigPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp of the preset request.
|
||||
|
||||
37
machinery/src/models/recording_upload_metadata.go
Normal file
37
machinery/src/models/recording_upload_metadata.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const RecordingUploadMetadataExtension = ".metadata"
|
||||
|
||||
// RecordingUploadMetadata is persisted in the upload queue marker associated
|
||||
// with a recording. New optional fields can be added without changing the queue
|
||||
// mechanism or breaking older agents.
|
||||
type RecordingUploadMetadata struct {
|
||||
FileName string `json:"filename"`
|
||||
DeviceKey string `json:"device_key"`
|
||||
Timestamp int64 `json:"timestamp"` // Unix milliseconds.
|
||||
Duration uint64 `json:"duration"` // Milliseconds.
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
}
|
||||
|
||||
// RecordingUploadMetadataFileName returns the queue marker name associated
|
||||
// with a recording, replacing the recording extension with .metadata.
|
||||
func RecordingUploadMetadataFileName(recordingFileName string) string {
|
||||
name := filepath.Base(recordingFileName)
|
||||
extension := filepath.Ext(name)
|
||||
return strings.TrimSuffix(name, extension) + RecordingUploadMetadataExtension
|
||||
}
|
||||
|
||||
// RecordingFileNameFromUploadMarker resolves a queue entry to its recording.
|
||||
// Markers created by older agents used the recording filename directly.
|
||||
func RecordingFileNameFromUploadMarker(markerFileName string) string {
|
||||
name := filepath.Base(markerFileName)
|
||||
if strings.HasSuffix(name, RecordingUploadMetadataExtension) {
|
||||
return strings.TrimSuffix(name, RecordingUploadMetadataExtension) + ".mp4"
|
||||
}
|
||||
return name
|
||||
}
|
||||
15
machinery/src/models/recording_upload_metadata_test.go
Normal file
15
machinery/src/models/recording_upload_metadata_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRecordingUploadMetadataFileNames(t *testing.T) {
|
||||
if got := RecordingUploadMetadataFileName("141245_x_x_.mp4"); got != "141245_x_x_.metadata" {
|
||||
t.Fatalf("RecordingUploadMetadataFileName() = %q", got)
|
||||
}
|
||||
if got := RecordingFileNameFromUploadMarker("141245_x_x_.metadata"); got != "141245_x_x_.mp4" {
|
||||
t.Fatalf("RecordingFileNameFromUploadMarker() = %q", got)
|
||||
}
|
||||
if got := RecordingFileNameFromUploadMarker("legacy.mp4"); got != "legacy.mp4" {
|
||||
t.Fatalf("legacy RecordingFileNameFromUploadMarker() = %q", got)
|
||||
}
|
||||
}
|
||||
523
machinery/src/onvif/brands.go
Normal file
523
machinery/src/onvif/brands.go
Normal file
@@ -0,0 +1,523 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// brandProfile describes a camera brand together with the RTSP URL path
|
||||
// templates it exposes for its main (high quality) and sub (low quality)
|
||||
// streams. The paths are the well-known, widely documented defaults for each
|
||||
// vendor and are used both to identify the brand (by probing which path the
|
||||
// device recognises) and to pre-fill a working RTSP URL for the user.
|
||||
//
|
||||
// The order of the list matters: more specific / more common brands come first
|
||||
// so that when we actively probe a device the first matching profile wins.
|
||||
type brandProfile struct {
|
||||
Brand string
|
||||
// aliases are lower-cased tokens that, when seen in a banner/realm/MAC
|
||||
// vendor, map onto this brand.
|
||||
Aliases []string
|
||||
MainPath string
|
||||
SubPath string
|
||||
// extraMainPaths are alternative main-stream paths tried during active
|
||||
// probing when the primary MainPath is not recognised.
|
||||
extraMainPaths []string
|
||||
}
|
||||
|
||||
// brandProfiles is the built-in brand -> RTSP path mapping. It mirrors the
|
||||
// tables used by tools such as ONVIF Device Manager, iSpy/Agent DVR and
|
||||
// Blue Iris.
|
||||
var brandProfiles = []brandProfile{
|
||||
{
|
||||
Brand: "Hikvision",
|
||||
Aliases: []string{"hikvision", "dvrdvs", "ds-", "hik"},
|
||||
MainPath: "/Streaming/Channels/101",
|
||||
SubPath: "/Streaming/Channels/102",
|
||||
extraMainPaths: []string{"/h264/ch1/main/av_stream", "/ISAPI/Streaming/Channels/101"},
|
||||
},
|
||||
{
|
||||
Brand: "Dahua",
|
||||
Aliases: []string{"dahua", "dh-"},
|
||||
MainPath: "/cam/realmonitor?channel=1&subtype=0",
|
||||
SubPath: "/cam/realmonitor?channel=1&subtype=1",
|
||||
extraMainPaths: []string{"/live"},
|
||||
},
|
||||
{
|
||||
Brand: "Amcrest",
|
||||
Aliases: []string{"amcrest"},
|
||||
MainPath: "/cam/realmonitor?channel=1&subtype=0",
|
||||
SubPath: "/cam/realmonitor?channel=1&subtype=1",
|
||||
},
|
||||
{
|
||||
Brand: "Axis",
|
||||
Aliases: []string{"axis"},
|
||||
MainPath: "/axis-media/media.amp",
|
||||
SubPath: "/axis-media/media.amp?videocodec=h264&resolution=640x480",
|
||||
extraMainPaths: []string{"/mpeg4/media.amp"},
|
||||
},
|
||||
{
|
||||
Brand: "Reolink",
|
||||
Aliases: []string{"reolink", "rlc", "rln", "rlc-", "rln-", "trackmix", "duo"},
|
||||
MainPath: "/h264Preview_01_main",
|
||||
SubPath: "/h264Preview_01_sub",
|
||||
extraMainPaths: []string{"/Preview_01_main"},
|
||||
},
|
||||
{
|
||||
Brand: "Hanwha",
|
||||
Aliases: []string{"hanwha", "wisenet", "samsung techwin"},
|
||||
MainPath: "/profile2/media.smp",
|
||||
SubPath: "/profile3/media.smp",
|
||||
extraMainPaths: []string{"/profile1/media.smp", "/onvif/profile2/media.smp"},
|
||||
},
|
||||
{
|
||||
Brand: "Bosch",
|
||||
Aliases: []string{"bosch"},
|
||||
MainPath: "/rtsp_tunnel",
|
||||
SubPath: "/rtsp_tunnel?inst=2",
|
||||
extraMainPaths: []string{"/rtsp_tunnel?inst=1", "/?inst=1"},
|
||||
},
|
||||
{
|
||||
Brand: "Vivotek",
|
||||
Aliases: []string{"vivotek"},
|
||||
MainPath: "/live.sdp",
|
||||
SubPath: "/live2.sdp",
|
||||
extraMainPaths: []string{"/live1s1.sdp"},
|
||||
},
|
||||
{
|
||||
Brand: "Foscam",
|
||||
Aliases: []string{"foscam"},
|
||||
MainPath: "/videoMain",
|
||||
SubPath: "/videoSub",
|
||||
},
|
||||
{
|
||||
Brand: "Uniview",
|
||||
Aliases: []string{"uniview", "unv"},
|
||||
MainPath: "/media/video1",
|
||||
SubPath: "/media/video2",
|
||||
extraMainPaths: []string{"/unicast/c1/s0/live", "/unicast/c1/s1/live"},
|
||||
},
|
||||
{
|
||||
Brand: "TP-Link",
|
||||
Aliases: []string{"tp-link", "tplink", "tapo"},
|
||||
MainPath: "/stream1",
|
||||
SubPath: "/stream2",
|
||||
},
|
||||
{
|
||||
Brand: "Mobotix",
|
||||
Aliases: []string{"mobotix"},
|
||||
MainPath: "/cam0/mjpeg",
|
||||
SubPath: "/cam1/mjpeg",
|
||||
extraMainPaths: []string{"/live.sdp"},
|
||||
},
|
||||
{
|
||||
Brand: "Ubiquiti",
|
||||
Aliases: []string{"ubiquiti", "unifi"},
|
||||
MainPath: "/s0",
|
||||
SubPath: "/s1",
|
||||
extraMainPaths: []string{"/live/ch00_0"},
|
||||
},
|
||||
{
|
||||
Brand: "Panasonic",
|
||||
Aliases: []string{"panasonic", "i-pro", "ipro"},
|
||||
MainPath: "/MediaInput/h264",
|
||||
SubPath: "/MediaInput/h264/stream_2",
|
||||
},
|
||||
{
|
||||
Brand: "Sony",
|
||||
Aliases: []string{"sony"},
|
||||
MainPath: "/media/video1",
|
||||
SubPath: "/media/video2",
|
||||
},
|
||||
{
|
||||
// D-Link mydlink IP cameras. Older models stream MJPEG over HTTP; the
|
||||
// RTSP-capable ones expose SDP-named streams, newer DCS models use
|
||||
// "/live/profile.0".
|
||||
Brand: "D-Link",
|
||||
Aliases: []string{"d-link", "dlink", "dcs-", "dcs"},
|
||||
MainPath: "/live1.sdp",
|
||||
SubPath: "/live2.sdp",
|
||||
extraMainPaths: []string{"/live.sdp", "/live/profile.0", "/play1.sdp"},
|
||||
},
|
||||
{
|
||||
// TRENDnet. Newer PoE bullet/dome models (TV-IPxxxPI) use a
|
||||
// Hikvision-style path; older ones expose SDP streams.
|
||||
Brand: "Trendnet",
|
||||
Aliases: []string{"trendnet", "tv-ip"},
|
||||
MainPath: "/Streaming/Channels/101",
|
||||
SubPath: "/Streaming/Channels/102",
|
||||
extraMainPaths: []string{"/play1.sdp", "/play2.sdp", "/ch0_0.h264", "/live/av0"},
|
||||
},
|
||||
{
|
||||
// Lorex is built largely on Dahua hardware, so it shares Dahua's
|
||||
// realmonitor path scheme.
|
||||
Brand: "Lorex",
|
||||
Aliases: []string{"lorex"},
|
||||
MainPath: "/cam/realmonitor?channel=1&subtype=0",
|
||||
SubPath: "/cam/realmonitor?channel=1&subtype=1",
|
||||
extraMainPaths: []string{"/ch01/0"},
|
||||
},
|
||||
{
|
||||
// Honeywell ships both Dahua-OEM models (realmonitor) and in-house
|
||||
// firmwares exposing "/h264" or "/media".
|
||||
Brand: "Honeywell",
|
||||
Aliases: []string{"honeywell"},
|
||||
MainPath: "/cam/realmonitor?channel=1&subtype=0",
|
||||
SubPath: "/cam/realmonitor?channel=1&subtype=1",
|
||||
extraMainPaths: []string{"/h264", "/media", "/live.sdp"},
|
||||
},
|
||||
{
|
||||
Brand: "Pelco",
|
||||
Aliases: []string{"pelco"},
|
||||
MainPath: "/stream1",
|
||||
SubPath: "/stream2",
|
||||
extraMainPaths: []string{"/1/stream1"},
|
||||
},
|
||||
{
|
||||
// TOA network audio devices (IP horn speakers / intercoms, banner
|
||||
// "TOA rtsp server") expose their stream through ONVIF rather than a
|
||||
// documented fixed RTSP path. These ONVIF-style paths are a best-effort
|
||||
// default; the authoritative URL should come from an ONVIF GetStreamUri
|
||||
// query with credentials.
|
||||
Brand: "TOA",
|
||||
Aliases: []string{"toa"},
|
||||
MainPath: "/ONVIF/channel1",
|
||||
SubPath: "/ONVIF/channel2",
|
||||
extraMainPaths: []string{"/media/video1", "/live"},
|
||||
},
|
||||
{
|
||||
// Linksys/Cisco IP cameras (e.g. LCAD03FLN, LCAB03VLNOD, LCAM0336OD)
|
||||
// run a mini_httpd server and expose ONVIF-style stream paths with a
|
||||
// capitalised "ONVIF" segment (distinct from the generic "/onvif1").
|
||||
Brand: "Linksys",
|
||||
Aliases: []string{"linksys", "lcad", "lcab", "lcam", "lcae"},
|
||||
MainPath: "/ONVIF/channel1",
|
||||
SubPath: "/ONVIF/channel2",
|
||||
extraMainPaths: []string{"/img/media.sav", "/live"},
|
||||
},
|
||||
}
|
||||
|
||||
// genericRTSPPaths are last-resort, vendor-neutral RTSP paths used when the
|
||||
// brand is unknown. Many ONVIF/embedded cameras answer on one of these.
|
||||
var genericRTSPPaths = []string{
|
||||
"/ONVIF/channel1", "/ONVIF/channel2", "/onvif1", "/live", "/live/ch0", "/11", "/12",
|
||||
"/stream0", "/stream1", "/h264", "/media/video1", "/ch0_0.h264",
|
||||
}
|
||||
|
||||
// brandProfileFor returns the profile whose aliases best match the given brand
|
||||
// hint (from a banner, realm or MAC vendor). It returns nil when nothing
|
||||
// matches.
|
||||
func brandProfileFor(hint string) *brandProfile {
|
||||
hint = strings.ToLower(strings.TrimSpace(hint))
|
||||
if hint == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range brandProfiles {
|
||||
for _, alias := range brandProfiles[i].Aliases {
|
||||
if strings.Contains(hint, alias) {
|
||||
return &brandProfiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// realmBrands maps a lower-cased substring of an RTSP/HTTP WWW-Authenticate
|
||||
// realm to a manufacturer. The auth realm is one of the most reliable brand
|
||||
// signals because a camera advertises it even when it refuses every
|
||||
// unauthenticated request (e.g. Hikvision realm "IP Camera(E3669)", Dahua realm
|
||||
// "Login to <serial>"). Ordered so the most specific matches win.
|
||||
var realmBrands = []struct {
|
||||
Match string
|
||||
Vendor string
|
||||
}{
|
||||
{"login to", "Dahua"},
|
||||
{"surveillance server", "Dahua"},
|
||||
{"real time streaming", "Dahua"},
|
||||
{"dahua", "Dahua"},
|
||||
{"ip camera(", "Hikvision"},
|
||||
{"hikvision", "Hikvision"},
|
||||
{"ds-", "Hikvision"},
|
||||
{"axis", "Axis"},
|
||||
{"reolink", "Reolink"},
|
||||
{"amcrest", "Amcrest"},
|
||||
{"wisenet", "Hanwha"},
|
||||
{"hanwha", "Hanwha"},
|
||||
{"uniview", "Uniview"},
|
||||
{"tp-link", "TP-Link"},
|
||||
{"tapo", "TP-Link"},
|
||||
{"foscam", "Foscam"},
|
||||
{"vivotek", "Vivotek"},
|
||||
{"mobotix", "Mobotix"},
|
||||
{"bosch", "Bosch"},
|
||||
{"please log in with a valid username", "Bosch"},
|
||||
{"d-link", "D-Link"},
|
||||
{"dcs-", "D-Link"},
|
||||
{"trendnet", "Trendnet"},
|
||||
{"lorex", "Lorex"},
|
||||
{"honeywell", "Honeywell"},
|
||||
{"pelco", "Pelco"},
|
||||
{"linksys", "Linksys"},
|
||||
{"lcad", "Linksys"},
|
||||
{"lcab", "Linksys"},
|
||||
{"lcam", "Linksys"},
|
||||
}
|
||||
|
||||
// brandFromRealm resolves a manufacturer from an auth realm string.
|
||||
func brandFromRealm(realm string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(realm))
|
||||
if r == "" {
|
||||
return ""
|
||||
}
|
||||
for _, entry := range realmBrands {
|
||||
if strings.Contains(r, entry.Match) {
|
||||
return entry.Vendor
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// modelFromRealm extracts a model/device code embedded in an auth realm, e.g.
|
||||
// Hikvision's realm="IP Camera(E3669)" -> "E3669".
|
||||
func modelFromRealm(realm string) string {
|
||||
open := strings.Index(realm, "(")
|
||||
closeIdx := strings.Index(realm, ")")
|
||||
if open >= 0 && closeIdx > open+1 {
|
||||
return strings.TrimSpace(realm[open+1 : closeIdx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank value.
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// guessRTSPStreams determines the most likely RTSP stream URLs for a camera. It
|
||||
// combines the brand hint discovered from banners/MAC with an active,
|
||||
// unauthenticated RTSP DESCRIBE probe and the auth realm advertised by the
|
||||
// device.
|
||||
//
|
||||
// Detection strategy (most reliable first):
|
||||
// 1. Send a control DESCRIBE for a random, non-existent path. Its 401 response
|
||||
// usually carries a WWW-Authenticate realm that reveals the brand
|
||||
// (Hikvision "IP Camera(...)", Dahua "Login to ..."). The realm is the
|
||||
// strongest signal and works even when the device challenges auth for every
|
||||
// request. The control also tells us whether the device distinguishes valid
|
||||
// from invalid paths.
|
||||
// 2. If the device discriminates paths, probe each brand's main path (realm
|
||||
// brand first); the first the device recognises (200 or 401/403) confirms a
|
||||
// working URL.
|
||||
// 3. Otherwise fall back to the realm / hint / port brand's default paths and
|
||||
// return them as unverified suggestions.
|
||||
//
|
||||
// It returns the detected brand, an optional model code parsed from the realm,
|
||||
// and the ordered list of candidate streams (verified first).
|
||||
func guessRTSPStreams(ip string, port int, brandHint string, openPorts []int, timeout time.Duration) (brand string, model string, streams []models.RTSPStream) {
|
||||
base := "rtsp://" + net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
|
||||
build := func(profileBrand, stream, path string, verified, requiresAuth bool) models.RTSPStream {
|
||||
return models.RTSPStream{
|
||||
Brand: profileBrand,
|
||||
Stream: stream,
|
||||
Path: path,
|
||||
URL: base + path,
|
||||
Verified: verified,
|
||||
RequiresAuth: requiresAuth,
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Control probe: distinguish behaviour + capture the auth realm.
|
||||
bogusPath := "/kerberos-probe-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
controlStatus, controlRealm, _ := rtspDescribe(ip, port, bogusPath, timeout)
|
||||
controlExists := controlStatus == 200 || controlStatus == 401 || controlStatus == 403
|
||||
controlAuth := controlStatus == 401 || controlStatus == 403
|
||||
discriminates := !controlExists
|
||||
|
||||
realmBrand := brandFromRealm(controlRealm)
|
||||
model = modelFromRealm(controlRealm)
|
||||
|
||||
// The realm brand (when present) is authoritative and probed first.
|
||||
primaryHint := firstNonEmpty(realmBrand, brandHint)
|
||||
|
||||
var verified []models.RTSPStream
|
||||
var unverified []models.RTSPStream
|
||||
detected := ""
|
||||
|
||||
// 2) Trustworthy active per-brand probing (device discriminates paths).
|
||||
if discriminates {
|
||||
for _, profile := range orderedProfiles(primaryHint) {
|
||||
mainCandidates := append([]string{profile.MainPath}, profile.extraMainPaths...)
|
||||
matchedMain := ""
|
||||
matchedAuth := false
|
||||
for _, path := range mainCandidates {
|
||||
ok, requiresAuth := rtspPathExists(ip, port, path, timeout)
|
||||
if ok {
|
||||
matchedMain = path
|
||||
matchedAuth = requiresAuth
|
||||
break
|
||||
}
|
||||
}
|
||||
if matchedMain == "" {
|
||||
continue
|
||||
}
|
||||
detected = profile.Brand
|
||||
verified = append(verified, build(profile.Brand, "main", matchedMain, true, matchedAuth))
|
||||
if profile.SubPath != "" {
|
||||
subOK, subAuth := rtspPathExists(ip, port, profile.SubPath, timeout)
|
||||
verified = append(verified, build(profile.Brand, "sub", profile.SubPath, subOK, subAuth || matchedAuth))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Fall back to unverified suggestions from realm / hint / port signals.
|
||||
if len(verified) == 0 {
|
||||
profile := brandProfileFor(primaryHint)
|
||||
if profile == nil {
|
||||
profile = brandProfileForPorts(openPorts)
|
||||
}
|
||||
if profile != nil {
|
||||
detected = profile.Brand
|
||||
unverified = append(unverified, build(profile.Brand, "main", profile.MainPath, false, controlAuth))
|
||||
if profile.SubPath != "" {
|
||||
unverified = append(unverified, build(profile.Brand, "sub", profile.SubPath, false, controlAuth))
|
||||
}
|
||||
} else {
|
||||
for _, path := range genericRTSPPaths {
|
||||
unverified = append(unverified, build("Generic", "main", path, false, controlAuth))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The realm brand always wins for the manufacturer name.
|
||||
if realmBrand != "" {
|
||||
detected = realmBrand
|
||||
}
|
||||
|
||||
return detected, model, append(verified, unverified...)
|
||||
}
|
||||
|
||||
// brandProfileForPorts derives a brand from vendor-specific control ports that
|
||||
// were found open during the scan (used when banners give no hint).
|
||||
func brandProfileForPorts(openPorts []int) *brandProfile {
|
||||
if containsInt(openPorts, 37777) {
|
||||
return brandProfileByName("Dahua")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// brandProfileByName returns the profile with the given brand name (nil when
|
||||
// absent).
|
||||
func brandProfileByName(name string) *brandProfile {
|
||||
for i := range brandProfiles {
|
||||
if brandProfiles[i].Brand == name {
|
||||
return &brandProfiles[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderedProfiles returns the brand profiles with the profile matching the
|
||||
// brand hint (if any) moved to the front so it is probed first.
|
||||
func orderedProfiles(brandHint string) []brandProfile {
|
||||
match := brandProfileFor(brandHint)
|
||||
if match == nil {
|
||||
return brandProfiles
|
||||
}
|
||||
ordered := make([]brandProfile, 0, len(brandProfiles))
|
||||
ordered = append(ordered, *match)
|
||||
for i := range brandProfiles {
|
||||
if brandProfiles[i].Brand != match.Brand {
|
||||
ordered = append(ordered, brandProfiles[i])
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// rtspDescribe sends an unauthenticated RTSP DESCRIBE for the given path and
|
||||
// returns the response status code together with the WWW-Authenticate realm and
|
||||
// Server header (when present). status is 0 when the device does not answer.
|
||||
func rtspDescribe(ip string, port int, path string, timeout time.Duration) (status int, realm string, server string) {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return 0, "", ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
request := "DESCRIBE rtsp://" + address + path + " RTSP/1.0\r\n" +
|
||||
"CSeq: 1\r\n" +
|
||||
"User-Agent: KerberosDiscovery\r\n" +
|
||||
"Accept: application/sdp\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return 0, "", ""
|
||||
}
|
||||
|
||||
status, headers := readRTSPResponse(conn)
|
||||
return status, parseRealm(headers["www-authenticate"]), headers["server"]
|
||||
}
|
||||
|
||||
// rtspPathExists reports whether the device recognises the given RTSP path. A
|
||||
// 200 OK means the path is publicly accessible; a 401/403 means the path is
|
||||
// valid but requires credentials (still a positive match). Any other status
|
||||
// (404, 400, 455, ...) means the path is not recognised.
|
||||
func rtspPathExists(ip string, port int, path string, timeout time.Duration) (exists bool, requiresAuth bool) {
|
||||
status, _, _ := rtspDescribe(ip, port, path, timeout)
|
||||
switch status {
|
||||
case 200:
|
||||
return true, false
|
||||
case 401, 403:
|
||||
return true, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
// readRTSPResponse reads and parses the status code and headers of an RTSP
|
||||
// response. Only the first occurrence of each header is kept.
|
||||
func readRTSPResponse(conn net.Conn) (status int, headers map[string]string) {
|
||||
headers = make(map[string]string)
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return 0, headers
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && strings.HasPrefix(strings.ToUpper(fields[0]), "RTSP/") {
|
||||
status, _ = strconv.Atoi(fields[1])
|
||||
}
|
||||
|
||||
for {
|
||||
hline, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
hline = strings.TrimRight(hline, "\r\n")
|
||||
if hline == "" {
|
||||
break
|
||||
}
|
||||
idx := strings.Index(hline, ":")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(hline[:idx]))
|
||||
value := strings.TrimSpace(hline[idx+1:])
|
||||
if _, exists := headers[key]; !exists {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
return status, headers
|
||||
}
|
||||
171
machinery/src/onvif/brands_test.go
Normal file
171
machinery/src/onvif/brands_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockRTSPServer starts a TCP listener that answers RTSP DESCRIBE requests. For
|
||||
// each incoming request it extracts the path and calls respond(path) to obtain
|
||||
// the numeric status code and optional auth realm to return. It returns the
|
||||
// listener host, port and a cleanup function.
|
||||
func mockRTSPServer(t *testing.T, respond func(path string) (int, string)) (string, int, func()) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start mock RTSP server: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
reader := bufio.NewReader(c)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := ""
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
url := fields[1]
|
||||
url = strings.TrimPrefix(url, "rtsp://")
|
||||
if idx := strings.Index(url, "/"); idx >= 0 {
|
||||
path = url[idx:]
|
||||
}
|
||||
}
|
||||
status, realm := respond(path)
|
||||
reason := map[int]string{200: "OK", 401: "Unauthorized", 404: "Not Found"}[status]
|
||||
response := "RTSP/1.0 " + strconv.Itoa(status) + " " + reason + "\r\nCSeq: 1\r\n"
|
||||
if realm != "" {
|
||||
response += "WWW-Authenticate: Digest realm=\"" + realm + "\", nonce=\"abc\"\r\n"
|
||||
}
|
||||
response += "\r\n"
|
||||
_, _ = c.Write([]byte(response))
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(listener.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return host, port, func() { listener.Close() }
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_DiscriminatingHikvision verifies that a device which
|
||||
// distinguishes valid from invalid paths (returning 401 only for the Hikvision
|
||||
// path) is correctly identified as Hikvision with a confirmed main/sub stream.
|
||||
func TestGuessRTSPStreams_DiscriminatingHikvision(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
if strings.HasPrefix(path, "/Streaming/Channels/") {
|
||||
return 401, "" // valid path, needs auth
|
||||
}
|
||||
return 404, "" // everything else is unknown -> device discriminates
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
|
||||
if brand != "Hikvision" {
|
||||
t.Fatalf("expected brand Hikvision, got %q", brand)
|
||||
}
|
||||
if len(streams) == 0 || !streams[0].Verified {
|
||||
t.Fatalf("expected a verified main stream, got %+v", streams)
|
||||
}
|
||||
if !streams[0].RequiresAuth {
|
||||
t.Errorf("expected main stream to require auth")
|
||||
}
|
||||
if streams[0].Path != "/Streaming/Channels/101" {
|
||||
t.Errorf("expected main path /Streaming/Channels/101, got %q", streams[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_ChallengesEverything verifies that a device which returns
|
||||
// 401 for *any* path (including a bogus one) does NOT get mis-detected via path
|
||||
// probing, and instead falls back to the port hint (Dahua control port 37777)
|
||||
// with unverified suggestions.
|
||||
func TestGuessRTSPStreams_ChallengesEverything(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
return 401, "" // challenges auth before checking the path, no realm
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, _, streams := guessRTSPStreams(host, port, "", []int{37777}, 2*time.Second)
|
||||
if brand != "Dahua" {
|
||||
t.Fatalf("expected fallback brand Dahua from port hint, got %q", brand)
|
||||
}
|
||||
if len(streams) == 0 {
|
||||
t.Fatalf("expected suggested streams, got none")
|
||||
}
|
||||
if streams[0].Verified {
|
||||
t.Errorf("expected unverified suggestion for a non-discriminating device")
|
||||
}
|
||||
if streams[0].Path != "/cam/realmonitor?channel=1&subtype=0" {
|
||||
t.Errorf("expected Dahua main path, got %q", streams[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_RealmDetectsHikvision verifies that a device which
|
||||
// challenges auth for every path (so path probing cannot help) is still
|
||||
// identified from its RTSP auth realm, and the model code is extracted.
|
||||
func TestGuessRTSPStreams_RealmDetectsHikvision(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
return 401, "IP Camera(E3669)" // Hikvision realm signature, 401 for all paths
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, model, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
|
||||
if brand != "Hikvision" {
|
||||
t.Fatalf("expected brand Hikvision from realm, got %q", brand)
|
||||
}
|
||||
if model != "E3669" {
|
||||
t.Errorf("expected model E3669 from realm, got %q", model)
|
||||
}
|
||||
if len(streams) == 0 || streams[0].Path != "/Streaming/Channels/101" {
|
||||
t.Fatalf("expected Hikvision default main path, got %+v", streams)
|
||||
}
|
||||
if !streams[0].RequiresAuth {
|
||||
t.Errorf("expected the suggestion to be marked auth-required")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_RealmDetectsDahua verifies Dahua detection from its
|
||||
// "Login to ..." realm.
|
||||
func TestGuessRTSPStreams_RealmDetectsDahua(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
return 401, "Login to 5df61a6057b10cc99d471769516d3c11"
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
|
||||
if brand != "Dahua" {
|
||||
t.Fatalf("expected brand Dahua from realm, got %q", brand)
|
||||
}
|
||||
if len(streams) == 0 || streams[0].Path != "/cam/realmonitor?channel=1&subtype=0" {
|
||||
t.Fatalf("expected Dahua default main path, got %+v", streams)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_UnknownFallsBackToGeneric verifies that an unknown device
|
||||
// (discriminating but matching no brand) yields generic suggestions.
|
||||
func TestGuessRTSPStreams_UnknownFallsBackToGeneric(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
return 404, "" // discriminates, but nothing matches
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
|
||||
if brand != "" {
|
||||
t.Fatalf("expected no detected brand, got %q", brand)
|
||||
}
|
||||
if len(streams) == 0 || streams[0].Brand != "Generic" {
|
||||
t.Fatalf("expected generic suggestions, got %+v", streams)
|
||||
}
|
||||
}
|
||||
557
machinery/src/onvif/discovery.go
Normal file
557
machinery/src/onvif/discovery.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
onvifc "github.com/cedricve/go-onvif"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// scanPort describes a TCP port we probe while scanning the local network,
|
||||
// together with a human readable service name.
|
||||
type scanPort struct {
|
||||
Port int
|
||||
Service string
|
||||
// rtsp marks RTSP ports we can fingerprint via an OPTIONS request.
|
||||
rtsp bool
|
||||
// http marks HTTP ports we can fingerprint via a banner grab.
|
||||
http bool
|
||||
// camera marks ports that strongly hint the device is an IP camera or NVR
|
||||
// (RTSP, dedicated ONVIF ports and well-known DVR/NVR control ports).
|
||||
camera bool
|
||||
}
|
||||
|
||||
// commonCameraPorts is the list of TCP ports we probe on every host. These are
|
||||
// the ports most commonly exposed by IP cameras (RTSP, HTTP(S) and ONVIF).
|
||||
var commonCameraPorts = []scanPort{
|
||||
{Port: 554, Service: "RTSP", rtsp: true, camera: true},
|
||||
{Port: 8554, Service: "RTSP (alt)", rtsp: true, camera: true},
|
||||
{Port: 80, Service: "HTTP", http: true},
|
||||
{Port: 8080, Service: "HTTP (alt)", http: true},
|
||||
{Port: 8000, Service: "ONVIF", http: true, camera: true},
|
||||
{Port: 8899, Service: "ONVIF (alt)", camera: true},
|
||||
{Port: 443, Service: "HTTPS"},
|
||||
{Port: 37777, Service: "Dahua", camera: true},
|
||||
{Port: 34567, Service: "XMeye/Sofia", camera: true},
|
||||
}
|
||||
|
||||
// ouiVendors maps the first three octets (OUI) of a MAC address, upper-cased and
|
||||
// without separators, to a known camera/NVR vendor. This lets us flag likely
|
||||
// cameras the same way tools such as Fing or WiFiman do, even when a device does
|
||||
// not answer to ONVIF WS-Discovery.
|
||||
var ouiVendors = map[string]string{
|
||||
"BCAD01": "Hikvision", "C056E3": "Hikvision", "4CBD8F": "Hikvision",
|
||||
"44A642": "Hikvision", "E0509B": "Hikvision", "ACB927": "Hikvision",
|
||||
"18800C": "Hikvision", "C40BCB": "Hikvision",
|
||||
"3CEF8C": "Dahua", "90020A": "Dahua", "E0509B00": "Dahua",
|
||||
"08ED02": "Dahua", "3CE376": "Dahua", "38AF29": "Dahua", "E45D51": "Dahua",
|
||||
"00408C": "Axis", "AABBCC": "Axis", "B8A44F": "Axis", "ACCC8E": "Axis",
|
||||
"E82725": "Bosch", "000CAB": "Bosch",
|
||||
"001B9E": "Hanwha", "0009D2": "Hanwha", "E44CC7": "Hanwha",
|
||||
"EC7196": "Reolink", "9CA3BA": "Reolink",
|
||||
"3C33F1": "Amcrest", "9C8ECD": "Amcrest",
|
||||
"000FFC": "Vivotek", "0002D1": "Vivotek",
|
||||
"001C27": "Mobotix", "0003C5": "Mobotix",
|
||||
"00126A": "Ubiquiti", "FCECDA": "Ubiquiti", "744401": "Ubiquiti",
|
||||
"F0234B": "Foscam", "00626E": "Foscam",
|
||||
"C09424": "TP-Link", "50C7BF": "TP-Link",
|
||||
}
|
||||
|
||||
// DiscoverDevices performs an advanced, Fing/WiFiman-style scan of the local
|
||||
// network. It combines:
|
||||
//
|
||||
// 1. ONVIF WS-Discovery (multicast probe), and
|
||||
// 2. an active TCP port scan of every host on the local IPv4 subnets for the
|
||||
// ports typically exposed by IP cameras, and
|
||||
// 3. MAC address + vendor (OUI) resolution from the local ARP table, and
|
||||
// 4. best-effort reverse-DNS hostname lookup.
|
||||
//
|
||||
// The results are merged per IP address so a single device is reported once
|
||||
// with all the information we could gather. Devices are flagged as cameras when
|
||||
// they answer to ONVIF, expose an RTSP port, or have a MAC that belongs to a
|
||||
// known camera vendor.
|
||||
//
|
||||
// Optional subnets (CIDR notation, e.g. "192.168.1.0/24") override the
|
||||
// automatically detected local subnets. This is useful when the agent runs in a
|
||||
// container/devcontainer whose interfaces are not on the same range as the
|
||||
// cameras, but the target range is still routable from the host network.
|
||||
func DiscoverDevices(timeout time.Duration, subnets ...string) []models.DiscoveredDevice {
|
||||
devicesByIP := make(map[string]*models.DiscoveredDevice)
|
||||
var mutex sync.Mutex
|
||||
|
||||
// upsert returns the (possibly newly created) device entry for an IP in a
|
||||
// concurrency-safe way.
|
||||
upsert := func(ip string) *models.DiscoveredDevice {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
device, ok := devicesByIP[ip]
|
||||
if !ok {
|
||||
device = &models.DiscoveredDevice{IP: ip}
|
||||
devicesByIP[ip] = device
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
// 1) ONVIF WS-Discovery. This is quick and reliable for ONVIF cameras.
|
||||
onvifDevices, err := onvifc.StartDiscovery(timeout)
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.DiscoverDevices(): WS-Discovery failed: " + err.Error())
|
||||
} else {
|
||||
for _, onvifDevice := range onvifDevices {
|
||||
ip := hostFromXAddr(onvifDevice.XAddr)
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
device := upsert(ip)
|
||||
device.ONVIF = true
|
||||
device.ONVIFXAddr = onvifDevice.XAddr
|
||||
device.IsCamera = true
|
||||
if hostname, hostErr := onvifDevice.GetHostname(); hostErr == nil && hostname.Name != "" {
|
||||
device.Hostname = hostname.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Active port scan across the requested (or auto-detected) IPv4 subnets.
|
||||
var targets []string
|
||||
if len(subnets) > 0 {
|
||||
targets = targetsFromSubnets(subnets)
|
||||
} else {
|
||||
targets = localScanTargets()
|
||||
}
|
||||
log.Log.Info("onvif.DiscoverDevices(): scanning " + strconv.Itoa(len(targets)) + " hosts on the local network(s)")
|
||||
|
||||
// Bound the amount of concurrent dials so we do not exhaust file
|
||||
// descriptors on constrained devices (e.g. Raspberry Pi).
|
||||
semaphore := make(chan struct{}, 128)
|
||||
dialTimeout := perHostTimeout(timeout)
|
||||
var waitGroup sync.WaitGroup
|
||||
|
||||
for _, ip := range targets {
|
||||
waitGroup.Add(1)
|
||||
semaphore <- struct{}{}
|
||||
go func(ip string) {
|
||||
defer waitGroup.Done()
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
openPorts, services, isCamera := scanHost(ip, dialTimeout)
|
||||
if len(openPorts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Fingerprint the host (RTSP/HTTP banner grab) to determine its
|
||||
// manufacturer, model and type without any credentials.
|
||||
fingerprint := fingerprintHost(ip, openPorts, dialTimeout)
|
||||
|
||||
// Resolve a hostname now (ONVIF WS-Discovery may already have set
|
||||
// one; otherwise fall back to reverse DNS). Camera hostnames often
|
||||
// encode the model (e.g. Reolink "RLC-823S2"), which is a useful
|
||||
// brand hint when the RTSP/HTTP banners are anonymous.
|
||||
mutex.Lock()
|
||||
hostname := ""
|
||||
if existing, ok := devicesByIP[ip]; ok {
|
||||
hostname = existing.Hostname
|
||||
}
|
||||
mutex.Unlock()
|
||||
if hostname == "" {
|
||||
hostname = reverseDNS(ip, dialTimeout)
|
||||
}
|
||||
|
||||
// Guess (and actively confirm) the RTSP stream URLs from a built-in
|
||||
// brand -> RTSP path mapping when an RTSP port is open.
|
||||
var rtspPort int
|
||||
for _, port := range openPorts {
|
||||
if port == 554 || port == 8554 {
|
||||
rtspPort = port
|
||||
break
|
||||
}
|
||||
}
|
||||
// The banner manufacturer is most reliable; fall back to the
|
||||
// hostname (model code) so devices that only reveal themselves via
|
||||
// their name (e.g. Reolink RLC-*) still get the right stream paths.
|
||||
brandHint := fingerprint.Manufacturer
|
||||
if brandHint == "" {
|
||||
brandHint = hostname
|
||||
}
|
||||
var rtspStreams []models.RTSPStream
|
||||
detectedBrand := ""
|
||||
detectedModel := ""
|
||||
if rtspPort != 0 && !fingerprint.IsAudio {
|
||||
detectedBrand, detectedModel, rtspStreams = guessRTSPStreams(ip, rtspPort, brandHint, openPorts, dialTimeout)
|
||||
}
|
||||
|
||||
device := upsert(ip)
|
||||
mutex.Lock()
|
||||
device.OpenPorts = mergeSortedInts(device.OpenPorts, openPorts)
|
||||
device.Services = mergeUniqueStrings(device.Services, services)
|
||||
if hostname != "" && device.Hostname == "" {
|
||||
device.Hostname = hostname
|
||||
}
|
||||
if isCamera || fingerprint.IsCamera {
|
||||
device.IsCamera = true
|
||||
}
|
||||
if fingerprint.IsAudio {
|
||||
device.IsAudio = true
|
||||
device.IsCamera = false
|
||||
}
|
||||
if fingerprint.Manufacturer != "" {
|
||||
device.Manufacturer = fingerprint.Manufacturer
|
||||
}
|
||||
// A brand derived from the RTSP auth realm, a confirmed path probe or
|
||||
// a vendor-specific control port is more reliable than a banner
|
||||
// string, so let it win.
|
||||
if detectedBrand != "" && detectedBrand != "Generic" {
|
||||
device.Manufacturer = detectedBrand
|
||||
device.IsCamera = true
|
||||
}
|
||||
if fingerprint.Model != "" {
|
||||
device.Model = fingerprint.Model
|
||||
}
|
||||
if device.Model == "" && detectedModel != "" {
|
||||
device.Model = detectedModel
|
||||
}
|
||||
if fingerprint.Type != "" {
|
||||
device.Type = fingerprint.Type
|
||||
}
|
||||
if fingerprint.Server != "" {
|
||||
device.Server = fingerprint.Server
|
||||
}
|
||||
if len(rtspStreams) > 0 {
|
||||
device.RTSPStreams = rtspStreams
|
||||
// Prefer the first verified stream as the primary RTSP URL.
|
||||
device.RTSPURL = rtspStreams[0].URL
|
||||
for _, stream := range rtspStreams {
|
||||
if stream.Verified {
|
||||
device.RTSPURL = stream.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if rtspPort != 0 && !fingerprint.IsAudio {
|
||||
device.RTSPURL = "rtsp://" + ip + ":" + strconv.Itoa(rtspPort) + "/"
|
||||
}
|
||||
mutex.Unlock()
|
||||
}(ip)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
|
||||
// 3) Enrich with MAC address / vendor from the ARP table and hostnames.
|
||||
arpTable := readARPTable()
|
||||
results := make([]models.DiscoveredDevice, 0, len(devicesByIP))
|
||||
for ip, device := range devicesByIP {
|
||||
if mac, ok := arpTable[ip]; ok {
|
||||
device.MAC = mac
|
||||
if vendor := vendorFromMAC(mac); vendor != "" {
|
||||
device.Vendor = vendor
|
||||
device.IsCamera = true
|
||||
}
|
||||
}
|
||||
// Fall back to the MAC vendor for the manufacturer, and make sure a
|
||||
// camera always carries a device type.
|
||||
if device.Manufacturer == "" && device.Vendor != "" {
|
||||
device.Manufacturer = device.Vendor
|
||||
}
|
||||
if device.IsCamera && device.Type == "" {
|
||||
device.Type = "IP Camera"
|
||||
}
|
||||
if device.Hostname == "" {
|
||||
device.Hostname = reverseDNS(ip, dialTimeout)
|
||||
}
|
||||
results = append(results, *device)
|
||||
}
|
||||
|
||||
// Cameras first, then by IP, for a stable and useful ordering.
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].IsCamera != results[j].IsCamera {
|
||||
return results[i].IsCamera
|
||||
}
|
||||
return ipLess(results[i].IP, results[j].IP)
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// scanHost probes the common camera ports on a single host and reports the open
|
||||
// ports, their service names, and whether the host looks like a camera.
|
||||
func scanHost(ip string, dialTimeout time.Duration) (openPorts []int, services []string, isCamera bool) {
|
||||
for _, candidate := range commonCameraPorts {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(candidate.Port))
|
||||
conn, err := net.DialTimeout("tcp", address, dialTimeout)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
conn.Close()
|
||||
openPorts = append(openPorts, candidate.Port)
|
||||
services = append(services, candidate.Service)
|
||||
if candidate.camera {
|
||||
isCamera = true
|
||||
}
|
||||
}
|
||||
return openPorts, services, isCamera
|
||||
}
|
||||
|
||||
// targetsFromSubnets expands one or more explicit CIDR ranges (e.g.
|
||||
// "192.168.1.0/24") into a de-duplicated list of host addresses. Invalid or
|
||||
// oversized ranges (mask < /22) are skipped so scans stay bounded.
|
||||
func targetsFromSubnets(subnets []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var targets []string
|
||||
|
||||
for _, subnet := range subnets {
|
||||
subnet = strings.TrimSpace(subnet)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
// Allow passing a bare host address (e.g. "192.168.1.50") too.
|
||||
if !strings.Contains(subnet, "/") {
|
||||
if net.ParseIP(subnet).To4() != nil {
|
||||
if _, exists := seen[subnet]; !exists {
|
||||
seen[subnet] = struct{}{}
|
||||
targets = append(targets, subnet)
|
||||
}
|
||||
} else {
|
||||
log.Log.Error("onvif.targetsFromSubnets(): invalid address '" + subnet + "'")
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(subnet)
|
||||
if err != nil || ipNet.IP.To4() == nil {
|
||||
log.Log.Error("onvif.targetsFromSubnets(): invalid CIDR '" + subnet + "'")
|
||||
continue
|
||||
}
|
||||
if ones, bits := ipNet.Mask.Size(); bits != 32 || ones < 22 {
|
||||
log.Log.Error("onvif.targetsFromSubnets(): range '" + subnet + "' is too large to scan (use /22 or smaller)")
|
||||
continue
|
||||
}
|
||||
for _, host := range hostsInNetwork(ipNet) {
|
||||
if _, exists := seen[host]; exists {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
targets = append(targets, host)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// localScanTargets enumerates every usable IPv4 host address on the local
|
||||
// network interfaces. To keep scans bounded we only expand subnets with a mask
|
||||
// of /22 or smaller (at most ~1022 hosts per interface).
|
||||
func localScanTargets() []string {
|
||||
seen := make(map[string]struct{})
|
||||
var targets []string
|
||||
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.localScanTargets(): " + err.Error())
|
||||
return targets
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, addrErr := iface.Addrs()
|
||||
if addrErr != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok || ipNet.IP.To4() == nil {
|
||||
continue
|
||||
}
|
||||
ones, bits := ipNet.Mask.Size()
|
||||
if bits != 32 || ones < 22 {
|
||||
// Skip huge or non-IPv4 ranges to avoid endless scans.
|
||||
continue
|
||||
}
|
||||
for _, host := range hostsInNetwork(ipNet) {
|
||||
if _, exists := seen[host]; exists {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
targets = append(targets, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// hostsInNetwork returns all assignable host addresses in the given network,
|
||||
// excluding the network and broadcast addresses.
|
||||
func hostsInNetwork(ipNet *net.IPNet) []string {
|
||||
var hosts []string
|
||||
network := ipNet.IP.Mask(ipNet.Mask).To4()
|
||||
if network == nil {
|
||||
return hosts
|
||||
}
|
||||
|
||||
for ip := cloneIP(network); ipNet.Contains(ip); incrementIP(ip) {
|
||||
hosts = append(hosts, ip.String())
|
||||
}
|
||||
// Drop network + broadcast addresses when present.
|
||||
if len(hosts) > 2 {
|
||||
hosts = hosts[1 : len(hosts)-1]
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func cloneIP(ip net.IP) net.IP {
|
||||
dup := make(net.IP, len(ip))
|
||||
copy(dup, ip)
|
||||
return dup
|
||||
}
|
||||
|
||||
func incrementIP(ip net.IP) {
|
||||
for i := len(ip) - 1; i >= 0; i-- {
|
||||
ip[i]++
|
||||
if ip[i] != 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hostFromXAddr extracts the host (IP) part from an ONVIF XAddr URL such as
|
||||
// "http://192.168.1.69:8000/onvif/device_service".
|
||||
func hostFromXAddr(xaddr string) string {
|
||||
parsed, err := url.Parse(xaddr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
// Fall back to a naive split for values without a scheme.
|
||||
host = strings.TrimPrefix(xaddr, "//")
|
||||
if idx := strings.IndexAny(host, ":/"); idx >= 0 {
|
||||
host = host[:idx]
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// readARPTable parses /proc/net/arp (Linux) and returns a map of IP -> MAC. On
|
||||
// non-Linux platforms or when the file is unavailable it returns an empty map.
|
||||
func readARPTable() map[string]string {
|
||||
table := make(map[string]string)
|
||||
file, err := os.Open("/proc/net/arp")
|
||||
if err != nil {
|
||||
return table
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
// Skip the header line.
|
||||
if scanner.Scan() {
|
||||
_ = scanner.Text()
|
||||
}
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
ip := fields[0]
|
||||
mac := fields[3]
|
||||
if mac == "00:00:00:00:00:00" || mac == "" {
|
||||
continue
|
||||
}
|
||||
table[ip] = strings.ToLower(mac)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
// vendorFromMAC resolves a MAC address to a known camera vendor using its OUI.
|
||||
func vendorFromMAC(mac string) string {
|
||||
normalized := strings.ToUpper(strings.NewReplacer(":", "", "-", "", ".", "").Replace(mac))
|
||||
if len(normalized) < 6 {
|
||||
return ""
|
||||
}
|
||||
// Try a longer prefix first (some vendors share the first 3 octets).
|
||||
if len(normalized) >= 8 {
|
||||
if vendor, ok := ouiVendors[normalized[:8]]; ok {
|
||||
return vendor
|
||||
}
|
||||
}
|
||||
if vendor, ok := ouiVendors[normalized[:6]]; ok {
|
||||
return vendor
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// reverseDNS performs a best-effort, time-bounded reverse DNS lookup.
|
||||
func reverseDNS(ip string, timeout time.Duration) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
var resolver net.Resolver
|
||||
names, err := resolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(names[0], ".")
|
||||
}
|
||||
|
||||
// perHostTimeout derives a short per-connection dial timeout from the overall
|
||||
// discovery timeout, clamped to a sensible range.
|
||||
func perHostTimeout(timeout time.Duration) time.Duration {
|
||||
dialTimeout := timeout / 4
|
||||
if dialTimeout < 300*time.Millisecond {
|
||||
dialTimeout = 300 * time.Millisecond
|
||||
}
|
||||
if dialTimeout > 1500*time.Millisecond {
|
||||
dialTimeout = 1500 * time.Millisecond
|
||||
}
|
||||
return dialTimeout
|
||||
}
|
||||
|
||||
func mergeSortedInts(existing, added []int) []int {
|
||||
set := make(map[int]struct{}, len(existing)+len(added))
|
||||
for _, value := range existing {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
for _, value := range added {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
merged := make([]int, 0, len(set))
|
||||
for value := range set {
|
||||
merged = append(merged, value)
|
||||
}
|
||||
sort.Ints(merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeUniqueStrings(existing, added []string) []string {
|
||||
set := make(map[string]struct{}, len(existing)+len(added))
|
||||
merged := make([]string, 0, len(existing)+len(added))
|
||||
for _, value := range append(append([]string{}, existing...), added...) {
|
||||
if _, ok := set[value]; ok {
|
||||
continue
|
||||
}
|
||||
set[value] = struct{}{}
|
||||
merged = append(merged, value)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// ipLess compares two IPv4 address strings numerically.
|
||||
func ipLess(a, b string) bool {
|
||||
ipA := net.ParseIP(a).To4()
|
||||
ipB := net.ParseIP(b).To4()
|
||||
if ipA == nil || ipB == nil {
|
||||
return a < b
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if ipA[i] != ipB[i] {
|
||||
return ipA[i] < ipB[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
225
machinery/src/onvif/events.go
Normal file
225
machinery/src/onvif/events.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/onvif/event/stream"
|
||||
)
|
||||
|
||||
// The library handles in-stream reconnect; these guards cover the
|
||||
// initial-connect path the library cannot see.
|
||||
const (
|
||||
initialBackoff = time.Second
|
||||
maxBackoff = 5 * time.Minute
|
||||
)
|
||||
|
||||
// HandleONVIFEventStream opens an event/stream against the configured
|
||||
// ONVIF camera and routes Motion events into communication.HandleMotion.
|
||||
//
|
||||
// Behind the Capture.ONVIFMotion flag; the goroutine returns
|
||||
// immediately when not enabled. The flag is read once at start, so
|
||||
// toggling at runtime requires an agent restart. On transient
|
||||
// construction failure (camera not yet ready at boot, brief network
|
||||
// blip, credential reload) the goroutine retries with exponential
|
||||
// backoff. Exits when ctx is cancelled.
|
||||
func HandleONVIFEventStream(ctx context.Context, configuration *models.Configuration, communication *models.Communication) {
|
||||
log.Log.Debug("onvif.HandleONVIFEventStream(): started")
|
||||
defer log.Log.Debug("onvif.HandleONVIFEventStream(): finished")
|
||||
|
||||
if !isONVIFMotionEnabled(configuration.Config.Capture.ONVIFMotion) {
|
||||
return
|
||||
}
|
||||
if configuration.Config.Capture.IPCamera.ONVIFXAddr == "" {
|
||||
log.Log.Warning("onvif.HandleONVIFEventStream(): ONVIFMotion enabled but ONVIFXAddr is empty; nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
backoff := initialBackoff
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
recoverable := runStreamOnce(ctx, configuration, communication)
|
||||
if !recoverable {
|
||||
return
|
||||
}
|
||||
if !sleepCtx(ctx, backoff) {
|
||||
return
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runStreamOnce returns true when the caller should retry construction
|
||||
// (transient failure), false on clean ctx-driven shutdown.
|
||||
func runStreamOnce(ctx context.Context, configuration *models.Configuration, communication *models.Communication) (retry bool) {
|
||||
camera := configuration.Config.Capture.IPCamera
|
||||
|
||||
device, _, err := ConnectToOnvifDevice(&camera)
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.HandleONVIFEventStream(): connect: " + err.Error())
|
||||
return true
|
||||
}
|
||||
|
||||
deviceID := resolveDeviceID(configuration.Name, camera.ONVIFXAddr)
|
||||
s, err := stream.NewStream(ctx, device, stream.Options{DeviceID: deviceID})
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.HandleONVIFEventStream(): open stream: " + err.Error())
|
||||
return true
|
||||
}
|
||||
defer func() {
|
||||
if err := s.Close(); err != nil {
|
||||
log.Log.Debug("onvif.HandleONVIFEventStream(): close: " + err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
log.Log.Info("onvif.HandleONVIFEventStream(): consuming events for " + deviceID)
|
||||
|
||||
// recovering = the first successful event after an error streak
|
||||
// logs a recovery line so on-call operators see the clear-of-
|
||||
// condition for the ERROR they were paged on.
|
||||
var recovering bool
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case ev, ok := <-s.Events():
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if recovering {
|
||||
log.Log.Info("onvif.HandleONVIFEventStream(): event stream recovered for " + deviceID)
|
||||
recovering = false
|
||||
}
|
||||
dispatchEvent(ctx, ev, configuration, communication)
|
||||
case e, ok := <-s.Errors():
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
recovering = true
|
||||
logStreamError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchEvent routes motion-active events to HandleMotion.
|
||||
//
|
||||
// The ctx pre-check + ctx-in-select guards a shutdown race: the agent
|
||||
// closes HandleMotion shortly after cancelling ctx, and a stale event
|
||||
// reaching the send would otherwise panic on a closed channel.
|
||||
func dispatchEvent(ctx context.Context, ev stream.Event, configuration *models.Configuration, communication *models.Communication) {
|
||||
topic := sanitiseTopic(ev.Topic)
|
||||
if ev.Kind != stream.KindMotion {
|
||||
log.Log.Debug("onvif.dispatchEvent(): non-motion event " + ev.Kind.String() + " topic=" + topic)
|
||||
return
|
||||
}
|
||||
if ev.State != stream.StateActive {
|
||||
return
|
||||
}
|
||||
if !isTransition(ev.Operation) {
|
||||
log.Log.Debug("onvif.dispatchEvent(): " + ev.Operation.String() + " is not a transition, not a trigger: topic=" + topic)
|
||||
return
|
||||
}
|
||||
if configuration.Config.Capture.Recording == "false" {
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dataToPass := models.MotionDataPartial{
|
||||
Timestamp: time.Now().Unix(),
|
||||
NumberOfChanges: 0, // ONVIF does not quantify motion area.
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case communication.HandleMotion <- dataToPass:
|
||||
// Logged on the send, not before it: this line records that a
|
||||
// recording started, so a dropped event must not leave one.
|
||||
log.Log.Debug("onvif.dispatchEvent(): recording trigger " + ev.Kind.String() + " topic=" + topic)
|
||||
default:
|
||||
log.Log.Debug("onvif.dispatchEvent(): HandleMotion full, dropping ONVIF motion event")
|
||||
}
|
||||
}
|
||||
|
||||
// isTransition reports whether an operation represents a state change.
|
||||
// A camera replays every property's current state as Initialized on
|
||||
// each new subscription and announces removals as Deleted; neither is
|
||||
// motion starting. Absent (Unknown) counts — PropertyOperation is
|
||||
// optional per WS-Notification and many non-property events omit it.
|
||||
func isTransition(op stream.PropertyOperation) bool {
|
||||
return op == stream.PropertyChanged || op == stream.PropertyUnknown
|
||||
}
|
||||
|
||||
// maxLoggedTopic bounds a topic in the log; the wire imposes no limit,
|
||||
// and the reject path logs every event received.
|
||||
const maxLoggedTopic = 256
|
||||
|
||||
// sanitiseTopic makes a camera-controlled topic safe to concatenate
|
||||
// into a log line. logrus's coloured text formatter writes the message
|
||||
// unquoted, so a raw newline would let a camera forge entries in the
|
||||
// log being used to diagnose it.
|
||||
func sanitiseTopic(topic string) string {
|
||||
if len(topic) > maxLoggedTopic {
|
||||
topic = topic[:maxLoggedTopic] + "…(truncated)"
|
||||
}
|
||||
quoted := strconv.Quote(topic)
|
||||
return quoted[1 : len(quoted)-1]
|
||||
}
|
||||
|
||||
// logStreamError logs at a level matching severity: recreate is loud
|
||||
// because it usually means the camera is offline; pull and renew are
|
||||
// debug because the library recovers from them automatically.
|
||||
func logStreamError(e error) {
|
||||
var recreate stream.ErrRecreateFailed
|
||||
var pull stream.ErrPullFailed
|
||||
var renew stream.ErrRenewFailed
|
||||
switch {
|
||||
case errors.As(e, &recreate):
|
||||
log.Log.Error("onvif.HandleONVIFEventStream(): subscription recreate failed (camera may be offline): " + recreate.Err.Error())
|
||||
case errors.As(e, &renew):
|
||||
log.Log.Debug("onvif.HandleONVIFEventStream(): renew failed (will recover via pull/recreate): " + renew.Err.Error())
|
||||
case errors.As(e, &pull):
|
||||
log.Log.Debug("onvif.HandleONVIFEventStream(): pull failed (will retry): " + pull.Err.Error())
|
||||
default:
|
||||
log.Log.Info("onvif.HandleONVIFEventStream(): stream error: " + e.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func isONVIFMotionEnabled(v string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||
}
|
||||
|
||||
// resolveDeviceID falls back from operator-supplied name to ONVIF
|
||||
// endpoint to a constant placeholder so log lines always have
|
||||
// something to grep.
|
||||
func resolveDeviceID(configName, xaddr string) string {
|
||||
if n := strings.TrimSpace(configName); n != "" {
|
||||
return n
|
||||
}
|
||||
if x := strings.TrimSpace(xaddr); x != "" {
|
||||
return x
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// sleepCtx returns false if ctx was cancelled, true if d elapsed.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
375
machinery/src/onvif/events_test.go
Normal file
375
machinery/src/onvif/events_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/onvif/event/stream"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func makeConfig(recording, onvifMotion, name string) *models.Configuration {
|
||||
return &models.Configuration{
|
||||
Name: name,
|
||||
Config: models.Config{
|
||||
Capture: models.Capture{
|
||||
Recording: recording,
|
||||
ONVIFMotion: onvifMotion,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func makeCommunication(buffer int) *models.Communication {
|
||||
return &models.Communication{
|
||||
HandleMotion: make(chan models.MotionDataPartial, buffer),
|
||||
}
|
||||
}
|
||||
|
||||
// --- dispatchEvent ---------------------------------------------------
|
||||
|
||||
func TestDispatchEvent_MotionActive_SendsToHandleMotion(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case m := <-comm.HandleMotion:
|
||||
assert.NotZero(t, m.Timestamp)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected motion data on HandleMotion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_MotionInactive_DoesNotSend(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateInactive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case <-comm.HandleMotion:
|
||||
t.Fatal("inactive motion must not reach HandleMotion (motion-stop is a follow-up)")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_NonMotionKindIgnored(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindDigitalInput, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case <-comm.HandleMotion:
|
||||
t.Fatal("non-motion kinds must not reach HandleMotion")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// captureDebugLog redirects logrus to a buffer at debug level for the
|
||||
// duration of a test and returns what was written. It mutates package
|
||||
// globals, so callers must not run in parallel.
|
||||
func captureDebugLog(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
prevOut, prevLevel := logrus.StandardLogger().Out, logrus.GetLevel()
|
||||
logrus.SetOutput(&buf)
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
t.Cleanup(func() {
|
||||
logrus.SetOutput(prevOut)
|
||||
logrus.SetLevel(prevLevel)
|
||||
})
|
||||
return &buf
|
||||
}
|
||||
|
||||
// TestDispatchEvent_LogsTheTriggeringTopic — a dispatched event is what
|
||||
// actually starts a recording, so its topic is the one an operator needs
|
||||
// when a camera records for the wrong reason (or the right reason and
|
||||
// nobody can prove which). Rejected events were already logged; without
|
||||
// this the triggering topic is only knowable by elimination.
|
||||
func TestDispatchEvent_LogsTheTriggeringTopic(t *testing.T) {
|
||||
buf := captureDebugLog(t)
|
||||
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{
|
||||
Kind: stream.KindMotion,
|
||||
State: stream.StateActive,
|
||||
Topic: "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
assert.Contains(t, buf.String(), "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
|
||||
"the dispatched event's topic must appear in the log")
|
||||
assert.Contains(t, buf.String(), "Motion",
|
||||
"the dispatched event's Kind must appear in the log")
|
||||
}
|
||||
|
||||
// TestDispatchEvent_PropertyOperation — a camera replays the current
|
||||
// state of every property topic as Initialized whenever a pull-point
|
||||
// subscription is created. If that counts as a trigger, every
|
||||
// reconnect restarts a recording for any motion property that happens
|
||||
// to be active, and a flapping subscription manufactures motion out of
|
||||
// nothing. Only reject Initialized specifically: PropertyOperation is
|
||||
// optional per WS-Notification and absent on many non-property events,
|
||||
// which decode reports as PropertyUnknown.
|
||||
func TestDispatchEvent_PropertyOperation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op stream.PropertyOperation
|
||||
wantSend bool
|
||||
}{
|
||||
{"changed is a real transition", stream.PropertyChanged, true},
|
||||
{"absent attribute still counts", stream.PropertyUnknown, true},
|
||||
{"initialized is a subscription state replay", stream.PropertyInitialized, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{
|
||||
Kind: stream.KindMotion,
|
||||
State: stream.StateActive,
|
||||
Operation: tt.op,
|
||||
Topic: "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case <-comm.HandleMotion:
|
||||
if !tt.wantSend {
|
||||
t.Fatalf("%v must not trigger a recording", tt.op)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
if tt.wantSend {
|
||||
t.Fatalf("%v must trigger a recording", tt.op)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_RecordingDisabled_DoesNotSend(t *testing.T) {
|
||||
cfg := makeConfig("false", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case <-comm.HandleMotion:
|
||||
t.Fatal("Recording=false must gate the send (matches computervision behaviour)")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_HandleMotionFull_DropsRatherThanBlocks(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
// Pre-fill the buffer so the next send would block.
|
||||
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
|
||||
comm.HandleMotion <- models.MotionDataPartial{}
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dispatchEvent must drop when HandleMotion is full, not block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_CtxCancelledAndHandleMotionClosed_DoesNotPanic(t *testing.T) {
|
||||
// Regression for the shutdown race: between cancel() and
|
||||
// close(HandleMotion) the agent leaves a 3s window. If dispatchEvent
|
||||
// runs in that window AFTER the channel is closed, a non-protected
|
||||
// send would panic. The ctx pre-check must short-circuit before the
|
||||
// send is attempted.
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
|
||||
close(comm.HandleMotion)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // already cancelled, matching the shutdown sequence
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
})
|
||||
}
|
||||
|
||||
// --- isONVIFMotionEnabled --------------------------------------------
|
||||
|
||||
func TestIsONVIFMotionEnabled_CaseAndWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"true", true},
|
||||
{"True", true},
|
||||
{"TRUE", true},
|
||||
{" true", true},
|
||||
{"true ", true},
|
||||
{" true ", true},
|
||||
{"false", false},
|
||||
{"False", false},
|
||||
{"", false},
|
||||
{"yes", false},
|
||||
{"1", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isONVIFMotionEnabled(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveDeviceID -------------------------------------------------
|
||||
|
||||
func TestResolveDeviceID_FallbackChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfgName string
|
||||
xaddr string
|
||||
want string
|
||||
}{
|
||||
{"name_set", "front-door", "192.168.1.10", "front-door"},
|
||||
{"name_empty_xaddr_set", "", "192.168.1.10", "192.168.1.10"},
|
||||
{"name_whitespace_only_xaddr_set", " ", "192.168.1.10", "192.168.1.10"},
|
||||
{"both_empty", "", "", "unknown"},
|
||||
{"name_with_trailing_whitespace", "cam-2 ", "192.168.1.10", "cam-2"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, resolveDeviceID(tc.cfgName, tc.xaddr))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchEvent_OnlyRealTransitionsTrigger — a camera replays every
|
||||
// property topic's state on each new subscription (Initialized) and
|
||||
// announces removals (Deleted). Neither is a motion transition, and a
|
||||
// flapping pull-point would otherwise manufacture recordings out of
|
||||
// replayed state. PropertyOperation is optional per WS-Notification, so
|
||||
// absent (Unknown) still counts — many non-property events omit it.
|
||||
func TestDispatchEvent_OnlyRealTransitionsTrigger(t *testing.T) {
|
||||
tests := []struct {
|
||||
op stream.PropertyOperation
|
||||
wantSend bool
|
||||
}{
|
||||
{stream.PropertyChanged, true},
|
||||
{stream.PropertyUnknown, true},
|
||||
{stream.PropertyInitialized, false},
|
||||
{stream.PropertyDeleted, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.op.String(), func(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive, Operation: tt.op}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
if tt.wantSend {
|
||||
require.Len(t, comm.HandleMotion, 1, "%v must trigger a recording", tt.op)
|
||||
return
|
||||
}
|
||||
require.Empty(t, comm.HandleMotion, "%v must not trigger a recording", tt.op)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitiseTopic — ev.Topic is camera-controlled and reaches the log
|
||||
// unmodified. logrus's coloured text formatter (the default) writes the
|
||||
// message without quoting, so an embedded newline forges whole log
|
||||
// lines: a compromised camera can fabricate ERROR entries or spoof
|
||||
// another device's id, in the logs an operator is reading to diagnose
|
||||
// that very camera. Length is also unbounded on the wire, and the
|
||||
// reject path logs every event, so an oversized topic is a cheap way to
|
||||
// evict a container's whole retained history.
|
||||
func TestSanitiseTopic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ordinary topic passes through", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1"},
|
||||
{"newline cannot forge a line", "a\nERRO[fake] boom", `a\nERRO[fake] boom`},
|
||||
{"carriage return", "a\rb", `a\rb`},
|
||||
{"tab", "a\tb", `a\tb`},
|
||||
{"NUL", "a\x00b", `a\x00b`},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sanitiseTopic(tt.in)
|
||||
assert.Equal(t, tt.want, got)
|
||||
assert.NotContains(t, got, "\n", "no raw newline may survive")
|
||||
assert.NotContains(t, got, "\r", "no raw carriage return may survive")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitiseTopic_Truncates(t *testing.T) {
|
||||
got := sanitiseTopic(strings.Repeat("x", maxLoggedTopic*2))
|
||||
assert.LessOrEqual(t, len(got), maxLoggedTopic+len("…(truncated)"))
|
||||
assert.Contains(t, got, "truncated")
|
||||
}
|
||||
|
||||
// TestDispatchEvent_LogsTriggerOnlyWhenSent — the trigger line is the
|
||||
// record that a recording started. Logging it before the send means a
|
||||
// dropped event (full channel, or shutdown) leaves a line claiming a
|
||||
// recording that never began.
|
||||
func TestDispatchEvent_LogsTriggerOnlyWhenSent(t *testing.T) {
|
||||
buf := captureDebugLog(t)
|
||||
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
|
||||
comm.HandleMotion <- models.MotionDataPartial{} // full
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive, Topic: "tns1:VideoSource/MotionAlarm"}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
assert.NotContains(t, buf.String(), "recording trigger",
|
||||
"a dropped event must not be logged as a trigger")
|
||||
assert.Contains(t, buf.String(), "dropping", "the drop itself must still be logged")
|
||||
}
|
||||
380
machinery/src/onvif/fingerprint.go
Normal file
380
machinery/src/onvif/fingerprint.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// deviceFingerprint holds the identifying information we can gather from a host
|
||||
// without any credentials. It is populated by grabbing the RTSP and HTTP
|
||||
// service banners and is then distilled into a manufacturer, model and a
|
||||
// human-readable device type (e.g. "IP Camera", "DVR/NVR").
|
||||
type deviceFingerprint struct {
|
||||
Manufacturer string
|
||||
Model string
|
||||
Type string
|
||||
Server string
|
||||
// realm is the WWW-Authenticate realm advertised by the HTTP service. Many
|
||||
// cameras expose their model or vendor here (e.g. realm="Hikvision").
|
||||
realm string
|
||||
// body holds a lower-cased slice of the HTTP landing page, fetched only when
|
||||
// the banners are anonymous. Rebadged/OEM cameras often reveal their vendor
|
||||
// there (logo filenames, embedded scripts), e.g. ADI "Capture".
|
||||
body string
|
||||
// IsCamera is set when the collected evidence confidently identifies the
|
||||
// device as a camera, NVR or DVR.
|
||||
IsCamera bool
|
||||
// IsAudio is set for audio-only devices (IP speakers / intercoms, e.g. TOA)
|
||||
// that use RTSP for audio rather than video.
|
||||
IsAudio bool
|
||||
}
|
||||
|
||||
// bannerVendors maps a lower-cased substring commonly found in RTSP/HTTP
|
||||
// service banners or auth realms to a manufacturer. The list is ordered so the
|
||||
// most specific matches win. This mirrors how tools such as Fing or ONVIF
|
||||
// Device Manager fingerprint a device from its network banners.
|
||||
var bannerVendors = []struct {
|
||||
Match string
|
||||
Vendor string
|
||||
IsCamera bool
|
||||
}{
|
||||
{"hikvision", "Hikvision", true},
|
||||
{"dahua", "Dahua", true},
|
||||
{"axis", "Axis", true},
|
||||
{"reolink", "Reolink", true},
|
||||
{"amcrest", "Amcrest", true},
|
||||
{"vivotek", "Vivotek", true},
|
||||
{"mobotix", "Mobotix", true},
|
||||
{"hanwha", "Hanwha", true},
|
||||
{"wisenet", "Hanwha", true},
|
||||
{"bosch", "Bosch", true},
|
||||
{"foscam", "Foscam", true},
|
||||
{"ubiquiti", "Ubiquiti", true},
|
||||
{"unifi", "Ubiquiti", true},
|
||||
{"uniview", "Uniview", true},
|
||||
{"tp-link", "TP-Link", true},
|
||||
{"tapo", "TP-Link", true},
|
||||
{"linksys", "Linksys", true},
|
||||
{"d-link", "D-Link", true},
|
||||
{"dlink", "D-Link", true},
|
||||
{"trendnet", "Trendnet", true},
|
||||
{"lorex", "Lorex", true},
|
||||
{"honeywell", "Honeywell", true},
|
||||
{"pelco", "Pelco", true},
|
||||
{"toa rtsp", "TOA", false},
|
||||
{"hipcam", "Hipcam", true},
|
||||
{"h264dvr", "Generic DVR", true},
|
||||
{"dvrdvs", "Hikvision", true},
|
||||
{"webs", "", false}, // generic embedded web server, no vendor
|
||||
{"rtsp server", "", true},
|
||||
{"gstreamer", "", true},
|
||||
{"live555", "", true},
|
||||
}
|
||||
|
||||
// bodyVendors maps a distinctive lower-cased substring found in a camera's HTML
|
||||
// landing page (logo filename, embedded script, product string) to a
|
||||
// manufacturer. Used only when the RTSP/HTTP banners are anonymous, so it can
|
||||
// identify rebadged/OEM cameras (e.g. ADI "Capture") that hide their model
|
||||
// behind a generic "httpd" server and an "RTSP" realm.
|
||||
var bodyVendors = []struct {
|
||||
Match string
|
||||
Vendor string
|
||||
IsCamera bool
|
||||
}{
|
||||
{"logo_white(capture)", "Capture", true},
|
||||
{"logo_capture", "Capture", true},
|
||||
}
|
||||
|
||||
// genericRealms are auth realms that carry no useful model/vendor information.
|
||||
var genericRealms = map[string]struct{}{
|
||||
"": {},
|
||||
"ip camera": {},
|
||||
"ipcamera": {},
|
||||
"camera": {},
|
||||
"login": {},
|
||||
"index": {},
|
||||
"streaming": {},
|
||||
"realm": {},
|
||||
"network video": {},
|
||||
"web": {},
|
||||
"protected": {},
|
||||
"authorized users only": {},
|
||||
"please log in with a valid username.": {},
|
||||
"please log in with a valid username": {},
|
||||
}
|
||||
|
||||
// fingerprintHost grabs the RTSP and HTTP banners for the given host (based on
|
||||
// the ports found open during the scan) and classifies the device. It performs
|
||||
// at most two lightweight, unauthenticated requests and is safe to run
|
||||
// concurrently for every host.
|
||||
func fingerprintHost(ip string, openPorts []int, timeout time.Duration) deviceFingerprint {
|
||||
var fp deviceFingerprint
|
||||
|
||||
// 1) RTSP OPTIONS on the first open RTSP port. The Server response header of
|
||||
// most camera RTSP stacks reveals the device (e.g. "Dahua Rtsp Server",
|
||||
// "Hipcam RealServer/V1.0", "H264DVR 1.0").
|
||||
for _, port := range openPorts {
|
||||
if port == 554 || port == 8554 {
|
||||
if banner := rtspServerBanner(ip, port, timeout); banner != "" {
|
||||
fp.Server = banner
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2) HTTP banner + auth realm on the first open HTTP/ONVIF port. Cameras
|
||||
// frequently expose their vendor/model in the Server header or the
|
||||
// WWW-Authenticate realm.
|
||||
httpPort := 0
|
||||
for _, port := range openPorts {
|
||||
if port == 80 || port == 8080 || port == 8000 {
|
||||
server, realm := httpBanner(ip, port, timeout)
|
||||
if fp.Server == "" {
|
||||
fp.Server = server
|
||||
}
|
||||
fp.realm = realm
|
||||
httpPort = port
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 3) When the banners are anonymous (generic server, no vendor realm), fetch
|
||||
// a slice of the landing page. Rebadged/OEM cameras (e.g. ADI "Capture")
|
||||
// only reveal their vendor in the HTML.
|
||||
if httpPort != 0 && isGenericServer(fp.Server) {
|
||||
fp.body = httpBody(ip, httpPort, timeout)
|
||||
}
|
||||
|
||||
classifyFingerprint(&fp, openPorts)
|
||||
return fp
|
||||
}
|
||||
|
||||
// isGenericServer reports whether an HTTP Server header is a generic embedded
|
||||
// web server that carries no vendor information (so the HTML body is worth a
|
||||
// look).
|
||||
func isGenericServer(server string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(server))
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
for _, generic := range []string{"httpd", "webs", "boa", "lighttpd", "nginx", "gsoap", "mini_httpd", "thttpd", "apache"} {
|
||||
if strings.Contains(s, generic) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// httpBody issues an unauthenticated HTTP GET / and returns a lower-cased,
|
||||
// size-bounded slice of the response (headers + body). Best-effort; empty on
|
||||
// error.
|
||||
func httpBody(ip string, port int, timeout time.Duration) string {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
request := "GET / HTTP/1.0\r\nHost: " + ip + "\r\nUser-Agent: KerberosDiscovery\r\nAccept: */*\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for builder.Len() < 65536 {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
builder.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.ToLower(builder.String())
|
||||
}
|
||||
|
||||
// rtspServerBanner issues an unauthenticated RTSP OPTIONS request and returns
|
||||
// the value of the Server response header (empty when the host does not answer
|
||||
// or exposes no banner).
|
||||
func rtspServerBanner(ip string, port int, timeout time.Duration) string {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
request := "OPTIONS rtsp://" + address + " RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: KerberosDiscovery\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return ""
|
||||
}
|
||||
headers := readBannerHeaders(conn)
|
||||
return headers["server"]
|
||||
}
|
||||
|
||||
// httpBanner issues an unauthenticated HTTP HEAD request and returns the Server
|
||||
// header and the WWW-Authenticate realm (both best-effort, empty when absent).
|
||||
func httpBanner(ip string, port int, timeout time.Duration) (server string, realm string) {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
request := "HEAD / HTTP/1.0\r\nHost: " + ip + "\r\nUser-Agent: KerberosDiscovery\r\nAccept: */*\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
headers := readBannerHeaders(conn)
|
||||
return headers["server"], parseRealm(headers["www-authenticate"])
|
||||
}
|
||||
|
||||
// readBannerHeaders reads a status line followed by header lines from an
|
||||
// RTSP/HTTP response and returns the headers keyed by their lower-cased name.
|
||||
// Only the first occurrence of a header is kept.
|
||||
func readBannerHeaders(conn net.Conn) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
// Discard the status line (e.g. "RTSP/1.0 200 OK" or "HTTP/1.1 401 ...").
|
||||
if _, err := reader.ReadString('\n'); err != nil {
|
||||
return headers
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if _, exists := headers[key]; !exists {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// parseRealm extracts the realm token from a WWW-Authenticate header value such
|
||||
// as `Digest realm="Hikvision", nonce="..."`.
|
||||
func parseRealm(header string) string {
|
||||
lower := strings.ToLower(header)
|
||||
marker := "realm="
|
||||
idx := strings.Index(lower, marker)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
value := header[idx+len(marker):]
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
value = value[1:]
|
||||
if end := strings.Index(value, "\""); end >= 0 {
|
||||
value = value[:end]
|
||||
}
|
||||
} else if end := strings.IndexAny(value, ", "); end >= 0 {
|
||||
value = value[:end]
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// classifyFingerprint distils the collected banners and open ports into a
|
||||
// manufacturer, model and device type. It also decides whether the evidence is
|
||||
// strong enough to consider the host a camera/NVR.
|
||||
func classifyFingerprint(fp *deviceFingerprint, openPorts []int) {
|
||||
haystack := strings.ToLower(fp.Server + " " + fp.realm)
|
||||
|
||||
// Manufacturer from the banner/realm.
|
||||
for _, entry := range bannerVendors {
|
||||
if !strings.Contains(haystack, entry.Match) {
|
||||
continue
|
||||
}
|
||||
if entry.Vendor != "" && fp.Manufacturer == "" {
|
||||
fp.Manufacturer = entry.Vendor
|
||||
}
|
||||
if entry.IsCamera {
|
||||
fp.IsCamera = true
|
||||
}
|
||||
if fp.Manufacturer != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Model from the auth realm when it looks specific (not a generic word).
|
||||
if fp.Model == "" && fp.realm != "" {
|
||||
if _, generic := genericRealms[strings.ToLower(fp.realm)]; !generic {
|
||||
if !strings.EqualFold(fp.realm, fp.Manufacturer) {
|
||||
fp.Model = fp.realm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor from the HTML landing page when the banners revealed nothing.
|
||||
// Rebadged/OEM cameras (e.g. ADI "Capture") only identify themselves via
|
||||
// logo filenames or embedded scripts.
|
||||
if fp.Manufacturer == "" && fp.body != "" {
|
||||
for _, entry := range bodyVendors {
|
||||
if strings.Contains(fp.body, entry.Match) {
|
||||
fp.Manufacturer = entry.Vendor
|
||||
if entry.IsCamera {
|
||||
fp.IsCamera = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device type from ports and banners.
|
||||
hasRTSP := containsInt(openPorts, 554) || containsInt(openPorts, 8554)
|
||||
hasONVIF := containsInt(openPorts, 8000) || containsInt(openPorts, 8899)
|
||||
hasDVRPort := containsInt(openPorts, 37777) || containsInt(openPorts, 34567)
|
||||
|
||||
// Audio devices (IP speakers / intercoms) also speak RTSP, but for audio
|
||||
// rather than video, so classify them separately and never as a camera.
|
||||
if fp.Manufacturer == "TOA" ||
|
||||
strings.Contains(haystack, "speaker") ||
|
||||
strings.Contains(haystack, "sip audio") ||
|
||||
strings.Contains(haystack, "audio server") {
|
||||
fp.IsAudio = true
|
||||
fp.IsCamera = false
|
||||
fp.Type = "IP Speaker/Audio"
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(haystack, "nvr"):
|
||||
fp.Type = "NVR"
|
||||
fp.IsCamera = true
|
||||
case strings.Contains(haystack, "dvr") || hasDVRPort:
|
||||
fp.Type = "DVR/NVR"
|
||||
fp.IsCamera = true
|
||||
case hasRTSP || hasONVIF:
|
||||
fp.Type = "IP Camera"
|
||||
fp.IsCamera = true
|
||||
case fp.IsCamera:
|
||||
fp.Type = "IP Camera"
|
||||
}
|
||||
}
|
||||
|
||||
func containsInt(values []int, target int) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
onvifc "github.com/cedricve/go-onvif"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
@@ -24,19 +23,81 @@ import (
|
||||
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
func Discover(timeout time.Duration) {
|
||||
log.Log.Info("onvif.Discover(): Discovering devices")
|
||||
log.Log.Info("Waiting for " + timeout.String())
|
||||
devices, err := onvifc.StartDiscovery(timeout)
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.Discover(): " + err.Error())
|
||||
} else {
|
||||
for _, device := range devices {
|
||||
hostname, _ := device.GetHostname()
|
||||
log.Log.Info("onvif.Discover(): " + hostname.Name + " (" + device.XAddr + ")")
|
||||
// Discover performs an advanced Fing/WiFiman-style scan of the local network
|
||||
// (ONVIF WS-Discovery + active port scan + MAC/vendor lookup) and prints a
|
||||
// human readable summary of everything it finds. It is used by the
|
||||
// `-action discover` CLI command. Optional subnets (CIDR, e.g.
|
||||
// "192.168.1.0/24") override the auto-detected local subnets.
|
||||
func Discover(timeout time.Duration, subnets ...string) {
|
||||
log.Log.Info("onvif.Discover(): starting advanced network discovery")
|
||||
log.Log.Info("onvif.Discover(): this may take up to " + timeout.String() + " for the ONVIF probe plus the port scan")
|
||||
|
||||
devices := DiscoverDevices(timeout, subnets...)
|
||||
if len(devices) == 0 {
|
||||
log.Log.Info("onvif.Discover(): no devices discovered on the local network")
|
||||
return
|
||||
}
|
||||
|
||||
cameraCount := 0
|
||||
for _, device := range devices {
|
||||
if device.IsCamera {
|
||||
cameraCount++
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
log.Log.Info("onvif.Discover(): No devices descovered\n")
|
||||
}
|
||||
log.Log.Info("onvif.Discover(): found " + strconv.Itoa(len(devices)) + " device(s), " + strconv.Itoa(cameraCount) + " likely camera(s)")
|
||||
|
||||
for _, device := range devices {
|
||||
label := "device"
|
||||
if device.IsCamera {
|
||||
label = "camera"
|
||||
} else if device.IsAudio {
|
||||
label = "speaker"
|
||||
}
|
||||
summary := "onvif.Discover(): [" + label + "] " + device.IP
|
||||
if device.Hostname != "" {
|
||||
summary += " (" + device.Hostname + ")"
|
||||
}
|
||||
if device.MAC != "" {
|
||||
summary += " mac=" + device.MAC
|
||||
}
|
||||
if device.Vendor != "" {
|
||||
summary += " vendor=" + device.Vendor
|
||||
}
|
||||
if device.Type != "" {
|
||||
summary += " type=" + device.Type
|
||||
}
|
||||
if device.Manufacturer != "" {
|
||||
summary += " manufacturer=" + device.Manufacturer
|
||||
}
|
||||
if device.Model != "" {
|
||||
summary += " model=" + device.Model
|
||||
}
|
||||
if device.Server != "" {
|
||||
summary += " server=\"" + device.Server + "\""
|
||||
}
|
||||
if device.ONVIF {
|
||||
summary += " onvif=" + device.ONVIFXAddr
|
||||
}
|
||||
if len(device.Services) > 0 {
|
||||
summary += " services=[" + strings.Join(device.Services, ", ") + "]"
|
||||
}
|
||||
if device.RTSPURL != "" {
|
||||
summary += " rtsp=" + device.RTSPURL
|
||||
}
|
||||
log.Log.Info(summary)
|
||||
|
||||
// Detail the guessed RTSP stream URLs from the brand -> RTSP mapping.
|
||||
for _, stream := range device.RTSPStreams {
|
||||
status := "guess"
|
||||
if stream.Verified {
|
||||
status = "confirmed"
|
||||
}
|
||||
line := "onvif.Discover(): -> " + stream.Stream + " stream [" + status + "]"
|
||||
if stream.RequiresAuth {
|
||||
line += " (auth required)"
|
||||
}
|
||||
line += ": " + stream.URL
|
||||
log.Log.Info(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
@@ -17,6 +21,38 @@ import (
|
||||
// @Success 200 {object} models.Authorization
|
||||
func Login() {}
|
||||
|
||||
// DiscoverCameras godoc
|
||||
// @Router /api/camera/discover [get]
|
||||
// @ID camera-discover
|
||||
// @Tags onvif
|
||||
// @Param timeout query int false "Discovery timeout in milliseconds (default 2000)"
|
||||
// @Param subnet query string false "Optional subnet(s) to scan, e.g. '192.168.1.0/24' (comma-separated). Defaults to the local interfaces."
|
||||
// @Summary Discover cameras and other devices on the local network.
|
||||
// @Description Runs an advanced Fing/WiFiman-style scan (ONVIF WS-Discovery + TCP port scan + MAC/vendor lookup) and returns the devices found on the local network.
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func DiscoverCameras(c *gin.Context) {
|
||||
timeout := 2000 * time.Millisecond
|
||||
if raw := c.Query("timeout"); raw != "" {
|
||||
if milliseconds, err := strconv.Atoi(raw); err == nil && milliseconds > 0 {
|
||||
timeout = time.Duration(milliseconds) * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
var subnets []string
|
||||
if raw := c.Query("subnet"); raw != "" {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
subnets = append(subnets, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devices := onvif.DiscoverDevices(timeout, subnets...)
|
||||
c.JSON(200, models.APIResponse{
|
||||
Data: devices,
|
||||
})
|
||||
}
|
||||
|
||||
// LoginToOnvif godoc
|
||||
// @Router /api/camera/onvif/login [post]
|
||||
// @ID camera-onvif-login
|
||||
|
||||
@@ -96,6 +96,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
|
||||
})
|
||||
|
||||
// Onvif specific methods.
|
||||
api.GET("/camera/discover", DiscoverCameras)
|
||||
api.POST("/camera/onvif/verify", onvif.VerifyOnvifConnection)
|
||||
api.POST("/camera/onvif/login", LoginToOnvif)
|
||||
api.POST("/camera/onvif/capabilities", GetOnvifCapabilities)
|
||||
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
configService "github.com/kerberos-io/agent/machinery/src/config"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
@@ -329,7 +332,7 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
|
||||
case "record":
|
||||
go HandleRecording(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "get-audio-backchannel":
|
||||
go HandleAudio(mqttClient, hubKey, payload, configuration, communication)
|
||||
HandleAudio(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "get-ptz-position":
|
||||
go HandleGetPTZPosition(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "update-ptz-position":
|
||||
@@ -338,6 +341,8 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
|
||||
go HandleNavigatePTZ(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "request-config":
|
||||
go HandleRequestConfig(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "verify-stream":
|
||||
go HandleVerifyStream(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "update-config":
|
||||
go HandleUpdateConfig(mqttClient, hubKey, payload, configDirectory, configuration, communication)
|
||||
case "request-sd-stream":
|
||||
@@ -375,11 +380,52 @@ func HandleRecording(mqttClient mqtt.Client, hubKey string, payload models.Paylo
|
||||
var recordPayload models.RecordPayload
|
||||
json.Unmarshal(jsonData, &recordPayload)
|
||||
|
||||
if recordPayload.Timestamp != 0 {
|
||||
motionDataPartial := models.MotionDataPartial{
|
||||
Timestamp: recordPayload.Timestamp,
|
||||
timestamp := recordPayload.Timestamp
|
||||
if timestamp == 0 {
|
||||
timestamp = time.Now().Unix()
|
||||
}
|
||||
|
||||
if recordPayload.Recording {
|
||||
now := time.Now().UnixMilli()
|
||||
if recordPayload.Heartbeat {
|
||||
// Keep-alive from a viewer that supports heartbeats. Only refresh while
|
||||
// a manual recording is actually running; if it already auto-stopped
|
||||
// (heartbeat timeout / max duration) we IGNORE it so a stray heartbeat
|
||||
// can't restart a recording we just ended. Seeing a heartbeat also arms
|
||||
// the recorder's heartbeat-timeout auto-stop.
|
||||
if communication.IsRecordingManual.IsSet() {
|
||||
communication.RecordingManualHeartbeat.Store(now)
|
||||
communication.RecordingManualHeartbeatSeen.Set()
|
||||
log.Log.Debug("routers.mqtt.main.HandleRecording(): manual recording heartbeat received.")
|
||||
} else {
|
||||
log.Log.Debug("routers.mqtt.main.HandleRecording(): ignoring heartbeat, no active manual recording.")
|
||||
}
|
||||
} else {
|
||||
// Explicit start from the live view (record button). Start a manual
|
||||
// recording and keep it running — the motion recorder honours
|
||||
// communication.IsRecordingManual and won't auto-close on the
|
||||
// post-recording timeout while it's set. We also inject a motion event
|
||||
// so the recording starts immediately, even when nothing is moving.
|
||||
communication.RecordingManualHeartbeat.Store(now)
|
||||
if communication.IsRecordingManual.SetToIf(false, true) {
|
||||
communication.RecordingManualStart.Store(now)
|
||||
communication.RecordingManualHeartbeatSeen.UnSet()
|
||||
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.")
|
||||
select {
|
||||
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}:
|
||||
default:
|
||||
log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.")
|
||||
}
|
||||
}
|
||||
}
|
||||
communication.HandleMotion <- motionDataPartial
|
||||
} else {
|
||||
// Stop the manual recording; the motion recorder closes the clip once the
|
||||
// post-recording window elapses. Clear the heartbeat/start markers too.
|
||||
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording stopped.")
|
||||
communication.IsRecordingManual.UnSet()
|
||||
communication.RecordingManualHeartbeat.Store(0)
|
||||
communication.RecordingManualStart.Store(0)
|
||||
communication.RecordingManualHeartbeatSeen.UnSet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,10 +442,31 @@ func HandleAudio(mqttClient mqtt.Client, hubKey string, payload models.Payload,
|
||||
Timestamp: audioPayload.Timestamp,
|
||||
Data: audioPayload.Data,
|
||||
}
|
||||
communication.HandleAudio <- audioDataPartial
|
||||
if enqueueLatestAudio(communication.HandleAudio, audioDataPartial) {
|
||||
log.Log.Debug("routers.mqtt.main.HandleAudio(): dropped stale audio because the backchannel queue was full")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueLatestAudio(audioChannel chan models.AudioDataPartial, audio models.AudioDataPartial) bool {
|
||||
select {
|
||||
case audioChannel <- audio:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case <-audioChannel:
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case audioChannel <- audio:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func HandleGetPTZPosition(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
|
||||
value := payload.Value
|
||||
|
||||
@@ -505,6 +572,98 @@ func HandleRequestConfig(mqttClient mqtt.Client, hubKey string, payload models.P
|
||||
}
|
||||
}
|
||||
|
||||
// HandleVerifyStream probes an RTSP stream (the one supplied in the request, or
|
||||
// the currently configured main/sub stream) and reports back whether it can be
|
||||
// connected to and decoded, along with the discovered codec/resolution/fps.
|
||||
func HandleVerifyStream(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
|
||||
value := payload.Value
|
||||
|
||||
// Convert map[string]interface{} to VerifyStreamPayload
|
||||
jsonData, _ := json.Marshal(value)
|
||||
var verifyPayload models.VerifyStreamPayload
|
||||
json.Unmarshal(jsonData, &verifyPayload)
|
||||
|
||||
if verifyPayload.Timestamp == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
stream := verifyPayload.Stream
|
||||
if stream != "sub" {
|
||||
stream = "main"
|
||||
}
|
||||
|
||||
// Resolve which RTSP url to verify: prefer the one supplied in the request
|
||||
// (so users can verify unsaved edits), otherwise fall back to the configured
|
||||
// stream url for the requested stream type.
|
||||
rtspUrl := verifyPayload.RTSP
|
||||
if rtspUrl == "" {
|
||||
if stream == "sub" {
|
||||
rtspUrl = configuration.Config.Capture.IPCamera.SubRTSP
|
||||
} else {
|
||||
rtspUrl = configuration.Config.Capture.IPCamera.RTSP
|
||||
}
|
||||
}
|
||||
|
||||
success := false
|
||||
errMsg := ""
|
||||
width := 0
|
||||
height := 0
|
||||
codec := ""
|
||||
fps := 0.0
|
||||
|
||||
if rtspUrl == "" {
|
||||
errMsg = "No RTSP url configured for this stream."
|
||||
} else {
|
||||
// Probe the stream with a bounded timeout so a dead/unreachable camera
|
||||
// can't hang the handler goroutine.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rtspClient := &capture.Golibrtsp{Url: rtspUrl}
|
||||
errConnect := rtspClient.Connect(ctx, ctx)
|
||||
if errConnect != nil {
|
||||
errMsg = errConnect.Error()
|
||||
} else {
|
||||
videoStreams, errStreams := rtspClient.GetVideoStreams()
|
||||
if errStreams != nil || len(videoStreams) == 0 {
|
||||
errMsg = "Connected, but no decodable video stream was found."
|
||||
} else {
|
||||
success = true
|
||||
vs := videoStreams[0]
|
||||
width = vs.Width
|
||||
height = vs.Height
|
||||
codec = vs.Name
|
||||
fps = vs.FPS
|
||||
}
|
||||
}
|
||||
// Always release the connection.
|
||||
rtspClient.Close(ctx)
|
||||
}
|
||||
|
||||
message := models.Message{
|
||||
Payload: models.Payload{
|
||||
Action: "verify-stream-result",
|
||||
DeviceId: configuration.Config.Key,
|
||||
Value: map[string]interface{}{
|
||||
"timestamp": verifyPayload.Timestamp,
|
||||
"stream": stream,
|
||||
"success": success,
|
||||
"error": errMsg,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"codec": codec,
|
||||
"fps": fps,
|
||||
},
|
||||
},
|
||||
}
|
||||
packagedPayload, err := models.PackageMQTTMessage(configuration, message)
|
||||
if err == nil {
|
||||
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, packagedPayload)
|
||||
} else {
|
||||
log.Log.Info("routers.mqtt.main.HandleVerifyStream(): something went wrong while sending result to hub: " + string(packagedPayload))
|
||||
}
|
||||
}
|
||||
|
||||
func HandleUpdateConfig(mqttClient mqtt.Client, hubKey string, payload models.Payload, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
|
||||
value := payload.Value
|
||||
|
||||
|
||||
39
machinery/src/routers/mqtt/main_test.go
Normal file
39
machinery/src/routers/mqtt/main_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestEnqueueLatestAudioReplacesOldestFrameWhenFull(t *testing.T) {
|
||||
audioChannel := make(chan models.AudioDataPartial, 2)
|
||||
audioChannel <- models.AudioDataPartial{Timestamp: 1}
|
||||
audioChannel <- models.AudioDataPartial{Timestamp: 2}
|
||||
|
||||
dropped := enqueueLatestAudio(audioChannel, models.AudioDataPartial{Timestamp: 3})
|
||||
if !dropped {
|
||||
t.Fatal("enqueueLatestAudio() dropped = false, want true")
|
||||
}
|
||||
|
||||
first := <-audioChannel
|
||||
second := <-audioChannel
|
||||
if first.Timestamp != 2 || second.Timestamp != 3 {
|
||||
t.Fatalf("queued timestamps = (%d, %d), want (2, 3)", first.Timestamp, second.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueLatestAudioDoesNotBlockNilChannel(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
enqueueLatestAudio(nil, models.AudioDataPartial{Timestamp: 1})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("enqueueLatestAudio() blocked on a nil channel")
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,8 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
|
||||
err := mp4.MultiTrackFragment.AddFullSampleToTrack(*mp4.VideoFullSample, uint32(mp4.VideoTrack))
|
||||
if err != nil {
|
||||
log.Log.Error("mp4.flushPendingVideoSample(): error adding sample: " + err.Error())
|
||||
} else {
|
||||
mp4.SampleCount++
|
||||
}
|
||||
if isKF {
|
||||
mp4.TotalKeyframesWritten++
|
||||
@@ -296,6 +298,15 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// AverageFPS returns the average frame rate of the video samples actually
|
||||
// committed to this recording.
|
||||
func (mp4 *MP4) AverageFPS() float64 {
|
||||
if mp4.SampleCount == 0 || mp4.VideoTotalDuration == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(mp4.SampleCount) * 1000 / float64(mp4.VideoTotalDuration)
|
||||
}
|
||||
|
||||
// AddSampleToTrack appends a sample to the given track.
|
||||
//
|
||||
// For video, pts is the decode timestamp (DTS, in milliseconds) and
|
||||
|
||||
@@ -2,6 +2,7 @@ package video
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
@@ -173,4 +174,10 @@ func TestMP4Duration(t *testing.T) {
|
||||
t.Errorf("MISMATCH: mdhd.Duration should be 0 for fragmented MP4, got %d",
|
||||
parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration)
|
||||
}
|
||||
if mp4Video.SampleCount != sampleCount {
|
||||
t.Errorf("SampleCount = %d, finalized MP4 contains %d video samples", mp4Video.SampleCount, sampleCount)
|
||||
}
|
||||
if fps := mp4Video.AverageFPS(); math.Abs(fps-25) > 0.001 {
|
||||
t.Errorf("AverageFPS() = %.3f, want 25", fps)
|
||||
}
|
||||
}
|
||||
|
||||
23
machinery/verify-moq-devcontainer.sh
Normal file
23
machinery/verify-moq-devcontainer.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
required_glibc="2.38"
|
||||
current_glibc="$(getconf GNU_LIBC_VERSION | awk '{print $2}')"
|
||||
|
||||
if ! dpkg --compare-versions "$current_glibc" ge "$required_glibc"; then
|
||||
echo "MoQ requires glibc ${required_glibc}+; this container has ${current_glibc}." >&2
|
||||
echo "Rebuild the devcontainer with the Trixie base, then run this check again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Testing MoQ packages (glibc ${current_glibc})"
|
||||
GOWORK=off go test -tags moq ./src/cloud/livemoq ./src/cloud
|
||||
|
||||
binary="${TMPDIR:-/tmp}/agent-moq"
|
||||
trap 'rm -f "$binary"' EXIT
|
||||
|
||||
echo "==> Linking the MoQ Agent"
|
||||
GOWORK=off go build -tags moq -o "$binary" ./main.go
|
||||
|
||||
echo "==> Running the linked binary"
|
||||
"$binary" -action version
|
||||
Reference in New Issue
Block a user