Compare commits

...
Author SHA1 Message Date
Copilot bb5dbe23c9 Investigate supported Debian alternative and document exact media blockers
Compare all 44 blocking CVEs against supported Bookworm LTS and reject an unsafe ABI-only swap or unsupported Forky substitution. Preserve hard gates, native package inventory, codecs and retained architecture requirements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 22:24:27 +00:00
Copilot 31c908b910 Record native image evidence and fail closed on unfixed media CVEs
Keep both native matrix jobs running so one vulnerability failure cannot cancel evidence from the other architecture. Preserve matching Debian native-library metadata and SPDX inventory.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 22:19:06 +00:00
Copilot 84cf2b9ff0 Create the UI build destination before packaging assets
Fix the native ARM64 CI failure after the optimized React build succeeded.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 22:12:06 +00:00
Copilot c3355c8301 Match image tests to production tags and document ARMv6 blocker
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 22:09:59 +00:00
Copilot 1910ed6b20 Keep native image evidence names unique across release jobs
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 22:07:42 +00:00
Copilot 24de9a89ee Harden Agent production images and enforce media-aware release gates
Preserve native FFmpeg capture, the existing command and port, persistent data, and the optional remote terminal while removing ambient capabilities and mutable packaged assets. Patch reachable Go dependency vulnerabilities and retain symbols with -w for binary analysis.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 22:06:46 +00:00
Cédric Verstraeten 24bcc296f1 Merge pull request #345 from kerberos-io/fix/preserve-tus-failures
fix/preserve-tus-failures
2026-10-21 21:45:05 +02:00
Cédric Verstraeten 42e67599ab Preserve TUS sessions and latest retry errors
Keep successful upload creation sessions usable when the optional response body is interrupted, while still requiring Location. Preserve the latest failure across retries and add regression coverage and documentation.
2026-10-21 21:34:25 +02:00
Cédric Verstraeten f64b762eee Merge pull request #344 from kerberos-io/fix/tus-minio-upload-empty-region
fix/tus-minio-upload-empty-region?expand=1
2026-10-21 21:24:03 +02:00
Cédric Verstraeten 9c754b9b91 Preserve resumable upload failure details
Include up to 4 KiB of server error responses and retain the latest failure cause when retries are exhausted. Add coverage for Hub and Vault creation failures and document provider troubleshooting and the temporary ordinary-upload workaround.
2026-10-21 21:19:20 +02:00
Cédric Verstraeten 2f13af62ef Merge pull request #343 from kerberos-io/fix/add-walk-dir
fix/add-walk-dir
2026-10-21 19:39:40 +02:00
Cédric Verstraeten 64e6901574 Fix legacy metadata and recording scan races
Backfill missing S3 checkpoint descriptions from validated legacy filenames while preserving checkpoint values, timestamp, and device key. Skip recordings deleted during directory scans while continuing to report other errors. Add regression coverage and document the metadata fallback.
2026-10-21 19:34:52 +02:00
Cédric Verstraeten c70253ddef Merge pull request #342 from kerberos-io/feature/update-file-format-user-yyyy-mm-dd-filemp4
feature/update-file-format-user-yyyy-mm-dd-filemp4
2026-10-21 19:24:53 +02:00
Cédric Verstraeten 04497dd877 Adopt dated, millisecond-based recording paths
Store new recordings in UTC date directories with stable timestamp-device filenames, moving recording attributes into upload checkpoints. Use checkpoint metadata for S3 keys and HTTP/tus uploads, rejecting invalid S3 checkpoints instead of silently falling back.

Support flat and dated layouts across listing, playback, uploads, cleanup, and decryption while retaining legacy filename compatibility.
2026-10-21 19:18:26 +02:00
Cédric Verstraeten 3bd195834b Organize direct S3 recordings by UTC date
Use the filename's recording timestamp to build username/yyyy/mm/dd object keys, keeping retries consistent across midnight. Reject malformed filenames and timestamps, add date-layout coverage, and document compatibility with historical objects.
2026-10-21 16:19:52 +02:00
Cédric Verstraeten 949762c91b Merge pull request #340 from kerberos-io/fix/hub-failed-error-messages
fix/hub-failed-error-messages
2026-10-10 18:47:05 +02:00
Cédric Verstraeten b805b84d0a Align development API port to 8082
Use port 8082 for UI development endpoints, VS Code debugging, and devcontainer forwarding. Allow overriding the UI API port with REACT_APP_AGENT_API_PORT and document the matching backend setup.
2026-10-10 18:18:35 +02:00
Cédric Verstraeten a57b6310b2 Restrict Hub verification to saved endpoints
Require Hub and persistence verification URIs to match the saved configuration and reject redirects to prevent credential leakage. Add regression tests for unsaved URIs and cross-host redirects.
2026-10-10 18:09:53 +02:00
Cédric Verstraeten 2ec6faffe1 Update cloud_test.go 2026-10-10 18:01:17 +02:00
Cédric Verstraeten d40c544584 Verify Hub persistence with HEAD requests
Check upload authorization without uploading a test video. Guard against missing S3 configuration, strip credentials on cross-host redirects, and clarify rejection errors. Add coverage for HEAD requests and status-specific messages.
2026-10-10 17:52:52 +02:00
Cédric Verstraeten 02ed31da1f Merge pull request #339 from kerberos-io/fix/idr-loss-recovery
fix/idr-loss-recovery
2026-10-08 21:06:58 +02:00
cedricveandCopilot 1db00728c9 fix(capture): honor permitted H264 frame gaps
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-08 18:33:57 +00:00
Cédric Verstraeten 58095365a9 Preserve video reference chains after packet loss
Drop dependent H264/H265 frames after RTP gaps or decode errors until random access restores the reference chain. Detect H264 frame_num gaps and evict complete GOPs so lagging queue readers resume at keyframes. Add regression coverage.
2026-10-08 20:00:23 +02:00
Cédric Verstraeten 7a00398952 Remove DockerHub login from PR builds
Reduce the build job's contents permission to read and remove DockerHub authentication, since pull request builds only need to build the image locally.
2026-10-07 16:00:09 +02:00
Cédric Verstraeten 71decc0edb Merge pull request #338 from EpicMouse123/chore/bump-onvif-v1.2.3
chore(deps): bump github.com/kerberos-io/onvif to v1.2.3
2026-10-02 14:47:54 +02:00
adam.witwickiandClaude Opus 5.5 e2ed932aab chore(deps): bump github.com/kerberos-io/onvif to v1.2.3
v1.2.3 includes:
- kerberos-io/onvif#13: send WS-Security first in "both" auth mode, so
  authenticated calls work again on WS-Security-only cameras.
- kerberos-io/onvif#14: register DeviceIO endpoints listed only in
  GetServices, declare the tmd namespace, and marshal Unsubscribe as
  wsnt:Unsubscribe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:43:17 +01:00
Cédric Verstraeten 30b6f46bfb Merge pull request #337 from EpicMouse123/fix/onvif-pullpoint-addressing-and-io-fallback
fix(onvif): address pull-point requests and fall back to direct I/O queries
2026-10-02 14:12:32 +02:00
adam.witwickiandClaude Opus 5.5 3c5abf89bc fix(onvif): address pull-point requests and fall back to direct I/O queries
Digital inputs and relay outputs were never reported for cameras that
accept the pull-point subscription but reject the pulls:

- PullMessages and Unsubscribe are now sent with WS-Addressing Action and
  To headers. Some devices identify the subscription from wsa:To rather
  than the request URL, and reject requests without it as NotAuthorized.
- The heartbeat now queries the relay outputs and digital inputs directly
  whenever the subscriptions produce no I/O events, not only when no
  subscription could be created. Pulls that fail, and devices that never
  publish the initial I/O state, previously left the I/O list empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:05:55 +01:00
Cédric Verstraeten a91d06c3bc Merge pull request #336 from 4alvit/fix/snapshot-closed-queue
fix(capture): stop snapshot readers when the packet queue closes
2026-10-02 08:00:56 +02:00
alvit 022881d707 fix(capture): stop snapshot readers when the packet queue closes 2026-09-29 11:55:38 -07:00
Cédric Verstraeten 15088e5b70 Merge pull request #335 from kerberos-io/feature/improve-custom-ptz-preset
feature/improve-custom-ptz-preset
2026-09-27 22:11:04 +02:00
Cédric Verstraeten be933a4b7c Rework ONVIF absolute move with verified fallback
Replace the old AbsolutePanTiltMoveFake logic, which zoomed out and then moved one axis at a time with fixed speeds, with a new MoveToPosition implementation.

The new flow:
- Tries a native AbsoluteMove when the camera advertises absolute position spaces.
- Polls the position to confirm the move, with overall and no-progress timeouts.
- Falls back to a closed-loop ContinuousMove that drives pan, tilt and zoom at the same time. Velocity is proportional to the remaining error, clamped between min and max limits.
- Always stops the camera after a failed or finished move and returns combined errors instead of dropping them.
- Validates the target (NaN/Inf) and rejects incomplete position responses.

AbsolutePanTiltMoveFake is kept as a continuous-only wrapper. AbsolutePanTiltMove and GetPTZFunctionsFromDevice now guard against an empty PTZConfiguration. Movement failures are now logged at error level.

Adds unit tests that use a fake PTZ client to cover the native path, the fallback paths, timeouts, stop cleanup and XML encoding of combined ContinuousMove velocities.
2026-09-27 22:00:52 +02:00
46 changed files with 3740 additions and 900 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
},
"forwardPorts": [
3000,
8080
8082
],
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
}
+30
View File
@@ -0,0 +1,30 @@
**
!Dockerfile
!machinery/
!machinery/go.mod
!machinery/go.sum
!machinery/*.go
!machinery/docs/
!machinery/docs/**
!machinery/src/
!machinery/src/**
!machinery/data/
!machinery/data/config/
!machinery/data/config/config.json
!machinery/data/config/user.json
!machinery/data/test-480p.mp4
!ui/
!ui/package.json
!ui/yarn.lock
!ui/.eslintrc.json
!ui/.prettierrc.json
!ui/public/
!ui/public/**
!ui/src/
!ui/src/**
**/.env
**/.env.*
**/*.pem
**/*.key
**/node_modules
**/.git
+52
View File
@@ -0,0 +1,52 @@
name: Verify Agent production image
description: Native media smoke, vulnerability and SBOM gates for the exact image to publish
inputs:
image:
required: true
description: Locally loaded image tag
runs:
using: composite
steps:
- name: Functional least-privilege smoke
shell: bash
env:
IMAGE: ${{ inputs.image }}
run: bash image/smoke.sh "$IMAGE"
- name: Complete vulnerability inventory
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
with:
image-ref: ${{ inputs.image }}
format: json
output: image-vulnerabilities.json
exit-code: '0'
ignore-unfixed: false
severity: UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL
version: v0.69.3
- name: Gate high and critical vulnerabilities including unfixed
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
with:
image-ref: ${{ inputs.image }}
format: table
exit-code: '1'
ignore-unfixed: false
severity: HIGH,CRITICAL
version: v0.69.3
- name: Generate SPDX SBOM
if: ${{ !cancelled() }}
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1
with:
image-ref: ${{ inputs.image }}
format: spdx-json
output: image.spdx.json
version: v0.69.3
- name: Upload image evidence
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: agent-image-evidence-${{ runner.arch }}-${{ strategy.job-index }}
path: |
image-vulnerabilities.json
image.spdx.json
image-env-*.js
image-expected-*.js
if-no-files-found: error
+4 -4
View File
@@ -17,7 +17,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.25.x"
go-version: "1.26.10"
check-latest: true
cache: true
cache-dependency-path: machinery/go.sum
@@ -34,8 +34,8 @@ jobs:
- name: Get dependencies
run: cd machinery && go mod download
- name: Build
run: cd machinery && go build -v ./...
run: cd machinery && go build -p=1 -v ./...
- name: Vet
run: cd machinery && go vet -v ./...
run: cd machinery && go vet -p=1 -v ./...
- name: Test
run: cd machinery && go test -v ./...
run: cd machinery && go test -p=1 -v ./...
+51 -45
View File
@@ -1,62 +1,68 @@
name: Nightly build
on:
# Triggers the workflow every day at 9PM (CET).
schedule:
- cron: "0 22 * * *"
# Allows manual triggering from the Actions tab.
workflow_dispatch:
permissions:
contents: read
jobs:
nightly-build-amd64:
runs-on: ubuntu-latest
build:
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
architecture: [amd64]
include:
- architecture: amd64
runner: ubuntu-24.04
- architecture: arm64
runner: ubuntu-24.04-arm
- architecture: arm/v7
runner: ubuntu-24.04
- architecture: arm/v6
runner: ubuntu-24.04
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Build native media tests and image
env:
ARCHITECTURE: ${{ matrix.architecture }}
run: |
docker buildx build --platform "linux/$ARCHITECTURE" --target test .
docker buildx build --platform "linux/$ARCHITECTURE" \
--build-arg VERSION="${GITHUB_SHA:0:7}" --build-arg REVISION="$GITHUB_SHA" \
--load -t agent-nightly:scan .
- name: Verify exact image before publication
uses: ./.github/actions/verify-image
with:
image: agent-nightly:scan
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --build-arg VERSION=$(echo $GITHUB_SHA | cut -c1-7) --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
nightly-build-other:
runs-on: ubuntu-latest
strategy:
matrix:
architecture: [arm64, arm/v7, arm/v6]
- name: Push verified architecture
env:
ARCHITECTURE: ${{ matrix.architecture }}
run: |
target="kerberos/agent-nightly:arch-${ARCHITECTURE//\//-}-${GITHUB_SHA:0:7}"
docker tag agent-nightly:scan "$target"
docker push "$target"
manifest:
needs: build
runs-on: ubuntu-24.04
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --build-arg VERSION=$(echo $GITHUB_SHA | cut -c1-7) --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
- name: Promote only the complete verified architecture set
run: |
sources=()
for architecture in amd64 arm64 arm-v7 arm-v6; do
sources+=("kerberos/agent-nightly:arch-$architecture-${GITHUB_SHA:0:7}")
done
docker buildx imagetools create -t "kerberos/agent-nightly:${GITHUB_SHA:0:7}" "${sources[@]}"
+14 -9
View File
@@ -1,7 +1,7 @@
name: Build pull request
on:
pull_request:
types: [opened, synchronize]
types: [opened, synchronize, reopened]
env:
REPO: kerberos/agent
@@ -10,8 +10,9 @@ jobs:
build:
runs-on: ${{ matrix.runner }}
permissions:
contents: write
contents: read
strategy:
fail-fast: false
matrix:
include:
- architecture: amd64
@@ -21,18 +22,22 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Run Build
env:
REVISION: ${{ github.event.pull_request.head.sha }}
run: |
docker build --target test .
docker build --build-arg VERSION="${REVISION:0:7}" --build-arg REVISION="$REVISION" -t ${{ matrix.architecture }} .
- name: Verify exact image
uses: ./.github/actions/verify-image
with:
image: ${{ matrix.architecture }}
- name: Extract verified release bundle
run: |
docker build --build-arg VERSION=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) -t ${{ matrix.architecture }} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Strip binary
- name: Package binary and runtime assets
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Upload artifact
uses: actions/upload-artifact@v4
+23 -5
View File
@@ -29,12 +29,21 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Run Build
env:
VERSION: ${{ github.event.inputs.tag || github.ref_name }}
run: |
docker build --target test .
docker build --build-arg VERSION="$VERSION" --build-arg REVISION="$GITHUB_SHA" -t ${{matrix.architecture}} .
- name: Verify exact image before publication
uses: ./.github/actions/verify-image
with:
image: ${{ matrix.architecture }}
- name: Extract verified release bundle
run: |
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}
- name: Strip binary
- name: Package binary and runtime assets
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Build and push Docker image
run: |
@@ -62,12 +71,21 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Run Build
env:
VERSION: ${{ github.event.inputs.tag || github.ref_name }}
run: |
docker build --target test .
docker build --build-arg VERSION="$VERSION" --build-arg REVISION="$GITHUB_SHA" -t ${{matrix.architecture}} .
- name: Verify exact image before publication
uses: ./.github/actions/verify-image
with:
image: ${{ matrix.architecture }}
- name: Extract verified release bundle
run: |
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}
- name: Strip binary
- name: Package binary and runtime assets
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
- name: Build and push Docker image
run: |
@@ -113,7 +131,7 @@ jobs:
- name: Create a release
uses: ncipollo/release-action@v1
with:
latest: true
makeLatest: true
allowUpdates: true
name: ${{ github.event.inputs.tag || github.ref_name }}
tag: ${{ github.event.inputs.tag || github.ref_name }}
+2 -1
View File
@@ -17,4 +17,5 @@ machinery/vendor
machinery/go.work
machinery/go.work.sum
deployments/docker/private-docker-compose.yaml
video.mp4
video.mp4
.checks/
+1 -1
View File
@@ -14,7 +14,7 @@
"-action",
"run",
"-port",
"8080"
"8082"
],
"envFile": "${workspaceFolder}/machinery/.env.local",
"buildFlags": "--tags dynamic,moq",
+78 -147
View File
@@ -1,155 +1,86 @@
ARG GO_IMAGE=golang:1.25-trixie
ARG RUNTIME_IMAGE=debian:trixie-slim
# syntax=docker/dockerfile:1.7
FROM golang:1.26.10-trixie@sha256:909771fef53e3dcb0950255f0c9a8b4728c3b841e61509c7b29b098de7ee02d4 AS build-machinery
ARG VERSION=0.0.0
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
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
ENV GOSUMDB=off
##########################################
# Installing some additional dependencies.
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 libavcodec-dev libavutil-dev libcurl4-openssl-dev \
libssl-dev libjpeg62-turbo-dev libswscale-dev && \
rm -rf /var/lib/apt/lists/*
##############################################################################
# Copy dependency metadata first so module downloads can be cached separately.
RUN mkdir -p /go/src/github.com/kerberos-io/agent
WORKDIR /go/src/github.com/kerberos-io/agent/machinery
COPY machinery/go.mod machinery/go.sum ./
RUN go mod download
##############################################################################
# Copy the rest of the source after dependencies are primed.
COPY machinery ./
RUN rm -rf .env
##################
# Build Machinery
RUN RESOLVED_VERSION="${VERSION:-0.0.0}" && \
printf '%s' "${RESOLVED_VERSION}" > version && \
BUILD_TAGS=timetzdata,netgo,osusergo && \
case "${TARGETARCH:-$(go env GOARCH)}" in amd64|arm64) BUILD_TAGS="moq,${BUILD_TAGS}" ;; esac && \
go build -o main -tags "${BUILD_TAGS}" --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${RESOLVED_VERSION}" . && \
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
WORKDIR /go/src/github.com/kerberos-io/agent/ui
COPY ui/package.json ui/yarn.lock ./
RUN yarn config set network-timeout 300000 && yarn --frozen-lockfile
COPY ui ./
RUN 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 ${RUNTIME_IMAGE}
############################
# Protect by non-root user.
ENV GOTOOLCHAIN=local CGO_ENABLED=1
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 && \
chmod 0755 /home/agent
pkg-config libavcodec-dev libavutil-dev libswscale-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /src/machinery
COPY machinery/go.mod machinery/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
go mod download && go mod verify
COPY machinery/*.go ./
COPY machinery/docs ./docs
COPY machinery/src ./src
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
BUILD_TAGS=timetzdata,netgo,osusergo && \
case "${TARGETARCH:-$(go env GOARCH)}" in amd64|arm64) BUILD_TAGS="moq,${BUILD_TAGS}" ;; esac && \
go build -mod=readonly -trimpath -buildvcs=false -tags "${BUILD_TAGS}" \
-ldflags="-w -buildid= -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION}" \
-o /out/main . && \
printf '%s' "${VERSION}" > /out/version && /out/main -action version
#################################
# Copy files from previous images
FROM build-machinery AS test
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
BUILD_TAGS=timetzdata,netgo,osusergo && \
case "${TARGETARCH:-$(go env GOARCH)}" in amd64|arm64) BUILD_TAGS="moq,${BUILD_TAGS}" ;; esac && \
go vet -tags "${BUILD_TAGS}" ./... && go test -p=1 -tags "${BUILD_TAGS}" ./... && \
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -tags "${BUILD_TAGS}" ./... && \
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -mode=binary /out/main
COPY --chown=0:0 --from=build-machinery /dist /
COPY --chown=0:0 --from=build-ui /dist /
FROM node:22-bookworm-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d AS build-ui
WORKDIR /src/ui
COPY ui/package.json ui/yarn.lock ./
RUN yarn install --frozen-lockfile --non-interactive
COPY ui/public ./public
COPY ui/src ./src
COPY ui/.eslintrc.json ui/.prettierrc.json ./
RUN mkdir -p /src/machinery && yarn build && test -s /src/machinery/www/index.html
##################
# Try running agent
# Native FFmpeg capture/transcoding and the opt-in remote terminal require
# glibc, codecs and /bin/sh. Keep this media exception, not a scratch image.
FROM debian:trixie-slim@sha256:62bd423aeee912639ca8ee681c2b762cb3e25137e0a92b20be455898a9f034ad AS runtime-files
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl ffmpeg libatomic1 libstdc++6 && \
rm -rf /var/lib/apt/lists/* /var/cache/apt/* && \
groupadd --gid 65532 kerberosio && \
useradd --uid 65532 --gid 65532 --groups video --home-dir /home/agent \
--no-create-home --shell /bin/sh agent && \
find / -xdev -type f -perm /6000 -exec chmod a-s {} + && \
rm -f /usr/bin/apt /usr/bin/apt-* /usr/bin/dpkg /usr/bin/dpkg-* \
/usr/sbin/dpkg-* /usr/sbin/useradd /usr/sbin/groupadd \
/usr/sbin/usermod /usr/sbin/userdel /usr/sbin/groupmod /usr/sbin/groupdel && \
rm -rf /usr/lib/apt /usr/lib/dpkg && \
install -d -o 0 -g 0 -m 0755 /home/agent && \
install -d -o 65532 -g 65532 -m 0750 \
/home/agent/data /home/agent/data/cloud /home/agent/data/snapshots \
/home/agent/data/log /home/agent/data/recordings /home/agent/data/capture-test \
/home/agent/data/config /home/agent/data/tus
COPY --chown=65532:65532 --chmod=0640 machinery/data/config/config.json machinery/data/config/user.json /home/agent/data/config/
COPY --chown=0:0 --chmod=0444 machinery/data/config/config.json /home/agent/data/config.template.json
COPY --chown=0:0 --chmod=0444 machinery/data/test-480p.mp4 /home/agent/data/test-480p.mp4
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 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:${AGENT_PORT:-80}/health" || exit 1
###################################################
# Leeeeettttt'ssss goooooo!!!
# Run the shizzle from the right working directory.
FROM runtime-files
ARG VERSION=0.0.0
ARG REVISION=unknown
ARG CREATED
LABEL org.opencontainers.image.title="agent" \
org.opencontainers.image.description="Kerberos edge camera and media agent" \
org.opencontainers.image.source="https://github.com/kerberos-io/agent" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.created="${CREATED}" \
org.opencontainers.image.vendor="UUG.AI"
COPY --from=build-machinery --chown=0:0 --chmod=0555 /out/main /home/agent/main
COPY --from=build-machinery --chown=0:0 --chmod=0444 /out/version /home/agent/version
COPY --from=build-ui --chown=0:0 /src/machinery/www /home/agent/www
COPY --chown=0:0 --chmod=0444 ui/package.json ui/yarn.lock /usr/share/agent-ui/
USER 65532:65532
WORKDIR /home/agent
CMD ["./main", "-action", "run"]
EXPOSE 80/tcp
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD curl --fail --silent "http://localhost:${AGENT_PORT:-80}/health" || exit 1
CMD ["./main", "-action", "run"]
+76 -3
View File
@@ -76,6 +76,60 @@ There are a myriad of cameras out there (USB, IP and other cameras), and it migh
1. [What is new?](#what-is-new)
1. [Contributors](#contributors)
### Direct S3 recording layout
Direct S3 uploads store recordings as
`<s3.username>/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4`. The timestamp is the
recording's Unix timestamp in milliseconds, and the date is its UTC date, not the upload
date. Timestamp and device key come from the recording's `.metadata` upload
checkpoint, which stores the timestamp in milliseconds. The uploader also
preserves checkpoint duration, FPS (when available), and encryption status as S3
object metadata. The device key must be non-empty and contain no path separators.
Retried uploads use the same object key, even after midnight. Millisecond
precision keeps recordings starting within the same second distinct.
This replaces the previous `<s3.username>/<filename>.mp4` layout for new uploads.
Existing objects are not moved; consumers of direct S3 uploads must support both
layouts when reading historical recordings. Updated Kerberos Vault deployments
use the same filename and dated layout under the authenticated account directory
for both direct Vault uploads and Hub-proxied uploads.
Only historical recordings with missing, empty, or FPS-only queue markers fall
back to the legacy filename format and configured Agent key. A structured
checkpoint takes precedence over both the filename and current configured key;
an invalid or unreadable checkpoint is reported as an upload error, not silently
replaced with filename-derived values.
For older structured checkpoints, missing camera name, region, and change count
are recovered from a validated legacy filename. Populated checkpoint fields
take precedence, and the checkpoint timestamp and device key remain authoritative.
The shorter filename cannot be decoded by legacy filename-only consumers.
Integrations must preserve structured recording metadata, including the device
key (`productid`) and recording timestamp (`event-timestamp`), as well as the full
object key.
### Local recording layout
New local recordings are stored under
`data/recordings/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4`, using the recording's UTC
start date and Unix-millisecond timestamp. For example:
`data/recordings/2026/01/02/1767355200123-device-key.mp4`.
The filename no longer embeds the camera name, region, change count, or duration,
and it does not change when the recording is finalized.
Those attributes, together with FPS and encryption status, are captured in the
`.metadata` upload checkpoint and sent as structured HTTP/tus metadata.
Existing flat recordings and dated recordings with older long filenames remain
supported without a migration. Recording lists, day filters, playback, uploaders,
and automatic cleanup handle both filename formats and layouts. Upload queue
checkpoints remain flat in `data/cloud/`. Successful-upload removal also resolves
both local layouts. Deploy the updated Vault alongside Agent for canonical
`<account-directory>/yyyy/mm/dd/<timestamp>-<deviceKey>.mp4` storage keys and
metadata-driven Hub integration events.
Directory decryption also processes dated subdirectories and writes each output
to a sibling `decrypted/` directory; those outputs are excluded from recording
lists and automatic cleanup.
## Quickstart - Docker
The easiest way to get your Kerberos Agent up and running is to use our public image on [Docker hub](https://hub.docker.com/r/kerberos/agent). Once you have selected a specific tag, run `docker` command below, which will open the web interface of your Kerberos agent on port `80`, and off you go. For a more configurable and persistent deployment have a look at [Running and automating a Kerberos Agent](#running-and-automating-a-kerberos-agent).
@@ -410,6 +464,17 @@ Smaller chunks provide more frequent resumable checkpoints but create more HTTP
requests. Larger chunks reduce request overhead but require more data to be
retransmitted when a request fails.
An HTTP 500 during resumable creation is a server/provider failure, not proof
that the recording filename is invalid. Creation errors include up to 4 KiB of
the server response, and the final retry error retains the latest failure cause.
Created upload sessions remain usable if only the optional creation response
body is interrupted; the successful response's `Location` is retained.
Check the corresponding Hub/Vault logs and storage-provider configuration.
As a temporary diagnostic workaround, set `AGENT_DISABLE_RESUMABLE_UPLOAD=true`
and restart Agent to use ordinary uploads. This does not change the recording
filename or remove its structured metadata, but interrupted uploads restart
from the beginning. Remove the override after fixing the provider issue.
### Custom metadata headers for Kerberos Vault
Use `AGENT_KERBEROSVAULT_CUSTOM_HEADERS` to attach your own metadata (for
@@ -602,7 +667,10 @@ The `web` is a **React** project which is the main entry point for an end user t
cd ui
yarn start
This will start a webserver and launches the web app on port `3000`.
This starts the web app on port `3000`. In development, the UI connects to the
Agent API on port `8082`. Override that port when needed:
REACT_APP_AGENT_API_PORT=9090 yarn start
![login-agent](./assets/img/agent-login.gif)
@@ -618,9 +686,14 @@ You can simply run the `machinery` using following commands.
git clone https://github.com/kerberos-io/agent
cd machinery
go run main.go -action run -port 80
go run main.go -action run -port 8082
This will launch the Kerberos Agent and run a webserver on port `80`. You can change the port by your own preference. We strongly support the usage of [Goland](https://www.jetbrains.com/go/) or [Visual Studio Code](https://code.visualstudio.com/), as it comes with all the debugging and linting features built in.
This will launch the Kerberos Agent and run a webserver on port `8082`, matching
the UI development default. You can change the port together with
`REACT_APP_AGENT_API_PORT`. We strongly support the usage of
[Goland](https://www.jetbrains.com/go/) or
[Visual Studio Code](https://code.visualstudio.com/), as it comes with all the
debugging and linting features built in.
![VSCode desktop](./assets/img/vscode-desktop.png)
+151
View File
@@ -38,3 +38,154 @@ If you do not receive a response in time, please resend your report and include
## Disclosure and Credits
We follow coordinated disclosure. After a fix is available, we will credit reporters unless they prefer to stay anonymous.
## Production container contract
The image follows the [image hardening guide](https://learn.uug.ai/guides/security/image-hardening/)
with explicit media/device exceptions. A Dockerfile change alone is not evidence
that an image is hardened: native architecture builds, functional smoke tests,
vulnerability reports and an SBOM must pass before release.
- Build and runtime bases are pinned to verified multi-platform digests. Refresh
them regularly; pins do not deliver security updates automatically.
- Public Go modules retain checksum verification. No build token is needed.
Building machinery requires Go 1.26 or newer; CI uses Go 1.26.10.
Go retains CGO for FFmpeg capture and uses `-w -buildid=` without `-s`, preserving
symbols for binary vulnerability analysis.
- The fixed runtime identity is `65532:65532`; `/home/agent/main`, `version` and
`www` are root-owned and not writable by the process. Demo UI configuration is
served without renaming or modifying packaged assets.
- `/home/agent/data` remains the persistent writable path, including `config`,
`cloud`, `snapshots`, `log`, `recordings`, `capture-test` and `tus`. Existing
bind mounts/PVCs must grant UID/GID 65532 access before rollout. Do not change
their paths or discard existing configuration/recordings.
- The existing `./main -action run` command, `/health`, UI and port **80** remain.
The executable has no file capabilities. For port 80 without capabilities,
set `net.ipv4.ip_unprivileged_port_start=0` in the container network namespace
(Docker normally defaults to this). Alternatively set `AGENT_PORT=8080` and
update the service/container port mapping. Kubernetes policy must permit the
chosen sysctl, or use the unprivileged port override.
- Preserve required camera/audio device mappings and add the host device's
numeric supplemental GID (`--group-add` or Kubernetes `supplementalGroups`).
Do not use privileged mode or make device nodes world-writable. The image
retains its `video` group; host device GIDs are not portable.
- Debian's glibc and FFmpeg codecs are required by native capture and media
processing. `/bin/sh` is retained for the product's optional MQTT remote
terminal, and curl for its existing healthcheck. This is a documented
media/remote-terminal exception to the shell-free distroless preference, not
a general permission to add debugging tools. Package-manager executables,
compiler toolchains and special setuid/setgid permission bits are removed.
Do not claim complete deployment CIS compliance.
### Verification and release gates
PR, release and nightly jobs build the `test` target (Go tests/vet plus source
and binary govulncheck), then verify the exact image before it can be published.
The local `verify-image` action runs `image/smoke.sh`, records all severities,
blocks HIGH/CRITICAL findings including unfixed findings, and uploads an SPDX
SBOM. There is no blanket ignore list or non-blocking release scan.
The smoke uses `--read-only --cap-drop ALL --security-opt no-new-privileges`,
a Docker volume initialized from the packaged data defaults, and the low-port
sysctl. It checks default production startup, demo mode with `AGENT_PORT=8080`,
health/UI/config responses, writable data, immutable assets, FFmpeg frame
extraction and ffprobe. Hardware/RTSP camera and MQTT remote-terminal integration
still require representative device-backed validation; synthetic media is not
proof of hardware compatibility.
Run on a native Docker host:
```sh
docker build --target test .
docker build -t agent:validation .
bash image/smoke.sh agent:validation
```
Shared workflow integration remains a release prerequisite: the shared
`worker`/`frontend` profiles do **not** match this image. An Agent profile must
preserve `/home/agent`, its command/port/data/device contract, allow the explicit
media/shell/healthcheck exceptions, publish to `kerberos/agent`, and retain the
downloadable release tar assets. Centralized digest-bound signing, provenance
and published-image verification must be integrated before treating this
rollout as fully validated. Do not substitute the incompatible worker profile.
Native release validation targets AMD64 and ARM64. The legacy nightly ARMv6
matrix is currently blocked: the pinned Go and Node upstream indexes provide
ARMv7, not ARMv6 variants. The complete nightly manifest must therefore remain
blocked rather than silently publishing an incomplete architecture set. ARMv6
needs an independently validated cross-build/media-runtime strategy before its
support can be claimed; do not resolve the failure by weakening the gates.
### Current native validation blocker
The ARM64 build and read-only/cap-drop production, demo and FFmpeg smoke passed
in [CI run 40410591639](https://github.com/kerberos-io/agent/actions/runs/40410591639).
The strict vulnerability gate then failed: the refreshed Debian 13.7 runtime
reported 212 HIGH/CRITICAL package findings, including FFmpeg 7.1.5 and curl
8.14.1, with no fixed Debian versions reported. The run uploaded the complete
inventory and SPDX SBOM in `agent-image-evidence-ARM64-1`. This is **not** a
security-clean image or permission to ship it; resolving these native-library
findings without dropping required capture/codecs remains a rollout blocker.
### Supported-base alternative investigation (2026-10-26)
Compared the current **Debian 13 Trixie** runtime against **Debian 12.15
Bookworm LTS**, using the [Debian security tracker JSON](https://security-tracker.debian.org/tracker/data/json)
and the exact 44 distinct HIGH/CRITICAL CVEs from the native image report.
[Bookworm remains supported until 2028-06-30](https://www.debian.org/releases/bookworm/)
on its listed LTS architectures. Its current FFmpeg is
`7:5.1.9-0+deb12u1`, curl is `7.88.1-10+deb12u15`, and libxml2 is
`2.9.14+dfsg-1.3~deb12u6`.
This supported alternative does **not** clear the gate: **42 of the 44 CVEs
remain open**, including the critical libxml2 `CVE-2026-6653`, both curl CVEs,
and 15 of the 16 FFmpeg CVEs. Bookworm is marked unaffected (`fixed_version=0`)
for FFmpeg `CVE-2026-66041` and libxml2 `CVE-2026-86139`; that improvement is
not sufficient to release.
Bookworm is also not a drop-in runtime replacement: this CGO binary is linked
to FFmpeg 7's `libavcodec.so.61`/`libavutil.so.59`, while Bookworm's FFmpeg 5
uses different SONAMEs (`libavcodec.so.59`/`libavutil.so.57`). Both the Go/native
builder and runtime would require a matched rebuild and codec/device regression
validation. No Bookworm image was built or claimed compatible after the
security metadata already demonstrated the unresolved blockers. Bookworm LTS
also no longer covers armel; it is not a silent solution for the retained ARMv6
requirement.
The tracker reports FFmpeg fixes in `7:9.0.2-1`, curl fixes in
`8.21.0~rc2-1`/`8.21.0~rc3-1`, and the critical libxml2 fix in
`2.14.5+dfsg-0.1` in **Forky**. However,
[Forky is testing, without timely Security Team support](https://www.debian.org/releases/forky/),
not a supported stable-base upgrade; cjson, libsndfile, libX11 and libXrender
blockers also remain open there. It was not substituted into production.
Dropping unused administration tools alone cannot resolve the required FFmpeg
decoder/parser findings. No codec, device integration, remote terminal or
ARMv6 target was removed to obtain a clean scan.
The required decision is whether to wait for supported-distribution fixes or
approve an explicitly maintained native-package/backport source or supported
media image, with ownership for security updates and validation across the
retained architectures. Such a change needs fresh matching native inventory,
image scans and camera/codec/device tests; it is not authorization to ignore
these findings or switch to an unsupported testing distribution.
#### Exact blocking native CVEs
These are the source components behind the 212 package findings in Debian 13.7;
several FFmpeg binaries/libraries share the same source advisories:
| Source component | CVEs |
| --- | --- |
| util-linux | CVE-2026-76642, CVE-2026-78408, CVE-2026-78409, CVE-2026-78410 |
| curl | CVE-2026-12064, CVE-2026-8458 |
| ffmpeg | CVE-2026-58049, CVE-2026-64830, CVE-2026-64832, CVE-2026-64833, CVE-2026-64834, CVE-2026-64835, CVE-2026-66036, CVE-2026-66039, CVE-2026-66040, CVE-2026-66041, CVE-2026-70628, CVE-2026-70632, CVE-2026-75142, CVE-2026-75143, CVE-2026-75144, CVE-2026-75146 |
| acl | CVE-2026-54369 |
| cjson | CVE-2026-16554, CVE-2026-29036, CVE-2026-67215, CVE-2026-67216, CVE-2026-87933 |
| ncurses | CVE-2025-69720 |
| libsndfile | CVE-2026-37555 |
| systemd | CVE-2026-16742 |
| tiff | CVE-2026-36849, CVE-2026-52490 |
| libX11 | CVE-2026-88806 |
| libxml2 | CVE-2026-6653 (CRITICAL), CVE-2026-74860, CVE-2026-86138, CVE-2026-86139, CVE-2026-86140, CVE-2026-86142, CVE-2026-86143, CVE-2026-86144 |
| libXrender | CVE-2026-88807 |
| perl | CVE-2026-9538 |
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
image=${1:?usage: image/smoke.sh IMAGE}
test "$(docker image inspect "$image" --format '{{.Config.User}}')" = 65532:65532
test "$(docker image inspect "$image" --format '{{json .Config.Cmd}}')" = '["./main","-action","run"]'
test "$(docker image inspect "$image" --format '{{.Config.WorkingDir}}')" = /home/agent
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
--entrypoint /bin/sh "$image" -ec '
test "$(id -u)" = 65532
test "$(stat -c %u:%g /home/agent/main)" = 0:0
test ! -w /home/agent/main
test ! -w /home/agent/www
test ! -e /usr/bin/apt
test ! -e /usr/bin/dpkg
test -z "$(find / -xdev -type f -perm /6000 -print 2>/dev/null)"
./main -action version
ffmpeg -version
ffprobe -version
'
container=
volume=
cleanup() {
if [[ -n "$container" ]]; then
docker logs "$container" || true
docker rm -f "$container" >/dev/null
fi
if [[ -n "$volume" ]]; then docker volume rm "$volume" >/dev/null; fi
}
trap cleanup EXIT
for mode in production demo; do
volume=$(docker volume create)
args=()
port=80
if [[ "$mode" == demo ]]; then
port=8080
args+=(-e AGENT_MODE=demo -e AGENT_PORT=8080)
fi
container=$(docker run -d --read-only --cap-drop ALL \
--security-opt no-new-privileges \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
--mount "type=volume,src=$volume,dst=/home/agent/data" \
-p "127.0.0.1::$port" "${args[@]}" "$image")
address=$(docker port "$container" "$port/tcp")
healthy=false
for attempt in {1..60}; do
if curl --fail --silent "http://$address/health" >/dev/null; then
healthy=true
break
fi
test "$(docker inspect "$container" --format '{{.State.Running}}')" = true
sleep 2
done
test "$healthy" = true
curl --fail --silent "http://$address/" | grep -q '<html'
curl --fail --silent "http://$address/env.js" > "image-env-$mode.js"
docker cp "$container:/home/agent/www/env.$([[ "$mode" == demo ]] && echo demo. || true)js" "image-expected-$mode.js"
cmp "image-env-$mode.js" "image-expected-$mode.js"
docker exec "$container" /bin/sh -ec '
test -w data/config/config.json
test -w data/config/user.json
ffmpeg -nostdin -v error -i data/test-480p.mp4 -frames:v 1 data/capture-test/smoke.jpg
test -s data/capture-test/smoke.jpg
ffprobe -v error -show_entries stream=codec_type data/test-480p.mp4
for path in cloud snapshots log recordings capture-test config tus; do
test -w "data/$path"
done
test ! -w www/env.js
'
cleanup
container=
volume=
done
+28 -27
View File
@@ -1,6 +1,6 @@
module github.com/kerberos-io/agent/machinery
go 1.25.0
go 1.26.0
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237
@@ -14,7 +14,7 @@ require (
github.com/creack/pty v1.1.24
github.com/dromara/carbon/v2 v2.6.8
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/elastic/go-sysinfo v1.15.3
github.com/gin-contrib/cors v1.7.5
github.com/gin-contrib/pprof v1.5.3
@@ -25,7 +25,7 @@ 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.2.2
github.com/kerberos-io/onvif v1.2.3
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
@@ -39,12 +39,13 @@ require (
github.com/swaggo/swag v1.16.4
github.com/tevino/abool v1.2.0
github.com/zaf/g711 v1.4.0
go.mongodb.org/mongo-driver v1.17.3
go.opentelemetry.io/otel v1.36.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/trace v1.36.0
go.mongodb.org/mongo-driver v1.17.7
go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
golang.org/x/net v0.60.0
)
require (
@@ -55,7 +56,8 @@ require (
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/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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
@@ -65,7 +67,7 @@ require (
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
@@ -77,11 +79,11 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
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/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // 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
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/cpuid v1.2.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kylelemons/go-gypsy v1.0.0 // indirect
@@ -121,22 +123,21 @@ require (
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/ziutek/mymysql v1.5.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
golang.org/x/arch v0.16.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.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.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
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/tools v0.49.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.42.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+61 -51
View File
@@ -408,14 +408,15 @@ github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCN
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6 h1:bzFZYgZD5vf4PWaa2GjOh90HG88uKi2a+B6VnQcDlCA=
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6/go.mod h1:nBrjN2nMHendp0Cvb/6GaJ1v92Qv/kzqxWtNBnKJEK0=
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
@@ -468,8 +469,8 @@ github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 h1:FT+t0UEDykcor4y3dMVKXI
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5/go.mod h1:rSS3kM9XMzSQ6pw91Qgd6yB5jdt70N4OdtrAf74As5M=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/elastic/go-sysinfo v1.15.3 h1:W+RnmhKFkqPTCRoFq2VCTmsT4p/fwpo+3gKNQsn1XU0=
github.com/elastic/go-sysinfo v1.15.3/go.mod h1:K/cNrqYTDrSoMh2oDkYEMS2+a72GRxMvNP+GC+vRIlo=
github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI=
@@ -548,8 +549,9 @@ github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpx
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -746,8 +748,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFb
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hamba/avro/v2 v2.17.2/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -776,13 +778,14 @@ 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.2.2 h1:QnxITps7xvAVD2abWRsa3+p9QexjKESQQldUFMx/mYA=
github.com/kerberos-io/onvif v1.2.2/go.mod h1:XSgEQXmEDjUQTbdXvsaRJt6Az8YPGj7L+j5iXKEGijU=
github.com/kerberos-io/onvif v1.2.3 h1:RDnDvLBd92VCgFOrlbGavzCVN9lQA/qs9novdNFIAOU=
github.com/kerberos-io/onvif v1.2.3/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=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.3 h1:CCtW0xUnWGVINKvE/WWOYKdsPV6mawAtvQuSl8guwQs=
github.com/klauspost/cpuid v1.2.3/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -924,8 +927,9 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
@@ -1009,8 +1013,8 @@ github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
go.einride.tech/aip v0.67.1/go.mod h1:ZGX4/zKw8dcgzdLsrvpOOGxfxI2QSk12SlP7d6c0/XI=
go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU=
go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -1019,8 +1023,9 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.28.0/go.mod h1:9BIqH22qyHWAiZxQh0whuJygro59z+nbMVuc7ciiGug=
go.opentelemetry.io/contrib/detectors/gcp v1.29.0/go.mod h1:GW2aWZNwR2ZxDLdv8OyC2G8zkRoQBuURgV7RPQgcPoU=
go.opentelemetry.io/contrib/detectors/gcp v1.31.0/go.mod h1:tzQL6E1l+iV44YFTkcAeNQqzXUiekSYP9jjJjXwEd00=
@@ -1059,12 +1064,12 @@ go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGi
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM=
@@ -1080,8 +1085,8 @@ go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzau
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E=
go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
@@ -1093,16 +1098,17 @@ go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJC
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg=
go.opentelemetry.io/otel/sdk/metric v1.29.0/go.mod h1:6zZLdCl2fkauYoZIOn/soQIDSWFmNSRcICarHfuhNJQ=
go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ=
go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo=
go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk=
@@ -1116,14 +1122,14 @@ go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
@@ -1169,8 +1175,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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
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=
@@ -1240,8 +1246,8 @@ 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/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/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
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=
@@ -1329,8 +1335,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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
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=
@@ -1380,8 +1386,9 @@ golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1410,8 +1417,8 @@ 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/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/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
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=
@@ -1520,8 +1527,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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
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=
@@ -1588,8 +1595,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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
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=
@@ -1677,8 +1684,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
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/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/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
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=
@@ -1693,6 +1700,8 @@ gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJ
gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0=
gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
gonum.org/v1/plot v0.9.0/go.mod h1:3Pcqqmp6RHvJI72kgb8fThyUnav364FOsdDo2aGW5lY=
@@ -1866,8 +1875,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250425173222-7b384671a197/go.
google.golang.org/genproto/googleapis/api v0.0.0-20250428153025-10db94c68c34/go.mod h1:0awUlEkap+Pb1UMeJwJQQAdJQrt3moU7J2moTy69irI=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:W3S/3np0/dPWsWLi1h/UymYctGXaGBM2StwzD0y140U=
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20230530153820-e85fd2cbaebc/go.mod h1:ylj+BE99M198VPbBh6A8d9n3w8fChvyLK3wwBOjXBFA=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20231012201019-e917dd12ba7a/go.mod h1:+34luvCflYKiKylNwGJfn9cFBbcL/WrkciMmDmsTQ/A=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20231030173426-d783a09b4405/go.mod h1:GRUCuLdzVqZte8+Dl/D4N25yLzcGqqWaYkeVOwulFqw=
@@ -1986,8 +1995,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250425173222-7b384671a197/go.
google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -2062,8 +2071,8 @@ google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40Rmc
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.3.0/go.mod h1:Dk1tviKTvMCz5tvh7t+fh94dhmQVHuCt2OzJB3CTW9Y=
google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0=
@@ -2096,8 +2105,9 @@ google.golang.org/protobuf v1.36.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojt
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+47
View File
@@ -10,11 +10,58 @@ import (
"github.com/kerberos-io/agent/machinery/src/models"
)
func TestPickRecordingToCleanupAcrossDatedDirectories(t *testing.T) {
for _, legacyMarker := range []bool{false, true} {
t.Run(map[bool]string{false: "metadata", true: "legacy"}[legacyMarker], func(t *testing.T) {
recordingsDir, cloudDir := newCleanupDirs(t)
pendingName := "2026/01/01/oldest_pending.mp4"
safeName := "2026/01/02/uploaded.mp4"
writeRecording(t, recordingsDir, pendingName, 30)
if legacyMarker {
if err := os.WriteFile(filepath.Join(cloudDir, filepath.Base(pendingName)), nil, 0o644); err != nil {
t.Fatal(err)
}
} else {
markPending(t, cloudDir, pendingName)
}
writeRecording(t, recordingsDir, safeName, 20)
writeRecording(t, recordingsDir, "flat_uploaded.mp4", 10)
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
if err != nil || pending || name != safeName {
t.Fatalf("cleanup = (%q, %v, %v), want (%q, false, nil)", name, pending, err, safeName)
}
})
}
}
func TestCleanupDatedPendingRecordingRemovesFlatMarker(t *testing.T) {
recordingsDir, cloudDir := newCleanupDirs(t)
name := "2026/01/01/pending.mp4"
if err := os.MkdirAll(filepath.Dir(filepath.Join(recordingsDir, name)), 0o755); err != nil {
t.Fatal(err)
}
writeSizedRecording(t, recordingsDir, name, 2*1000*1000)
markPending(t, cloudDir, name)
config := &models.Configuration{Config: models.Config{AutoClean: "true", MaxDirectorySize: 1}}
CleanupRecordingDirectory(filepath.Dir(filepath.Dir(recordingsDir)), config)
for _, filePath := range []string{
filepath.Join(recordingsDir, name),
filepath.Join(cloudDir, models.RecordingUploadMetadataFileName(name)),
} {
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Fatalf("expected %s to be removed, got %v", filePath, err)
}
}
}
// 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.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir recording directory: %v", err)
}
if err := os.WriteFile(full, []byte("data"), 0o644); err != nil {
t.Fatalf("write recording %s: %v", name, err)
}
+248 -13
View File
@@ -32,6 +32,7 @@ import (
"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/bits"
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
"github.com/bluenviron/mediacommon/pkg/codecs/h265"
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
@@ -159,13 +160,10 @@ type Golibrtsp struct {
}
// streamHealth instruments the RTSP read path. gortsplib delivers every RTP
// packet on a single read goroutine; queue.WritePacket() is synchronous, so if
// a downstream consumer (recording, muxing, WebRTC) is slow or the process is
// CPU-starved, WritePacket() blocks, the TCP socket is not drained, and the
// camera advances RTP sequence numbers -> "RTP packets lost". This type makes
// the two failure modes distinguishable:
// - large writeMax / writeAvg => downstream back-pressure (our side).
// - large gapMax with fast writes => upstream network / camera stall.
// packet on a single read goroutine, so prolonged queue lock contention or
// process starvation can delay socket reads. Queue consumers process packets
// after releasing the lock; a slow recorder therefore falls behind its cursor
// instead of directly blocking WritePacket.
type streamHealth struct {
mu sync.Mutex
windowStart time.Time
@@ -178,6 +176,152 @@ type streamHealth struct {
decodeErrs int64
}
type videoReferenceState struct {
haveSequence bool
lastSequence uint16
valid bool
droppedAccessUnits uint64
}
type h264FrameNumTracker struct {
sps *h264.SPS
haveFrameNum bool
lastFrameNum uint32
}
const maxH264SliceHeaderBytes = 64
func (t *h264FrameNumTracker) setSPS(sps *h264.SPS) {
if sps == nil {
return
}
if t.sps == nil ||
t.sps.Log2MaxFrameNumMinus4 != sps.Log2MaxFrameNumMinus4 ||
t.sps.SeparateColourPlaneFlag != sps.SeparateColourPlaneFlag ||
t.sps.FrameMbsOnlyFlag != sps.FrameMbsOnlyFlag {
t.haveFrameNum = false
}
copy := *sps
t.sps = &copy
}
func (t *h264FrameNumTracker) observe(au [][]byte) (uint32, uint32, bool, bool) {
if t.sps == nil || !t.sps.FrameMbsOnlyFlag {
return 0, 0, false, false
}
for _, nalu := range au {
if len(nalu) == 0 {
continue
}
naluType := h264.NALUType(nalu[0] & 0x1F)
if naluType != h264.NALUTypeIDR && naluType != h264.NALUTypeNonIDR {
continue
}
frameNum, err := h264SliceFrameNum(nalu, t.sps)
if err != nil {
return 0, 0, false, false
}
if naluType == h264.NALUTypeIDR {
t.haveFrameNum = true
t.lastFrameNum = frameNum
return frameNum, frameNum, false, true
}
// Non-reference pictures do not advance the reference-picture frame_num
// sequence and therefore cannot prove that a reference picture is missing.
if (nalu[0]>>5)&0x03 == 0 {
return frameNum, frameNum, false, true
}
if !t.haveFrameNum {
t.haveFrameNum = true
t.lastFrameNum = frameNum
return frameNum, frameNum, false, true
}
frameNumBits := t.sps.Log2MaxFrameNumMinus4 + 4
if frameNumBits > 16 {
return 0, 0, false, false
}
maxFrameNum := uint32(1) << frameNumBits
expected := (t.lastFrameNum + 1) & (maxFrameNum - 1)
// When the SPS permits frame_num gaps, the decoder is required to
// synthesize non-existing pictures and the discontinuity is not evidence
// of transport loss. RTP/depacketizer errors still invalidate references.
gap := frameNum != expected && !t.sps.GapsInFrameNumValueAllowedFlag
t.lastFrameNum = frameNum
return expected, frameNum, gap, true
}
return 0, 0, false, false
}
func h264SliceFrameNum(nalu []byte, sps *h264.SPS) (uint32, error) {
if len(nalu) < 2 || sps == nil {
return 0, errors.New("H264 slice or SPS is missing")
}
frameNumBits := sps.Log2MaxFrameNumMinus4 + 4
if frameNumBits > 16 {
return 0, fmt.Errorf("invalid H264 frame_num width: %d", frameNumBits)
}
header := nalu[1:]
if len(header) > maxH264SliceHeaderBytes {
header = header[:maxH264SliceHeaderBytes]
}
header = h264.EmulationPreventionRemove(header)
pos := 0
for range 3 {
if _, err := bits.ReadGolombUnsigned(header, &pos); err != nil {
return 0, fmt.Errorf("read H264 slice header: %w", err)
}
}
if sps.SeparateColourPlaneFlag {
if _, err := bits.ReadBits(header, &pos, 2); err != nil {
return 0, fmt.Errorf("read H264 colour plane: %w", err)
}
}
frameNum, err := bits.ReadBits(header, &pos, int(frameNumBits))
if err != nil {
return 0, fmt.Errorf("read H264 frame_num: %w", err)
}
return uint32(frameNum), nil
}
func (s *videoReferenceState) observeSequence(sequence uint16) (uint16, bool) {
expected := sequence
gap := false
if s.haveSequence {
expected = s.lastSequence + 1
gap = sequence != expected
if gap {
s.valid = false
}
}
s.haveSequence = true
s.lastSequence = sequence
return expected, gap
}
func (s *videoReferenceState) invalidate() {
s.valid = false
}
func (s *videoReferenceState) accept(randomAccess bool) (bool, uint64) {
if randomAccess {
dropped := s.droppedAccessUnits
s.droppedAccessUnits = 0
s.valid = true
return true, dropped
}
if !s.valid {
s.droppedAccessUnits++
return false, 0
}
return true, 0
}
const (
streamHealthWindow = 10 * time.Second
streamHealthWriteWarn = 150 * time.Millisecond
@@ -190,9 +334,9 @@ func newStreamHealth() *streamHealth {
}
// observePacket records one processed video frame: the wall-clock gap since the
// previous frame (arrival cadence) and how long WritePacket() blocked
// (back-pressure). It emits an immediate warning when either side stalls and a
// periodic summary every streamHealthWindow.
// previous frame and how long WritePacket spent acquiring and updating the
// queue. It emits an immediate warning when either side stalls and a periodic
// summary every streamHealthWindow.
func (h *streamHealth) observePacket(streamType string, writeDur time.Duration) {
if h == nil {
return
@@ -264,9 +408,9 @@ func (h *streamHealth) observePacket(streamType string, writeDur time.Duration)
}
}
// observeLost is invoked by gortsplib when RTP sequence numbers skip. On a TCP
// transport this means the sender (camera) dropped packets because we were not
// reading fast enough, not loss on the wire.
// observeLost is invoked by gortsplib when RTP sequence numbers skip. With the
// configured TCP transport, the sender or an upstream RTSP component omitted
// those packets; sender-side pressure is one possible cause.
func (h *streamHealth) observeLost(streamType string, lost uint64) {
if h == nil {
return
@@ -887,6 +1031,14 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
// called when a video RTP packet arrives for H264
var filteredAU [][]byte
if g.VideoH264Media != nil && g.VideoH264Forma != nil {
referenceState := &videoReferenceState{}
frameNumTracker := &h264FrameNumTracker{}
if len(g.VideoH264Forma.SPS) > 0 {
var sps h264.SPS
if sps.Unmarshal(g.VideoH264Forma.SPS) == nil {
frameNumTracker.setSPS(&sps)
}
}
// Extracts DTS from the bitstream to support B-frame H264 streams.
// Created once per stream (tracks reorder state across access units).
@@ -902,6 +1054,16 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
default:
}
if expected, gap := referenceState.observeSequence(rtppkt.SequenceNumber); gap {
log.WithFields(log.Fields{
"component": "capture",
"event": "video_rtp_sequence_gap",
"expected_sequence_number": expected,
"received_sequence_number": rtppkt.SequenceNumber,
"stream": streamType,
}).Warn("RTP sequence gap invalidated video reference chain")
}
if len(rtppkt.Payload) > 0 {
// decode timestamps — validate each call separately
@@ -917,6 +1079,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
// this is a keyframe.
au, errDecode := g.VideoH264Decoder.Decode(rtppkt)
if errDecode != nil {
if errDecode != rtph264.ErrMorePacketsNeeded {
referenceState.invalidate()
}
if errDecode != rtph264.ErrNonStartingPacketAndNoPrevious && errDecode != rtph264.ErrMorePacketsNeeded {
log.Error("capture.golibrtsp.Start(): " + errDecode.Error())
}
@@ -961,6 +1126,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
if errSPS == nil {
// Debug SPS information
g.debugSPSInfo(&sps, streamType)
frameNumTracker.setSPS(&sps)
// Get width
g.Streams[g.VideoH264Index].Width = sps.Width()
@@ -1022,6 +1188,39 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
return
}
if expected, received, gap, ok := frameNumTracker.observe(au); ok && gap {
referenceState.invalidate()
log.WithFields(log.Fields{
"component": "capture",
"event": "h264_frame_num_gap",
"expected_frame_num": expected,
"received_frame_num": received,
"stream": streamType,
}).Warn("H264 frame_num gap invalidated video reference chain")
}
accepted, recoveredDropped := referenceState.accept(idrPresent)
if !accepted {
if referenceState.droppedAccessUnits == 1 {
log.WithFields(log.Fields{
"codec": "H264",
"component": "capture",
"event": "dependent_access_units_dropped",
"stream": streamType,
}).Warn("Dropping dependent video frames until the next IDR")
}
return
}
if recoveredDropped > 0 {
log.WithFields(log.Fields{
"codec": "H264",
"component": "capture",
"dropped_access_units": recoveredDropped,
"event": "video_reference_chain_recovered",
"stream": streamType,
}).Info("Video reference chain recovered at IDR")
}
if idrPresent {
log.WithFields(log.Fields{
"component": "capture",
@@ -1165,6 +1364,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
// called when a video RTP packet arrives for H265
if g.VideoH265Media != nil && g.VideoH265Forma != nil {
referenceState := &videoReferenceState{}
// Extracts DTS from the bitstream to support B-frame H265 streams.
// Created once per stream (tracks reorder state across access units).
@@ -1180,6 +1380,16 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
default:
}
if expected, gap := referenceState.observeSequence(rtppkt.SequenceNumber); gap {
log.WithFields(log.Fields{
"component": "capture",
"event": "video_rtp_sequence_gap",
"expected_sequence_number": expected,
"received_sequence_number": rtppkt.SequenceNumber,
"stream": streamType,
}).Warn("RTP sequence gap invalidated video reference chain")
}
if len(rtppkt.Payload) > 0 {
// decode timestamps — validate each call separately
@@ -1195,6 +1405,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
// this is a keyframe.
au, errDecode := g.VideoH265Decoder.Decode(rtppkt)
if errDecode != nil {
if errDecode != rtph265.ErrMorePacketsNeeded {
referenceState.invalidate()
}
if errDecode != rtph265.ErrNonStartingPacketAndNoPrevious && errDecode != rtph265.ErrMorePacketsNeeded {
log.Error("capture.golibrtsp.Start(): " + errDecode.Error())
}
@@ -1244,6 +1457,28 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
return
}
accepted, recoveredDropped := referenceState.accept(isRandomAccess)
if !accepted {
if referenceState.droppedAccessUnits == 1 {
log.WithFields(log.Fields{
"codec": "H265",
"component": "capture",
"event": "dependent_access_units_dropped",
"stream": streamType,
}).Warn("Dropping dependent video frames until the next random-access frame")
}
return
}
if recoveredDropped > 0 {
log.WithFields(log.Fields{
"codec": "H265",
"component": "capture",
"dropped_access_units": recoveredDropped,
"event": "video_reference_chain_recovered",
"stream": streamType,
}).Info("Video reference chain recovered at random-access frame")
}
// add VPS, SPS and PPS before random access access unit
if isRandomAccess {
au = append([][]byte{
+113
View File
@@ -11,6 +11,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
)
func TestPreRecordingGOPCount(t *testing.T) {
@@ -134,3 +136,114 @@ func TestRTSPSTLSConfig(t *testing.T) {
}
})
}
func TestVideoReferenceStateDropsDependentFramesUntilRandomAccess(t *testing.T) {
state := &videoReferenceState{}
accepted, recovered := state.accept(false)
if accepted || recovered != 0 || state.droppedAccessUnits != 1 {
t.Fatalf("initial dependent frame = (accepted=%t, recovered=%d, dropped=%d), want (false, 0, 1)",
accepted, recovered, state.droppedAccessUnits)
}
accepted, recovered = state.accept(true)
if !accepted || recovered != 1 || !state.valid {
t.Fatalf("random-access recovery = (accepted=%t, recovered=%d, valid=%t), want (true, 1, true)",
accepted, recovered, state.valid)
}
if expected, gap := state.observeSequence(100); gap || expected != 100 {
t.Fatalf("first sequence observation = (expected=%d, gap=%t), want (100, false)", expected, gap)
}
if expected, gap := state.observeSequence(102); !gap || expected != 101 || state.valid {
t.Fatalf("sequence gap = (expected=%d, gap=%t, valid=%t), want (101, true, false)",
expected, gap, state.valid)
}
accepted, recovered = state.accept(false)
if accepted || recovered != 0 || state.droppedAccessUnits != 1 {
t.Fatalf("post-loss dependent frame = (accepted=%t, recovered=%d, dropped=%d), want (false, 0, 1)",
accepted, recovered, state.droppedAccessUnits)
}
accepted, recovered = state.accept(true)
if !accepted || recovered != 1 || !state.valid {
t.Fatalf("post-loss recovery = (accepted=%t, recovered=%d, valid=%t), want (true, 1, true)",
accepted, recovered, state.valid)
}
}
func TestVideoReferenceStateAcceptsSequenceWraparound(t *testing.T) {
state := &videoReferenceState{valid: true}
if _, gap := state.observeSequence(^uint16(0)); gap {
t.Fatal("first sequence observation reported a gap")
}
if expected, gap := state.observeSequence(0); gap || expected != 0 || !state.valid {
t.Fatalf("sequence wraparound = (expected=%d, gap=%t, valid=%t), want (0, false, true)",
expected, gap, state.valid)
}
}
func TestH264FrameNumTrackerDetectsMissingIDR(t *testing.T) {
tracker := &h264FrameNumTracker{}
tracker.setSPS(&h264.SPS{
Log2MaxFrameNumMinus4: 1,
FrameMbsOnlyFlag: true,
})
if _, _, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)}); !ok || gap {
t.Fatalf("initial IDR observation = (ok=%t, gap=%t), want (true, false)", ok, gap)
}
for frameNum := uint32(1); frameNum <= 24; frameNum++ {
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, frameNum)}); !ok || gap {
t.Fatalf("frame_num %d observation = (expected=%d, received=%d, ok=%t, gap=%t), want no gap",
frameNum, expected, received, ok, gap)
}
}
expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)})
if !ok || !gap || expected != 25 || received != 1 {
t.Fatalf("missing IDR transition = (expected=%d, received=%d, ok=%t, gap=%t), want (25, 1, true, true)",
expected, received, ok, gap)
}
}
func TestH264FrameNumTrackerResetsAtIDR(t *testing.T) {
tracker := &h264FrameNumTracker{}
tracker.setSPS(&h264.SPS{
Log2MaxFrameNumMinus4: 1,
FrameMbsOnlyFlag: true,
})
tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)})
tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)})
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)}); !ok || gap {
t.Fatalf("IDR reset = (expected=%d, received=%d, ok=%t, gap=%t), want no gap",
expected, received, ok, gap)
}
if expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 1)}); !ok || gap || expected != 1 || received != 1 {
t.Fatalf("post-IDR frame = (expected=%d, received=%d, ok=%t, gap=%t), want (1, 1, true, false)",
expected, received, ok, gap)
}
}
func TestH264FrameNumTrackerHonorsPermittedGaps(t *testing.T) {
tracker := &h264FrameNumTracker{}
tracker.setSPS(&h264.SPS{
Log2MaxFrameNumMinus4: 1,
FrameMbsOnlyFlag: true,
GapsInFrameNumValueAllowedFlag: true,
})
tracker.observe([][]byte{testH264Slice(h264.NALUTypeIDR, 0)})
expected, received, gap, ok := tracker.observe([][]byte{testH264Slice(h264.NALUTypeNonIDR, 2)})
if !ok || gap || expected != 1 || received != 2 {
t.Fatalf("permitted frame_num gap = (expected=%d, received=%d, ok=%t, gap=%t), want (1, 2, true, false)",
expected, received, ok, gap)
}
}
func testH264Slice(naluType h264.NALUType, frameNum uint32) []byte {
return []byte{0x60 | byte(naluType), 0xE0 | byte(frameNum&0x1F)}
}
+43 -161
View File
@@ -53,13 +53,16 @@ func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration
}
}
func recordingUploadMetadata(name, deviceKey string, timestamp int64, mp4Video *video.MP4, encrypted bool) models.RecordingUploadMetadata {
func recordingUploadMetadata(name string, config models.Config, timestamp int64, mp4Video *video.MP4, encrypted bool, coordinates string, changes int) models.RecordingUploadMetadata {
metadata := models.RecordingUploadMetadata{
FileName: filepath.Base(name),
DeviceKey: deviceKey,
Timestamp: timestamp,
Duration: mp4Video.VideoTotalDuration,
Encrypted: encrypted,
FileName: filepath.Base(name),
DeviceKey: config.Key,
Timestamp: timestamp,
Duration: mp4Video.VideoTotalDuration,
Encrypted: encrypted,
DeviceName: config.Name,
RegionCoordinates: coordinates,
NumberOfChanges: strconv.Itoa(changes),
}
value := mp4Video.AverageFPS()
if value > 0 && value <= 240 && !math.IsInf(value, 0) && !math.IsNaN(value) {
@@ -190,7 +193,7 @@ func CleanupRecordingDirectory(configDirectory string, configuration *models.Con
return
}
if err := os.Remove(recordingsDirectory + "/" + name); err != nil {
if err := utils.RemoveRecording(recordingsDirectory, name); err != nil {
log.Info("HandleRecordStream: something went wrong, " + err.Error())
return
}
@@ -275,7 +278,7 @@ func defaultReserveMB(totalMB int64) int64 {
// 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)
entries, err := utils.ReadRecordingDirectory(recordingsDirectory)
if err != nil {
return "", false, err
}
@@ -284,14 +287,7 @@ func pickRecordingToCleanup(recordingsDirectory, cloudDirectory string) (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()
modTime := entry.ModTime()
if oldestAnyName == "" || modTime.Before(oldestAnyTime) {
oldestAnyName = entry.Name()
@@ -449,39 +445,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Cleanup muxer
start = false
// 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 {
duration := mp4Video.VideoTotalDuration
// Update the name with the duration in milliseconds.
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
config.Name + "_" +
"0-0-0-0" + "_" + // region coordinates, we
"-1" + "_" + // token
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
}
encrypted := false
// Check if we need to encrypt the recording.
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
@@ -506,7 +469,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
}
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video, encrypted))
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, startRecording, mp4Video, encrypted, "0-0-0-0", -1))
recordingStatus = "idle"
@@ -531,29 +494,17 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
start = true
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
// - Timestamp
// - Size + - + microseconds
// - device
// - Region
// - Number of changes
// - Token
startRecording = pkt.CurrentTime
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" + // start timestamp in seconds
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" + // length of milliseconds
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" + // milliseconds
config.Name + "_" + // device name
"0-0-0-0" + "_" + // region coordinates, we will not use this for continuous recording
"0" + "_" + // token
"0" + "_" //+ // duration of recording in milliseconds
//utils.VERSION // version of the agent
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
name, err = utils.RecordingFileName(startRecording, config.Key)
if err == nil {
fullName, err = utils.CreateRecordingPath(configDirectory+"/data/recordings", name, time.UnixMilli(startRecording))
}
if err != nil {
log.WithError(err).Error("capture: could not create continuous recording path")
start = false
pkt = nextPkt
continue
}
// Running...
log.Info("capture.main.HandleRecordStream(continuous): recording started")
@@ -609,39 +560,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Cleanup muxer
start = false
// 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 {
duration := mp4Video.VideoTotalDuration
// Update the name with the duration in milliseconds.
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
config.Name + "_" +
"0-0-0-0" + "_" + // region coordinates, we
"-1" + "_" + // token
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
}
encrypted := false
// Check if we need to encrypt the recording.
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
@@ -666,7 +584,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
}
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video, encrypted))
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, startRecording, mp4Video, encrypted, "0-0-0-0", -1))
recordingStatus = "idle"
@@ -721,17 +639,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
continue
}
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
// - Timestamp
// - Size + - + microseconds
// - device
// - Region
// - Number of changes
// - Token
displayTimeSeconds := displayTime / 1000 // convert to seconds
displayTimeMilliseconds := displayTime % 1000 // convert to milliseconds
displayTimeSeconds := displayTime / 1000
motionRectangleString := "0-0-0-0"
if motion.Rectangle.X != 0 || motion.Rectangle.Y != 0 ||
motion.Rectangle.Width != 0 || motion.Rectangle.Height != 0 {
@@ -742,17 +650,15 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Get the number of changes from the motion detection.
numberOfChanges := motion.NumberOfChanges
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" + // start timestamp in seconds
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" + // length of milliseconds
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" + // milliseconds
config.Name + "_" + // device name
motionRectangleString + "_" + // region coordinates, we will not use this for continuous recording
strconv.Itoa(numberOfChanges) + "_" + // number of changes
"0" // + "_" + // duration of recording in milliseconds
//utils.VERSION // version of the agent
name := s + ".mp4"
fullName := configDirectory + "/data/recordings/" + name
name, err := utils.RecordingFileName(displayTime, config.Key)
var fullName string
if err == nil {
fullName, err = utils.CreateRecordingPath(configDirectory+"/data/recordings", name, time.UnixMilli(displayTime))
}
if err != nil {
log.WithError(err).Error("capture: could not create motion recording path")
continue
}
// Running...
log.Info("capture.main.HandleRecordStream(motiondetection): recording started (" + name + ")" + " at " + strconv.FormatInt(displayTimeSeconds, 10) + " unix")
@@ -880,38 +786,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
communication.TrySendMotion(models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000})
}
// 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 {
duration := mp4Video.VideoTotalDuration
// Update the name with the duration in milliseconds.
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" +
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" +
config.Name + "_" +
motionRectangleString + "_" +
strconv.Itoa(numberOfChanges) + "_" + // number of changes
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording in milliseconds
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Info("capture.main.HandleRecordStream(motiondetection): no video data recorded, not renaming file.")
}
encrypted := false
// Check if we need to encrypt the recording.
if config.Encryption != nil && config.Encryption.Enabled == "true" && config.Encryption.Recordings == "true" && config.Encryption.SymmetricKey != "" {
@@ -936,7 +810,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
}
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, displayTime, mp4Video, encrypted))
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config, displayTime, mp4Video, encrypted, motionRectangleString, numberOfChanges))
// Clean up the recording directory if necessary.
CleanupRecordingDirectory(configDirectory, configuration)
@@ -1070,6 +944,10 @@ func Base64Image(captureDevice *Capture, communication *models.Communication, co
count++
continue
}
} else {
// A stopped run closes this queue permanently. Retrying its
// cursor would spin on EOF even after the HTTP client leaves.
break
}
} else {
break
@@ -1113,6 +991,10 @@ func JpegImage(captureDevice *Capture, communication *models.Communication) imag
} else {
break
}
} else {
// A stopped run closes this queue permanently. Retrying its
// cursor would spin on EOF even after the HTTP client leaves.
break
}
} else {
break
+5 -3
View File
@@ -42,10 +42,11 @@ func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
}
mp4Video := &video.MP4{VideoTotalDuration: 20452, SampleCount: 613}
metadata := recordingUploadMetadata("recording.mp4", "device-key", 1785934709414, mp4Video, true)
config := models.Config{Key: "device-key", Name: "camera-name"}
metadata := recordingUploadMetadata("1785934709414-device-key.mp4", config, 1785934709414, mp4Video, true, "1-2-3-4", 57)
queueRecordingForUpload(configDirectory, metadata)
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "1785934709414-device-key.metadata"))
if err != nil {
t.Fatalf("read upload marker: %v", err)
}
@@ -54,7 +55,8 @@ func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
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 || !stored.Encrypted {
if stored.FileName != "1785934709414-device-key.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 || !stored.Encrypted ||
stored.DeviceName != "camera-name" || stored.RegionCoordinates != "1-2-3-4" || stored.NumberOfChanges != "57" {
t.Fatalf("upload marker = %+v", stored)
}
if stored.FPS == math.Floor(stored.FPS) {
+68
View File
@@ -0,0 +1,68 @@
package capture
import (
"context"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
)
func TestSnapshotReturnsAfterQueueClosure(t *testing.T) {
for _, stream := range []string{"main", "sub"} {
for _, format := range []string{"jpeg", "base64"} {
for _, alreadyClosed := range []bool{true, false} {
name := stream + "/" + format + "/closed_during_read"
if alreadyClosed {
name = stream + "/" + format + "/already_closed"
}
t.Run(name, func(t *testing.T) {
communication := &models.Communication{}
run := models.NewAgentRun(context.Background(), communication, false)
queue := packets.NewQueue()
captureDevice := &Capture{}
if stream == "main" {
captureDevice.SetMainClient("rtsp://unused.invalid/main")
run.SetMainQueue(queue)
} else {
captureDevice.SetSubClient("rtsp://unused.invalid/sub")
run.SetSubQueue(queue)
}
if err := run.Activate(); err != nil {
t.Fatal(err)
}
defer queue.Close()
if alreadyClosed {
queue.Close()
}
done := make(chan bool, 1)
go func() {
if format == "jpeg" {
image := JpegImage(captureDevice, communication)
done <- image.Rect.Empty()
} else {
done <- Base64Image(captureDevice, communication, &models.Configuration{}) == ""
}
}()
if !alreadyClosed {
select {
case <-done:
t.Fatal("snapshot returned before a frame or queue closure")
case <-time.After(10 * time.Millisecond):
}
queue.Close()
}
select {
case empty := <-done:
if !empty {
t.Fatal("closed empty queue produced a snapshot")
}
case <-time.After(time.Second):
t.Fatal("snapshot did not return after its packet queue closed")
}
})
}
}
}
}
+67 -19
View File
@@ -125,7 +125,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
// Check if we need to remove the original recording
// removeAfterUpload is set to false by default
if config.RemoveAfterUpload != "false" {
err := os.Remove(configDirectory + "/data/recordings/" + fileName)
err := utils.RemoveRecording(configDirectory+"/data/recordings", fileName)
if err != nil {
log.Error("HandleUpload: " + err.Error())
}
@@ -541,13 +541,23 @@ func (state *heartbeatONVIFState) fetchEvents(device *goonvif.Device) ([]byte, b
}
}
if !initialStateSubscriptionCreated && state.loopPullPoint == "" {
// Fall back to querying the relay outputs and digital inputs directly when
// no subscription could be created, and also when the subscriptions
// produced no I/O events: some devices accept the subscription but reject
// the pulls, or never publish the initial I/O state.
noSubscription := !initialStateSubscriptionCreated && state.loopPullPoint == ""
if noSubscription || heartbeatEventsListEmpty(onvifEventsList) {
return state.fallbackEvents(device), operationFailed
}
return onvifEventsList, operationFailed
}
func heartbeatEventsListEmpty(eventsList []byte) bool {
trimmed := strings.TrimSpace(string(eventsList))
return trimmed == "" || trimmed == "[]" || trimmed == "null"
}
func getHeartbeatONVIFPayload(camera models.IPCamera, state *heartbeatONVIFState) heartbeatONVIFPayload {
if state == nil {
state = newHeartbeatONVIFState()
@@ -1444,13 +1454,19 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
// @Summary Will verify the hub connectivity.
// @Description Will verify the hub connectivity.
// @Success 200 {object} models.APIResponse
func VerifyHub(c *gin.Context) {
func VerifyHub(c *gin.Context, configuredHubURI string) {
var config models.Config
err := c.BindJSON(&config)
if err == nil {
hubURI := config.HubURI
hubURI, err := configuredHubVerificationURI(config.HubURI, configuredHubURI)
if err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Data: "cloud.VerifyHub(): " + err.Error(),
})
return
}
publicKey := config.HubKey
privateKey := config.HubPrivateKey
@@ -1463,9 +1479,9 @@ func VerifyHub(c *gin.Context) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
client = &http.Client{Transport: tr, CheckRedirect: rejectHubVerificationRedirect}
} else {
client = &http.Client{}
client = &http.Client{CheckRedirect: rejectHubVerificationRedirect}
}
resp, err := client.Do(req)
@@ -1514,7 +1530,7 @@ func VerifyHub(c *gin.Context) {
// @Summary Will verify the persistence.
// @Description Will verify the persistence.
// @Success 200 {object} models.APIResponse
func VerifyPersistence(c *gin.Context, configDirectory string) {
func VerifyPersistence(c *gin.Context, configDirectory, configuredHubURI string) {
var config models.Config
err := c.BindJSON(&config)
@@ -1527,6 +1543,7 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
if config.HubURI == "" ||
config.HubKey == "" ||
config.HubPrivateKey == "" ||
config.S3 == nil ||
config.S3.Region == "" {
msg := "cloud.VerifyPersistence(kerberoshub): Kerberos Hub not properly configured."
log.Error(msg)
@@ -1534,25 +1551,24 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
Data: msg,
})
} else {
// Open test-480p.mp4
file, err := os.Open(configDirectory + "/data/test-480p.mp4")
hubURI, err := configuredHubVerificationURI(config.HubURI, configuredHubURI)
if err != nil {
msg := "cloud.VerifyPersistence(kerberoshub): error reading test-480p.mp4: " + err.Error()
msg := "cloud.VerifyPersistence(kerberoshub): " + err.Error()
log.Error(msg)
c.JSON(400, models.APIResponse{
c.JSON(http.StatusBadRequest, models.APIResponse{
Data: msg,
})
return
}
defer file.Close()
req, err := http.NewRequest("POST", config.HubURI+"/storage/upload", file)
req, err := http.NewRequest(http.MethodHead, hubURI+"/storage/upload", nil)
if err != nil {
msg := "cloud.VerifyPersistence(kerberoshub): error reading Kerberos Hub HEAD request, " + config.HubURI + "/storage: " + err.Error()
msg := "cloud.VerifyPersistence(kerberoshub): error creating Kerberos Hub upload authorization request: " + err.Error()
log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
return
}
timestamp := time.Now().Unix()
@@ -1570,9 +1586,9 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
client = &http.Client{Transport: tr, CheckRedirect: rejectHubVerificationRedirect}
} else {
client = &http.Client{}
client = &http.Client{CheckRedirect: rejectHubVerificationRedirect}
}
resp, err := client.Do(req)
@@ -1588,14 +1604,17 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
Data: msg,
})
} else {
msg := "cloud.VerifyPersistence(kerberoshub): upload not allowed using the configured credentials"
msg := hubPersistenceVerificationError(resp.StatusCode)
log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifyPersistence(kerberoshub): Error creating Kerberos Hub request"
msg := "cloud.VerifyPersistence(kerberoshub): Kerberos Hub upload authorization request failed"
if err != nil {
msg += ": " + err.Error()
}
log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
@@ -1764,6 +1783,35 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
}
}
func configuredHubVerificationURI(requested, configured string) (string, error) {
requested = strings.TrimRight(strings.TrimSpace(requested), "/")
configured = strings.TrimRight(strings.TrimSpace(configured), "/")
if configured == "" {
return "", errors.New("save the Hub URI before verifying it")
}
if requested != configured {
return "", errors.New("the Hub URI does not match the saved configuration; save it before verifying")
}
return configured, nil
}
func rejectHubVerificationRedirect(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
func hubPersistenceVerificationError(statusCode int) string {
reason := "Kerberos Hub rejected upload authorization"
switch statusCode {
case http.StatusBadRequest:
reason += ": the credentials or device/project assignment are invalid, or the storage limit was reached"
case http.StatusUnauthorized:
reason += ": the account has no active subscription or the subscription expired"
case http.StatusForbidden:
reason += ": the device is muted"
}
return "cloud.VerifyPersistence(kerberoshub): " + reason + " (HTTP " + strconv.Itoa(statusCode) + ")"
}
// VerifySecondaryPersistence godoc
// @Router /api/persistence/secondary/verify [post]
// @ID verify-secondary-persistence
+257
View File
@@ -1,22 +1,222 @@
package cloud
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/kerberos-io/agent/machinery/src/models"
agentonvif "github.com/kerberos-io/agent/machinery/src/onvif"
goonvif "github.com/kerberos-io/onvif"
goonvifdevice "github.com/kerberos-io/onvif/device"
goonvifptz "github.com/kerberos-io/onvif/ptz"
goonvifxsd "github.com/kerberos-io/onvif/xsd/onvif"
)
func TestVerifyPersistenceKerberosHubUsesAuthorizationHead(t *testing.T) {
var requestMethod string
var requestBody []byte
var requestHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestMethod = r.Method
requestHeaders = r.Header.Clone()
requestBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := models.Config{
Cloud: "kerberoshub",
Key: "camera-1",
Name: "front-door",
HubURI: server.URL,
HubKey: "project-public",
HubPrivateKey: "project-private",
S3: &models.S3{Region: "eu"},
}
payload, err := json.Marshal(config)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
ctx.Request.Header.Set("Content-Type", "application/json")
VerifyPersistence(ctx, t.TempDir(), server.URL)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if requestMethod != http.MethodHead {
t.Fatalf("Hub request method = %q, want HEAD", requestMethod)
}
if len(requestBody) != 0 {
t.Fatalf("Hub request body has %d bytes, want none", len(requestBody))
}
for header, want := range map[string]string{
"X-Kerberos-Storage-Device": "camera-1",
"X-Kerberos-Hub-PublicKey": "project-public",
"X-Kerberos-Hub-PrivateKey": "project-private",
"X-Kerberos-Hub-Region": "eu",
"X-Kerberos-Storage-Capture": "IPCamera",
} {
if got := requestHeaders.Get(header); got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
}
func TestVerifyPersistenceKerberosHubRejectsCrossHostRedirect(t *testing.T) {
var redirectedHeaders http.Header
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
var originalHeaders http.Header
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
originalHeaders = r.Header.Clone()
http.Redirect(w, r, target.URL+"/storage/upload", http.StatusTemporaryRedirect)
}))
defer hub.Close()
config := models.Config{
Cloud: "kerberoshub",
Key: "camera-1",
Name: "front-door",
HubURI: hub.URL,
HubKey: "project-public",
HubPrivateKey: "project-private",
S3: &models.S3{Region: "eu"},
}
payload, err := json.Marshal(config)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
ctx.Request.Header.Set("Content-Type", "application/json")
VerifyPersistence(ctx, t.TempDir(), hub.URL)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
for _, header := range []string{"X-Kerberos-Hub-PublicKey", "X-Kerberos-Hub-PrivateKey"} {
if originalHeaders.Get(header) == "" {
t.Errorf("%s was not sent to the configured Hub", header)
}
if redirectedHeaders != nil {
t.Errorf("redirect target received a request with %s=%q", header, redirectedHeaders.Get(header))
}
}
}
func TestVerifyPersistenceKerberosHubRejectsUnsavedURI(t *testing.T) {
requestCount := 0
untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestCount++
w.WriteHeader(http.StatusOK)
}))
defer untrusted.Close()
config := models.Config{
Cloud: "kerberoshub",
Key: "camera-1",
Name: "front-door",
HubURI: untrusted.URL,
HubKey: "project-public",
HubPrivateKey: "project-private",
S3: &models.S3{Region: "eu"},
}
payload, err := json.Marshal(config)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
ctx.Request.Header.Set("Content-Type", "application/json")
VerifyPersistence(ctx, t.TempDir(), "https://saved-hub.example.com")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
if requestCount != 0 {
t.Fatalf("untrusted Hub received %d requests, want none", requestCount)
}
}
func TestVerifyHubRejectsUnsavedURI(t *testing.T) {
requestCount := 0
untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestCount++
w.WriteHeader(http.StatusOK)
}))
defer untrusted.Close()
config := models.Config{
HubURI: untrusted.URL,
HubKey: "project-public",
HubPrivateKey: "project-private",
}
payload, err := json.Marshal(config)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/hub/verify", bytes.NewReader(payload))
ctx.Request.Header.Set("Content-Type", "application/json")
VerifyHub(ctx, "https://saved-hub.example.com")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
if requestCount != 0 {
t.Fatalf("untrusted Hub received %d requests, want none", requestCount)
}
}
func TestHubPersistenceVerificationErrorExplainsStatus(t *testing.T) {
for _, test := range []struct {
status int
want string
}{
{status: http.StatusBadRequest, want: "credentials or device/project assignment"},
{status: http.StatusUnauthorized, want: "no active subscription"},
{status: http.StatusForbidden, want: "device is muted"},
{status: http.StatusBadGateway, want: "HTTP 502"},
} {
t.Run(http.StatusText(test.status), func(t *testing.T) {
if got := hubPersistenceVerificationError(test.status); !strings.Contains(got, test.want) {
t.Fatalf("hubPersistenceVerificationError(%d) = %q, want it to contain %q", test.status, got, test.want)
}
})
}
}
func TestHeartbeatFailureLogOmitsHubResponseBody(t *testing.T) {
response := &http.Response{
StatusCode: http.StatusBadRequest,
@@ -590,3 +790,60 @@ func countString(values []string, want string) int {
}
return count
}
// Some devices accept the subscription but reject the pulls, or never publish
// the initial I/O state. The relay outputs and digital inputs must then be
// queried directly instead of reporting no I/O at all.
func TestHeartbeatONVIFPayloadFallsBackToDirectIOWhenSubscriptionsReturnNoEvents(t *testing.T) {
restoreHeartbeatONVIFStubs(t)
device := newTestONVIFDevice()
camera := models.IPCamera{
ONVIFXAddr: "http://camera/onvif",
ONVIFUsername: "operator",
ONVIFPassword: "secret",
}
state := newHeartbeatONVIFState()
state.cameraConfiguration = camera
state.cameraKey = heartbeatONVIFCameraKey(camera)
state.device = device
state.staticLoaded = true
state.staticPayload.enabled = "true"
heartbeatCreatePullPointSubscription = func(*goonvif.Device) (string, error) {
return "pull-point", nil
}
heartbeatGetEventMessages = func(*goonvif.Device, string) ([]agentonvif.ONVIFEvents, error) {
return nil, errors.New("pull rejected")
}
heartbeatUnsubscribePullPoint = func(*goonvif.Device, string) error {
return nil
}
var relayCalls, inputCalls int
heartbeatGetRelayOutputs = func(*goonvif.Device) (goonvifdevice.GetRelayOutputsResponse, error) {
relayCalls++
return goonvifdevice.GetRelayOutputsResponse{RelayOutputs: []goonvifxsd.RelayOutput{{DeviceEntity: goonvifxsd.DeviceEntity{Token: "relay0"}}}}, nil
}
heartbeatGetDigitalInputs = func(*goonvif.Device) (goonvifdevice.GetDigitalInputsResponse, error) {
inputCalls++
return goonvifdevice.GetDigitalInputsResponse{DigitalInputs: []goonvifxsd.DigitalInput{{Token: "di0"}}}, nil
}
payload := getHeartbeatONVIFPayload(camera, state)
if relayCalls != 1 || inputCalls != 1 {
t.Fatalf("direct I/O queries: relay=%d inputs=%d, want 1 each", relayCalls, inputCalls)
}
var events []agentonvif.ONVIFEvents
if err := json.Unmarshal(payload.eventsList, &events); err != nil {
t.Fatalf("payload.eventsList is not valid JSON: %v", err)
}
got := map[string]string{}
for _, event := range events {
got[event.Key] = event.Type
}
if got["relay0"] != "output" || got["di0"] != "input" || len(got) != 2 {
t.Fatalf("payload.eventsList = %s, want relay0 output and di0 input", payload.eventsList)
}
}
+2 -4
View File
@@ -5,13 +5,13 @@ package cloud
import (
"bytes"
"errors"
"os"
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/users"
"github.com/gin-gonic/gin"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/utils"
log "github.com/sirupsen/logrus"
)
@@ -37,14 +37,12 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
// Upload to Dropbox
log.Info("UploadDropbox: Uploading to Dropbox")
log.Info("UploadDropbox: Upload started for " + fileName)
fullname := "data/recordings/" + fileName
dConfig := dropbox.Config{
Token: token,
LogLevel: dropbox.LogInfo, // if needed, set the desired logging level. Default is off
}
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
file, err := utils.OpenRecording("data/recordings", fileName)
if file != nil {
defer func() {
if cerr := file.Close(); cerr != nil {
+2 -3
View File
@@ -9,6 +9,7 @@ import (
"os"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/utils"
log "github.com/sirupsen/logrus"
)
@@ -78,10 +79,8 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
}).Info("Resumable Hub upload unavailable; using legacy upload")
}
fullname := "data/recordings/" + fileName
// Check if we still have the file otherwise we abort the request.
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
file, err := utils.OpenRecording("data/recordings", fileName)
if file != nil {
defer file.Close()
}
+18 -6
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/utils"
log "github.com/sirupsen/logrus"
)
@@ -35,10 +36,23 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
// This can happen when the file was already removed (e.g. cleanup, or an
// earlier successful upload). Skip it so the watcher drops the marker
// instead of retrying indefinitely.
info, err := os.Stat("data/recordings/" + fileName)
filePath, err := utils.ResolveRecordingPath("data/recordings", fileName)
if err != nil {
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
return false, false, nil
if os.IsNotExist(err) {
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
return false, false, nil
}
log.WithError(err).Error("UploadKerberosVault: could not resolve recording path")
return false, true, err
}
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
return false, false, nil
}
log.WithError(err).Error("UploadKerberosVault: could not stat recording")
return false, true, err
}
if info.Size() == 0 {
log.Warn("UploadKerberosVault: skipping " + fileName + ", recording is empty")
@@ -182,9 +196,7 @@ func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, la
if err != nil {
return false, false, "", err
}
fullname := "data/recordings/" + fileName
file, err := os.Open(fullname)
file, err := utils.OpenRecording("data/recordings", fileName)
if file != nil {
defer file.Close()
}
+30 -2
View File
@@ -2,6 +2,7 @@ package cloud
import (
"encoding/json"
"fmt"
"math"
"net/http"
"os"
@@ -10,12 +11,16 @@ import (
"strings"
"github.com/kerberos-io/agent/machinery/src/models"
log "github.com/sirupsen/logrus"
)
const recordingFPSHeader = "X-Kerberos-Storage-Fps"
const recordingDurationHeader = "X-Kerberos-Storage-Duration"
const recordingTimestampHeader = "X-Kerberos-Storage-Timestamp"
const recordingEncryptedHeader = "X-Kerberos-Storage-Encrypted"
const recordingDeviceNameHeader = "X-Kerberos-Storage-DeviceName"
const recordingRegionHeader = "X-Kerberos-Storage-RegionCoordinates"
const recordingChangesHeader = "X-Kerberos-Storage-NumberOfChanges"
// queuedRecordingFPS reads the FPS snapshot written into the upload marker
// when the recording was finalized. Historical empty markers intentionally
@@ -61,6 +66,14 @@ func decodeRecordingUploadMetadata(value []byte) (models.RecordingUploadMetadata
}
func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
value, err := readRecordingUploadMetadataFile(fileName)
if err != nil && !os.IsNotExist(err) {
log.WithError(err).Error("Failed to read recording upload metadata")
}
return value, err == nil
}
func readRecordingUploadMetadataFile(fileName string) ([]byte, error) {
markerNames := []string{
models.RecordingUploadMetadataFileName(fileName),
filepath.Base(fileName),
@@ -68,10 +81,13 @@ func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
for _, markerName := range markerNames {
value, err := os.ReadFile(filepath.Join("data", "cloud", markerName))
if err == nil {
return value, true
return value, nil
}
if !os.IsNotExist(err) {
return nil, fmt.Errorf("read recording upload metadata %s: %w", markerName, err)
}
}
return nil, false
return nil, os.ErrNotExist
}
func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
@@ -79,6 +95,18 @@ func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
header.Set(recordingFPSHeader, fps)
}
if metadata, ok := queuedRecordingMetadata(fileName); ok {
if metadata.DeviceKey != "" {
header.Set("X-Kerberos-Storage-Device", metadata.DeviceKey)
}
if metadata.DeviceName != "" {
header.Set(recordingDeviceNameHeader, metadata.DeviceName)
}
if metadata.RegionCoordinates != "" {
header.Set(recordingRegionHeader, metadata.RegionCoordinates)
}
if metadata.NumberOfChanges != "" {
header.Set(recordingChangesHeader, metadata.NumberOfChanges)
}
if metadata.Duration > 0 {
header.Set(recordingDurationHeader, strconv.FormatUint(metadata.Duration, 10))
}
+155 -36
View File
@@ -2,14 +2,20 @@ package cloud
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/utils"
"github.com/minio/minio-go/v6"
log "github.com/sirupsen/logrus"
)
@@ -18,15 +24,6 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
config := configuration.Config
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
// - Timestamp
// - Size + - + microseconds
// - device
// - Region
// - Number of changes
// - Token
if config.S3 == nil {
errorMessage := "UploadS3: Uploading Failed, as no settings found"
log.Error(errorMessage)
@@ -71,24 +68,14 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
s3Client.SetCustomTransport(transport)
}
fileParts := strings.Split(fileName, "_")
if len(fileParts) == 1 {
errorMessage := "UploadS3: " + fileName + " is not a valid name."
log.Error(errorMessage)
return false, true, errors.New(errorMessage)
objectName, userMetadata, err := s3RecordingUploadDetails(config, fileName, aws_access_key_id)
if err != nil {
log.WithError(err).Error("UploadS3: invalid recording upload metadata")
return false, true, err
}
deviceKey := config.Key
startRecording, _ := strconv.ParseInt(fileParts[0], 10, 64)
devicename := fileParts[2]
coordinates := fileParts[3]
//numberOfChanges := fileParts[4]
token, _ := strconv.Atoi(fileParts[5])
log.Info("UploadS3: Upload started for " + fileName)
fullname := "data/recordings/" + fileName
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
file, err := utils.OpenRecording("data/recordings", fileName)
if file != nil {
defer file.Close()
}
@@ -107,23 +94,13 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
}
n, err := s3Client.PutObject(config.S3.Bucket,
config.S3.Username+"/"+fileName,
objectName,
file,
fileInfo.Size(),
minio.PutObjectOptions{
ContentType: "video/mp4",
StorageClass: "ONEZONE_IA",
UserMetadata: map[string]string{
"event-timestamp": strconv.FormatInt(startRecording, 10),
"event-microseconds": deviceKey,
"event-instancename": devicename,
"event-regioncoordinates": coordinates,
"event-numberofchanges": deviceKey,
"event-token": strconv.Itoa(token),
"productid": deviceKey,
"publickey": aws_access_key_id,
"uploadtime": "now",
},
UserMetadata: userMetadata,
})
if err != nil {
@@ -135,3 +112,145 @@ func UploadS3(configuration *models.Configuration, fileName string) (bool, bool,
return true, true, nil
}
}
func s3RecordingObjectName(username, deviceKey string, startRecording int64) string {
return username + "/" + time.UnixMilli(startRecording).UTC().Format("2006/01/02") +
"/" + strconv.FormatInt(startRecording, 10) + "-" + deviceKey + ".mp4"
}
func s3RecordingUploadDetails(config models.Config, fileName, publicKey string) (string, map[string]string, error) {
value, err := readRecordingUploadMetadataFile(fileName)
if err != nil && !os.IsNotExist(err) {
return "", nil, err
}
deviceKey := config.Key
var startRecording int64
userMetadata := map[string]string{
"publickey": publicKey,
"uploadtime": "now",
}
marker := strings.TrimSpace(string(value))
_, fpsErr := strconv.ParseFloat(marker, 64)
if err == nil && marker != "" && fpsErr != nil {
var metadata models.RecordingUploadMetadata
if err := json.Unmarshal(value, &metadata); err != nil {
return "", nil, fmt.Errorf("decode recording upload metadata for %s: %w", fileName, err)
}
if metadata.FileName != "" && metadata.FileName != filepath.Base(fileName) {
return "", nil, fmt.Errorf("recording upload metadata filename does not match %s", fileName)
}
if metadata.Timestamp <= 0 {
return "", nil, fmt.Errorf("invalid recording timestamp in upload metadata for %s", fileName)
}
deviceKey = metadata.DeviceKey
startRecording = metadata.Timestamp
userMetadata["event-instancename"] = config.Name
if metadata.DeviceName == "" || metadata.RegionCoordinates == "" || metadata.NumberOfChanges == "" {
if description, ok := s3LegacyRecordingDescription(fileName); ok {
for key, value := range description {
userMetadata[key] = value
}
}
}
if metadata.DeviceName != "" {
userMetadata["event-instancename"] = metadata.DeviceName
}
if metadata.RegionCoordinates != "" {
userMetadata["event-regioncoordinates"] = metadata.RegionCoordinates
}
if metadata.NumberOfChanges != "" {
userMetadata["event-numberofchanges"] = metadata.NumberOfChanges
}
userMetadata["event-microseconds"] = strconv.FormatInt(metadata.Timestamp%1000*1000, 10)
userMetadata["duration"] = strconv.FormatUint(metadata.Duration, 10)
userMetadata["encrypted"] = strconv.FormatBool(metadata.Encrypted)
if metadata.FPS > 0 && metadata.FPS <= 240 {
userMetadata["fps"] = strconv.FormatFloat(metadata.FPS, 'f', -1, 64)
}
} else {
// Empty and FPS-only markers predate structured recording metadata.
fileParts := strings.Split(fileName, "_")
if len(fileParts) < 6 {
return "", nil, fmt.Errorf("%s is not a valid legacy recording name", fileName)
}
startRecording, err = strconv.ParseInt(fileParts[0], 10, 64)
if err != nil {
return "", nil, fmt.Errorf("invalid recording timestamp in %s: %w", fileName, err)
}
if startRecording <= 0 || startRecording > math.MaxInt64/1000 {
return "", nil, fmt.Errorf("invalid recording timestamp in %s", fileName)
}
startRecording *= 1000
_, precision, found := strings.Cut(fileParts[1], "-")
if found {
fraction, err := strconv.ParseInt(precision, 10, 64)
if err != nil || fraction < 0 || fraction >= 1000000 {
return "", nil, fmt.Errorf("invalid recording precision in %s", fileName)
}
if len(precision) > 3 {
fraction /= 1000
}
startRecording += fraction
}
token, _ := strconv.Atoi(fileParts[5])
userMetadata["event-microseconds"] = deviceKey
userMetadata["event-instancename"] = fileParts[2]
userMetadata["event-regioncoordinates"] = fileParts[3]
userMetadata["event-numberofchanges"] = deviceKey
userMetadata["event-token"] = strconv.Itoa(token)
}
if deviceKey == "" || strings.ContainsAny(deviceKey, "/\\") {
return "", nil, errors.New("missing or invalid recording device key")
}
if startRecording <= 0 {
return "", nil, fmt.Errorf("invalid recording timestamp for %s", fileName)
}
userMetadata["event-timestamp"] = strconv.FormatInt(startRecording/1000, 10)
userMetadata["productid"] = deviceKey
return s3RecordingObjectName(config.S3.Username, deviceKey, startRecording), userMetadata, nil
}
func s3LegacyRecordingDescription(fileName string) (map[string]string, bool) {
base := filepath.Base(fileName)
if filepath.Ext(base) != ".mp4" {
return nil, false
}
parts := strings.Split(strings.TrimSuffix(base, ".mp4"), "_")
if len(parts) != 6 || parts[2] == "" {
return nil, false
}
seconds, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || seconds <= 0 || seconds > math.MaxInt64/1000 {
return nil, false
}
digits, fraction, found := strings.Cut(parts[1], "-")
precision, err := strconv.Atoi(digits)
if !found || err != nil || precision < 1 || precision > 6 || len(fraction) != precision {
return nil, false
}
if _, err := strconv.ParseUint(fraction, 10, 64); err != nil {
return nil, false
}
coordinates := strings.Split(parts[3], "-")
if len(coordinates) != 4 {
return nil, false
}
for _, coordinate := range coordinates {
if _, err := strconv.ParseUint(coordinate, 10, 64); err != nil {
return nil, false
}
}
changes, err := strconv.ParseInt(parts[4], 10, 64)
if err != nil || changes < -1 {
return nil, false
}
if _, err := strconv.ParseUint(parts[5], 10, 64); err != nil {
return nil, false
}
return map[string]string{
"event-instancename": parts[2],
"event-regioncoordinates": parts[3],
"event-numberofchanges": parts[4],
}, true
}
+328
View File
@@ -0,0 +1,328 @@
package cloud
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
func TestS3RecordingUploadDetailsUsesCheckpoint(t *testing.T) {
for _, fileName := range []string{
"1767355200123-checkpoint-key.mp4",
"1564859471_6-474162_old-camera_0-0-0-0_1153_27.mp4",
} {
t.Run(fileName, func(t *testing.T) {
withRecording(t, fileName, []byte("recording"))
recorded := time.Date(2026, time.January, 2, 12, 0, 0, 123000000, time.UTC)
metadata := models.RecordingUploadMetadata{
FileName: fileName,
DeviceKey: "checkpoint-key",
Timestamp: recorded.UnixMilli(),
Duration: 20452,
FPS: 29.97,
Encrypted: true,
DeviceName: "checkpoint-camera",
RegionCoordinates: "1-2-3-4",
NumberOfChanges: "57",
}
payload, err := json.Marshal(metadata)
if err != nil {
t.Fatal(err)
}
withQueuedRecordingFPS(t, fileName, string(payload))
config := models.Config{
Key: "changed-config-key",
Name: "camera-name",
S3: &models.S3{Username: "user"},
}
objectName, userMetadata, err := s3RecordingUploadDetails(config, fileName, "public-key")
if err != nil {
t.Fatal(err)
}
wantObjectName := "user/2026/01/02/" + strconv.FormatInt(recorded.UnixMilli(), 10) + "-checkpoint-key.mp4"
if objectName != wantObjectName {
t.Fatalf("object name = %q, want %q", objectName, wantObjectName)
}
wantMetadata := map[string]string{
"publickey": "public-key",
"uploadtime": "now",
"event-instancename": "checkpoint-camera",
"event-regioncoordinates": "1-2-3-4",
"event-numberofchanges": "57",
"event-microseconds": "123000",
"event-timestamp": strconv.FormatInt(recorded.Unix(), 10),
"productid": "checkpoint-key",
"duration": "20452",
"fps": "29.97",
"encrypted": "true",
}
if !reflect.DeepEqual(userMetadata, wantMetadata) {
t.Fatalf("metadata = %#v, want %#v", userMetadata, wantMetadata)
}
})
}
}
func TestS3RecordingUploadDetailsBackfillsLegacyCheckpointDescriptions(t *testing.T) {
const legacyName = "1564859471_6-474162_recorded-camera_1-2-3-4_1153_27.mp4"
tests := []struct {
name string
fileName string
camera string
region string
changes string
wantCamera string
wantRegion string
wantChanges string
}{
{"pre-upgrade checkpoint", legacyName, "", "", "", "recorded-camera", "1-2-3-4", "1153"},
{"checkpoint camera", legacyName, "snapshot-camera", "", "", "snapshot-camera", "1-2-3-4", "1153"},
{"checkpoint region", legacyName, "", "5-6-7-8", "", "recorded-camera", "5-6-7-8", "1153"},
{"checkpoint zero changes", legacyName, "", "", "0", "recorded-camera", "1-2-3-4", "0"},
{"dated reference", "2019/08/03/" + legacyName, "", "", "", "recorded-camera", "1-2-3-4", "1153"},
{"continuous recording", "1564859471_3-474_recorded-camera_0-0-0-0_-1_27.mp4", "", "", "", "recorded-camera", "0-0-0-0", "-1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
withRecording(t, filepath.Base(tt.fileName), []byte("recording"))
metadata := models.RecordingUploadMetadata{
FileName: filepath.Base(tt.fileName),
DeviceKey: "checkpoint-key",
Timestamp: 1767355200123,
Duration: 20452,
FPS: 29.97,
Encrypted: true,
DeviceName: tt.camera,
RegionCoordinates: tt.region,
NumberOfChanges: tt.changes,
}
payload, err := json.Marshal(metadata)
if err != nil {
t.Fatal(err)
}
withQueuedRecordingFPS(t, tt.fileName, string(payload))
config := models.Config{Key: "current-key", Name: "current-camera", S3: &models.S3{Username: "user"}}
objectName, got, err := s3RecordingUploadDetails(config, tt.fileName, "public-key")
if err != nil {
t.Fatal(err)
}
if want := "user/2026/01/02/1767355200123-checkpoint-key.mp4"; objectName != want {
t.Fatalf("object name = %q, want %q", objectName, want)
}
want := map[string]string{
"publickey": "public-key",
"uploadtime": "now",
"event-instancename": tt.wantCamera,
"event-regioncoordinates": tt.wantRegion,
"event-numberofchanges": tt.wantChanges,
"event-microseconds": "123000",
"event-timestamp": "1767355200",
"productid": "checkpoint-key",
"duration": "20452",
"fps": "29.97",
"encrypted": "true",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("metadata = %#v, want %#v", got, want)
}
})
}
}
func TestS3RecordingUploadDetailsDoesNotBackfillInvalidLegacyNames(t *testing.T) {
for _, fileName := range []string{
"1767355200123-checkpoint-key.mp4",
"invalid_6-474162_camera_0-0-0-0_1153_27.mp4",
"1564859471_6-invalid_camera_0-0-0-0_1153_27.mp4",
"1564859471_3-474162_camera_0-0-0-0_1153_27.mp4",
"1564859471_6-474162__0-0-0-0_1153_27.mp4",
"1564859471_6-474162_camera_invalid_1153_27.mp4",
"1564859471_6-474162_camera_0-0-0-0_invalid_27.mp4",
"1564859471_6-474162_camera_0-0-0-0_1153_invalid.mp4",
"1564859471_6-474162_camera_0-0-0-0_1153_27_extra.mp4",
} {
t.Run(fileName, func(t *testing.T) {
withRecording(t, fileName, []byte("recording"))
metadata := models.RecordingUploadMetadata{
FileName: fileName, DeviceKey: "checkpoint-key", Timestamp: 1767355200123, Duration: 20452,
}
payload, err := json.Marshal(metadata)
if err != nil {
t.Fatal(err)
}
withQueuedRecordingFPS(t, fileName, string(payload))
config := models.Config{Key: "current-key", Name: "current-camera", S3: &models.S3{Username: "user"}}
objectName, got, err := s3RecordingUploadDetails(config, fileName, "public-key")
if err != nil {
t.Fatal(err)
}
if objectName != "user/2026/01/02/1767355200123-checkpoint-key.mp4" ||
got["event-instancename"] != "current-camera" || got["event-regioncoordinates"] != "" ||
got["event-numberofchanges"] != "" || got["event-timestamp"] != "1767355200" ||
got["productid"] != "checkpoint-key" {
t.Fatalf("unexpected fallback for invalid legacy name: %q, %#v", objectName, got)
}
})
}
}
func TestS3RecordingUploadDetailsLegacyMarkers(t *testing.T) {
for _, marker := range []string{"missing", "empty", "fps-only", "old-marker-name"} {
t.Run(marker, func(t *testing.T) {
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
withRecording(t, fileName, []byte("recording"))
switch marker {
case "empty":
withQueuedRecordingFPS(t, fileName, "")
case "fps-only", "old-marker-name":
withQueuedRecordingFPS(t, fileName, "29.97")
if marker == "old-marker-name" {
err := os.Rename(filepath.Join("data/cloud", models.RecordingUploadMetadataFileName(fileName)),
filepath.Join("data/cloud", fileName))
if err != nil {
t.Fatal(err)
}
}
}
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
objectName, metadata, err := s3RecordingUploadDetails(config, fileName, "public-key")
if err != nil {
t.Fatal(err)
}
if want := "user/2019/08/03/1564859471474-legacy-key.mp4"; objectName != want {
t.Fatalf("object name = %q, want %q", objectName, want)
}
if metadata["productid"] != "legacy-key" || metadata["event-instancename"] != "camera" ||
metadata["event-regioncoordinates"] != "0-0-0-0" || metadata["event-timestamp"] != "1564859471" {
t.Fatalf("unexpected legacy metadata: %#v", metadata)
}
})
}
}
func TestS3RecordingUploadDetailsRejectsInvalidCheckpoint(t *testing.T) {
tests := []struct {
name string
marker string
want string
}{
{"malformed JSON", `{"timestamp":`, "decode"},
{"missing timestamp", `{"device_key":"camera"}`, "timestamp"},
{"missing device key", `{"timestamp":1787015373000}`, "device key"},
{"invalid device key", `{"timestamp":1787015373000,"device_key":"camera/key"}`, "device key"},
{"mismatched filename", `{"timestamp":1787015373000,"device_key":"camera","filename":"other.mp4"}`, "filename"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
withRecording(t, fileName, []byte("recording"))
withQueuedRecordingFPS(t, fileName, tt.marker)
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
_, _, err := s3RecordingUploadDetails(config, fileName, "public-key")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want error containing %q instead of legacy fallback", err, tt.want)
}
})
}
}
func TestS3RecordingUploadDetailsReportsCheckpointReadError(t *testing.T) {
const fileName = "1564859471_6-474162_camera_0-0-0-0_1153_27.mp4"
withRecording(t, fileName, []byte("recording"))
if err := os.MkdirAll(filepath.Join("data/cloud", models.RecordingUploadMetadataFileName(fileName)), 0o755); err != nil {
t.Fatal(err)
}
config := models.Config{Key: "legacy-key", S3: &models.S3{Username: "user"}}
_, _, err := s3RecordingUploadDetails(config, fileName, "public-key")
if err == nil || !strings.Contains(err.Error(), "read recording upload metadata") {
t.Fatalf("error = %v, want checkpoint read error instead of legacy fallback", err)
}
}
func TestS3RecordingObjectName(t *testing.T) {
tests := []struct {
name string
recorded time.Time
directory string
}{
{
name: "zero padded month and day",
recorded: time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC),
directory: "2026/01/02",
},
{
name: "UTC date across year boundary",
recorded: time.Date(2026, time.January, 1, 1, 0, 0, 0, time.FixedZone("UTC+2", 2*60*60)),
directory: "2025/12/31",
},
{
name: "leap day",
recorded: time.Date(2024, time.February, 29, 23, 59, 59, 0, time.UTC),
directory: "2024/02/29",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
const deviceKey = "camera-key-123"
want := "user/" + tt.directory + "/" + strconv.FormatInt(tt.recorded.UnixMilli(), 10) + "-" + deviceKey + ".mp4"
for attempt := 0; attempt < 2; attempt++ {
if got := s3RecordingObjectName("user", deviceKey, tt.recorded.UnixMilli()); got != want {
t.Fatalf("s3RecordingObjectName() = %q, want %q", got, want)
}
}
})
}
}
func TestUploadS3RejectsInvalidRecordingNames(t *testing.T) {
configuration := &models.Configuration{
Config: models.Config{
Key: "camera-key-123",
S3: &models.S3{
Publickey: "test-public-key",
Secretkey: "test-secret-key",
Region: "eu-west-1",
},
},
}
for _, fileName := range []string{
"recording.mp4",
"1787015373_camera.mp4",
"invalid_6-474162_camera_0-0-0-0_1153_27.mp4",
} {
t.Run(fileName, func(t *testing.T) {
uploaded, configured, err := UploadS3(configuration, fileName)
if uploaded || !configured || err == nil {
t.Fatalf("UploadS3() = (%v, %v, %v), want (false, true, error)", uploaded, configured, err)
}
})
}
}
func TestUploadS3RejectsInvalidDeviceKeys(t *testing.T) {
for _, deviceKey := range []string{"", "camera/key", `camera\key`} {
t.Run(deviceKey, func(t *testing.T) {
configuration := &models.Configuration{
Config: models.Config{
Key: deviceKey,
S3: &models.S3{
Publickey: "test-public-key",
Secretkey: "test-secret-key",
Region: "eu-west-1",
},
},
}
uploaded, configured, err := UploadS3(configuration, "1787015373_6-474162_camera_0-0-0-0_1153_27.mp4")
if uploaded || !configured || err == nil || !strings.Contains(err.Error(), "device key") {
t.Fatalf("UploadS3() = (%v, %v, %v), want (false, true, device key error)", uploaded, configured, err)
}
})
}
}
+41 -7
View File
@@ -16,6 +16,7 @@ import (
"time"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/utils"
log "github.com/sirupsen/logrus"
)
@@ -106,7 +107,7 @@ func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64)
// creation request; it is empty on HEAD/PATCH/DELETE.
type tusHeaderFunc func(h http.Header, fileName string)
// runTusUpload performs a resumable (tus) upload of data/recordings/<fileName>
// runTusUpload performs a resumable (tus) upload of a flat or dated recording.
// to baseURL, sending target-specific authentication/routing headers via
// setHeaders on every request. It encapsulates the create/resume/chunk/finalize
// state machine shared by the Kerberos Vault (direct) and Kerberos Hub (proxied)
@@ -120,9 +121,7 @@ type tusHeaderFunc func(h http.Header, fileName string)
// should fall back to the legacy single-POST upload (older deployments).
// - body: a short message for logging.
func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tusHeaderFunc) (uploaded bool, responded bool, supported bool, body string, err error) {
fullname := "data/recordings/" + fileName
file, ferr := os.Open(fullname)
file, ferr := utils.OpenRecording("data/recordings", fileName)
if file != nil {
defer file.Close()
}
@@ -165,6 +164,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
// and loop forever, wedging the upload worker on one recording and saturating the
// uplink.
highWaterOffset := int64(0)
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
// (1) Ensure we have an active upload URL, creating one if needed.
@@ -172,6 +172,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
lastStatus = status
if cerr != nil {
lastErr = cerr
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
// The vault does not implement tus; let the caller fall back.
return false, false, false, "", cerr
@@ -188,6 +189,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
offset, status, herr := tusHead(client, uploadURL, setHeaders)
lastStatus = status
if herr != nil {
lastErr = herr
if status == http.StatusNotFound || status == http.StatusGone {
// The upload expired/was removed server-side; start over.
removeTusResumeState(sidecar)
@@ -244,6 +246,7 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
lastStatus = status
if perr != nil {
lastErr = perr
if status >= 400 {
// Definitive rejection (e.g. provider push failed during finalize).
// Re-evaluate via HEAD on the next iteration to decide retry/restart.
@@ -290,7 +293,12 @@ func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tu
// 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")
message := label + ": resumable upload did not complete after retries"
err = errors.New(message)
if lastErr != nil {
err = fmt.Errorf("%s: %w", message, lastErr)
}
return false, lastStatus > 0, true, "resumable upload did not complete after retries", err
}
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
@@ -358,6 +366,12 @@ func addRecordingTusMetadata(values map[string]string, fileName string) {
if !ok {
return
}
if metadata.DeviceKey != "" {
values["device"] = metadata.DeviceKey
}
values["device_name"] = metadata.DeviceName
values["region_coordinates"] = metadata.RegionCoordinates
values["number_of_changes"] = metadata.NumberOfChanges
if metadata.Duration > 0 {
values["duration"] = strconv.FormatUint(metadata.Duration, 10)
}
@@ -390,11 +404,29 @@ func tusCreate(client *http.Client, baseURL string, size int64, metadata string,
if err != nil {
return "", 0, err
}
io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusCreated {
const maxErrorBodyBytes = 4096
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes+1))
if readErr != nil {
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s; read error response: %w", resp.Status, readErr)
}
truncated := len(body) > maxErrorBodyBytes
if truncated {
body = body[:maxErrorBodyBytes]
}
message := strings.TrimSpace(string(body))
if truncated {
message += " [truncated]"
}
if message != "" {
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s: %q", resp.Status, message)
}
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s", resp.Status)
}
// Location identifies the created upload; its response body is optional.
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
log.WithError(err).WithField("component", "tus").Debug("Could not drain optional upload creation response body")
}
location := resp.Header.Get("Location")
if location == "" {
return "", resp.StatusCode, errors.New("missing Location header in create response")
@@ -500,6 +532,7 @@ func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceK
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
if fileName != "" {
h.Set("X-Kerberos-Storage-FileName", fileName)
setQueuedRecordingMetadataHeaders(h, fileName)
}
}
@@ -516,6 +549,7 @@ func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
if fileName != "" {
h.Set("X-Kerberos-Storage-FileName", fileName)
setQueuedRecordingMetadataHeaders(h, fileName)
}
}
+219
View File
@@ -14,12 +14,231 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/utils"
)
func TestResumableEOFRestartPreservesLatestFailure(t *testing.T) {
previousDelay := tusBackoffBaseDelay
tusBackoffBaseDelay = 0
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
const fileName = "1767355200123-device-key.mp4"
withRecording(t, fileName, []byte("recording"))
fake := newFakeTus()
fake.failFinalize = 1
var heads atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead && heads.Add(1) <= 2 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
fake.ServeHTTP(w, r)
}))
defer server.Close()
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
if uploaded || !responded || !supported || err == nil ||
!strings.Contains(err.Error(), "unexpected status on PATCH: 502 Bad Gateway") ||
strings.Contains(err.Error(), "503 Service Unavailable") {
t.Fatalf("upload = %t/%t/%t, %v; want the latest finalization failure after EOF restart", uploaded, responded, supported, err)
}
patches, _ := fake.patchCounts()
if heads.Load() != 4 || fake.createCount() != 1 || patches != 1 ||
len(fake.requestsForMethod(http.MethodDelete)) != 1 {
t.Fatalf("unexpected retry sequence: heads=%d, creates=%d, patches=%d", heads.Load(), fake.createCount(), patches)
}
}
func TestResumableCreationKeepsLocationAfterBodyReadError(t *testing.T) {
previousDelay := tusBackoffBaseDelay
tusBackoffBaseDelay = 0
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
const fileName = "1767355200123-device-key.mp4"
payload := []byte("recording")
withRecording(t, fileName, payload)
fake := newFakeTus()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.Header().Set("Content-Length", "100")
fake.ServeHTTP(w, r)
_, _ = io.WriteString(w, "short")
return
}
fake.ServeHTTP(w, r)
}))
defer server.Close()
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
if !uploaded || !responded || !supported || err != nil {
t.Fatalf("upload = %t/%t/%t, %v; want success despite the interrupted optional creation body", uploaded, responded, supported, err)
}
if fake.createCount() != 1 || fake.totalBytes() != int64(len(payload)) {
t.Fatalf("created %d slots and uploaded %d bytes; want one reused slot and %d bytes", fake.createCount(), fake.totalBytes(), len(payload))
}
}
func TestTusCreateStillRequiresLocationAfterBodyReadError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "100")
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, "short")
}))
defer server.Close()
location, status, err := tusCreate(server.Client(), server.URL, 8, "",
func(http.Header, string) {}, "1767355200123-device-key.mp4")
if location != "" || status != http.StatusCreated || err == nil ||
!strings.Contains(err.Error(), "missing Location") {
t.Fatalf("creation = %q, %d, %v; want missing Location error", location, status, err)
}
}
func TestTusCreateReportsBoundedServerErrors(t *testing.T) {
for _, tc := range []struct {
name string
body string
want string
}{
{"server error", "ERR_INTERNAL_SERVER_ERROR: A region must be set when sending requests to S3.\n", "A region must be set"},
{"empty error", "", "500 Internal Server Error"},
{"large error", strings.Repeat("x", 10000), "[truncated]"},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, tc.body)
}))
defer server.Close()
location, status, err := tusCreate(server.Client(), server.URL, 8, "",
func(http.Header, string) {}, "1767355200123-device-key.mp4")
if location != "" || status != http.StatusInternalServerError || err == nil ||
!strings.Contains(err.Error(), tc.want) || len(err.Error()) > 4300 {
t.Fatalf("creation = %q, %d, %v; want bounded server error", location, status, err)
}
})
}
}
func TestResumableCreateFailureRetainsServerCause(t *testing.T) {
previousDelay := tusBackoffBaseDelay
tusBackoffBaseDelay = 0
t.Cleanup(func() { tusBackoffBaseDelay = previousDelay })
for _, integration := range []string{"vault", "hub"} {
t.Run(integration, func(t *testing.T) {
const fileName = "1767355200123-device-key.mp4"
withRecording(t, fileName, []byte("recording"))
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Method != http.MethodPost || r.URL.Path != tusUploadPath {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, "ERR_INTERNAL_SERVER_ERROR: A region must be set when sending requests to S3.")
}))
defer server.Close()
var uploaded, responded, supported bool
var err error
if integration == "vault" {
uploaded, responded, supported, _, err = uploadVaultResumable(testVault(server.URL), "public", "device-key", fileName, "test", "primary")
} else {
uploaded, responded, supported, _, err = uploadHubResumable(testHubConfig(server.URL), fileName, "test", "hub")
}
if uploaded || !responded || !supported || err == nil ||
!strings.Contains(err.Error(), "resumable upload did not complete after retries") ||
!strings.Contains(err.Error(), "A region must be set") {
t.Fatalf("upload = %t/%t/%t, %v; want original server cause after retries", uploaded, responded, supported, err)
}
if requests.Load() != 4 {
t.Fatalf("creation attempts = %d, want 4 without legacy fallback", requests.Load())
}
if _, err := os.Stat(filepath.Join("data/recordings", fileName)); err != nil {
t.Fatalf("failed upload must retain the local recording: %v", err)
}
})
}
}
func TestUploadKerberosVaultReadsDatedLocalRecordings(t *testing.T) {
previousCount, previousTimeout := kstorageRetryCount, kstorageRetryTimeout
t.Cleanup(func() { kstorageRetryCount, kstorageRetryTimeout = previousCount, previousTimeout })
for _, transport := range []string{"resumable", "legacy"} {
t.Run(transport, func(t *testing.T) {
const fileName = "1564859471123-device-key.mp4"
payload := bytes.Repeat([]byte("video"), 1024)
withRecording(t, fileName, payload)
datedPath, err := utils.CreateRecordingPath("data/recordings", fileName, time.Unix(1564859471, 0))
if err != nil {
t.Fatal(err)
}
if err := os.Rename(filepath.Join("data/recordings", fileName), datedPath); err != nil {
t.Fatal(err)
}
withQueuedRecordingFPS(t, fileName, `{"filename":"1564859471123-device-key.mp4","device_key":"device-key","timestamp":1564859471123,"duration":27,"fps":25,"device_name":"camera-name","region_coordinates":"1-2-3-4","number_of_changes":"57"}`)
fake := newFakeTus()
var legacyBody []byte
var legacyHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if transport == "resumable" {
fake.ServeHTTP(w, r)
return
}
body, readErr := io.ReadAll(r.Body)
if readErr != nil {
t.Error(readErr)
}
legacyBody = body
legacyHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
if transport == "legacy" {
t.Setenv("AGENT_DISABLE_RESUMABLE_UPLOAD", "true")
} else {
t.Setenv("AGENT_DISABLE_RESUMABLE_UPLOAD", "false")
}
kstorageRetryCount, kstorageRetryTimeout = 0, 0
vault := testVault(server.URL)
config := &models.Configuration{Config: models.Config{
Key: "changed-config-key",
KStorage: &vault,
KStorageSecondary: &models.KStorage{},
}}
uploaded, configured, err := UploadKerberosVault(config, fileName)
if err != nil || !uploaded || !configured {
t.Fatalf("UploadKerberosVault() = (%v, %v, %v), want success", uploaded, configured, err)
}
if transport == "resumable" {
if got := fake.totalBytes(); got != int64(len(payload)) {
t.Fatalf("uploaded %d bytes, want %d", got, len(payload))
}
posts := fake.requestsForMethod(http.MethodPost)
if len(posts) != 1 {
t.Fatalf("got %d creation requests, want 1", len(posts))
}
metadata := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
if metadata["filename"] != fileName || metadata["directory"] != "dir" || metadata["timestamp"] != "1564859471123" ||
metadata["device"] != "device-key" || metadata["device_name"] != "camera-name" ||
metadata["region_coordinates"] != "1-2-3-4" || metadata["number_of_changes"] != "57" {
t.Fatalf("unexpected upload metadata: %#v", metadata)
}
} else if !bytes.Equal(legacyBody, payload) ||
legacyHeaders.Get("X-Kerberos-Storage-FileName") != fileName ||
legacyHeaders.Get("X-Kerberos-Storage-Directory") != "dir" ||
legacyHeaders.Get("X-Kerberos-Storage-Device") != "device-key" ||
legacyHeaders.Get(recordingDeviceNameHeader) != "camera-name" ||
legacyHeaders.Get(recordingRegionHeader) != "1-2-3-4" ||
legacyHeaders.Get(recordingChangesHeader) != "57" ||
legacyHeaders.Get(recordingTimestampHeader) != "1564859471123" {
t.Fatalf("unexpected legacy upload: %d bytes, headers %#v", len(legacyBody), legacyHeaders)
}
})
}
}
// fakeUpload tracks the state of a single resumable upload on the fake server.
type fakeUpload struct {
size int64
+5 -3
View File
@@ -964,7 +964,7 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
// All days stored in this agent.
days := []string{}
latestEvents := []models.Media{}
files, err := utils.ReadDirectory(recordingDirectory)
files, err := utils.ReadRecordingDirectory(recordingDirectory)
if err == nil {
events := utils.GetSortedDirectory(files)
@@ -975,6 +975,8 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
var eventFilter models.EventFilter
eventFilter.NumberOfElements = 5
latestEvents = utils.GetMediaFormatted(events, recordingDirectory, configuration, eventFilter) // will get 5 latest recordings.
} else {
log.WithError(err).Error("Could not list recordings for Agent status")
}
c.JSON(200, gin.H{
@@ -1007,7 +1009,7 @@ func GetLatestEvents(c *gin.Context, configDirectory string, configuration *mode
eventFilter.NumberOfElements = 10
}
recordingDirectory := configDirectory + "/data/recordings"
files, err := utils.ReadDirectory(recordingDirectory)
files, err := utils.ReadRecordingDirectory(recordingDirectory)
if err == nil {
events := utils.GetSortedDirectory(files)
// We will get all recordings from the directory (as defined by the filter).
@@ -1036,7 +1038,7 @@ func GetLatestEvents(c *gin.Context, configDirectory string, configuration *mode
// @Success 200
func GetDays(c *gin.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
recordingDirectory := configDirectory + "/data/recordings"
files, err := utils.ReadDirectory(recordingDirectory)
files, err := utils.ReadRecordingDirectory(recordingDirectory)
if err == nil {
events := utils.GetSortedDirectory(files)
days := utils.GetDays(events, recordingDirectory, configuration)
@@ -11,12 +11,15 @@ const RecordingUploadMetadataExtension = ".metadata"
// 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"`
Encrypted bool `json:"encrypted,omitempty"`
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"`
Encrypted bool `json:"encrypted,omitempty"`
DeviceName string `json:"device_name,omitempty"`
RegionCoordinates string `json:"region_coordinates,omitempty"`
NumberOfChanges string `json:"number_of_changes,omitempty"`
}
// RecordingUploadMetadataFileName returns the queue marker name associated
+320 -237
View File
@@ -5,7 +5,9 @@ import (
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
@@ -130,7 +132,7 @@ func HandleONVIFActions(configuration *models.Configuration, communication *mode
if onvifAction.Action == "absolute-move" {
// We will move the camera to zero position.
// Move the camera to the saved absolute position.
x := ptzAction.X
y := ptzAction.Y
z := ptzAction.Z
@@ -141,42 +143,13 @@ func HandleONVIFActions(configuration *models.Configuration, communication *mode
// Log functions
log.Debug("onvif.HandleONVIFActions(): functions: " + strings.Join(functions, ", "))
// Check if we need to use absolute or continuous move
/*canAbsoluteMove := false
canContinuousMove := false
if len(functions) > 0 {
for _, function := range functions {
if function == "AbsolutePanTiltMove" || function == "AbsoluteZoomMove" {
canAbsoluteMove = true
} else if function == "ContinuousPanTiltMove" || function == "ContinuousZoomMove" {
canContinuousMove = true
}
}
}*/
// Ideally we should be able to use the AbsolutePanTiltMove function, but it looks like
// the current detection through GetPTZFuntionsFromDevice is not working properly. Therefore we will fallback
// on the ContinuousPanTiltMove function which is more compatible with more cameras.
err = AbsolutePanTiltMoveFake(device, configurations, token, x, y, z)
err = MoveToPosition(device, configurations, token, x, y, z)
if err != nil {
log.Debug("onvif.HandleONVIFActions() - AbsolutePanTitleMoveFake: " + err.Error())
log.Error("onvif.HandleONVIFActions() - MoveToPosition: " + err.Error())
} else {
log.Info("onvif.HandleONVIFActions() - AbsolutePanTitleMoveFake: successfully moved camera.")
log.Info("onvif.HandleONVIFActions() - MoveToPosition: successfully moved camera.")
}
/*if canAbsoluteMove {
err = AbsolutePanTiltMove(device, configurations, token, x, y, z)
if err != nil {
log.Error("HandleONVIFActions (AbsolutePanTitleMove): " + err.Error())
}
} else if canContinuousMove {
err = AbsolutePanTiltMoveFake(device, configurations, token, x, y, z)
if err != nil {
log.Error("HandleONVIFActions (AbsolutePanTitleMoveFake): " + err.Error())
}
}*/
} else if onvifAction.Action == "preset" {
// Execute the preset
@@ -471,16 +444,20 @@ func handleONVIFResponse(operation string, response *http.Response, requestErr e
}
func AbsolutePanTiltMove(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken, pan float64, tilt float64, zoom float64) error {
if len(configuration.PTZConfiguration) == 0 {
return errors.New("camera returned no PTZ configuration")
}
ptzConfiguration := configuration.PTZConfiguration[0]
absolutePantiltVector := xsdonvif.Vector2D{
X: pan,
Y: tilt,
Space: configuration.PTZConfiguration[0].DefaultAbsolutePantTiltPositionSpace,
Space: ptzConfiguration.DefaultAbsolutePantTiltPositionSpace,
}
absoluteZoomVector := xsdonvif.Vector1D{
X: zoom,
Space: configuration.PTZConfiguration[0].DefaultAbsoluteZoomPositionSpace,
Space: ptzConfiguration.DefaultAbsoluteZoomPositionSpace,
}
resp, err := device.CallMethod(ptz.AbsoluteMove{
@@ -493,240 +470,325 @@ func AbsolutePanTiltMove(device *onvif.Device, configuration ptz.GetConfiguratio
return handleONVIFResponse("absolute_move", resp, err)
}
// This function will simulate the AbsolutePanTiltMove function.
// However the AboslutePanTiltMove function is not working on all cameras.
// So we'll use the ContinuousMove function to simulate the AbsolutePanTiltMove function using the position polling.
func AbsolutePanTiltMoveFake(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken, pan float64, tilt float64, zoom float64) error {
position, err := GetPosition(device, token)
if position.PanTilt.X >= pan-0.01 && position.PanTilt.X <= pan+0.01 && position.PanTilt.Y >= tilt-0.01 && position.PanTilt.Y <= tilt+0.01 && position.Zoom.X >= zoom-0.01 && position.Zoom.X <= zoom+0.01 {
log.Debug("AbsolutePanTiltMoveFake: already at position")
} else {
// The speed of panning, the higher the faster we'll pan the camera
// value is a range between 0 and 1.
speed := 0.6
wait := 100 * time.Millisecond
// We'll move quickly to the position (might be inaccurate)
err = ZoomOutCompletely(device, configuration, token)
err = PanUntilPosition(device, configuration, token, pan, zoom, speed, wait)
err = TiltUntilPosition(device, configuration, token, tilt, zoom, speed, wait)
// Now we'll move a bit slower to make sure we are ok (will be more accurate)
speed = 0.1
wait = 200 * time.Millisecond
err = PanUntilPosition(device, configuration, token, pan, zoom, speed, wait)
err = TiltUntilPosition(device, configuration, token, tilt, zoom, speed, wait)
err = ZoomUntilPosition(device, configuration, token, zoom, speed, wait)
return err
}
return err
type ptzMoveClient interface {
GetPosition() (xsdonvif.PTZVector, error)
AbsoluteMove(ptzTarget) error
ContinuousMove(ptzVelocity) error
Stop() error
}
func ZoomOutCompletely(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken) error {
// Zoom out completely!!!
zoomOut := xsdonvif.Vector1D{
X: -1,
Space: configuration.PTZConfiguration[0].DefaultContinuousZoomVelocitySpace,
type ptzTarget struct {
Pan float64
Tilt float64
Zoom float64
}
type ptzVelocity struct {
Pan float64
Tilt float64
Zoom float64
}
type ptzMoveSupport struct {
Absolute bool
Continuous bool
}
type ptzMoveOptions struct {
PanTolerance float64
TiltTolerance float64
ZoomTolerance float64
NativeTimeout time.Duration
ContinuousTimeout time.Duration
NoProgressTimeout time.Duration
PollInterval time.Duration
ProgressEpsilon float64
VelocityGain float64
MinVelocity float64
MaxVelocity float64
VelocityChange float64
}
var defaultPTZMoveOptions = ptzMoveOptions{
PanTolerance: 0.01,
TiltTolerance: 0.005,
ZoomTolerance: 0.005,
NativeTimeout: 8 * time.Second,
ContinuousTimeout: 15 * time.Second,
NoProgressTimeout: 3 * time.Second,
PollInterval: 150 * time.Millisecond,
ProgressEpsilon: 0.001,
VelocityGain: 1.5,
MinVelocity: 0.08,
MaxVelocity: 0.7,
VelocityChange: 0.05,
}
type devicePTZMoveClient struct {
device *onvif.Device
configuration xsdonvif.PTZConfiguration
token xsdonvif.ReferenceToken
}
func (client *devicePTZMoveClient) GetPosition() (xsdonvif.PTZVector, error) {
return GetPosition(client.device, client.token)
}
func (client *devicePTZMoveClient) AbsoluteMove(target ptzTarget) error {
configuration := ptz.GetConfigurationsResponse{
PTZConfiguration: []xsdonvif.PTZConfiguration{client.configuration},
}
_, err := device.CallMethod(ptz.ContinuousMove{
ProfileToken: &token,
Velocity: xsdonvif.PTZSpeedZoom{
Zoom: zoomOut,
return AbsolutePanTiltMove(client.device, configuration, client.token, target.Pan, target.Tilt, target.Zoom)
}
func (client *devicePTZMoveClient) ContinuousMove(velocity ptzVelocity) error {
panTilt := xsdonvif.Vector2D{
X: velocity.Pan,
Y: velocity.Tilt,
Space: client.configuration.DefaultContinuousPanTiltVelocitySpace,
}
zoom := xsdonvif.Vector1D{
X: velocity.Zoom,
Space: client.configuration.DefaultContinuousZoomVelocitySpace,
}
resp, err := client.device.CallMethod(ptz.ContinuousMove{
ProfileToken: &client.token,
Velocity: ptz.Speed{
PanTilt: &panTilt,
Zoom: &zoom,
},
})
if err != nil {
log.Error("ZoomOutCompletely: " + err.Error())
}
return handleONVIFResponse("continuous_move_to_position", resp, err)
}
for {
position, _ := GetPosition(device, token)
if position.Zoom.X == 0 {
break
}
time.Sleep(250 * time.Millisecond)
}
_, err = device.CallMethod(ptz.Stop{
ProfileToken: token,
func (client *devicePTZMoveClient) Stop() error {
resp, err := client.device.CallMethod(ptz.Stop{
ProfileToken: client.token,
PanTilt: true,
Zoom: true,
})
return handleONVIFResponse("move_to_position_stop", resp, err)
}
func MoveToPosition(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken, pan float64, tilt float64, zoom float64) error {
if len(configuration.PTZConfiguration) == 0 {
return errors.New("camera returned no PTZ configuration")
}
ptzConfiguration := configuration.PTZConfiguration[0]
client := &devicePTZMoveClient{
device: device,
configuration: ptzConfiguration,
token: token,
}
support := ptzMoveSupport{
Absolute: ptzConfiguration.DefaultAbsolutePantTiltPositionSpace != nil &&
ptzConfiguration.DefaultAbsoluteZoomPositionSpace != nil,
Continuous: ptzConfiguration.DefaultContinuousPanTiltVelocitySpace != nil &&
ptzConfiguration.DefaultContinuousZoomVelocitySpace != nil,
}
return moveToPosition(client, support, ptzTarget{Pan: pan, Tilt: tilt, Zoom: zoom}, defaultPTZMoveOptions)
}
// AbsolutePanTiltMoveFake preserves the continuous-move compatibility path for callers that explicitly request it.
func AbsolutePanTiltMoveFake(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken, pan float64, tilt float64, zoom float64) error {
if len(configuration.PTZConfiguration) == 0 {
return errors.New("camera returned no PTZ configuration")
}
ptzConfiguration := configuration.PTZConfiguration[0]
client := &devicePTZMoveClient{
device: device,
configuration: ptzConfiguration,
token: token,
}
support := ptzMoveSupport{
Continuous: ptzConfiguration.DefaultContinuousPanTiltVelocitySpace != nil &&
ptzConfiguration.DefaultContinuousZoomVelocitySpace != nil,
}
return moveToPosition(client, support, ptzTarget{Pan: pan, Tilt: tilt, Zoom: zoom}, defaultPTZMoveOptions)
}
func moveToPosition(client ptzMoveClient, support ptzMoveSupport, target ptzTarget, options ptzMoveOptions) error {
if err := validatePTZTarget(target); err != nil {
return err
}
position, err := client.GetPosition()
if err != nil {
log.Error("ZoomOutCompletely: " + err.Error())
return fmt.Errorf("read initial PTZ position: %w", err)
}
return err
_, _, reached, err := ptzTargetError(position, target, options)
if err != nil {
return err
}
if reached {
return nil
}
var nativeErr error
if support.Absolute {
if err := client.AbsoluteMove(target); err != nil {
nativeErr = fmt.Errorf("native absolute move: %w", err)
} else if err := waitForPTZTarget(client, target, options.NativeTimeout, options); err != nil {
nativeErr = fmt.Errorf("verify native absolute move: %w", err)
} else {
return nil
}
if err := client.Stop(); err != nil {
nativeErr = errors.Join(nativeErr, fmt.Errorf("stop native absolute move: %w", err))
}
log.WithError(nativeErr).Warn("Native ONVIF absolute move failed; using continuous-move fallback")
}
if !support.Continuous {
if nativeErr != nil {
return errors.Join(nativeErr, errors.New("camera does not advertise complete continuous PTZ support"))
}
return errors.New("camera does not advertise a supported absolute or continuous PTZ movement method")
}
if err := moveContinuouslyToPTZTarget(client, target, options); err != nil {
if nativeErr != nil {
return errors.Join(nativeErr, fmt.Errorf("continuous-move fallback: %w", err))
}
return fmt.Errorf("continuous move: %w", err)
}
return nil
}
func PanUntilPosition(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken, pan float64, zoom float64, speed float64, wait time.Duration) error {
position, err := GetPosition(device, token)
if position.PanTilt.X >= pan-0.01 && position.PanTilt.X <= pan+0.01 {
} else {
// We'll need to determine if we need to move CW or CCW.
// Check the current position and compare it with the desired position.
directionX := speed
if position.PanTilt.X > pan {
directionX = speed * -1
}
panTiltVector := xsdonvif.Vector2D{
X: directionX,
Y: 0,
Space: configuration.PTZConfiguration[0].DefaultContinuousPanTiltVelocitySpace,
}
resp, err := device.CallMethod(ptz.ContinuousMove{
ProfileToken: &token,
Velocity: xsdonvif.PTZSpeedPanTilt{
PanTilt: panTiltVector,
},
})
err = handleONVIFResponse("continuous_pan", resp, err)
// While moving we'll check if we reached the desired position.
// or if we overshot the desired position.
// Break after 3seconds
now := time.Now()
for {
position, _ := GetPosition(device, token)
if position.PanTilt.X == -1 || position.PanTilt.X == 1 || (directionX > 0 && position.PanTilt.X >= pan) || (directionX < 0 && position.PanTilt.X <= pan) || (position.PanTilt.X >= pan-0.01 && position.PanTilt.X <= pan+0.01) {
break
}
if time.Since(now) > 3*time.Second {
break
}
time.Sleep(wait)
}
_, err = device.CallMethod(ptz.Stop{
ProfileToken: token,
PanTilt: true,
Zoom: true,
})
func waitForPTZTarget(client ptzMoveClient, target ptzTarget, timeout time.Duration, options ptzMoveOptions) error {
deadline := time.Now().Add(timeout)
lastProgress := time.Now()
bestDistance := math.Inf(1)
for {
position, err := client.GetPosition()
if err != nil {
log.Error("ContinuousPanTiltMove (Pan): " + err.Error())
return fmt.Errorf("read PTZ position: %w", err)
}
_, distance, reached, err := ptzTargetError(position, target, options)
if err != nil {
return err
}
if reached {
return nil
}
now := time.Now()
if bestDistance-distance >= options.ProgressEpsilon {
bestDistance = distance
lastProgress = now
}
if !now.Before(deadline) {
return fmt.Errorf("target not reached within %s", timeout)
}
if now.Sub(lastProgress) >= options.NoProgressTimeout {
return fmt.Errorf("PTZ position made no progress for %s", options.NoProgressTimeout)
}
time.Sleep(options.PollInterval)
}
return err
}
func TiltUntilPosition(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken, tilt float64, zoom float64, speed float64, wait time.Duration) error {
position, err := GetPosition(device, token)
func moveContinuouslyToPTZTarget(client ptzMoveClient, target ptzTarget, options ptzMoveOptions) (err error) {
defer func() {
if stopErr := client.Stop(); stopErr != nil {
err = errors.Join(err, fmt.Errorf("stop continuous move: %w", stopErr))
}
}()
if position.PanTilt.Y >= tilt-0.005 && position.PanTilt.Y <= tilt+0.005 {
deadline := time.Now().Add(options.ContinuousTimeout)
lastProgress := time.Now()
bestDistance := math.Inf(1)
var lastVelocity ptzVelocity
haveVelocity := false
} else {
// We'll need to determine if we need to move CW or CCW.
// Check the current position and compare it with the desired position.
directionY := speed
if position.PanTilt.Y > tilt {
directionY = speed * -1
for {
position, positionErr := client.GetPosition()
if positionErr != nil {
return fmt.Errorf("read PTZ position: %w", positionErr)
}
delta, distance, reached, targetErr := ptzTargetError(position, target, options)
if targetErr != nil {
return targetErr
}
if reached {
return nil
}
panTiltVector := xsdonvif.Vector2D{
X: 0,
Y: directionY,
Space: configuration.PTZConfiguration[0].DefaultContinuousPanTiltVelocitySpace,
}
velocity := xsdonvif.PTZSpeedPanTilt{
PanTilt: panTiltVector,
}
resp, err := device.CallMethod(ptz.ContinuousMove{
ProfileToken: &token,
Velocity: velocity,
})
err = handleONVIFResponse("continuous_tilt", resp, err)
// While moving we'll check if we reached the desired position.
// or if we overshot the desired position.
// Break after 3seconds
now := time.Now()
for {
position, _ := GetPosition(device, token)
if position.PanTilt.Y == -1 || position.PanTilt.Y == 1 || (directionY > 0 && position.PanTilt.Y >= tilt) || (directionY < 0 && position.PanTilt.Y <= tilt) || (position.PanTilt.Y >= tilt-0.005 && position.PanTilt.Y <= tilt+0.005) {
break
}
if time.Since(now) > 3*time.Second {
break
}
time.Sleep(wait)
if bestDistance-distance >= options.ProgressEpsilon {
bestDistance = distance
lastProgress = now
}
if !now.Before(deadline) {
return fmt.Errorf("target not reached within %s", options.ContinuousTimeout)
}
if now.Sub(lastProgress) >= options.NoProgressTimeout {
return fmt.Errorf("PTZ position made no progress for %s", options.NoProgressTimeout)
}
_, err = device.CallMethod(ptz.Stop{
ProfileToken: token,
PanTilt: true,
Zoom: true,
})
if err != nil {
log.Error("ContinuousPanTiltMove (Tilt): " + err.Error())
velocity := ptzVelocity{
Pan: proportionalPTZVelocity(delta.Pan, options.PanTolerance, options),
Tilt: proportionalPTZVelocity(delta.Tilt, options.TiltTolerance, options),
Zoom: proportionalPTZVelocity(delta.Zoom, options.ZoomTolerance, options),
}
if !haveVelocity || ptzVelocityChanged(lastVelocity, velocity, options.VelocityChange) {
if moveErr := client.ContinuousMove(velocity); moveErr != nil {
return fmt.Errorf("set continuous PTZ velocity: %w", moveErr)
}
lastVelocity = velocity
haveVelocity = true
}
time.Sleep(options.PollInterval)
}
return err
}
func ZoomUntilPosition(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken, zoom float64, speed float64, wait time.Duration) error {
position, err := GetPosition(device, token)
func ptzTargetError(position xsdonvif.PTZVector, target ptzTarget, options ptzMoveOptions) (ptzVelocity, float64, bool, error) {
if position.PanTilt == nil || position.Zoom == nil {
return ptzVelocity{}, 0, false, errors.New("camera returned an incomplete PTZ position")
}
delta := ptzVelocity{
Pan: target.Pan - position.PanTilt.X,
Tilt: target.Tilt - position.PanTilt.Y,
Zoom: target.Zoom - position.Zoom.X,
}
reached := math.Abs(delta.Pan) <= options.PanTolerance &&
math.Abs(delta.Tilt) <= options.TiltTolerance &&
math.Abs(delta.Zoom) <= options.ZoomTolerance
distance := math.Abs(delta.Pan) + math.Abs(delta.Tilt) + math.Abs(delta.Zoom)
return delta, distance, reached, nil
}
if position.Zoom.X >= zoom-0.005 && position.Zoom.X <= zoom+0.005 {
func proportionalPTZVelocity(delta float64, tolerance float64, options ptzMoveOptions) float64 {
if math.Abs(delta) <= tolerance {
return 0
}
speed := math.Abs(delta) * options.VelocityGain
speed = math.Max(options.MinVelocity, math.Min(speed, options.MaxVelocity))
return math.Copysign(speed, delta)
}
} else {
func ptzVelocityChanged(previous ptzVelocity, next ptzVelocity, threshold float64) bool {
return math.Abs(previous.Pan-next.Pan) >= threshold ||
math.Abs(previous.Tilt-next.Tilt) >= threshold ||
math.Abs(previous.Zoom-next.Zoom) >= threshold
}
// We'll need to determine if we need to move CW or CCW.
// Check the current position and compare it with the desired position.
directionZ := speed
if position.Zoom.X > zoom {
directionZ = speed * -1
}
zoomVector := xsdonvif.Vector1D{
X: directionZ,
Space: configuration.PTZConfiguration[0].DefaultContinuousZoomVelocitySpace,
}
resp, err := device.CallMethod(ptz.ContinuousMove{
ProfileToken: &token,
Velocity: xsdonvif.PTZSpeedZoom{
Zoom: zoomVector,
},
})
err = handleONVIFResponse("continuous_zoom_to_position", resp, err)
// While moving we'll check if we reached the desired position.
// or if we overshot the desired position.
// Break after 3seconds
now := time.Now()
for {
position, _ := GetPosition(device, token)
if position.Zoom.X == -1 || position.Zoom.X == 1 || (directionZ > 0 && position.Zoom.X >= zoom) || (directionZ < 0 && position.Zoom.X <= zoom) || (position.Zoom.X >= zoom-0.005 && position.Zoom.X <= zoom+0.005) {
break
}
if time.Since(now) > 3*time.Second {
break
}
time.Sleep(wait)
}
_, err = device.CallMethod(ptz.Stop{
ProfileToken: token,
PanTilt: true,
Zoom: true,
})
if err != nil {
log.Error("ContinuousPanTiltMove (Zoom): " + err.Error())
func validatePTZTarget(target ptzTarget) error {
values := []struct {
name string
value float64
}{
{name: "pan", value: target.Pan},
{name: "tilt", value: target.Tilt},
{name: "zoom", value: target.Zoom},
}
for _, value := range values {
if math.IsNaN(value.value) || math.IsInf(value.value, 0) {
return fmt.Errorf("invalid PTZ %s target", value.name)
}
}
return err
return nil
}
func ContinuousPanTilt(device *onvif.Device, configuration ptz.GetConfigurationsResponse, token xsdonvif.ReferenceToken, pan float64, tilt float64) error {
@@ -895,6 +957,10 @@ func GetPTZFunctionsFromDevice(configurations ptz.GetConfigurationsResponse) ([]
canZoom := false
canPanTilt := false
if len(configurations.PTZConfiguration) == 0 {
return functions, canZoom, canPanTilt
}
if configurations.PTZConfiguration[0].DefaultAbsolutePantTiltPositionSpace != nil {
functions = append(functions, "AbsolutePanTiltMove")
canPanTilt = true
@@ -1042,6 +1108,23 @@ func CreatePullPointSubscription(dev *onvif.Device) (string, error) {
return pullPointAdress, err
}
const (
wsAddressingNamespace = "http://www.w3.org/2005/08/addressing"
pullMessagesAction = "http://www.onvif.org/ver10/events/wsdl/PullPointSubscription/PullMessagesRequest"
unsubscribeAction = "http://docs.oasis-open.org/wsn/bw-2/SubscriptionManager/UnsubscribeRequest"
)
// subscriptionAddressingHeader builds the WS-Addressing Action and To headers
// for a request sent to a pull-point subscription. Some devices identify the
// subscription from wsa:To rather than the request URL, and reject requests
// without it as NotAuthorized.
func subscriptionAddressingHeader(subscriptionAddress string, action string) string {
var address bytes.Buffer
_ = xml.EscapeText(&address, []byte(subscriptionAddress))
return `<wsa:Action xmlns:wsa="` + wsAddressingNamespace + `">` + action + `</wsa:Action>` +
`<wsa:To xmlns:wsa="` + wsAddressingNamespace + `">` + address.String() + `</wsa:To>`
}
func UnsubscribePullPoint(dev *onvif.Device, pullPointAddress string) error {
// Unsubscribe from the device
@@ -1055,7 +1138,7 @@ func UnsubscribePullPoint(dev *onvif.Device, pullPointAddress string) error {
return err
}
res, err := dev.SendSoap(pullPointAddress, string(requestBody))
res, err := dev.SendSoapWithHeader(pullPointAddress, string(requestBody), subscriptionAddressingHeader(pullPointAddress, unsubscribeAction))
if err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "onvif",
@@ -1137,7 +1220,7 @@ func GetEventMessages(dev *onvif.Device, pullPointAddress string) ([]ONVIFEvents
log.Error("onvif.main.GetEventMessages(pullMessages): " + err.Error())
return eventsArray, err
}
res, err := dev.SendSoap(string(subscriptionURI), string(requestBody))
res, err := dev.SendSoapWithHeader(string(subscriptionURI), string(requestBody), subscriptionAddressingHeader(subscriptionURI, pullMessagesAction))
if err != nil {
log.Error("onvif.main.GetEventMessages(pullMessages): " + err.Error())
return eventsArray, err
+264
View File
@@ -0,0 +1,264 @@
package onvif
import (
"encoding/xml"
"errors"
"strings"
"testing"
"time"
"github.com/kerberos-io/onvif/ptz"
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
)
type fakePTZMoveClient struct {
current ptzTarget
target ptzTarget
lastVelocity ptzVelocity
absoluteErr error
continuousErr error
stopErr error
ignoreAbsolute bool
stalled bool
incompletePosition bool
positionErr error
positionErrAtCall int
positionCalls int
absoluteCalls int
continuousVelocities []ptzVelocity
stopCalls int
moving bool
}
func (client *fakePTZMoveClient) GetPosition() (xsdonvif.PTZVector, error) {
client.positionCalls++
if client.positionErr != nil && client.positionCalls == client.positionErrAtCall {
return xsdonvif.PTZVector{}, client.positionErr
}
if client.moving && !client.stalled {
client.current.Pan = moveFakeAxis(client.current.Pan, client.target.Pan, client.lastVelocity.Pan)
client.current.Tilt = moveFakeAxis(client.current.Tilt, client.target.Tilt, client.lastVelocity.Tilt)
client.current.Zoom = moveFakeAxis(client.current.Zoom, client.target.Zoom, client.lastVelocity.Zoom)
}
panTilt := &xsdonvif.Vector2D{X: client.current.Pan, Y: client.current.Tilt}
if client.incompletePosition {
return xsdonvif.PTZVector{PanTilt: panTilt}, nil
}
return xsdonvif.PTZVector{
PanTilt: panTilt,
Zoom: &xsdonvif.Vector1D{X: client.current.Zoom},
}, nil
}
func (client *fakePTZMoveClient) AbsoluteMove(target ptzTarget) error {
client.absoluteCalls++
client.target = target
if client.absoluteErr != nil {
return client.absoluteErr
}
if !client.ignoreAbsolute {
client.current = target
}
return nil
}
func (client *fakePTZMoveClient) ContinuousMove(velocity ptzVelocity) error {
if client.continuousErr != nil {
return client.continuousErr
}
client.lastVelocity = velocity
client.continuousVelocities = append(client.continuousVelocities, velocity)
client.moving = true
return nil
}
func (client *fakePTZMoveClient) Stop() error {
client.stopCalls++
client.moving = false
return client.stopErr
}
func moveFakeAxis(current float64, target float64, velocity float64) float64 {
step := velocity * 0.5
if step == 0 {
return current
}
next := current + step
if (velocity > 0 && next >= target) || (velocity < 0 && next <= target) {
return target
}
return next
}
func testPTZMoveOptions() ptzMoveOptions {
options := defaultPTZMoveOptions
options.NativeTimeout = 50 * time.Millisecond
options.ContinuousTimeout = 100 * time.Millisecond
options.NoProgressTimeout = 10 * time.Millisecond
options.PollInterval = time.Millisecond
options.ProgressEpsilon = 0.0001
options.VelocityChange = 0.01
return options
}
func TestMoveToPositionUsesVerifiedNativeMove(t *testing.T) {
target := ptzTarget{Pan: 0.5, Tilt: -0.25, Zoom: 0.4}
client := &fakePTZMoveClient{}
err := moveToPosition(client, ptzMoveSupport{Absolute: true, Continuous: true}, target, testPTZMoveOptions())
if err != nil {
t.Fatalf("moveToPosition() error = %v", err)
}
if client.absoluteCalls != 1 {
t.Fatalf("absolute calls = %d, want 1", client.absoluteCalls)
}
if len(client.continuousVelocities) != 0 {
t.Fatalf("continuous calls = %d, want 0", len(client.continuousVelocities))
}
}
func TestMoveToPositionFallsBackToSimultaneousContinuousMove(t *testing.T) {
target := ptzTarget{Pan: 0.5, Tilt: -0.25, Zoom: 0.4}
client := &fakePTZMoveClient{
target: target,
absoluteErr: errors.New("absolute move rejected"),
}
err := moveToPosition(client, ptzMoveSupport{Absolute: true, Continuous: true}, target, testPTZMoveOptions())
if err != nil {
t.Fatalf("moveToPosition() error = %v", err)
}
if len(client.continuousVelocities) == 0 {
t.Fatal("continuous fallback was not used")
}
first := client.continuousVelocities[0]
if first.Pan == 0 || first.Tilt == 0 || first.Zoom == 0 {
t.Fatalf("first fallback velocity = %+v, want all axes moving", first)
}
if client.stopCalls != 2 {
t.Fatalf("stop calls = %d, want native cleanup and fallback cleanup", client.stopCalls)
}
}
func TestMoveToPositionFallsBackWhenNativeMoveMakesNoProgress(t *testing.T) {
target := ptzTarget{Pan: 0.3, Tilt: 0.2, Zoom: 0.1}
client := &fakePTZMoveClient{
target: target,
ignoreAbsolute: true,
}
options := testPTZMoveOptions()
options.NoProgressTimeout = 3 * time.Millisecond
err := moveToPosition(client, ptzMoveSupport{Absolute: true, Continuous: true}, target, options)
if err != nil {
t.Fatalf("moveToPosition() error = %v", err)
}
if len(client.continuousVelocities) == 0 {
t.Fatal("continuous fallback was not used")
}
}
func TestMoveToPositionStopsAfterContinuousStatusError(t *testing.T) {
target := ptzTarget{Pan: 0.3, Tilt: 0.2, Zoom: 0.1}
client := &fakePTZMoveClient{
target: target,
positionErr: errors.New("status unavailable"),
positionErrAtCall: 3,
}
err := moveToPosition(client, ptzMoveSupport{Continuous: true}, target, testPTZMoveOptions())
if err == nil || !strings.Contains(err.Error(), "status unavailable") {
t.Fatalf("moveToPosition() error = %v, want status error", err)
}
if client.stopCalls != 1 {
t.Fatalf("stop calls = %d, want 1", client.stopCalls)
}
}
func TestMoveToPositionStopsWhenContinuousMoveIsRejected(t *testing.T) {
target := ptzTarget{Pan: 0.3, Tilt: 0.2, Zoom: 0.1}
client := &fakePTZMoveClient{
target: target,
continuousErr: errors.New("continuous move rejected"),
}
err := moveToPosition(client, ptzMoveSupport{Continuous: true}, target, testPTZMoveOptions())
if err == nil || !strings.Contains(err.Error(), "continuous move rejected") {
t.Fatalf("moveToPosition() error = %v, want movement error", err)
}
if client.stopCalls != 1 {
t.Fatalf("stop calls = %d, want 1", client.stopCalls)
}
}
func TestMoveToPositionReturnsNoProgressErrorAndStops(t *testing.T) {
target := ptzTarget{Pan: 0.3, Tilt: 0.2, Zoom: 0.1}
client := &fakePTZMoveClient{
target: target,
stalled: true,
}
options := testPTZMoveOptions()
options.NoProgressTimeout = 3 * time.Millisecond
err := moveToPosition(client, ptzMoveSupport{Continuous: true}, target, options)
if err == nil || !strings.Contains(err.Error(), "made no progress") {
t.Fatalf("moveToPosition() error = %v, want no-progress error", err)
}
if client.stopCalls != 1 {
t.Fatalf("stop calls = %d, want 1", client.stopCalls)
}
}
func TestMoveToPositionReturnsDeadlineErrorAndStops(t *testing.T) {
target := ptzTarget{Pan: 100, Tilt: 100, Zoom: 100}
client := &fakePTZMoveClient{target: target}
options := testPTZMoveOptions()
options.ContinuousTimeout = 3 * time.Millisecond
options.NoProgressTimeout = 50 * time.Millisecond
err := moveToPosition(client, ptzMoveSupport{Continuous: true}, target, options)
if err == nil || !strings.Contains(err.Error(), "target not reached within") {
t.Fatalf("moveToPosition() error = %v, want deadline error", err)
}
if client.stopCalls != 1 {
t.Fatalf("stop calls = %d, want 1", client.stopCalls)
}
}
func TestMoveToPositionRejectsIncompletePosition(t *testing.T) {
client := &fakePTZMoveClient{incompletePosition: true}
err := moveToPosition(client, ptzMoveSupport{Continuous: true}, ptzTarget{Pan: 0.1}, testPTZMoveOptions())
if err == nil || !strings.Contains(err.Error(), "incomplete PTZ position") {
t.Fatalf("moveToPosition() error = %v, want incomplete-position error", err)
}
}
func TestCombinedContinuousMoveVelocityIncludesEveryAxis(t *testing.T) {
request := ptz.ContinuousMove{
Velocity: ptz.Speed{
PanTilt: &xsdonvif.Vector2D{X: 0.5, Y: -0.25},
Zoom: &xsdonvif.Vector1D{X: 0.2},
},
}
encoded, err := xml.Marshal(request)
if err != nil {
t.Fatalf("xml.Marshal() error = %v", err)
}
xmlBody := string(encoded)
if !strings.Contains(xmlBody, `PanTilt x="0.5" y="-0.25"`) {
t.Fatalf("ContinuousMove XML = %s, want pan and tilt", xmlBody)
}
if !strings.Contains(xmlBody, `Zoom x="0.2"`) {
t.Fatalf("ContinuousMove XML = %s, want zoom", xmlBody)
}
}
func TestGetPTZFunctionsHandlesMissingConfiguration(t *testing.T) {
functions, canZoom, canPanTilt := GetPTZFunctionsFromDevice(ptz.GetConfigurationsResponse{})
if len(functions) != 0 || canZoom || canPanTilt {
t.Fatalf("GetPTZFunctionsFromDevice() = %v, %t, %t; want empty capabilities", functions, canZoom, canPanTilt)
}
}
+88
View File
@@ -0,0 +1,88 @@
package onvif
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/kerberos-io/onvif"
)
// newSubscriptionTestDevice starts a fake ONVIF device and returns a connected
// device plus the request bodies it received on the subscription endpoint.
func newSubscriptionTestDevice(t *testing.T, response string) (*onvif.Device, string, func() []string) {
t.Helper()
var mu sync.Mutex
var subscriptionBodies []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if r.URL.Path == "/onvif/subscription" {
mu.Lock()
subscriptionBodies = append(subscriptionBodies, string(body))
mu.Unlock()
_, _ = w.Write([]byte(response))
return
}
_, _ = w.Write([]byte(`<Envelope><Body/></Envelope>`))
}))
t.Cleanup(srv.Close)
dev, err := onvif.NewDevice(onvif.DeviceParams{Xaddr: strings.TrimPrefix(srv.URL, "http://")})
if err != nil {
t.Fatalf("NewDevice() error = %v", err)
}
subscriptionAddress := srv.URL + "/onvif/subscription?subscription=7&idx=2"
return dev, subscriptionAddress, func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), subscriptionBodies...)
}
}
func assertSubscriptionAddressing(t *testing.T, body string, action string) {
t.Helper()
for _, want := range []string{
`<wsa:Action xmlns:wsa="http://www.w3.org/2005/08/addressing">` + action + `</wsa:Action>`,
`<wsa:To xmlns:wsa="http://www.w3.org/2005/08/addressing">`,
`/onvif/subscription?subscription=7&amp;idx=2</wsa:To>`,
} {
if !strings.Contains(body, want) {
t.Fatalf("request body does not contain %q:\n%s", want, body)
}
}
}
// Some devices identify a pull-point subscription by wsa:To and reject
// PullMessages without it as NotAuthorized.
func TestGetEventMessagesSendsSubscriptionAddressingHeaders(t *testing.T) {
dev, subscriptionAddress, bodies := newSubscriptionTestDevice(t, `<Envelope><Body><PullMessagesResponse/></Body></Envelope>`)
if _, err := GetEventMessages(dev, subscriptionAddress); err != nil {
t.Fatalf("GetEventMessages() error = %v", err)
}
received := bodies()
if len(received) != 1 {
t.Fatalf("subscription requests = %d, want 1", len(received))
}
assertSubscriptionAddressing(t, received[0], pullMessagesAction)
}
func TestUnsubscribePullPointSendsSubscriptionAddressingHeaders(t *testing.T) {
dev, subscriptionAddress, bodies := newSubscriptionTestDevice(t, `<Envelope><Body><UnsubscribeResponse/></Body></Envelope>`)
if err := UnsubscribePullPoint(dev, subscriptionAddress); err != nil {
t.Fatalf("UnsubscribePullPoint() error = %v", err)
}
received := bodies()
if len(received) != 1 {
t.Fatalf("subscription requests = %d, want 1", len(received))
}
assertSubscriptionAddressing(t, received[0], unsubscribeAction)
}
+24 -10
View File
@@ -40,6 +40,9 @@ func NewQueue() *Queue {
}
func (self *Queue) SetMaxGopCount(n int) {
if n < 1 {
n = 1
}
self.lock.Lock()
self.maxgopcount = n
self.lock.Unlock()
@@ -97,20 +100,31 @@ func (self *Queue) WritePacket(pkt Packet) (err error) {
self.lock.Lock()
self.buf.Push(pkt)
if pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame {
isVideoKeyframe := pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame
if isVideoKeyframe {
self.curgopcount++
}
for self.curgopcount >= self.maxgopcount && self.buf.Count > 1 {
pkt := self.buf.Pop()
if pkt.Idx == int8(self.videoidx) && pkt.IsKeyFrame {
self.curgopcount--
}
if self.curgopcount < self.maxgopcount {
break
for self.curgopcount > self.maxgopcount && self.buf.Count > 0 {
for self.buf.Count > 0 {
oldest := self.buf.Pop()
if oldest.Idx == int8(self.videoidx) && oldest.IsKeyFrame {
self.curgopcount--
break
}
}
// Discard the dependent frames from the evicted GOP as well. Keeping
// them would let an overrun cursor resume on a P-frame whose reference
// keyframe is no longer in the queue.
for self.buf.Count > 0 {
oldest := self.buf.Get(self.buf.Head)
if oldest.Idx == int8(self.videoidx) && oldest.IsKeyFrame {
break
}
self.buf.Pop()
}
}
}
//println("shrink", self.curgopcount, self.maxgopcount, self.buf.Head, self.buf.Tail, "count", self.buf.Count, "size", self.buf.Size)
self.cond.Broadcast()
+99
View File
@@ -107,3 +107,102 @@ func TestQueueCursorReadPacketContextDoesNotAllocateWhenPacketIsAvailable(t *tes
t.Fatalf("ReadPacketContext() allocations = %.2f, want 0", allocations)
}
}
func TestQueueEvictionKeepsKeyframeAtHead(t *testing.T) {
queue := NewQueue()
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
t.Fatal(err)
}
queue.SetMaxGopCount(2)
for _, packet := range []Packet{
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
{Idx: 1, IsAudio: true, Data: []byte("audio-0")},
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
{Idx: 0, IsVideo: true, Data: []byte("P-1")},
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-2")},
} {
if err := queue.WritePacket(packet); err != nil {
t.Fatal(err)
}
}
oldest, err := queue.Oldest().ReadPacket()
if err != nil {
t.Fatal(err)
}
if !oldest.IsKeyFrame || string(oldest.Data) != "IDR-1" {
t.Fatalf("oldest packet = %q (keyframe=%t), want IDR-1 keyframe", oldest.Data, oldest.IsKeyFrame)
}
}
func TestQueueWithSingleGOPRetainsCurrentKeyframe(t *testing.T) {
queue := NewQueue()
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
t.Fatal(err)
}
queue.SetMaxGopCount(1)
for _, packet := range []Packet{
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
} {
if err := queue.WritePacket(packet); err != nil {
t.Fatal(err)
}
}
oldest, err := queue.Oldest().ReadPacket()
if err != nil {
t.Fatal(err)
}
if !oldest.IsKeyFrame || string(oldest.Data) != "IDR-1" {
t.Fatalf("oldest packet = %q (keyframe=%t), want IDR-1 keyframe", oldest.Data, oldest.IsKeyFrame)
}
}
func TestLaggingQueueCursorResumesAtKeyframe(t *testing.T) {
queue := NewQueue()
if err := queue.WriteHeader([]Stream{{Index: 0, IsVideo: true}}); err != nil {
t.Fatal(err)
}
queue.SetMaxGopCount(2)
for _, packet := range []Packet{
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-0")},
{Idx: 0, IsVideo: true, Data: []byte("P-0")},
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-1")},
} {
if err := queue.WritePacket(packet); err != nil {
t.Fatal(err)
}
}
cursor := queue.Oldest()
first, err := cursor.ReadPacket()
if err != nil {
t.Fatal(err)
}
if string(first.Data) != "IDR-0" {
t.Fatalf("first packet = %q, want IDR-0", first.Data)
}
for _, packet := range []Packet{
{Idx: 0, IsVideo: true, Data: []byte("P-1")},
{Idx: 0, IsVideo: true, IsKeyFrame: true, Data: []byte("IDR-2")},
} {
if err := queue.WritePacket(packet); err != nil {
t.Fatal(err)
}
}
resumed, err := cursor.ReadPacket()
if err != nil {
t.Fatal(err)
}
if !resumed.IsKeyFrame || string(resumed.Data) != "IDR-1" {
t.Fatalf("resumed packet = %q (keyframe=%t), want IDR-1 keyframe", resumed.Data, resumed.IsKeyFrame)
}
}
@@ -0,0 +1,41 @@
package http
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
)
func TestDemoEnvironmentReadOnly(t *testing.T) {
directory := t.TempDir()
for name, contents := range map[string]string{
"env.js": "production", "env.demo.js": "demo", "index.html": "index",
} {
if err := os.WriteFile(filepath.Join(directory, name), []byte(contents), 0444); err != nil {
t.Fatal(err)
}
}
if err := os.Chmod(directory, 0555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(directory, 0755) })
router := gin.New()
router.Use(demoEnvironmentMiddleware(filepath.Join(directory, "env.demo.js")))
router.Use(static.Serve("/", static.LocalFile(directory, true)))
for path, want := range map[string]string{"/env.js": "demo", "/": "index"} {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusOK || response.Body.String() != want {
t.Fatalf("%s: status %d, body %q; want %q", path, response.Code, response.Body.String(), want)
}
}
contents, err := os.ReadFile(filepath.Join(directory, "env.js"))
if err != nil || string(contents) != "production" {
t.Fatalf("production environment was changed: %q, %v", contents, err)
}
}
@@ -0,0 +1,81 @@
package http
import (
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/kerberos-io/agent/machinery/src/encryption"
"github.com/kerberos-io/agent/machinery/src/models"
)
func TestFilesServesFlatAndDatedRecordings(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, layout := range []string{"flat", "dated"} {
for _, encrypted := range []bool{false, true} {
name := layout + "-plain"
if encrypted {
name = layout + "-encrypted"
}
t.Run(name, func(t *testing.T) {
const fileName = "1767355200123-device-key.mp4"
directory := t.TempDir()
relativePath := fileName
if layout == "dated" {
relativePath = "2026/01/02/" + fileName
}
filePath := filepath.Join(directory, "data/recordings", relativePath)
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
t.Fatal(err)
}
config := &models.Configuration{Config: models.Config{Encryption: &models.Encryption{}}}
content := []byte("recording-payload")
if encrypted {
config.Config.Encryption.Recordings = "true"
config.Config.Encryption.SymmetricKey = "0123456789abcdef0123456789abcdef"
ciphertext, err := encryption.AesEncrypt(content, config.Config.Encryption.SymmetricKey)
if err != nil {
t.Fatal(err)
}
content = []byte(ciphertext)
}
if err := os.WriteFile(filePath, content, 0o644); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/file/*filepath", func(c *gin.Context) { Files(c, directory, config) })
for _, reference := range []string{relativePath, fileName} {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("GET", "/file/"+reference, nil))
if response.Code != 200 || response.Body.String() != "recording-payload" {
t.Fatalf("GET %s = %d %q", reference, response.Code, response.Body.String())
}
if got := response.Header().Get("Content-Type"); got != "video/mp4" {
t.Fatalf("Content-Type = %q, want video/mp4", got)
}
}
})
}
}
}
func TestFilesRejectsMissingAndOutsideRecordings(t *testing.T) {
directory := t.TempDir()
if err := os.MkdirAll(filepath.Join(directory, "data/recordings"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "data/outside.mp4"), []byte("not a recording"), 0o644); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/file/*filepath", func(c *gin.Context) { Files(c, directory, &models.Configuration{}) })
for _, reference := range []string{"missing.mp4", "../outside.mp4", "2026/01/02/missing.mp4"} {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("GET", "/file/"+reference, nil))
if response.Code != 404 {
t.Fatalf("GET %s = %d, want 404", reference, response.Code)
}
}
}
+2 -2
View File
@@ -65,12 +65,12 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
// Will verify the hub settings.
api.POST("/hub/verify", func(c *gin.Context) {
cloud.VerifyHub(c)
cloud.VerifyHub(c, configuration.Config.HubURI)
})
// Will verify the persistence settings.
api.POST("/persistence/verify", func(c *gin.Context) {
cloud.VerifyPersistence(c, configDirectory)
cloud.VerifyPersistence(c, configDirectory, configuration.Config.HubURI)
})
// Will verify the secondary persistence settings.
+14 -11
View File
@@ -5,6 +5,7 @@ import (
"io"
"os"
"strconv"
"strings"
"time"
jwt "github.com/appleboy/gin-jwt/v2"
@@ -19,6 +20,7 @@ import (
"github.com/kerberos-io/agent/machinery/src/capture"
"github.com/kerberos-io/agent/machinery/src/encryption"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/utils"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
@@ -41,6 +43,15 @@ import (
// @in header
// @name Authorization
func demoEnvironmentMiddleware(path string) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.URL.Path == "/env.js" && (c.Request.Method == "GET" || c.Request.Method == "HEAD") {
c.File(path)
c.Abort()
}
}
}
func StartServer(configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
// Set release mode
@@ -82,17 +93,9 @@ func StartServer(configDirectory string, configuration *models.Configuration, co
AddRoutes(r, authMiddleware, configDirectory, configuration, communication, captureDevice)
// Update environment variables
environmentVariables := configDirectory + "/www/env.js"
if os.Getenv("AGENT_MODE") == "demo" {
demoEnvironmentVariables := configDirectory + "/www/env.demo.js"
// Move demo environment variables to environment variables
err := os.Rename(demoEnvironmentVariables, environmentVariables)
if err != nil {
log.WithError(err).WithFields(log.Fields{
"component": "http",
"event": "demo_environment_move_failed",
}).Fatal("Failed to activate demo UI environment")
}
r.Use(demoEnvironmentMiddleware(demoEnvironmentVariables))
}
// Add static routes to UI
@@ -160,9 +163,9 @@ func requestLogger() gin.HandlerFunc {
func Files(c *gin.Context, configDirectory string, configuration *models.Configuration) {
// Get File
filePath := configDirectory + "/data/recordings" + c.Param("filepath")
_, err := os.Open(filePath)
filePath, err := utils.ResolveRecordingPath(configDirectory+"/data/recordings", strings.TrimPrefix(c.Param("filepath"), "/"))
if err != nil {
log.WithError(err).Warn("Could not resolve recording for playback")
c.JSON(404, gin.H{"error": "File not found"})
return
}
+45 -80
View File
@@ -191,51 +191,32 @@ func GetMediaFormatted(files []os.FileInfo, recordingDirectory string, configura
count := 0
for _, file := range files {
fileName := file.Name()
fileParts := strings.Split(fileName, "_")
if len(fileParts) == 6 {
timestamp := fileParts[0]
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
if err == nil {
if eventFilter.TimestampOffsetStart > 0 {
// TimestampOffsetStart represents the newest lower bound to include.
if timestampInt < eventFilter.TimestampOffsetStart {
continue
}
}
// If we have an offset we will check if we should skip or not
if eventFilter.TimestampOffsetEnd > 0 {
// Medias are sorted from new to older. TimestampOffsetEnd holds the oldest
// timestamp of the previous batch of events. By doing this check, we make sure
// to skip the previous batch.
if timestampInt >= eventFilter.TimestampOffsetEnd {
continue
}
}
loc, _ := time.LoadLocation(configuration.Config.Timezone)
time := time.Unix(timestampInt, 0).In(loc)
day := time.Format("02-01-2006")
timeString := time.Format("15:04:05")
shortDay := time.Format("Jan _2")
media := models.Media{
Key: fileName,
Path: recordingDirectory + "/" + fileName,
CameraName: configuration.Config.Name,
CameraKey: configuration.Config.Key,
Day: day,
ShortDay: shortDay,
Time: timeString,
Timestamp: timestamp,
}
filePaths = append(filePaths, media)
count = count + 1
if eventFilter.NumberOfElements > 0 && count >= eventFilter.NumberOfElements {
break
}
}
recordedAt, err := RecordingStartTime(fileName)
if err != nil {
continue
}
timestamp := recordedAt.Unix()
if eventFilter.TimestampOffsetStart > 0 && timestamp < eventFilter.TimestampOffsetStart {
continue
}
if eventFilter.TimestampOffsetEnd > 0 && timestamp >= eventFilter.TimestampOffsetEnd {
continue
}
loc, _ := time.LoadLocation(configuration.Config.Timezone)
localTime := recordedAt.In(loc)
filePaths = append(filePaths, models.Media{
Key: fileName,
Path: recordingDirectory + "/" + fileName,
CameraName: configuration.Config.Name,
CameraKey: configuration.Config.Key,
Day: localTime.Format("02-01-2006"),
ShortDay: localTime.Format("Jan _2"),
Time: localTime.Format("15:04:05"),
Timestamp: strconv.FormatInt(timestamp, 10),
})
count++
if eventFilter.NumberOfElements > 0 && count >= eventFilter.NumberOfElements {
break
}
}
return filePaths
@@ -244,18 +225,12 @@ func GetMediaFormatted(files []os.FileInfo, recordingDirectory string, configura
func GetDays(files []os.FileInfo, recordingDirectory string, configuration *models.Configuration) []string {
days := []string{}
for _, file := range files {
fileName := file.Name()
fileParts := strings.Split(fileName, "_")
if len(fileParts) == 6 {
timestamp := fileParts[0]
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
if err == nil {
loc, _ := time.LoadLocation(configuration.Config.Timezone)
time := time.Unix(timestampInt, 0).In(loc)
day := time.Format("02-01-2006")
days = append(days, day)
}
recordedAt, err := RecordingStartTime(file.Name())
if err != nil {
continue
}
loc, _ := time.LoadLocation(configuration.Config.Timezone)
days = append(days, recordedAt.In(loc).Format("02-01-2006"))
}
uniqueDays := Unique(days)
return uniqueDays
@@ -278,10 +253,13 @@ func NumberOfFilesInDirectory(path string) int {
return len(files)
}
// NumberOfMP4sInDirectory returns the count of all files with mp4 extension in current directory
// NumberOfMP4sInDirectory counts MP4 files, including dated subdirectories.
func NumberOfMP4sInDirectory(path string) int {
pattern := filepath.Join(path, "*.mp4")
files, _ := filepath.Glob(pattern)
files, err := ReadRecordingDirectory(path)
if err != nil {
log.WithError(err).Error("Could not count recordings")
return 0
}
return len(files)
}
@@ -409,25 +387,13 @@ func Decrypt(directoryOrFile string, symmetricKey []byte) {
var files []string
if fileInfo.IsDir() {
// Create decrypted directory
err = os.MkdirAll(directoryOrFile+"/decrypted", 0755)
if err != nil {
log.Fatal(err.Error())
return
}
dir, err := os.ReadDir(directoryOrFile)
dir, err := ReadRecordingDirectory(directoryOrFile)
if err != nil {
log.Fatal(err.Error())
return
}
for _, file := range dir {
// Check if file is not a directory
if !file.IsDir() {
// Check if an mp4 file
if strings.HasSuffix(file.Name(), ".mp4") {
files = append(files, directoryOrFile+"/"+file.Name())
}
}
files = append(files, filepath.Join(directoryOrFile, file.Name()))
}
} else {
files = append(files, directoryOrFile)
@@ -449,13 +415,12 @@ func Decrypt(directoryOrFile string, symmetricKey []byte) {
return
}
// Write decrypted content to file with appended .decrypted
// Get filename split by / and get last element.
fileParts := strings.Split(file, "/")
fileName := fileParts[len(fileParts)-1]
pathToFile := strings.Join(fileParts[:len(fileParts)-1], "/")
err = os.WriteFile(pathToFile+"/decrypted/"+fileName, []byte(decrypted), 0644)
outputDirectory := filepath.Join(filepath.Dir(file), "decrypted")
if err := os.MkdirAll(outputDirectory, 0o755); err != nil {
log.Fatal(err.Error())
return
}
err = os.WriteFile(filepath.Join(outputDirectory, filepath.Base(file)), []byte(decrypted), 0644)
if err != nil {
log.Fatal(err.Error())
return
+146
View File
@@ -0,0 +1,146 @@
package utils
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
func RecordingFileName(timestamp int64, deviceKey string) (string, error) {
if timestamp <= 0 {
return "", fmt.Errorf("invalid recording timestamp %d", timestamp)
}
if deviceKey == "" || strings.ContainsAny(deviceKey, "/\\") {
return "", fmt.Errorf("missing or invalid recording device key")
}
return strconv.FormatInt(timestamp, 10) + "-" + deviceKey + ".mp4", nil
}
func RecordingStartTime(name string) (time.Time, error) {
base := filepath.Base(name)
if !strings.HasSuffix(base, ".mp4") {
return time.Time{}, fmt.Errorf("invalid recording filename %q", name)
}
timestamp, deviceKey, short := strings.Cut(strings.TrimSuffix(base, ".mp4"), "-")
if short && deviceKey != "" {
if millis, err := strconv.ParseInt(timestamp, 10, 64); err == nil && millis > 0 {
return time.UnixMilli(millis), nil
}
}
parts := strings.Split(base, "_")
if len(parts) == 6 {
seconds, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && seconds > 0 {
return time.Unix(seconds, 0), nil
}
}
return time.Time{}, fmt.Errorf("invalid recording filename %q", name)
}
func CreateRecordingPath(directory, fileName string, recordedAt time.Time) (string, error) {
if !filepath.IsLocal(fileName) || fileName == "." || filepath.Base(fileName) != fileName || strings.ContainsAny(fileName, "/\\") {
return "", fmt.Errorf("invalid recording filename %q", fileName)
}
if recordedAt.UnixMilli() <= 0 {
return "", fmt.Errorf("invalid recording start time %s", recordedAt)
}
directory = filepath.Join(directory, recordedAt.UTC().Format("2006/01/02"))
if err := os.MkdirAll(directory, 0o755); err != nil {
return "", fmt.Errorf("create recording directory: %w", err)
}
return filepath.Join(directory, fileName), nil
}
// ResolveRecordingPath accepts relative paths from listings and historical
// basename-only references from upload markers and playback URLs.
func ResolveRecordingPath(directory, name string) (string, error) {
if !filepath.IsLocal(name) {
return "", fmt.Errorf("invalid recording path %q", name)
}
filePath := filepath.Join(directory, name)
info, err := os.Stat(filePath)
if os.IsNotExist(err) && filepath.Base(name) == name {
recordedAt, parseErr := RecordingStartTime(name)
if parseErr == nil {
filePath = filepath.Join(directory, recordedAt.UTC().Format("2006/01/02"), name)
info, err = os.Stat(filePath)
}
}
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("recording %q is not a regular file", name)
}
return filePath, nil
}
func OpenRecording(directory, name string) (*os.File, error) {
filePath, err := ResolveRecordingPath(directory, name)
if err != nil {
return nil, err
}
return os.Open(filePath)
}
func RemoveRecording(directory, name string) error {
filePath, err := ResolveRecordingPath(directory, name)
if err != nil {
return err
}
return os.Remove(filePath)
}
type recordingFileInfo struct {
os.FileInfo
relativePath string
}
func (file recordingFileInfo) Name() string {
return file.relativePath
}
// ReadRecordingDirectory returns MP4s with root-relative names, preserving the
// file information used by the existing sorting and media formatting helpers.
func ReadRecordingDirectory(directory string) ([]os.FileInfo, error) {
return readRecordingDirectory(directory, filepath.WalkDir)
}
func readRecordingDirectory(directory string, walkDir func(string, fs.WalkDirFunc) error) ([]os.FileInfo, error) {
files := []os.FileInfo{}
err := walkDir(directory, func(filePath string, entry fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) && filePath != directory {
return nil
}
return err
}
if entry.IsDir() && entry.Name() == "decrypted" && filePath != directory {
return filepath.SkipDir
}
if !entry.Type().IsRegular() || filepath.Ext(entry.Name()) != ".mp4" {
return nil
}
info, err := entry.Info()
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
name, err := filepath.Rel(directory, filePath)
if err != nil {
return err
}
files = append(files, recordingFileInfo{FileInfo: info, relativePath: filepath.ToSlash(name)})
return nil
})
if err != nil {
return nil, err
}
return files, nil
}
+311
View File
@@ -0,0 +1,311 @@
package utils
import (
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"reflect"
"strconv"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/encryption"
"github.com/kerberos-io/agent/machinery/src/models"
)
func TestShortRecordingNamePreservesMillisecondsAndDeviceKey(t *testing.T) {
const timestamp = int64(1767355200123)
const deviceKey = "device-key_with-hyphens"
name, err := RecordingFileName(timestamp, deviceKey)
if err != nil || name != "1767355200123-device-key_with-hyphens.mp4" {
t.Fatalf("RecordingFileName() = %q, %v", name, err)
}
other, err := RecordingFileName(timestamp+1, deviceKey)
if err != nil || other == name {
t.Fatalf("recordings within the same second must have distinct names: %q, %v", other, err)
}
directory := t.TempDir()
filePath, err := CreateRecordingPath(directory, name, time.UnixMilli(timestamp))
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(directory, "2026/01/02", name); filePath != want {
t.Fatalf("path = %q, want %q", filePath, want)
}
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
resolved, err := ResolveRecordingPath(directory, name)
if err != nil || resolved != filePath {
t.Fatalf("ResolveRecordingPath() = %q, %v", resolved, err)
}
files, err := ReadRecordingDirectory(directory)
if err != nil {
t.Fatal(err)
}
config := &models.Configuration{Config: models.Config{Timezone: "UTC"}}
media := GetMediaFormatted(files, directory, config, models.EventFilter{TimestampOffsetStart: timestamp / 1000})
if len(media) != 1 || media[0].Key != "2026/01/02/"+name || media[0].Timestamp != "1767355200" {
t.Fatalf("unexpected short-name media: %+v", media)
}
if days := GetDays(files, directory, config); !reflect.DeepEqual(days, []string{"02-01-2026"}) {
t.Fatalf("unexpected short-name days: %v", days)
}
if err := RemoveRecording(directory, name); err != nil {
t.Fatal(err)
}
for _, invalidKey := range []string{"", "camera/key", `camera\key`} {
if _, err := RecordingFileName(timestamp, invalidKey); err == nil {
t.Errorf("accepted invalid device key %q", invalidKey)
}
}
if _, err := RecordingFileName(0, deviceKey); err == nil {
t.Fatal("accepted invalid recording timestamp")
}
}
func TestCreateRecordingPathUsesRecordingUTCDate(t *testing.T) {
directory := t.TempDir()
recordedAt := time.Date(2026, time.January, 1, 1, 0, 0, 0, time.FixedZone("UTC+2", 2*60*60))
name := strconv.FormatInt(recordedAt.Unix(), 10) + "_3-123_camera_0-0-0-0_1_1000.mp4"
filePath, err := CreateRecordingPath(directory, name, recordedAt)
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(directory, "2025/12/31", name); filePath != want {
t.Fatalf("path = %q, want %q", filePath, want)
}
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
for _, reference := range []string{name, "2025/12/31/" + name} {
got, err := ResolveRecordingPath(directory, reference)
if err != nil || got != filePath {
t.Fatalf("ResolveRecordingPath(%q) = %q, %v; want %q", reference, got, err, filePath)
}
file, err := OpenRecording(directory, reference)
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(file)
file.Close()
if err != nil || string(content) != "video" {
t.Fatalf("recording content = %q, %v", content, err)
}
}
if err := RemoveRecording(directory, name); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Fatalf("recording still exists after removal: %v", err)
}
}
func TestRecordingPathsKeepFlatFilesAccessible(t *testing.T) {
directory := t.TempDir()
name := "legacy-recording.mp4"
filePath := filepath.Join(directory, name)
if err := os.WriteFile(filePath, []byte("legacy"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ResolveRecordingPath(directory, name)
if err != nil || got != filePath {
t.Fatalf("ResolveRecordingPath() = %q, %v; want %q", got, err, filePath)
}
if err := RemoveRecording(directory, name); err != nil {
t.Fatal(err)
}
}
func TestRecordingPathsReportInvalidAndMissingPaths(t *testing.T) {
directory := t.TempDir()
for _, name := range []string{"", ".", "..", "../outside.mp4", "/outside.mp4", "nested/recording.mp4", `nested\recording.mp4`} {
if _, err := CreateRecordingPath(directory, name, time.Now()); err == nil {
t.Errorf("CreateRecordingPath(%q) should reject invalid filename", name)
}
}
for _, name := range []string{"", ".", "../outside.mp4", "/outside.mp4"} {
if _, err := ResolveRecordingPath(directory, name); err == nil {
t.Errorf("ResolveRecordingPath(%q) should reject invalid path", name)
}
}
for _, name := range []string{"missing.mp4", "1700000000_missing.mp4", "2026/01/01/missing.mp4"} {
if _, err := ResolveRecordingPath(directory, name); !os.IsNotExist(err) {
t.Errorf("ResolveRecordingPath(%q) error = %v, want not-exist", name, err)
}
}
blocked := filepath.Join(directory, "not-a-directory")
if err := os.WriteFile(blocked, nil, 0o644); err != nil {
t.Fatal(err)
}
if _, err := CreateRecordingPath(blocked, "recording.mp4", time.Now()); err == nil {
t.Fatal("expected directory creation error")
}
if _, err := CreateRecordingPath(directory, "recording.mp4", time.Time{}); err == nil {
t.Fatal("expected invalid recording start time error")
}
if _, err := ReadRecordingDirectory(filepath.Join(directory, "missing")); !os.IsNotExist(err) {
t.Fatalf("ReadRecordingDirectory() error = %v, want not-exist", err)
}
}
func TestReadRecordingDirectoryIncludesFlatAndDatedMedia(t *testing.T) {
directory := t.TempDir()
names := []string{
"1700000000_6_7_8_9_10.mp4",
"2026/01/02/1767355200_6_7_8_9_10.mp4",
}
for index, name := range names {
filePath := filepath.Join(directory, name)
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
modified := time.Unix(int64(index+1), 0)
if err := os.Chtimes(filePath, modified, modified); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(directory, "2026/01/02/ignore.txt"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(directory, "2026/01/02/decrypted"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "2026/01/02/decrypted/output.mp4"), nil, 0o644); err != nil {
t.Fatal(err)
}
files, err := ReadRecordingDirectory(directory)
if err != nil {
t.Fatal(err)
}
if len(files) != 2 || NumberOfMP4sInDirectory(directory) != 2 {
t.Fatalf("expected 2 MP4 recordings, got %d", len(files))
}
config := &models.Configuration{Config: models.Config{Timezone: "UTC", Name: "camera", Key: "device"}}
files = GetSortedDirectory(files)
media := GetMediaFormatted(files, directory, config, models.EventFilter{})
if len(media) != 2 || media[0].Key != names[1] || media[1].Key != names[0] {
t.Fatalf("unexpected media ordering or relative keys: %+v", media)
}
if media[0].Path != filepath.Join(directory, names[1]) || media[0].Timestamp != "1767355200" {
t.Fatalf("unexpected nested media path or timestamp: %+v", media[0])
}
if got, want := GetDays(files, directory, config), []string{"02-01-2026", "14-11-2023"}; !reflect.DeepEqual(got, want) {
t.Fatalf("days = %v, want %v", got, want)
}
filtered := GetMediaFormatted(files, directory, config, models.EventFilter{TimestampOffsetStart: 1767355200})
if len(filtered) != 1 || filtered[0].Key != names[1] {
t.Fatalf("unexpected filtered nested media: %+v", filtered)
}
}
func TestReadRecordingDirectoryContinuesAfterConcurrentDeletion(t *testing.T) {
for _, origin := range []string{"Info", "WalkDir"} {
t.Run(origin, func(t *testing.T) {
directory := t.TempDir()
for _, name := range []string{"deleted.mp4", "surviving.mp4"} {
if err := os.WriteFile(filepath.Join(directory, name), []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
}
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
files, err := readRecordingDirectory(directory, func(root string, visit fs.WalkDirFunc) error {
deleted := filepath.Join(root, entries[0].Name())
if err := os.Remove(deleted); err != nil {
t.Fatal(err)
}
var err error
if origin == "Info" {
err = visit(deleted, entries[0], nil)
} else {
err = visit(deleted, nil, &fs.PathError{Op: "lstat", Path: deleted, Err: fs.ErrNotExist})
}
if err != nil {
return err
}
return visit(filepath.Join(root, entries[1].Name()), entries[1], nil)
})
if err != nil {
t.Fatal(err)
}
if len(files) != 1 || files[0].Name() != "surviving.mp4" || files[0].Size() != 5 {
t.Fatalf("expected surviving recording after concurrent deletion, got %v", files)
}
})
}
}
type recordingDirEntryInfoError struct {
fs.DirEntry
err error
}
func (entry recordingDirEntryInfoError) Info() (fs.FileInfo, error) {
return nil, entry.err
}
func TestReadRecordingDirectoryReportsOtherScanErrors(t *testing.T) {
for _, origin := range []string{"Info", "WalkDir"} {
t.Run(origin, func(t *testing.T) {
directory := t.TempDir()
filePath := filepath.Join(directory, "recording.mp4")
if err := os.WriteFile(filePath, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
wantErr := &fs.PathError{Op: "lstat", Path: filePath, Err: fs.ErrPermission}
files, err := readRecordingDirectory(directory, func(root string, visit fs.WalkDirFunc) error {
if origin == "Info" {
return visit(filePath, recordingDirEntryInfoError{DirEntry: entries[0], err: wantErr}, nil)
}
return visit(filePath, nil, wantErr)
})
if !errors.Is(err, fs.ErrPermission) || files != nil {
t.Fatalf("scan = %v, %v; want permission error without partial results", files, err)
}
})
}
}
func TestDecryptDirectoryIncludesDatedRecordingsAndSkipsOutputs(t *testing.T) {
directory := t.TempDir()
key := []byte("0123456789abcdef0123456789abcdef")
payload := []byte("recording-payload")
ciphertext, err := encryption.AesEncrypt(payload, string(key))
if err != nil {
t.Fatal(err)
}
names := []string{"legacy.mp4", "2026/01/02/recording.mp4"}
for _, name := range names {
filePath := filepath.Join(directory, name)
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filePath, []byte(ciphertext), 0o644); err != nil {
t.Fatal(err)
}
}
Decrypt(directory, key)
Decrypt(directory, key)
for _, name := range names {
output := filepath.Join(directory, filepath.Dir(name), "decrypted", filepath.Base(name))
content, err := os.ReadFile(output)
if err != nil || string(content) != string(payload) {
t.Fatalf("decrypted %q = %q, %v", name, content, err)
}
}
if got := NumberOfMP4sInDirectory(directory); got != len(names) {
t.Fatalf("recording count = %d, want %d (excluding decrypted outputs)", got, len(names))
}
}
+4 -3
View File
@@ -1,5 +1,6 @@
const { hostname, host, protocol } = window.location;
const websocketprotocol = protocol === 'http:' ? 'ws:' : 'wss:';
const devAgentPort = process.env.REACT_APP_AGENT_API_PORT || '8082';
// Uncomment this when using codespaces or other special DNS names (which you can't control)
// replace this with the DNS name of the kerberos agent server (the codespace url)
@@ -9,9 +10,9 @@ const dev = {
ENV: 'dev',
// Comment the below lines, when using codespaces or other special DNS names (which you can't control)
HOSTNAME: hostname,
API_URL: `${protocol}//${hostname}:8080/api`,
URL: `${protocol}//${hostname}:8080`,
WS_URL: `${websocketprotocol}//${hostname}:8080/ws`,
API_URL: `${protocol}//${hostname}:${devAgentPort}/api`,
URL: `${protocol}//${hostname}:${devAgentPort}`,
WS_URL: `${websocketprotocol}//${hostname}:${devAgentPort}/ws`,
MODE: window['env']['mode'],
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
// HOSTNAME: externalHost,