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
28 changed files with 640 additions and 1511 deletions
+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[@]}"
+13 -3
View File
@@ -1,7 +1,7 @@
name: Build pull request
on:
pull_request:
types: [opened, synchronize]
types: [opened, synchronize, reopened]
env:
REPO: kerberos/agent
@@ -12,6 +12,7 @@ jobs:
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- architecture: amd64
@@ -22,12 +23,21 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- 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/
+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"]
-1
View File
@@ -384,7 +384,6 @@ See [RTSPS and TLS certificates](README-RTSPS-TLS.md) for the complete Bosch UI,
| `AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR` | ONVIF endpoint/address running on the camera. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME` | ONVIF username to authenticate against. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD` | ONVIF password to authenticate against. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_CHANNEL` | 1-based channel on multi-channel ONVIF devices (encoders, NVRs) used for PTZ, presets and focus. | "0" (first profile) |
| `AGENT_CAPTURE_MOTION` | Toggle for enabling or disabling motion. | "true" |
| `AGENT_CAPTURE_LIVEVIEW` | Toggle for enabling or disabling liveview. | "true" |
| `AGENT_CAPTURE_SNAPSHOTS` | Toggle for enabling or disabling snapshot generation. | "true" |
+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
+27 -26
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
@@ -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
+59 -49
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=
@@ -781,8 +783,9 @@ github.com/kerberos-io/onvif v1.2.3/go.mod h1:XSgEQXmEDjUQTbdXvsaRJt6Az8YPGj7L+j
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=
+1 -2
View File
@@ -319,7 +319,6 @@ func heartbeatONVIFCameraKey(camera models.IPCamera) string {
camera.ONVIFXAddr,
camera.ONVIFUsername,
camera.ONVIFPassword,
strconv.Itoa(camera.ONVIFChannel),
}, "\x00")
}
@@ -414,7 +413,7 @@ func (state *heartbeatONVIFState) refreshStaticPayload(device *goonvif.Device) h
payload.panTilt = "true"
}
presets, err := heartbeatGetPresetsFromDevice(device, state.cameraConfiguration.ONVIFChannel)
presets, err := heartbeatGetPresetsFromDevice(device)
if err == nil && len(presets) > 0 {
payload.presets = "true"
presetsList, marshalErr := json.Marshal(presets)
+4 -4
View File
@@ -296,7 +296,7 @@ func TestHeartbeatONVIFPayloadCachesStaticAndReusesLoopSubscription(t *testing.T
ptzFunctionCalls++
return nil, true, true
}
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
presetCalls++
return []models.OnvifActionPreset{{Name: "Lobby", Token: "1"}}, nil
}
@@ -397,7 +397,7 @@ func TestHeartbeatONVIFPayloadRefreshesAfterCameraConfigChange(t *testing.T) {
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
return nil, false, true
}
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
presetCalls++
return nil, nil
}
@@ -477,7 +477,7 @@ func TestHeartbeatONVIFPayloadRetriesStaticFetchFailuresWithoutReconnect(t *test
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
return nil, true, true
}
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
presetCalls++
if presetCalls == 1 {
return nil, errors.New("temporary preset failure")
@@ -618,7 +618,7 @@ func TestHeartbeatONVIFPayloadReconnectFailureInvalidatesCache(t *testing.T) {
heartbeatGetPTZFunctionsFromDevice = func(goonvifptz.GetConfigurationsResponse) ([]string, bool, bool) {
return nil, true, false
}
heartbeatGetPresetsFromDevice = func(*goonvif.Device, int) ([]models.OnvifActionPreset, error) {
heartbeatGetPresetsFromDevice = func(*goonvif.Device) ([]models.OnvifActionPreset, error) {
presetCalls++
return []models.OnvifActionPreset{{Name: "Preset", Token: "1"}}, nil
}
-3
View File
@@ -394,9 +394,6 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
case "AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD":
configuration.Config.Capture.IPCamera.ONVIFPassword = value
break
case "AGENT_CAPTURE_IPCAMERA_ONVIF_CHANNEL":
configuration.Config.Capture.IPCamera.ONVIFChannel, _ = strconv.Atoi(value)
break
/* Recording mode */
case "AGENT_CAPTURE_RECORDING":
-6
View File
@@ -12,7 +12,6 @@ type OnvifCredentials struct {
ONVIFXAddr string `json:"onvif_xaddr,omitempty" bson:"onvif_xaddr"`
ONVIFUsername string `json:"onvif_username,omitempty" bson:"onvif_username"`
ONVIFPassword string `json:"onvif_password,omitempty" bson:"onvif_password"`
ONVIFChannel int `json:"onvif_channel,omitempty" bson:"onvif_channel,omitempty"` // 1-based; 0 = first profile
}
type CameraStreams struct {
@@ -69,11 +68,6 @@ type OnvifZoom struct {
Zoom float64 `json:"zoom,omitempty" bson:"zoom"`
}
type OnvifFocus struct {
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
Focus float64 `json:"focus,omitempty" bson:"focus"`
}
type OnvifPreset struct {
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
Preset string `json:"preset,omitempty" bson:"preset"`
-1
View File
@@ -105,7 +105,6 @@ type IPCamera struct {
ONVIFXAddr string `json:"onvif_xaddr" bson:"onvif_xaddr"`
ONVIFUsername string `json:"onvif_username" bson:"onvif_username"`
ONVIFPassword string `json:"onvif_password" bson:"onvif_password"`
ONVIFChannel int `json:"onvif_channel,omitempty" bson:"onvif_channel,omitempty"` // 1-based video input on multi-channel devices (encoders, NVRs); 0 = first profile
SPSNALUs [][]byte `json:"sps_nalus,omitempty" bson:"sps_nalus,omitempty"`
PPSNALUs [][]byte `json:"pps_nalus,omitempty" bson:"pps_nalus,omitempty"`
VPSNALUs [][]byte `json:"vps_nalus,omitempty" bson:"vps_nalus,omitempty"`
-1
View File
@@ -12,7 +12,6 @@ type OnvifActionPTZ struct {
Down int `json:"down" bson:"down"`
Center int `json:"center" bson:"center"`
Zoom float64 `json:"zoom" bson:"zoom"`
Focus float64 `json:"focus" bson:"focus"`
X float64 `json:"x" bson:"x"`
Y float64 `json:"y" bson:"y"`
Z float64 `json:"z" bson:"z"`
-113
View File
@@ -1,113 +0,0 @@
package onvif
import (
"errors"
"fmt"
"io"
"github.com/kerberos-io/onvif"
"github.com/kerberos-io/onvif/media"
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
)
// Multi-channel devices (encoders, NVRs) expose several media profiles per
// video input, e.g. a Hanwha SPE-420 returns DefaultProfile-01-0..03-0 for
// channel 1 and DefaultProfile-01-1..03-1 for channel 2. PTZ, presets and
// focus must be addressed with a profile (or video source) of the channel the
// camera is wired to, otherwise commands drive the wrong camera.
//
// A channel is 1-based, counted in the order the device first lists each video
// source in GetProfiles. Channel 0 (unset) keeps the historic behaviour of
// using the first profile, so single-sensor cameras need no configuration.
// GetTokenFromChannel returns the media profile token to use for PTZ commands
// on the given 1-based channel.
func GetTokenFromChannel(device *onvif.Device, channel int) (xsdonvif.ReferenceToken, error) {
if channel <= 0 {
return GetTokenFromProfile(device, 0)
}
profiles, err := getMediaProfiles(device)
if err != nil {
return "", err
}
profile, err := selectChannelProfile(profiles, channel)
if err != nil {
return "", err
}
return profile.Token, nil
}
// GetVideoSourceTokenFromChannel returns the video source token of the given
// 1-based channel, as used by the imaging (focus) service.
func GetVideoSourceTokenFromChannel(device *onvif.Device, channel int) (xsdonvif.ReferenceToken, error) {
if channel <= 0 {
return GetVideoSourceTokenFromProfile(device, 0)
}
profiles, err := getMediaProfiles(device)
if err != nil {
return "", err
}
profile, err := selectChannelProfile(profiles, channel)
if err != nil {
return "", err
}
return *profile.VideoSourceConfiguration.SourceToken, nil
}
func getMediaProfiles(device *onvif.Device) ([]xsdonvif.Profile, error) {
resp, err := device.CallMethod(media.GetProfiles{})
if err != nil {
if resp != nil {
resp.Body.Close()
}
return nil, err
}
b, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
decodedXML, et, err := getXMLNode(string(b), "GetProfilesResponse")
if err != nil {
return nil, err
}
var profilesResp media.GetProfilesResponse
if err := decodedXML.DecodeElement(&profilesResp, et); err != nil {
return nil, err
}
return profilesResp.Profiles, nil
}
// selectChannelProfile groups profiles by video source and returns a profile
// of the channel-th source (1-based). Within a channel it prefers the first
// profile that has a PTZ configuration attached.
func selectChannelProfile(profiles []xsdonvif.Profile, channel int) (xsdonvif.Profile, error) {
if channel <= 0 {
return xsdonvif.Profile{}, errors.New("onvif channel must be 1 or higher")
}
var sources []xsdonvif.ReferenceToken
bySource := map[xsdonvif.ReferenceToken][]xsdonvif.Profile{}
for _, profile := range profiles {
if profile.VideoSourceConfiguration == nil || profile.VideoSourceConfiguration.SourceToken == nil {
continue
}
source := *profile.VideoSourceConfiguration.SourceToken
if _, seen := bySource[source]; !seen {
sources = append(sources, source)
}
bySource[source] = append(bySource[source], profile)
}
if channel > len(sources) {
return xsdonvif.Profile{}, fmt.Errorf("onvif channel %d not found: device exposes %d video source(s)", channel, len(sources))
}
candidates := bySource[sources[channel-1]]
for _, profile := range candidates {
if profile.PTZConfiguration != nil {
return profile, nil
}
}
return candidates[0], nil
}
-105
View File
@@ -1,105 +0,0 @@
package onvif
import (
"fmt"
"testing"
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
)
func testProfile(token string, source string, ptz bool) xsdonvif.Profile {
profile := xsdonvif.Profile{Token: xsdonvif.ReferenceToken(token)}
if source != "" {
sourceToken := xsdonvif.ReferenceToken(source)
profile.VideoSourceConfiguration = &xsdonvif.VideoSourceConfiguration{SourceToken: &sourceToken}
}
if ptz {
profile.PTZConfiguration = &xsdonvif.PTZConfiguration{}
}
return profile
}
// spe420Profiles mirrors a Hanwha SPE-420: three profiles per channel, token
// suffix -(N-1), listed grouped by profile type rather than by channel.
func spe420Profiles() []xsdonvif.Profile {
var profiles []xsdonvif.Profile
for kind := 1; kind <= 3; kind++ {
for ch := 0; ch < 4; ch++ {
profiles = append(profiles, testProfile(
fmt.Sprintf("DefaultProfile-0%d-%d", kind, ch),
fmt.Sprintf("VideoSourceToken-%d", ch),
true,
))
}
}
return profiles
}
func TestSelectChannelProfileMapsEncoderChannels(t *testing.T) {
profiles := spe420Profiles()
for channel := 1; channel <= 4; channel++ {
profile, err := selectChannelProfile(profiles, channel)
if err != nil {
t.Fatalf("channel %d: %v", channel, err)
}
want := fmt.Sprintf("DefaultProfile-01-%d", channel-1)
if string(profile.Token) != want {
t.Fatalf("channel %d: got %s, want %s", channel, profile.Token, want)
}
}
}
func TestSelectChannelProfilePrefersProfileWithPTZ(t *testing.T) {
profiles := []xsdonvif.Profile{
testProfile("mjpeg", "src-0", false),
testProfile("main", "src-0", true),
}
profile, err := selectChannelProfile(profiles, 1)
if err != nil {
t.Fatal(err)
}
if profile.Token != "main" {
t.Fatalf("got %s, want main", profile.Token)
}
}
func TestSelectChannelProfileFallsBackToFirstProfileOfChannel(t *testing.T) {
profiles := []xsdonvif.Profile{
testProfile("a", "src-0", false),
testProfile("b", "src-1", false),
testProfile("c", "src-1", false),
}
profile, err := selectChannelProfile(profiles, 2)
if err != nil {
t.Fatal(err)
}
if profile.Token != "b" {
t.Fatalf("got %s, want b", profile.Token)
}
}
func TestSelectChannelProfileSkipsProfilesWithoutVideoSource(t *testing.T) {
profiles := []xsdonvif.Profile{
testProfile("audio-only", "", false),
testProfile("video", "src-0", false),
}
profile, err := selectChannelProfile(profiles, 1)
if err != nil {
t.Fatal(err)
}
if profile.Token != "video" {
t.Fatalf("got %s, want video", profile.Token)
}
}
func TestSelectChannelProfileRejectsUnknownChannel(t *testing.T) {
if _, err := selectChannelProfile(spe420Profiles(), 5); err == nil {
t.Fatal("expected error for channel beyond the device's video sources")
}
if _, err := selectChannelProfile(spe420Profiles(), 0); err == nil {
t.Fatal("expected error for channel 0")
}
if _, err := selectChannelProfile(nil, 1); err == nil {
t.Fatal("expected error when the device has no profiles")
}
}
-466
View File
@@ -1,466 +0,0 @@
package onvif
import (
"encoding/xml"
"errors"
"fmt"
"io"
"math"
"net/http"
"regexp"
"slices"
"time"
"github.com/kerberos-io/onvif"
"github.com/kerberos-io/onvif/imaging"
"github.com/kerberos-io/onvif/media"
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
log "github.com/sirupsen/logrus"
)
// Focus control goes through the ONVIF imaging service. The request types of
// the onvif library serialise every field (all three move types, every imaging
// setting), which cameras reject or which would overwrite unrelated imaging
// settings. The types below only carry the fields we want to send. Schema
// elements use the "onvif" prefix, which the library declares on the envelope
// as http://www.onvif.org/ver10/schema.
const (
focusModeAuto = "AUTO"
focusModeManual = "MANUAL"
// Same move duration as ContinuousZoom.
continuousFocusDuration = 500 * time.Millisecond
// Step for cameras that only support relative focus moves.
relativeFocusDistance = 0.1
// Step at full speed for cameras that only support absolute focus moves
// (e.g. Axis); scaled by the requested speed.
absoluteFocusStep = 0.1
// Time to let the camera settle in MANUAL before re-enabling AUTO.
autoFocusRetriggerDelay = 300 * time.Millisecond
)
type imagingMoveRequest struct {
XMLName xml.Name `xml:"timg:Move"`
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
Focus focusMove `xml:"timg:Focus"`
}
type focusMove struct {
Absolute *absoluteFocusMove `xml:"onvif:Absolute,omitempty"`
Relative *relativeFocusMove `xml:"onvif:Relative,omitempty"`
Continuous *continuousFocusMove `xml:"onvif:Continuous,omitempty"`
}
type absoluteFocusMove struct {
Position float64 `xml:"onvif:Position"`
}
type relativeFocusMove struct {
Distance float64 `xml:"onvif:Distance"`
Speed float64 `xml:"onvif:Speed,omitempty"`
}
type continuousFocusMove struct {
Speed float64 `xml:"onvif:Speed"`
}
type imagingStopRequest struct {
XMLName xml.Name `xml:"timg:Stop"`
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
}
type imagingGetSettingsRequest struct {
XMLName xml.Name `xml:"timg:GetImagingSettings"`
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
}
type imagingGetSettingsResponse struct {
ImagingSettings struct {
Brightness *float64 `xml:"Brightness"`
Focus struct {
AutoFocusMode string `xml:"AutoFocusMode"`
} `xml:"Focus"`
} `xml:"ImagingSettings"`
}
type imagingSetFocusModeRequest struct {
XMLName xml.Name `xml:"timg:SetImagingSettings"`
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
ImagingSettings struct {
Brightness *float64 `xml:"onvif:Brightness,omitempty"`
Focus struct {
AutoFocusMode string `xml:"onvif:AutoFocusMode"`
} `xml:"onvif:Focus"`
} `xml:"timg:ImagingSettings"`
}
type imagingGetOptionsRequest struct {
XMLName xml.Name `xml:"timg:GetOptions"`
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
}
type imagingGetOptionsResponse struct {
ImagingOptions struct {
Focus struct {
AutoFocusModes []string `xml:"AutoFocusModes"`
} `xml:"Focus"`
} `xml:"ImagingOptions"`
}
type imagingGetMoveOptionsRequest struct {
XMLName xml.Name `xml:"timg:GetMoveOptions"`
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
}
type imagingGetMoveOptionsResponse struct {
MoveOptions struct {
Absolute *struct {
Position struct {
Min float64 `xml:"Min"`
Max float64 `xml:"Max"`
} `xml:"Position"`
} `xml:"Absolute"`
Relative *struct{} `xml:"Relative"`
Continuous *struct{} `xml:"Continuous"`
} `xml:"MoveOptions"`
}
type imagingGetStatusRequest struct {
XMLName xml.Name `xml:"timg:GetStatus"`
VideoSourceToken xsdonvif.ReferenceToken `xml:"timg:VideoSourceToken"`
}
type imagingGetStatusResponse struct {
Status struct {
FocusStatus20 *struct {
Position *float64 `xml:"Position"`
} `xml:"FocusStatus20"`
FocusStatus *struct {
Position *float64 `xml:"Position"`
} `xml:"FocusStatus"`
} `xml:"Status"`
}
// focusMoveOptions lists the focus move types a camera supports.
type focusMoveOptions struct {
Continuous bool
Relative bool
Absolute bool
AbsoluteMin float64
AbsoluteMax float64
}
// allFocusMoveOptions is used when a camera does not report its move options:
// every move type is tried in turn.
var allFocusMoveOptions = focusMoveOptions{Continuous: true, Relative: true, Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1}
// focusController abstracts the imaging calls so the focus logic can be tested
// without a camera.
type focusController interface {
FocusMode() (string, error)
FocusModes() ([]string, error)
SetFocusMode(mode string) error
MoveOptions() (focusMoveOptions, error)
FocusPosition() (float64, error)
MoveFocus(move focusMove) error
StopFocus() error
}
type onvifFocusClient struct {
device *onvif.Device
endpoint string
token xsdonvif.ReferenceToken
}
func newONVIFFocusClient(device *onvif.Device, channel int) (*onvifFocusClient, error) {
endpoint, err := device.GetEndpointByRequestStruct(&imaging.Move{})
if err != nil {
return nil, errors.New("camera does not expose the ONVIF imaging service: " + err.Error())
}
token, err := GetVideoSourceTokenFromChannel(device, channel)
if err != nil {
return nil, err
}
return &onvifFocusClient{device: device, endpoint: endpoint, token: token}, nil
}
var soapFaultReason = regexp.MustCompile(`<(?:[\w.-]+:)?Text[^>]*>([^<]+)</`)
// call sends an imaging request and returns the response body. A rejected
// request returns an error carrying the camera's SOAP fault reason.
func (client *onvifFocusClient) call(operation string, request interface{}) ([]byte, error) {
body, err := xml.Marshal(request)
if err != nil {
return nil, err
}
resp, err := client.device.SendSoap(client.endpoint, string(body))
if resp == nil {
if err == nil {
err = errors.New("no response")
}
return nil, fmt.Errorf("%s: %w", operation, err)
}
b, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
reason := http.StatusText(resp.StatusCode)
if m := soapFaultReason.FindSubmatch(b); m != nil {
reason = string(m[1])
}
return nil, fmt.Errorf("%s: camera returned HTTP %d: %s", operation, resp.StatusCode, reason)
}
if readErr != nil {
return nil, fmt.Errorf("%s: %w", operation, readErr)
}
return b, nil
}
func (client *onvifFocusClient) query(operation string, request interface{}, responseNode string, response interface{}) error {
b, err := client.call(operation, request)
if err != nil {
return err
}
decodedXML, et, err := getXMLNode(string(b), responseNode)
if err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
if err := decodedXML.DecodeElement(response, et); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
func (client *onvifFocusClient) imagingSettings() (imagingGetSettingsResponse, error) {
var settings imagingGetSettingsResponse
err := client.query("get_imaging_settings", imagingGetSettingsRequest{VideoSourceToken: client.token}, "GetImagingSettingsResponse", &settings)
return settings, err
}
func (client *onvifFocusClient) FocusMode() (string, error) {
settings, err := client.imagingSettings()
return settings.ImagingSettings.Focus.AutoFocusMode, err
}
func (client *onvifFocusClient) SetFocusMode(mode string) error {
request := imagingSetFocusModeRequest{VideoSourceToken: client.token}
request.ImagingSettings.Focus.AutoFocusMode = mode
_, err := client.call("set_focus_mode", request)
if err == nil {
return nil
}
// Some cameras (seen on Axis) fail on a SetImagingSettings that only
// carries Focus. Retry with the current brightness, which leaves it as is.
settings, settingsErr := client.imagingSettings()
if settingsErr != nil || settings.ImagingSettings.Brightness == nil {
return err
}
request.ImagingSettings.Brightness = settings.ImagingSettings.Brightness
if _, retryErr := client.call("set_focus_mode", request); retryErr != nil {
return errors.Join(err, retryErr)
}
return nil
}
func (client *onvifFocusClient) FocusModes() ([]string, error) {
var response imagingGetOptionsResponse
if err := client.query("get_imaging_options", imagingGetOptionsRequest{VideoSourceToken: client.token}, "GetOptionsResponse", &response); err != nil {
return nil, err
}
return response.ImagingOptions.Focus.AutoFocusModes, nil
}
func (client *onvifFocusClient) MoveOptions() (focusMoveOptions, error) {
var response imagingGetMoveOptionsResponse
if err := client.query("get_focus_move_options", imagingGetMoveOptionsRequest{VideoSourceToken: client.token}, "GetMoveOptionsResponse", &response); err != nil {
return focusMoveOptions{}, err
}
moveOptions := response.MoveOptions
options := focusMoveOptions{
Continuous: moveOptions.Continuous != nil,
Relative: moveOptions.Relative != nil,
Absolute: moveOptions.Absolute != nil,
AbsoluteMin: 0,
AbsoluteMax: 1,
}
if moveOptions.Absolute != nil && moveOptions.Absolute.Position.Max > moveOptions.Absolute.Position.Min {
options.AbsoluteMin = moveOptions.Absolute.Position.Min
options.AbsoluteMax = moveOptions.Absolute.Position.Max
}
return options, nil
}
func (client *onvifFocusClient) FocusPosition() (float64, error) {
var response imagingGetStatusResponse
if err := client.query("get_focus_status", imagingGetStatusRequest{VideoSourceToken: client.token}, "GetStatusResponse", &response); err != nil {
return 0, err
}
status := response.Status
if status.FocusStatus20 != nil && status.FocusStatus20.Position != nil {
return *status.FocusStatus20.Position, nil
}
if status.FocusStatus != nil && status.FocusStatus.Position != nil {
return *status.FocusStatus.Position, nil
}
return 0, errors.New("get_focus_status: camera did not report a focus position")
}
func (client *onvifFocusClient) MoveFocus(move focusMove) error {
_, err := client.call("focus_move", imagingMoveRequest{VideoSourceToken: client.token, Focus: move})
return err
}
func (client *onvifFocusClient) StopFocus() error {
_, err := client.call("focus_stop", imagingStopRequest{VideoSourceToken: client.token})
return err
}
// ContinuousFocus moves the focus motor a short step, the focus equivalent of
// ContinuousZoom. Speed is in [-1, 1]: positive focuses near, negative focuses
// far. Cameras with continuous focus move for a moment and stop; cameras with
// only relative or absolute focus (e.g. Axis) step by an amount scaled to the
// speed. The camera is switched to MANUAL focus first, because cameras ignore
// manual moves while autofocus is active; use AutoFocus to hand control back.
func ContinuousFocus(device *onvif.Device, channel int, speed float64) error {
client, err := newONVIFFocusClient(device, channel)
if err != nil {
return err
}
return continuousFocus(client, speed, continuousFocusDuration)
}
// AutoFocus triggers the camera's native ONVIF autofocus. Setting AUTO while
// already in AUTO is a no-op on most cameras, so we toggle through MANUAL to
// force a fresh focus cycle.
func AutoFocus(device *onvif.Device, channel int) error {
client, err := newONVIFFocusClient(device, channel)
if err != nil {
return err
}
return autoFocus(client, autoFocusRetriggerDelay)
}
func continuousFocus(client focusController, speed float64, duration time.Duration) error {
speed = math.Max(-1, math.Min(1, speed))
if speed == 0 {
return client.StopFocus()
}
if err := ensureFocusMode(client, focusModeManual); err != nil {
// Some cameras have no focus mode setting but still accept moves.
log.Debug("onvif.continuousFocus(): could not switch to manual focus: " + err.Error())
}
options, err := client.MoveOptions()
if err != nil {
log.Debug("onvif.continuousFocus(): could not read focus move options, trying all move types: " + err.Error())
options = allFocusMoveOptions
}
var moveErrs []error
if options.Continuous {
err := client.MoveFocus(focusMove{Continuous: &continuousFocusMove{Speed: speed}})
if err == nil {
time.Sleep(duration)
return client.StopFocus()
}
moveErrs = append(moveErrs, err)
}
if options.Relative {
// A single relative step needs no stop.
err := client.MoveFocus(focusMove{Relative: &relativeFocusMove{
Distance: math.Copysign(relativeFocusDistance, speed),
Speed: math.Abs(speed),
}})
if err == nil {
return nil
}
moveErrs = append(moveErrs, err)
}
if options.Absolute {
position, err := client.FocusPosition()
if err == nil {
target := math.Max(options.AbsoluteMin, math.Min(options.AbsoluteMax, position+speed*absoluteFocusStep))
err = client.MoveFocus(focusMove{Absolute: &absoluteFocusMove{Position: target}})
}
if err == nil {
return nil
}
moveErrs = append(moveErrs, err)
}
if len(moveErrs) == 0 {
return errors.New("camera does not support any focus move")
}
return errors.Join(moveErrs...)
}
func ensureFocusMode(client focusController, mode string) error {
current, err := client.FocusMode()
if err == nil && current == mode {
return nil
}
return client.SetFocusMode(mode)
}
var errNoNativeAutoFocus = errors.New("camera does not support native ONVIF autofocus")
func autoFocus(client focusController, delay time.Duration) error {
// Some cameras (seen on Dahua) only advertise MANUAL, yet accept a switch
// to AUTO and silently ignore it.
if modes, err := client.FocusModes(); err == nil && !slices.Contains(modes, focusModeAuto) {
return errNoNativeAutoFocus
}
mode, err := client.FocusMode()
if err == nil && mode == focusModeAuto {
if err := client.SetFocusMode(focusModeManual); err != nil {
return err
}
time.Sleep(delay)
}
if err := client.SetFocusMode(focusModeAuto); err != nil {
return err
}
if mode, err := client.FocusMode(); err == nil && mode != focusModeAuto {
return fmt.Errorf("%w: camera accepted AUTO but reports %s", errNoNativeAutoFocus, mode)
}
return nil
}
// GetVideoSourceTokenFromProfile returns the video source token of a media
// profile; the imaging service is addressed by video source, not by profile.
func GetVideoSourceTokenFromProfile(device *onvif.Device, profileId int) (xsdonvif.ReferenceToken, error) {
var sourceToken xsdonvif.ReferenceToken
resp, err := device.CallMethod(media.GetProfiles{})
if err != nil {
if resp != nil {
resp.Body.Close()
}
return sourceToken, err
}
b, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return sourceToken, err
}
decodedXML, et, err := getXMLNode(string(b), "GetProfilesResponse")
if err != nil {
return sourceToken, err
}
var mProfilesResp media.GetProfilesResponse
if err := decodedXML.DecodeElement(&mProfilesResp, et); err != nil {
return sourceToken, err
}
for i, profile := range mProfilesResp.Profiles {
if i == profileId && profile.VideoSourceConfiguration != nil && profile.VideoSourceConfiguration.SourceToken != nil {
return *profile.VideoSourceConfiguration.SourceToken, nil
}
}
return sourceToken, errors.New("no video source token found for profile")
}
-418
View File
@@ -1,418 +0,0 @@
package onvif
import (
"encoding/xml"
"errors"
"math"
"strings"
"testing"
)
type fakeFocusClient struct {
mode string
modeErr error
focusModes []string
focusModesErr error
ignoreAuto bool
setModeErr error
options *focusMoveOptions
optionsErr error
position float64
positionErr error
continuousErr error
relativeErr error
absoluteErr error
modes []string
moves []focusMove
stopCalls int
}
func (client *fakeFocusClient) FocusMode() (string, error) {
return client.mode, client.modeErr
}
func (client *fakeFocusClient) FocusModes() ([]string, error) {
if client.focusModes == nil && client.focusModesErr == nil {
return []string{focusModeManual, focusModeAuto}, nil
}
return client.focusModes, client.focusModesErr
}
func (client *fakeFocusClient) SetFocusMode(mode string) error {
client.modes = append(client.modes, mode)
if client.setModeErr != nil {
return client.setModeErr
}
if mode == focusModeAuto && client.ignoreAuto {
return nil
}
client.mode = mode
return nil
}
func (client *fakeFocusClient) MoveOptions() (focusMoveOptions, error) {
if client.optionsErr != nil {
return focusMoveOptions{}, client.optionsErr
}
if client.options == nil {
return allFocusMoveOptions, nil
}
return *client.options, nil
}
func (client *fakeFocusClient) FocusPosition() (float64, error) {
return client.position, client.positionErr
}
func (client *fakeFocusClient) MoveFocus(move focusMove) error {
client.moves = append(client.moves, move)
switch {
case move.Continuous != nil:
return client.continuousErr
case move.Relative != nil:
return client.relativeErr
default:
return client.absoluteErr
}
}
func (client *fakeFocusClient) StopFocus() error {
client.stopCalls++
return nil
}
func TestContinuousFocusSwitchesToManualMovesAndStops(t *testing.T) {
client := &fakeFocusClient{mode: focusModeAuto}
if err := continuousFocus(client, 0.5, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if len(client.modes) != 1 || client.modes[0] != focusModeManual {
t.Fatalf("modes = %v, want [MANUAL]", client.modes)
}
if len(client.moves) != 1 || client.moves[0].Continuous == nil || client.moves[0].Continuous.Speed != 0.5 {
t.Fatalf("moves = %+v, want one continuous move at 0.5", client.moves)
}
if client.stopCalls != 1 {
t.Fatalf("stopCalls = %d, want 1", client.stopCalls)
}
}
func TestContinuousFocusKeepsManualMode(t *testing.T) {
client := &fakeFocusClient{mode: focusModeManual}
if err := continuousFocus(client, 0.5, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if len(client.modes) != 0 {
t.Fatalf("modes = %v, want no mode change when already MANUAL", client.modes)
}
}
func TestContinuousFocusClampsSpeed(t *testing.T) {
client := &fakeFocusClient{}
if err := continuousFocus(client, -3, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if got := client.moves[0].Continuous.Speed; got != -1 {
t.Fatalf("speed = %v, want -1", got)
}
}
func TestContinuousFocusIgnoresUnsupportedFocusMode(t *testing.T) {
client := &fakeFocusClient{setModeErr: errors.New("not supported")}
if err := continuousFocus(client, 0.5, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if len(client.moves) != 1 || client.stopCalls != 1 {
t.Fatalf("moves = %d stopCalls = %d, want 1 and 1", len(client.moves), client.stopCalls)
}
}
func TestContinuousFocusFallsBackToRelative(t *testing.T) {
client := &fakeFocusClient{continuousErr: errors.New("continuous not supported")}
if err := continuousFocus(client, -0.4, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if len(client.moves) != 2 || client.moves[1].Relative == nil {
t.Fatalf("moves = %+v, want continuous then relative", client.moves)
}
relative := client.moves[1].Relative
if relative.Distance != -relativeFocusDistance || relative.Speed != 0.4 {
t.Fatalf("relative = %+v, want distance %v speed 0.4", relative, -relativeFocusDistance)
}
if client.stopCalls != 0 {
t.Fatalf("stopCalls = %d, want 0 for a relative move", client.stopCalls)
}
}
func TestContinuousFocusUsesAbsoluteWhenOnlyAbsoluteSupported(t *testing.T) {
// Axis cameras report only absolute focus moves.
client := &fakeFocusClient{
mode: focusModeManual,
options: &focusMoveOptions{Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1},
position: 0.48,
}
if err := continuousFocus(client, 0.5, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if len(client.moves) != 1 || client.moves[0].Absolute == nil {
t.Fatalf("moves = %+v, want a single absolute move", client.moves)
}
if got, want := client.moves[0].Absolute.Position, 0.48+0.5*absoluteFocusStep; math.Abs(got-want) > 1e-9 {
t.Fatalf("position = %v, want %v", got, want)
}
if client.stopCalls != 0 {
t.Fatalf("stopCalls = %d, want 0 for an absolute move", client.stopCalls)
}
}
func TestContinuousFocusClampsAbsoluteToRange(t *testing.T) {
client := &fakeFocusClient{
options: &focusMoveOptions{Absolute: true, AbsoluteMin: 0, AbsoluteMax: 1},
position: 0.02,
}
if err := continuousFocus(client, -1, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if got := client.moves[0].Absolute.Position; got != 0 {
t.Fatalf("position = %v, want clamped to 0", got)
}
}
func TestContinuousFocusTriesAllMovesWithoutOptions(t *testing.T) {
client := &fakeFocusClient{
optionsErr: errors.New("no move options"),
continuousErr: errors.New("continuous not supported"),
relativeErr: errors.New("relative not supported"),
position: 0.5,
}
if err := continuousFocus(client, 0.5, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if len(client.moves) != 3 || client.moves[2].Absolute == nil {
t.Fatalf("moves = %+v, want continuous, relative then absolute", client.moves)
}
}
func TestContinuousFocusReturnsErrorWhenAllMovesFail(t *testing.T) {
client := &fakeFocusClient{
continuousErr: errors.New("continuous not supported"),
relativeErr: errors.New("relative not supported"),
positionErr: errors.New("no position"),
}
err := continuousFocus(client, 0.5, 0)
if err == nil || !strings.Contains(err.Error(), "continuous") || !strings.Contains(err.Error(), "relative") || !strings.Contains(err.Error(), "position") {
t.Fatalf("continuousFocus() error = %v, want every move error", err)
}
}
func TestContinuousFocusReturnsErrorWhenNoMoveSupported(t *testing.T) {
client := &fakeFocusClient{options: &focusMoveOptions{}}
if err := continuousFocus(client, 0.5, 0); err == nil {
t.Fatal("continuousFocus() error = nil, want an error")
}
if len(client.moves) != 0 {
t.Fatalf("moves = %+v, want none", client.moves)
}
}
func TestContinuousFocusZeroSpeedStops(t *testing.T) {
client := &fakeFocusClient{}
if err := continuousFocus(client, 0, 0); err != nil {
t.Fatalf("continuousFocus() error = %v", err)
}
if len(client.moves) != 0 || len(client.modes) != 0 || client.stopCalls != 1 {
t.Fatalf("moves = %d modes = %v stopCalls = %d, want only a stop", len(client.moves), client.modes, client.stopCalls)
}
}
func TestAutoFocusRetriggersWhenAlreadyAuto(t *testing.T) {
client := &fakeFocusClient{mode: focusModeAuto}
if err := autoFocus(client, 0); err != nil {
t.Fatalf("autoFocus() error = %v", err)
}
if strings.Join(client.modes, ",") != "MANUAL,AUTO" {
t.Fatalf("modes = %v, want [MANUAL AUTO]", client.modes)
}
}
func TestAutoFocusFromManual(t *testing.T) {
client := &fakeFocusClient{mode: focusModeManual}
if err := autoFocus(client, 0); err != nil {
t.Fatalf("autoFocus() error = %v", err)
}
if strings.Join(client.modes, ",") != "AUTO" {
t.Fatalf("modes = %v, want [AUTO]", client.modes)
}
}
func TestAutoFocusWhenModeUnreadable(t *testing.T) {
client := &fakeFocusClient{modeErr: errors.New("unreadable")}
if err := autoFocus(client, 0); err != nil {
t.Fatalf("autoFocus() error = %v", err)
}
if strings.Join(client.modes, ",") != "AUTO" {
t.Fatalf("modes = %v, want [AUTO]", client.modes)
}
}
func TestAutoFocusRejectsCameraWithoutAutoMode(t *testing.T) {
// Dahua cameras advertise only MANUAL.
client := &fakeFocusClient{mode: focusModeManual, focusModes: []string{focusModeManual}}
if err := autoFocus(client, 0); !errors.Is(err, errNoNativeAutoFocus) {
t.Fatalf("autoFocus() error = %v, want errNoNativeAutoFocus", err)
}
if len(client.modes) != 0 {
t.Fatalf("modes = %v, want no mode change", client.modes)
}
}
func TestAutoFocusDetectsIgnoredAutoMode(t *testing.T) {
client := &fakeFocusClient{mode: focusModeManual, ignoreAuto: true}
if err := autoFocus(client, 0); !errors.Is(err, errNoNativeAutoFocus) {
t.Fatalf("autoFocus() error = %v, want errNoNativeAutoFocus", err)
}
}
func TestAutoFocusWhenFocusModesUnreadable(t *testing.T) {
client := &fakeFocusClient{mode: focusModeManual, focusModesErr: errors.New("no options")}
if err := autoFocus(client, 0); err != nil {
t.Fatalf("autoFocus() error = %v", err)
}
if strings.Join(client.modes, ",") != "AUTO" {
t.Fatalf("modes = %v, want [AUTO]", client.modes)
}
}
func TestImagingRequestsOnlyCarryFocusFields(t *testing.T) {
move, err := xml.Marshal(imagingMoveRequest{
VideoSourceToken: "source",
Focus: focusMove{Continuous: &continuousFocusMove{Speed: 0.5}},
})
if err != nil {
t.Fatalf("marshal move: %v", err)
}
wantMove := "<timg:Move><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:Focus><onvif:Continuous><onvif:Speed>0.5</onvif:Speed></onvif:Continuous></timg:Focus></timg:Move>"
if string(move) != wantMove {
t.Fatalf("move XML = %s\nwant %s", move, wantMove)
}
absolute, err := xml.Marshal(imagingMoveRequest{
VideoSourceToken: "source",
Focus: focusMove{Absolute: &absoluteFocusMove{Position: 0.53}},
})
if err != nil {
t.Fatalf("marshal absolute move: %v", err)
}
wantAbsolute := "<timg:Move><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:Focus><onvif:Absolute><onvif:Position>0.53</onvif:Position></onvif:Absolute></timg:Focus></timg:Move>"
if string(absolute) != wantAbsolute {
t.Fatalf("absolute move XML = %s\nwant %s", absolute, wantAbsolute)
}
request := imagingSetFocusModeRequest{VideoSourceToken: "source"}
request.ImagingSettings.Focus.AutoFocusMode = focusModeManual
settings, err := xml.Marshal(request)
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
wantSettings := "<timg:SetImagingSettings><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:ImagingSettings><onvif:Focus><onvif:AutoFocusMode>MANUAL</onvif:AutoFocusMode></onvif:Focus></timg:ImagingSettings></timg:SetImagingSettings>"
if string(settings) != wantSettings {
t.Fatalf("settings XML = %s\nwant %s", settings, wantSettings)
}
// Brightness must come before Focus (schema order) for the Axis retry.
brightness := 50.0
request.ImagingSettings.Brightness = &brightness
settings, err = xml.Marshal(request)
if err != nil {
t.Fatalf("marshal settings with brightness: %v", err)
}
wantSettings = "<timg:SetImagingSettings><timg:VideoSourceToken>source</timg:VideoSourceToken><timg:ImagingSettings><onvif:Brightness>50</onvif:Brightness><onvif:Focus><onvif:AutoFocusMode>MANUAL</onvif:AutoFocusMode></onvif:Focus></timg:ImagingSettings></timg:SetImagingSettings>"
if string(settings) != wantSettings {
t.Fatalf("settings XML = %s\nwant %s", settings, wantSettings)
}
}
const testImagingEnvelope = `<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:timg="http://www.onvif.org/ver20/imaging/wsdl" xmlns:tt="http://www.onvif.org/ver10/schema"><SOAP-ENV:Body>%s</SOAP-ENV:Body></SOAP-ENV:Envelope>`
func decodeTestImagingResponse(t *testing.T, body string, node string, out interface{}) {
t.Helper()
decoder, start, err := getXMLNode(strings.Replace(testImagingEnvelope, "%s", body, 1), node)
if err != nil {
t.Fatalf("getXMLNode() error = %v", err)
}
if err := decoder.DecodeElement(out, start); err != nil {
t.Fatalf("DecodeElement() error = %v", err)
}
}
func TestImagingSettingsResponseDecodesFocusModeAndBrightness(t *testing.T) {
var settings imagingGetSettingsResponse
decodeTestImagingResponse(t, `<timg:GetImagingSettingsResponse><timg:ImagingSettings><tt:Brightness>50</tt:Brightness><tt:Focus><tt:AutoFocusMode>AUTO</tt:AutoFocusMode></tt:Focus></timg:ImagingSettings></timg:GetImagingSettingsResponse>`, "GetImagingSettingsResponse", &settings)
if settings.ImagingSettings.Focus.AutoFocusMode != focusModeAuto {
t.Fatalf("AutoFocusMode = %q, want AUTO", settings.ImagingSettings.Focus.AutoFocusMode)
}
if settings.ImagingSettings.Brightness == nil || *settings.ImagingSettings.Brightness != 50 {
t.Fatalf("Brightness = %v, want 50", settings.ImagingSettings.Brightness)
}
}
func TestImagingOptionsResponseDecodesFocusModes(t *testing.T) {
// Response captured from a Dahua camera.
var response imagingGetOptionsResponse
decodeTestImagingResponse(t, `<timg:GetOptionsResponse><timg:ImagingOptions><tt:Brightness><tt:Min>0</tt:Min><tt:Max>100</tt:Max></tt:Brightness><tt:Focus><tt:AutoFocusModes>MANUAL</tt:AutoFocusModes><tt:DefaultSpeed><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:DefaultSpeed></tt:Focus></timg:ImagingOptions></timg:GetOptionsResponse>`, "GetOptionsResponse", &response)
if got := strings.Join(response.ImagingOptions.Focus.AutoFocusModes, ","); got != "MANUAL" {
t.Fatalf("AutoFocusModes = %q, want MANUAL", got)
}
}
func TestMoveOptionsResponseDecodesAbsoluteOnly(t *testing.T) {
// Response captured from an Axis camera.
var response imagingGetMoveOptionsResponse
decodeTestImagingResponse(t, `<timg:GetMoveOptionsResponse><timg:MoveOptions><tt:Absolute><tt:Position><tt:Min>0</tt:Min><tt:Max>1</tt:Max></tt:Position></tt:Absolute></timg:MoveOptions></timg:GetMoveOptionsResponse>`, "GetMoveOptionsResponse", &response)
options := response.MoveOptions
if options.Absolute == nil || options.Relative != nil || options.Continuous != nil {
t.Fatalf("options = %+v, want only absolute", options)
}
if options.Absolute.Position.Min != 0 || options.Absolute.Position.Max != 1 {
t.Fatalf("absolute range = %+v, want 0..1", options.Absolute.Position)
}
}
func TestStatusResponseDecodesFocusPosition(t *testing.T) {
// Response captured from an Axis camera.
var response imagingGetStatusResponse
decodeTestImagingResponse(t, `<timg:GetStatusResponse><timg:Status><tt:FocusStatus20><tt:Position>0.479226589</tt:Position><tt:MoveStatus>IDLE</tt:MoveStatus></tt:FocusStatus20></timg:Status></timg:GetStatusResponse>`, "GetStatusResponse", &response)
if response.Status.FocusStatus20 == nil || response.Status.FocusStatus20.Position == nil || *response.Status.FocusStatus20.Position != 0.479226589 {
t.Fatalf("status = %+v, want position 0.479226589", response.Status)
}
}
func TestSOAPFaultReasonExtraction(t *testing.T) {
fault := `<SOAP-ENV:Fault><SOAP-ENV:Reason><SOAP-ENV:Text xml:lang="en">Argument value invalid</SOAP-ENV:Text></SOAP-ENV:Reason></SOAP-ENV:Fault>`
m := soapFaultReason.FindStringSubmatch(fault)
if m == nil || m[1] != "Argument value invalid" {
t.Fatalf("fault reason = %v, want Argument value invalid", m)
}
}
+14 -35
View File
@@ -119,30 +119,10 @@ func HandleONVIFActions(configuration *models.Configuration, communication *mode
// Connect to Onvif device
cameraConfiguration := configuration.Config.Capture.IPCamera
device, _, err := ConnectToOnvifDevice(&cameraConfiguration)
if err == nil && (onvifAction.Action == "focus" || onvifAction.Action == "autofocus") {
if err == nil {
// Focus goes through the imaging service, so it also works on
// cameras without PTZ (e.g. fixed cameras with a motorised lens).
if onvifAction.Action == "focus" {
err := ContinuousFocus(device, cameraConfiguration.ONVIFChannel, ptzAction.Focus)
if err != nil {
log.Error("onvif.HandleONVIFActions() - ContinuousFocus: " + err.Error())
} else {
log.Info("onvif.HandleONVIFActions() - ContinuousFocus: successfully focused camera")
}
} else {
err := AutoFocus(device, cameraConfiguration.ONVIFChannel)
if err != nil {
log.Error("onvif.HandleONVIFActions() - AutoFocus: " + err.Error())
} else {
log.Info("onvif.HandleONVIFActions() - AutoFocus: successfully triggered autofocus")
}
}
} else if err == nil {
// Get the profile token of the configured channel
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
// Get token from the first profile
token, err := GetTokenFromProfile(device, 0)
if err == nil {
// Get the configurations from the device
@@ -174,7 +154,7 @@ func HandleONVIFActions(configuration *models.Configuration, communication *mode
// Execute the preset
preset := ptzAction.Preset
err := GoToPresetFromDevice(device, cameraConfiguration.ONVIFChannel, preset)
err := GoToPresetFromDevice(device, preset)
if err != nil {
log.Debug("onvif.HandleONVIFActions() - GotoPreset: " + err.Error())
} else {
@@ -369,8 +349,8 @@ func GetPositionFromDevice(configuration models.Configuration) (xsdonvif.PTZVect
device, _, err := ConnectToOnvifDevice(&cameraConfiguration)
if err == nil {
// Get the profile token of the configured channel
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
// Get token from the first profile
token, err := GetTokenFromProfile(device, 0)
if err == nil {
// Get the PTZ configurations from the device
position, err := GetPosition(device, token)
@@ -883,12 +863,12 @@ func GetCapabilitiesFromDevice(dev *onvif.Device) []string {
return capabilities
}
func GetPresetsFromDevice(device *onvif.Device, channel int) ([]models.OnvifActionPreset, error) {
func GetPresetsFromDevice(device *onvif.Device) ([]models.OnvifActionPreset, error) {
var presets []models.OnvifActionPreset
var presetsResponse ptz.GetPresetsResponse
// Get the profile token of the requested channel
token, err := GetTokenFromChannel(device, channel)
// Get token from the first profile
token, err := GetTokenFromProfile(device, 0)
if err == nil {
resp, err := device.CallMethod(ptz.GetPresets{
ProfileToken: token,
@@ -933,11 +913,11 @@ func GetPresetsFromDevice(device *onvif.Device, channel int) ([]models.OnvifActi
return presets, err
}
func GoToPresetFromDevice(device *onvif.Device, channel int, presetName string) error {
func GoToPresetFromDevice(device *onvif.Device, presetName string) error {
var goToPresetResponse ptz.GotoPresetResponse
// Get the profile token of the requested channel
token, err := GetTokenFromChannel(device, channel)
// Get token from the first profile
token, err := GetTokenFromProfile(device, 0)
if err == nil {
preset := xsdonvif.ReferenceToken(presetName)
resp, err := device.CallMethod(ptz.GotoPreset{
@@ -1040,7 +1020,6 @@ func VerifyOnvifConnection(c *gin.Context) {
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifCredentials.ONVIFUsername,
ONVIFPassword: onvifCredentials.ONVIFPassword,
ONVIFChannel: onvifCredentials.ONVIFChannel,
},
},
},
@@ -1049,8 +1028,8 @@ func VerifyOnvifConnection(c *gin.Context) {
cameraConfiguration := configuration.Config.Capture.IPCamera
device, capabilities, err := ConnectToOnvifDevice(&cameraConfiguration)
if err == nil {
// Get the profile token of the requested channel
token, err := GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
// Get token from the first profile
token, err := GetTokenFromProfile(device, 0)
if err == nil {
c.JSON(200, gin.H{
"device": device,
@@ -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)
}
}
+8 -66
View File
@@ -1,7 +1,6 @@
package http
import (
"errors"
"strconv"
"strings"
"time"
@@ -75,7 +74,6 @@ func LoginToOnvif(c *gin.Context) {
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifCredentials.ONVIFUsername,
ONVIFPassword: onvifCredentials.ONVIFPassword,
ONVIFChannel: onvifCredentials.ONVIFChannel,
},
},
},
@@ -84,8 +82,8 @@ func LoginToOnvif(c *gin.Context) {
cameraConfiguration := configuration.Config.Capture.IPCamera
device, capabilities, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
if err == nil {
// Get the profile token of the requested channel
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
// Get token from the first profile
token, err := onvif.GetTokenFromProfile(device, 0)
if err == nil {
c.JSON(200, gin.H{
"device": device,
@@ -130,7 +128,6 @@ func GetOnvifCapabilities(c *gin.Context) {
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifCredentials.ONVIFUsername,
ONVIFPassword: onvifCredentials.ONVIFPassword,
ONVIFChannel: onvifCredentials.ONVIFChannel,
},
},
},
@@ -175,7 +172,6 @@ func DoOnvifPanTilt(c *gin.Context) {
ONVIFXAddr: onvifPanTilt.OnvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifPanTilt.OnvifCredentials.ONVIFUsername,
ONVIFPassword: onvifPanTilt.OnvifCredentials.ONVIFPassword,
ONVIFChannel: onvifPanTilt.OnvifCredentials.ONVIFChannel,
},
},
},
@@ -185,8 +181,8 @@ func DoOnvifPanTilt(c *gin.Context) {
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
if err == nil {
// Get the profile token of the requested channel
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
// Get token from the first profile
token, err := onvif.GetTokenFromProfile(device, 0)
if err == nil {
@@ -250,7 +246,6 @@ func DoOnvifZoom(c *gin.Context) {
ONVIFXAddr: onvifZoom.OnvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifZoom.OnvifCredentials.ONVIFUsername,
ONVIFPassword: onvifZoom.OnvifCredentials.ONVIFPassword,
ONVIFChannel: onvifZoom.OnvifCredentials.ONVIFChannel,
},
},
},
@@ -260,8 +255,8 @@ func DoOnvifZoom(c *gin.Context) {
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
if err == nil {
// Get the profile token of the requested channel
token, err := onvif.GetTokenFromChannel(device, cameraConfiguration.ONVIFChannel)
// Get token from the first profile
token, err := onvif.GetTokenFromProfile(device, 0)
if err == nil {
@@ -303,54 +298,6 @@ func DoOnvifZoom(c *gin.Context) {
}
}
// DoOnvifFocus godoc
// @Router /api/camera/onvif/focus [post]
// @ID camera-onvif-focus
// @Tags onvif
// @Param focus body models.OnvifFocus true "OnvifFocus"
// @Summary Focusing the camera near or far.
// @Description Focusing the camera near (positive) or far (negative).
// @Success 200 {object} models.APIResponse
func DoOnvifFocus(c *gin.Context) {
var onvifFocus models.OnvifFocus
err := c.BindJSON(&onvifFocus)
if err == nil && onvifFocus.OnvifCredentials.ONVIFXAddr == "" {
err = errors.New("missing onvif_xaddr")
}
if err == nil {
cameraConfiguration := models.IPCamera{
ONVIFXAddr: onvifFocus.OnvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifFocus.OnvifCredentials.ONVIFUsername,
ONVIFPassword: onvifFocus.OnvifCredentials.ONVIFPassword,
ONVIFChannel: onvifFocus.OnvifCredentials.ONVIFChannel,
}
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
if err == nil {
err := onvif.ContinuousFocus(device, cameraConfiguration.ONVIFChannel, onvifFocus.Focus)
if err == nil {
c.JSON(200, models.APIResponse{
Message: "Successfully focused the camera",
})
} else {
c.JSON(400, models.APIResponse{
Message: "Something went wrong: " + err.Error(),
})
}
} else {
c.JSON(400, models.APIResponse{
Message: "Something went wrong: " + err.Error(),
})
}
} else {
c.JSON(400, models.APIResponse{
Message: "Something went wrong: " + err.Error(),
})
}
}
// GetOnvifPresets godoc
// @Router /api/camera/onvif/presets [post]
// @ID camera-onvif-presets
@@ -372,7 +319,6 @@ func GetOnvifPresets(c *gin.Context) {
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifCredentials.ONVIFUsername,
ONVIFPassword: onvifCredentials.ONVIFPassword,
ONVIFChannel: onvifCredentials.ONVIFChannel,
},
},
},
@@ -381,7 +327,7 @@ func GetOnvifPresets(c *gin.Context) {
cameraConfiguration := configuration.Config.Capture.IPCamera
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
if err == nil {
presets, err := onvif.GetPresetsFromDevice(device, cameraConfiguration.ONVIFChannel)
presets, err := onvif.GetPresetsFromDevice(device)
if err == nil {
c.JSON(200, gin.H{
"presets": presets,
@@ -424,7 +370,6 @@ func GoToOnvifPreset(c *gin.Context) {
ONVIFXAddr: onvifPreset.OnvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifPreset.OnvifCredentials.ONVIFUsername,
ONVIFPassword: onvifPreset.OnvifCredentials.ONVIFPassword,
ONVIFChannel: onvifPreset.OnvifCredentials.ONVIFChannel,
},
},
},
@@ -433,7 +378,7 @@ func GoToOnvifPreset(c *gin.Context) {
cameraConfiguration := configuration.Config.Capture.IPCamera
device, _, err := onvif.ConnectToOnvifDevice(&cameraConfiguration)
if err == nil {
err := onvif.GoToPresetFromDevice(device, cameraConfiguration.ONVIFChannel, onvifPreset.Preset)
err := onvif.GoToPresetFromDevice(device, onvifPreset.Preset)
if err == nil {
c.JSON(200, gin.H{
"data": "Camera preset activated: " + onvifPreset.Preset,
@@ -480,7 +425,6 @@ func DoGetDigitalInputs(c *gin.Context) {
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifCredentials.ONVIFUsername,
ONVIFPassword: onvifCredentials.ONVIFPassword,
ONVIFChannel: onvifCredentials.ONVIFChannel,
},
},
},
@@ -570,7 +514,6 @@ func DoGetRelayOutputs(c *gin.Context) {
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifCredentials.ONVIFUsername,
ONVIFPassword: onvifCredentials.ONVIFPassword,
ONVIFChannel: onvifCredentials.ONVIFChannel,
},
},
},
@@ -644,7 +587,6 @@ func DoTriggerRelayOutput(c *gin.Context) {
ONVIFXAddr: onvifCredentials.ONVIFXAddr,
ONVIFUsername: onvifCredentials.ONVIFUsername,
ONVIFPassword: onvifCredentials.ONVIFPassword,
ONVIFChannel: onvifCredentials.ONVIFChannel,
},
},
},
-1
View File
@@ -108,7 +108,6 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
api.POST("/camera/onvif/gotopreset", GoToOnvifPreset)
api.POST("/camera/onvif/pantilt", DoOnvifPanTilt)
api.POST("/camera/onvif/zoom", DoOnvifZoom)
api.POST("/camera/onvif/focus", DoOnvifFocus)
api.POST("/camera/onvif/inputs", DoGetDigitalInputs)
api.POST("/camera/onvif/outputs", DoGetRelayOutputs)
api.POST("/camera/onvif/outputs/:output", DoTriggerRelayOutput)
+10 -9
View File
@@ -43,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
@@ -84,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