mirror of
https://github.com/kerberos-io/agent.git
synced 2026-08-23 15:08:32 +00:00
Compare commits
171 Commits
v3.6.24
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6890d1889c | ||
|
|
6c71ff5039 | ||
|
|
efc90c76c9 | ||
|
|
7459eb02ee | ||
|
|
fc46da1398 | ||
|
|
51a11edb71 | ||
|
|
29e7f26c0e | ||
|
|
01d270fcfa | ||
|
|
92d3311192 | ||
|
|
97a8c1fcaf | ||
|
|
de5b0666bd | ||
|
|
ffdb8b6f22 | ||
|
|
250e3b0b20 | ||
|
|
2bb8144e79 | ||
|
|
72d4fca63c | ||
|
|
81cd95379b | ||
|
|
cd50f58138 | ||
|
|
b2f029117e | ||
|
|
db135acea9 | ||
|
|
7b589b53f9 | ||
|
|
c1740c752e | ||
|
|
5862786381 | ||
|
|
e8dd64f54b | ||
|
|
ba96b63002 | ||
|
|
c7c6bcbdf2 | ||
|
|
faa3b4eabb | ||
|
|
f0a6eb7d98 | ||
|
|
33a58cddf7 | ||
|
|
fea6d81246 | ||
|
|
dbff9fbc8e | ||
|
|
63b352b5e2 | ||
|
|
2ffb210ccb | ||
|
|
ff643d21ef | ||
|
|
8f04a6d42f | ||
|
|
4395fe2417 | ||
|
|
18392e136e | ||
|
|
ed916eb042 | ||
|
|
72b8160dc4 | ||
|
|
4f41786038 | ||
|
|
8fb186fd6d | ||
|
|
420b8b8a01 | ||
|
|
5a13416bed | ||
|
|
704011c20b | ||
|
|
6683c9b994 | ||
|
|
2092f3e49d | ||
|
|
d815e39e1d | ||
|
|
e2e1f8cfa8 | ||
|
|
ba5992378e | ||
|
|
2163a8e146 | ||
|
|
1fec49500e | ||
|
|
8175908073 | ||
|
|
1bcce4694d | ||
|
|
b26f0190c6 | ||
|
|
91194f5c1a | ||
|
|
57cfc90c4b | ||
|
|
357cc719a5 | ||
|
|
4f2a96b5e1 | ||
|
|
ed85261c8e | ||
|
|
5a58808f20 | ||
|
|
94b26cf096 | ||
|
|
57ef7ebaaf | ||
|
|
ddf58fe633 | ||
|
|
6f2d35cdf1 | ||
|
|
c836cef28d | ||
|
|
c97bb70cb5 | ||
|
|
96b145b046 | ||
|
|
09a697e00b | ||
|
|
1d0714f199 | ||
|
|
42e91867ec | ||
|
|
155c4a7e44 | ||
|
|
4fe4977559 | ||
|
|
67e66e863a | ||
|
|
bd34e9d836 | ||
|
|
1a0e6bf153 | ||
|
|
52aef0870e | ||
|
|
012ed3b658 | ||
|
|
7ced8a3044 | ||
|
|
f043be5371 | ||
|
|
b85d9858d1 | ||
|
|
434730b970 | ||
|
|
94df7298e3 | ||
|
|
0f76baec1f | ||
|
|
6ae61ea046 | ||
|
|
93e17ac73e | ||
|
|
0037f5a0ab | ||
|
|
79f225ad3c | ||
|
|
b6358ab56f | ||
|
|
bde5cf58eb | ||
|
|
6725411e8f | ||
|
|
675a8a4fb9 | ||
|
|
a77843fffc | ||
|
|
2dd9d50954 | ||
|
|
9c0a9452a7 | ||
|
|
61692e8346 | ||
|
|
e12f403fb9 | ||
|
|
484de49689 | ||
|
|
450d10acf7 | ||
|
|
8a0b5337f3 | ||
|
|
3590a0b39e | ||
|
|
976834cdfd | ||
|
|
d3ede93053 | ||
|
|
58a79f8278 | ||
|
|
422279985f | ||
|
|
99ff750c40 | ||
|
|
13c84a0f36 | ||
|
|
cb6bbe1609 | ||
|
|
b839cd985b | ||
|
|
476207c1bf | ||
|
|
fcd8ef8ff4 | ||
|
|
645b6aa0be | ||
|
|
67ee78dab5 | ||
|
|
5936c6eaae | ||
|
|
dafcd06696 | ||
|
|
02d60c71e4 | ||
|
|
52647d7f1d | ||
|
|
e1fa7d9d7e | ||
|
|
06e2694763 | ||
|
|
c0971ca3b2 | ||
|
|
1a788ebe6c | ||
|
|
a1b4026b4b | ||
|
|
9bc9825bb1 | ||
|
|
e9d2afa228 | ||
|
|
4b0e0eae9c | ||
|
|
e0204e1949 | ||
|
|
3c2a0ce0cf | ||
|
|
a5def2ccd8 | ||
|
|
6ede3c3add | ||
|
|
d5de6ae271 | ||
|
|
2035deaa31 | ||
|
|
5973ba025d | ||
|
|
52a54fbae1 | ||
|
|
5f828262eb | ||
|
|
17c1c5b04b | ||
|
|
bd5df30de3 | ||
|
|
2c063c39c6 | ||
|
|
2f0f29ce8c | ||
|
|
a05acb7fc8 | ||
|
|
2b88c0ff93 | ||
|
|
4aa2b6e51a | ||
|
|
0c439e34c7 | ||
|
|
d57bea3079 | ||
|
|
46a48db080 | ||
|
|
b7fe9947c2 | ||
|
|
f214a09826 | ||
|
|
7059503ac1 | ||
|
|
7ee79cc063 | ||
|
|
9bfbe4ee0f | ||
|
|
b8c05aa3e2 | ||
|
|
5f7ede40ca | ||
|
|
0ef84c5288 | ||
|
|
1a477bf42d | ||
|
|
22c352e946 | ||
|
|
55b0eb54fe | ||
|
|
68a4ca6bb9 | ||
|
|
baaa3f615a | ||
|
|
aeb214689b | ||
|
|
e353d46e73 | ||
|
|
4d163c4b53 | ||
|
|
014f0e312e | ||
|
|
195750a01d | ||
|
|
d203321770 | ||
|
|
51f1a52e17 | ||
|
|
6318c61323 | ||
|
|
5323105a60 | ||
|
|
af6e75426a | ||
|
|
6c2f38679b | ||
|
|
9b60223300 | ||
|
|
efdf8396ab | ||
|
|
d0f13187a1 | ||
|
|
bf46b55c92 | ||
|
|
88edcabf98 |
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/devcontainers/go:1.24-bookworm
|
||||
FROM mcr.microsoft.com/devcontainers/go:1.25-trixie
|
||||
|
||||
# Install node environment
|
||||
RUN apt-get update && \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "go:1.24-bookworm",
|
||||
"name": "go:1.24-trixie",
|
||||
"runArgs": [
|
||||
"--name=agent",
|
||||
"--network=host"
|
||||
@@ -20,5 +20,5 @@
|
||||
3000,
|
||||
8080
|
||||
],
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download"
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
|
||||
}
|
||||
4
.github/workflows/pr-build.yml
vendored
4
.github/workflows/pr-build.yml
vendored
@@ -16,10 +16,8 @@ jobs:
|
||||
include:
|
||||
- architecture: amd64
|
||||
runner: ubuntu-24.04
|
||||
dockerfile: Dockerfile
|
||||
- architecture: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
dockerfile: Dockerfile.arm64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
@@ -34,7 +32,7 @@ jobs:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build -t ${{ matrix.architecture }} -f ${{ matrix.dockerfile }} .
|
||||
docker build -t ${{ matrix.architecture }} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
|
||||
31
.github/workflows/pr-description.yaml
vendored
31
.github/workflows/pr-description.yaml
vendored
@@ -2,25 +2,16 @@ name: Autofill PR description
|
||||
|
||||
on: pull_request
|
||||
|
||||
env:
|
||||
ORGANIZATION: uugai
|
||||
PROJECT: ${{ github.event.repository.name }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
|
||||
jobs:
|
||||
openai-pr-description:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Autofill PR description if empty using OpenAI
|
||||
uses: cedricve/azureopenai-pr-description@master
|
||||
with:
|
||||
github_token: ${{ secrets.TOKEN }}
|
||||
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
|
||||
azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
|
||||
azure_openai_version: ${{ secrets.AZURE_OPENAI_VERSION }}
|
||||
openai_model: ${{ secrets.OPENAI_MODEL }}
|
||||
pull_request_url: https://pr${{ env.PR_NUMBER }}.api.kerberos.lol
|
||||
overwrite_description: true
|
||||
uses: uug-ai/workflows/.github/workflows/pr-description.yml@main
|
||||
with:
|
||||
pr_number: ${{ github.event.number }}
|
||||
pull_request_url: ""
|
||||
overwrite_description: true
|
||||
secrets:
|
||||
TOKEN: ${{ secrets.TOKEN }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
|
||||
AZURE_OPENAI_VERSION: ${{ secrets.AZURE_OPENAI_VERSION }}
|
||||
168
.github/workflows/release-bump.yml
vendored
Normal file
168
.github/workflows/release-bump.yml
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
name: Bump release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Which part of the version to bump"
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
|
||||
jobs:
|
||||
# Determine the next version, create the GitHub release and expose the tag.
|
||||
bump-release:
|
||||
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
|
||||
with:
|
||||
bump: ${{ github.event.inputs.bump }}
|
||||
secrets: inherit
|
||||
|
||||
# Publish the platform image to the uug-ai GitHub Container Registry
|
||||
# (ghcr.io/uug-ai/agent-platform).
|
||||
#release:
|
||||
# needs: bump-release
|
||||
# uses: uug-ai/workflows/.github/workflows/release-create.yml@main
|
||||
# with:
|
||||
# organization: uug-ai
|
||||
# project: ${{ github.event.repository.name }}
|
||||
# tag: ${{ needs.bump-release.outputs.tag }}
|
||||
# docker_context: "."
|
||||
# create_gitops_pr: false
|
||||
# runner_matrix: >-
|
||||
# [
|
||||
# {"architecture":"amd64","runner":"ubuntu-24.04"},
|
||||
# {"architecture":"arm64","runner":"ubuntu-24.04-arm"}
|
||||
# ]
|
||||
# secrets: inherit
|
||||
|
||||
# Everything below mirrors the agent's own release-create.yml pipeline and
|
||||
# publishes the multi-arch image to the kerberos/agent Docker Hub repo, driven
|
||||
# by the freshly bumped tag instead of a `release: created` event.
|
||||
build-amd64:
|
||||
needs: bump-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [amd64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- uses: benjlevesque/short-sha@v2.1
|
||||
id: short-sha
|
||||
with:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
build-arm64:
|
||||
needs: bump-release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- uses: benjlevesque/short-sha@v2.1
|
||||
id: short-sha
|
||||
with:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
create-manifest:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [bump-release, build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Create and push multi-arch manifest
|
||||
run: |
|
||||
docker manifest create $REPO:${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
|
||||
docker manifest push $REPO:${{ needs.bump-release.outputs.tag }}
|
||||
- name: Create and push latest manifest
|
||||
run: |
|
||||
docker manifest create $REPO:latest \
|
||||
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
|
||||
docker manifest push $REPO:latest
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [bump-release, build-amd64, build-arm64]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Create a release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
latest: true
|
||||
allowUpdates: true
|
||||
name: ${{ needs.bump-release.outputs.tag }}
|
||||
tag: ${{ needs.bump-release.outputs.tag }}
|
||||
generateReleaseNotes: false
|
||||
omitBodyDuringUpdate: true
|
||||
artifacts: "agent-*.tar/agent-*.tar"
|
||||
|
||||
|
||||
2
.github/workflows/release-create.yml
vendored
2
.github/workflows/release-create.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} -f Dockerfile.arm64 .
|
||||
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,5 +14,7 @@ machinery/test*
|
||||
machinery/init-dev.sh
|
||||
machinery/.env.local
|
||||
machinery/vendor
|
||||
machinery/go.work
|
||||
machinery/go.work.sum
|
||||
deployments/docker/private-docker-compose.yaml
|
||||
video.mp4
|
||||
5
.vscode/launch.json
vendored
5
.vscode/launch.json
vendored
@@ -17,7 +17,10 @@
|
||||
"8080"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/machinery/.env.local",
|
||||
"buildFlags": "--tags dynamic",
|
||||
"buildFlags": "--tags dynamic,moq",
|
||||
"env": {
|
||||
"GOWORK": "off"
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Launch React",
|
||||
|
||||
14
.vscode/tasks.json
vendored
Normal file
14
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "agent: moq verify",
|
||||
"type": "shell",
|
||||
"command": "bash ./verify-moq-devcontainer.sh",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/machinery"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
39
Dockerfile
39
Dockerfile
@@ -1,9 +1,16 @@
|
||||
|
||||
ARG BASE_IMAGE_VERSION=amd64-ddbe40e
|
||||
ARG GO_IMAGE=golang:1.25-trixie
|
||||
ARG RUNTIME_IMAGE=debian:trixie-slim
|
||||
ARG VERSION=0.0.0
|
||||
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
|
||||
FROM ${GO_IMAGE} AS build-machinery
|
||||
LABEL AUTHOR=uug.ai
|
||||
|
||||
# Re-declare VERSION inside this stage so the value passed via
|
||||
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
|
||||
# ARGs declared before the first FROM are not visible inside build stages.
|
||||
ARG VERSION
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV GOROOT=/usr/local/go
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
|
||||
@@ -12,9 +19,10 @@ ENV GOSUMDB=off
|
||||
##########################################
|
||||
# Installing some additional dependencies.
|
||||
|
||||
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
git build-essential cmake pkg-config unzip libgtk2.0-dev \
|
||||
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
|
||||
curl ca-certificates libavcodec-dev libavutil-dev libcurl4-openssl-dev \
|
||||
libssl-dev libjpeg62-turbo-dev libswscale-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
##############################################################################
|
||||
@@ -35,8 +43,12 @@ RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
|
||||
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
|
||||
go mod download && \
|
||||
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "${VERSION}") && \
|
||||
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
|
||||
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
|
||||
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
|
||||
fi && \
|
||||
BUILD_TAGS=timetzdata,netgo,osusergo && \
|
||||
case "${TARGETARCH:-$(go env GOARCH)}" in amd64|arm64) BUILD_TAGS="moq,${BUILD_TAGS}" ;; esac && \
|
||||
go build -tags "${BUILD_TAGS}" --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION}" main.go && \
|
||||
mkdir -p /agent && \
|
||||
mv main /agent && \
|
||||
mv version /agent && \
|
||||
@@ -60,7 +72,7 @@ RUN cp -r /agent ./
|
||||
|
||||
RUN /dist/agent/main version
|
||||
|
||||
FROM node:18.14.0-alpine3.16 AS build-ui
|
||||
FROM node:22-alpine AS build-ui
|
||||
|
||||
RUN apk update && apk upgrade --available && sync
|
||||
|
||||
@@ -82,12 +94,17 @@ RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www
|
||||
############################################
|
||||
# Publish main binary to GitHub release
|
||||
|
||||
FROM alpine:latest
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
############################
|
||||
# Protect by non-root user.
|
||||
|
||||
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl ffmpeg libatomic1 libcap2-bin libstdc++6 && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd --system kerberosio && \
|
||||
useradd --system --gid kerberosio --groups video --create-home agent && \
|
||||
chmod 0755 /home/agent
|
||||
|
||||
#################################
|
||||
# Copy files from previous images
|
||||
@@ -95,8 +112,6 @@ RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent v
|
||||
COPY --chown=0:0 --from=build-machinery /dist /
|
||||
COPY --chown=0:0 --from=build-ui /dist /
|
||||
|
||||
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
|
||||
|
||||
##################
|
||||
# Try running agent
|
||||
|
||||
@@ -116,7 +131,7 @@ RUN chown -R agent:kerberosio /home/agent/www
|
||||
|
||||
###########################
|
||||
# Grant the necessary root capabilities to the process trying to bind to the privileged port
|
||||
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main
|
||||
RUN setcap 'cap_net_bind_service=+ep' /home/agent/main
|
||||
|
||||
###################
|
||||
# Run non-root user
|
||||
|
||||
140
Dockerfile.arm64
140
Dockerfile.arm64
@@ -1,140 +0,0 @@
|
||||
|
||||
ARG BASE_IMAGE_VERSION=arm64-ddbe40e
|
||||
ARG VERSION=0.0.0
|
||||
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
|
||||
LABEL AUTHOR=uug.ai
|
||||
|
||||
ENV GOROOT=/usr/local/go
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
|
||||
ENV GOSUMDB=off
|
||||
|
||||
##########################################
|
||||
# Installing some additional dependencies.
|
||||
|
||||
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
git build-essential cmake pkg-config unzip libgtk2.0-dev \
|
||||
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
##############################################################################
|
||||
# Copy all the relevant source code in the Docker image, so we can build this.
|
||||
|
||||
RUN mkdir -p /go/src/github.com/kerberos-io/agent
|
||||
COPY machinery /go/src/github.com/kerberos-io/agent/machinery
|
||||
RUN rm -rf /go/src/github.com/kerberos-io/agent/machinery/.env
|
||||
|
||||
##################################################################
|
||||
# Get the latest commit hash, so we know which version we're running
|
||||
COPY .git /go/src/github.com/kerberos-io/agent/.git
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/.git && git log --format="%H" -n 1 | head -c7 > /go/src/github.com/kerberos-io/agent/machinery/version
|
||||
RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
|
||||
|
||||
##################
|
||||
# Build Machinery
|
||||
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
|
||||
go mod download && \
|
||||
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "${VERSION}") && \
|
||||
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
|
||||
mkdir -p /agent && \
|
||||
mv main /agent && \
|
||||
mv version /agent && \
|
||||
mv data /agent && \
|
||||
mkdir -p /agent/data/cloud && \
|
||||
mkdir -p /agent/data/snapshots && \
|
||||
mkdir -p /agent/data/log && \
|
||||
mkdir -p /agent/data/recordings && \
|
||||
mkdir -p /agent/data/capture-test && \
|
||||
mkdir -p /agent/data/config
|
||||
|
||||
####################################
|
||||
# Let's create a /dist folder containing just the files necessary for runtime.
|
||||
# Later, it will be copied as the / (root) of the output image.
|
||||
|
||||
WORKDIR /dist
|
||||
RUN cp -r /agent ./
|
||||
|
||||
####################################################################################
|
||||
# This will collect dependent libraries so they're later copied to the final image.
|
||||
|
||||
RUN /dist/agent/main version
|
||||
|
||||
FROM node:18.14.0-alpine3.16 AS build-ui
|
||||
|
||||
RUN apk update && apk upgrade --available && sync
|
||||
|
||||
########################
|
||||
# Build Web (React app)
|
||||
|
||||
RUN mkdir -p /go/src/github.com/kerberos-io/agent/machinery/www
|
||||
COPY ui /go/src/github.com/kerberos-io/agent/ui
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/ui && rm -rf yarn.lock && yarn config set network-timeout 300000 && \
|
||||
yarn && yarn build
|
||||
|
||||
####################################
|
||||
# Let's create a /dist folder containing just the files necessary for runtime.
|
||||
# Later, it will be copied as the / (root) of the output image.
|
||||
|
||||
WORKDIR /dist
|
||||
RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www ./agent/
|
||||
|
||||
############################################
|
||||
# Publish main binary to GitHub release
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
############################
|
||||
# Protect by non-root user.
|
||||
|
||||
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
|
||||
|
||||
#################################
|
||||
# Copy files from previous images
|
||||
|
||||
COPY --chown=0:0 --from=build-machinery /dist /
|
||||
COPY --chown=0:0 --from=build-ui /dist /
|
||||
|
||||
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
|
||||
|
||||
##################
|
||||
# Try running agent
|
||||
|
||||
RUN mv /agent/* /home/agent/
|
||||
RUN /home/agent/main version
|
||||
|
||||
#######################
|
||||
# Make template config
|
||||
|
||||
RUN cp /home/agent/data/config/config.json /home/agent/data/config.template.json
|
||||
|
||||
###########################
|
||||
# Set permissions correctly
|
||||
|
||||
RUN chown -R agent:kerberosio /home/agent/data
|
||||
RUN chown -R agent:kerberosio /home/agent/www
|
||||
|
||||
###########################
|
||||
# Grant the necessary root capabilities to the process trying to bind to the privileged port
|
||||
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main
|
||||
|
||||
###################
|
||||
# Run non-root user
|
||||
|
||||
USER agent
|
||||
|
||||
######################################
|
||||
# By default the app runs on port 80
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
######################################
|
||||
# Check if agent is still running
|
||||
|
||||
HEALTHCHECK CMD curl --fail http://localhost:80 || exit 1
|
||||
|
||||
###################################################
|
||||
# Leeeeettttt'ssss goooooo!!!
|
||||
# Run the shizzle from the right working directory.
|
||||
WORKDIR /home/agent
|
||||
CMD ["./main", "-action", "run", "-port", "80"]
|
||||
645
README-RTSPS-TLS.md
Normal file
645
README-RTSPS-TLS.md
Normal file
@@ -0,0 +1,645 @@
|
||||
# RTSPS and TLS certificates
|
||||
|
||||
This guide explains how Kerberos Agent connects to an IP camera over RTSPS,
|
||||
how to issue a camera certificate with a private CA, and how to validate the
|
||||
complete trust path. It also explains why some apparently corrupted trust
|
||||
bundles can still allow a connection.
|
||||
|
||||
The camera-specific steps were verified with a Bosch FLEXIDOME micro 3100i.
|
||||
Other Bosch firmware versions may use different labels or ports.
|
||||
|
||||
The commands were tested with Smallstep CLI `0.30.6` and OpenSSL `3.5.6` on
|
||||
Debian. Check `step certificate sign --help` when using an older Smallstep CLI.
|
||||
The OpenSSL isolation flags `-no-CApath` and `-no-CAstore` require a version that
|
||||
lists them in `openssl s_client -help`.
|
||||
|
||||
## Tested configuration
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Camera | Bosch FLEXIDOME micro 3100i |
|
||||
| Example camera address | `10.0.30.11` |
|
||||
| RTSPS port | `9554` |
|
||||
| Main stream | `rtsps://<user>:<password>@10.0.30.11:9554/?inst=1` |
|
||||
| Sub stream | `rtsps://<user>:<password>@10.0.30.11:9554/?inst=2` |
|
||||
| Certificate SAN | `IP Address:10.0.30.11` |
|
||||
| Bosch certificate usage | `HTTPS` |
|
||||
| Agent trust input | Issuing intermediate plus root CA |
|
||||
|
||||
Replace the example address and certificate names throughout this guide. Keep
|
||||
camera credentials out of source control and percent-encode reserved URL
|
||||
characters in usernames and passwords.
|
||||
|
||||
## Mental model
|
||||
|
||||
### RTSPS, SRTSP, TLS, and SRTP
|
||||
|
||||
- The standard URL scheme is `rtsps://`. Do not use `srtsp://`.
|
||||
- Bosch interfaces and documentation may use SRTSP or Secure RTSP as product
|
||||
terminology.
|
||||
- RTSPS carries the RTSP control connection over TLS. With gortsplib, media is
|
||||
normally interleaved over the same TCP/TLS connection for this camera.
|
||||
- SRTP is a separate media protection mechanism and is negotiated only when the
|
||||
camera advertises an appropriate secure RTP profile.
|
||||
|
||||
Encryption alone does not prove which camera the Agent reached. Verified TLS
|
||||
also checks that:
|
||||
|
||||
1. The camera certificate is signed by a trusted authority.
|
||||
2. The certificate is valid at the current time.
|
||||
3. The URL host matches a certificate Subject Alternative Name (SAN).
|
||||
|
||||
Modern Go verification uses SANs for identity. A Common Name alone is not
|
||||
sufficient. Connecting to `10.0.30.11` requires an IP SAN with that exact value,
|
||||
not `DNS:10.0.30.11` and not only a device-name DNS SAN.
|
||||
|
||||
### Agent behavior
|
||||
|
||||
Kerberos Agent uses gortsplib for RTSP and RTSPS. With the normal configuration,
|
||||
gortsplib receives a nil custom TLS configuration and Go performs standard
|
||||
certificate and hostname verification with the process trust pool.
|
||||
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` is an explicit escape hatch that
|
||||
sets `InsecureSkipVerify` for camera clients. It should be false in a verified
|
||||
deployment.
|
||||
|
||||
## Communication and certificate flow
|
||||
|
||||
The certificate is used during the TLS handshake, before the first RTSP command
|
||||
is exchanged. It is not attached to `DESCRIBE`, `SETUP`, or `PLAY`, and the CA
|
||||
trust bundle is never sent to the camera.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Agent as Kerberos Agent
|
||||
participant Trust as Go trust pool
|
||||
participant Camera as Camera RTSPS :9554
|
||||
|
||||
Agent->>Trust: Load system roots and append AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE
|
||||
Agent->>Camera: Open TCP connection
|
||||
Agent->>Camera: Send TLS ClientHello
|
||||
Camera-->>Agent: Send TLS ServerHello and camera certificate
|
||||
Agent->>Trust: Verify chain, validity, serverAuth, and URL host against SAN
|
||||
Trust-->>Agent: Accept or reject the camera identity
|
||||
Agent->>Camera: Complete TLS handshake
|
||||
Note over Agent,Camera: All following traffic is encrypted by TLS
|
||||
Agent->>Camera: DESCRIBE with RTSP authentication
|
||||
Camera-->>Agent: Return SDP and available media tracks
|
||||
Agent->>Camera: SETUP selected video and audio tracks over TCP
|
||||
Agent->>Camera: PLAY
|
||||
Camera-->>Agent: Send interleaved RTP and RTCP media over TLS
|
||||
```
|
||||
|
||||
The files and keys have distinct roles:
|
||||
|
||||
| Material | Location | Purpose | Sent over the connection |
|
||||
| --- | --- | --- | --- |
|
||||
| Camera leaf certificate | Camera | Identifies the camera and binds its public key to its SAN | Yes, by the camera during the TLS handshake |
|
||||
| Camera private key | Camera | Proves that the camera owns the presented certificate | No |
|
||||
| Intermediate and root CA PEM bundle | Agent | Lets Go build and trust the camera certificate chain | No |
|
||||
| RTSP username and password | Agent configuration or URL | Authenticates the Agent to the RTSP service after TLS succeeds | An authentication response is sent inside TLS; its form depends on the RTSP authentication method |
|
||||
|
||||
For an `rtsps://` URL, the Agent parses the URL and gives gortsplib the host and
|
||||
TLS settings. gortsplib opens the TCP connection and starts TLS. Go compares the
|
||||
certificate presented by the camera with the local trust pool, checks its
|
||||
validity period and server usage, and matches the URL hostname or IP address to
|
||||
the certificate SAN. Only a successful handshake creates the encrypted channel
|
||||
needed for the RTSP exchange.
|
||||
|
||||
The Agent then sends `DESCRIBE`, selects the advertised video and audio tracks,
|
||||
sends `SETUP`, and starts delivery with `PLAY`. For the tested camera, gortsplib
|
||||
uses interleaved TCP, so the RTSP control messages and RTP/RTCP media remain
|
||||
inside the same encrypted TLS connection. Main stream, sub stream, and enabled
|
||||
audio backchannel clients each establish and verify their own connection.
|
||||
|
||||
If certificate verification fails, the TLS handshake does not complete and no
|
||||
usable RTSP session is established. Setting
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` keeps traffic encrypted but skips
|
||||
certificate-chain and hostname verification, so an attacker could impersonate
|
||||
the camera. It is not equivalent to trusting the camera certificate.
|
||||
|
||||
## Decide the certificate identity first
|
||||
|
||||
Choose the stable name used in every Agent URL before creating the certificate:
|
||||
|
||||
- For an IP URL, add that address as an IP SAN.
|
||||
- For a DNS URL, add the exact hostname as a DNS SAN.
|
||||
- Add both when clients legitimately use both forms.
|
||||
|
||||
A certificate stops matching if the camera IP changes. Use a static address,
|
||||
DHCP reservation, or stable DNS name.
|
||||
|
||||
## Configure RTSPS in the Bosch UI
|
||||
|
||||
1. Sign in to the camera as an administrator.
|
||||
2. Open **Configuration**.
|
||||
3. Open **Network > Network Services**.
|
||||
4. Enable **RTSPS**.
|
||||
5. Confirm port `9554`, or record the configured alternative.
|
||||
6. Click **Set**.
|
||||
|
||||
RTSP on port `554` and RTSPS on port `9554` are separate services. Enabling
|
||||
RTSPS does not make an `rtsp://` URL secure.
|
||||
|
||||
## Generate the private key and CSR on the camera
|
||||
|
||||
Keeping the TLS private key on the camera avoids exporting it to an operator
|
||||
workstation or deployment system.
|
||||
|
||||
1. Open **Service > Certificates**.
|
||||
2. Click **Add**.
|
||||
3. Select **Generate signing request**.
|
||||
4. Select `RSA 2048bit` or the stronger option supported by all clients.
|
||||
5. Enter a unique file name, such as `agent-rtsps`.
|
||||
6. Enter a descriptive Common Name and any required organization fields.
|
||||
7. Click **Generate**.
|
||||
8. Download the resulting CSR from the certificate table.
|
||||
|
||||
On the tested firmware, this form contains no SAN field. The downloaded CSR
|
||||
therefore has no IP SAN. The CA must add the SAN while signing.
|
||||
|
||||
Inspect the CSR before signing:
|
||||
|
||||
```bash
|
||||
openssl req -in camera.csr.pem -noout -verify -subject
|
||||
openssl req -in camera.csr.pem -noout -text
|
||||
```
|
||||
|
||||
The first command must report `Certificate request self-signature verify OK`.
|
||||
An absent `Subject Alternative Name` section is expected for this firmware.
|
||||
|
||||
## Prepare Smallstep
|
||||
|
||||
Use an existing organizational CA when one is available. Creating a new CA
|
||||
creates a new long-lived trust domain that must be distributed, protected,
|
||||
backed up, and eventually rotated.
|
||||
|
||||
### Install the CLI on Debian amd64
|
||||
|
||||
```bash
|
||||
curl -fsSL \
|
||||
https://dl.smallstep.com/cli/docs-ca-install/latest/step-cli_amd64.deb \
|
||||
-o /tmp/step-cli_amd64.deb
|
||||
sudo dpkg -i /tmp/step-cli_amd64.deb
|
||||
rm /tmp/step-cli_amd64.deb
|
||||
step version
|
||||
```
|
||||
|
||||
Use the official package matching the host architecture on other systems.
|
||||
|
||||
### Create a dedicated offline CA
|
||||
|
||||
Skip this section when using an existing CA.
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.step/secrets" "$HOME/.step/camera"
|
||||
|
||||
openssl rand -base64 48 > "$HOME/.step/secrets/camera_ca_password"
|
||||
chmod 600 "$HOME/.step/secrets/camera_ca_password"
|
||||
|
||||
step ca init \
|
||||
--pki \
|
||||
--name "UUG Camera CA" \
|
||||
--password-file "$HOME/.step/secrets/camera_ca_password"
|
||||
```
|
||||
|
||||
This produces:
|
||||
|
||||
```text
|
||||
$HOME/.step/certs/root_ca.crt
|
||||
$HOME/.step/certs/intermediate_ca.crt
|
||||
$HOME/.step/secrets/root_ca_key
|
||||
$HOME/.step/secrets/intermediate_ca_key
|
||||
$HOME/.step/secrets/camera_ca_password
|
||||
```
|
||||
|
||||
The files under `secrets/` are sensitive. Keep them mode `600`, never commit
|
||||
them, and back them up to encrypted persistent storage. A devcontainer can be
|
||||
rebuilt or deleted; it is not sufficient as the only CA backup.
|
||||
|
||||
## Add the SAN while signing
|
||||
|
||||
Copy the camera CSR into a protected working directory:
|
||||
|
||||
```bash
|
||||
cp camera.csr.pem "$HOME/.step/camera/camera.csr.pem"
|
||||
```
|
||||
|
||||
Create `$HOME/.step/camera/bosch-rtsps.tpl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": {
|
||||
"commonName": {{ toJson .Insecure.CR.Subject.CommonName }}
|
||||
},
|
||||
"ipAddresses": ["10.0.30.11"],
|
||||
"keyUsage": ["keyEncipherment", "digitalSignature"],
|
||||
"extKeyUsage": ["serverAuth", "clientAuth"]
|
||||
}
|
||||
```
|
||||
|
||||
The template preserves the camera CSR public key, sets the IP identity, and
|
||||
creates a TLS leaf rather than a CA certificate.
|
||||
|
||||
Sign it with a validity period that ends before the intermediate CA expires.
|
||||
A one-year leaf is preferable to a ten-year leaf when automated renewal is
|
||||
available:
|
||||
|
||||
```bash
|
||||
step certificate sign \
|
||||
--template "$HOME/.step/camera/bosch-rtsps.tpl" \
|
||||
--bundle \
|
||||
--not-after 8760h \
|
||||
--password-file "$HOME/.step/secrets/camera_ca_password" \
|
||||
"$HOME/.step/camera/camera.csr.pem" \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/secrets/intermediate_ca_key" \
|
||||
> "$HOME/.step/camera/bosch-rtsps-chain.pem"
|
||||
```
|
||||
|
||||
For an online `step-ca`, do not assume `step ca sign` accepts a `--san` flag. It
|
||||
does not. Authorize SANs in the one-time token or configure a provisioner
|
||||
template that produces the required SANs.
|
||||
|
||||
## Validate before upload
|
||||
|
||||
Inspect the leaf certificate, which is the first PEM block in the chain file:
|
||||
|
||||
```bash
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -subject -issuer -dates -ext subjectAltName -ext extendedKeyUsage
|
||||
```
|
||||
|
||||
Confirm the SAN separately because some OpenSSL versions display only the last
|
||||
requested extension:
|
||||
|
||||
```bash
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -ext subjectAltName
|
||||
```
|
||||
|
||||
Verify the path and IP identity:
|
||||
|
||||
```bash
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-untrusted "$HOME/.step/certs/intermediate_ca.crt" \
|
||||
-verify_ip 10.0.30.11 \
|
||||
"$HOME/.step/camera/bosch-rtsps-chain.pem"
|
||||
```
|
||||
|
||||
Confirm that the signed leaf uses the exact public key from the camera CSR:
|
||||
|
||||
```bash
|
||||
csr_key=$(
|
||||
openssl req -in "$HOME/.step/camera/camera.csr.pem" -pubkey -noout |
|
||||
openssl pkey -pubin -outform DER 2>/dev/null |
|
||||
sha256sum | cut -d' ' -f1
|
||||
)
|
||||
|
||||
cert_key=$(
|
||||
openssl x509 -in "$HOME/.step/camera/bosch-rtsps-chain.pem" -pubkey -noout |
|
||||
openssl pkey -pubin -outform DER 2>/dev/null |
|
||||
sha256sum | cut -d' ' -f1
|
||||
)
|
||||
|
||||
test "$csr_key" = "$cert_key"
|
||||
```
|
||||
|
||||
Do not upload a certificate when any of these checks fail.
|
||||
|
||||
## Upload and assign the certificate
|
||||
|
||||
1. Return to **Service > Certificates**.
|
||||
2. Click **Add > Upload certificate**.
|
||||
3. Select the leaf-plus-intermediate PEM chain.
|
||||
4. Click **Upload** and wait for `100%`.
|
||||
5. Confirm that the former CSR row is now a `Certificate`.
|
||||
6. Confirm that the key icon is present. It proves that the camera associated
|
||||
the certificate with its retained private key.
|
||||
7. Open the new certificate's **Usage** selector.
|
||||
8. Select only **HTTPS**.
|
||||
9. Leave **CBS client** assigned to the original Bosch `DeviceCertificate`.
|
||||
10. Click **Set** and wait for the table to reload.
|
||||
|
||||
On the tested firmware, there is no separate SRTSP usage. RTSPS presents the
|
||||
certificate assigned to HTTPS. Reassigning HTTPS therefore changes both the
|
||||
web interface and RTSPS certificate.
|
||||
|
||||
After saving, the expected split is:
|
||||
|
||||
| Certificate | Usage |
|
||||
| --- | --- |
|
||||
| Private-CA camera certificate | `HTTPS` |
|
||||
| Bosch `DeviceCertificate` | `CBS client` |
|
||||
|
||||
The browser may warn about the new HTTPS certificate until the private root CA
|
||||
is trusted by the workstation.
|
||||
|
||||
## Account for the Bosch chain behavior
|
||||
|
||||
The tested firmware served only the leaf certificate on ports `443` and `9554`,
|
||||
even when the uploaded file contained the leaf and intermediate. Uploading the
|
||||
intermediate separately as a trusted camera certificate did not change the
|
||||
served chain.
|
||||
|
||||
Confirm the behavior:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-showcerts </dev/null 2>/dev/null |
|
||||
grep -c '^-----BEGIN CERTIFICATE-----$'
|
||||
```
|
||||
|
||||
A result of `1` means the client must already have the issuing intermediate.
|
||||
Create a portable trust bundle containing the intermediate and root:
|
||||
|
||||
```bash
|
||||
step certificate bundle \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
"$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
|
||||
chmod 644 "$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
```
|
||||
|
||||
The trust bundle is public material. The CA private keys and password are not.
|
||||
|
||||
## Configure Kerberos Agent
|
||||
|
||||
For a process running directly in the same environment:
|
||||
|
||||
```dotenv
|
||||
AGENT_CAPTURE_IPCAMERA_RTSP="rtsps://<user>:<password>@10.0.30.11:9554/?inst=1"
|
||||
AGENT_CAPTURE_IPCAMERA_SUB_RTSP="rtsps://<user>:<password>@10.0.30.11:9554/?inst=2"
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem
|
||||
```
|
||||
|
||||
For a container, mount the public bundle read-only at the exact path visible
|
||||
inside the container. The Agent image creates `/home/agent/data/config` and
|
||||
includes Debian's `ca-certificates` package:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v /secure/config/uug-camera-trust-bundle.pem:/home/agent/data/config/uug-camera-trust-bundle.pem:ro \
|
||||
-e AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem \
|
||||
-e AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
|
||||
-e 'AGENT_CAPTURE_IPCAMERA_RTSP=rtsps://<user>:<password>@10.0.30.11:9554/?inst=1' \
|
||||
-e 'AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsps://<user>:<password>@10.0.30.11:9554/?inst=2' \
|
||||
kerberos/agent:latest
|
||||
```
|
||||
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` starts with the operating system's roots
|
||||
and appends the camera bundle only to the gortsplib TLS configuration. Other
|
||||
clients, including MoQ, Hub, and Vault, retain the normal public CA chain.
|
||||
Restart the Agent after changing trust files.
|
||||
|
||||
Do not set `SSL_CERT_FILE` or `SSL_CERT_DIR` in production solely for camera
|
||||
trust. They are process-wide and can prevent other clients from validating
|
||||
public services. For a deliberate process-wide isolation test, mount an empty
|
||||
directory and set `SSL_CERT_DIR` to its path:
|
||||
|
||||
```bash
|
||||
-v /secure/config/empty-ca-dir:/home/agent/data/config/empty-ca-dir:ro \
|
||||
-e SSL_CERT_DIR=/home/agent/data/config/empty-ca-dir
|
||||
```
|
||||
|
||||
Do not use `SSL_CERT_DIR=`. Go treats an empty value as unset and scans its
|
||||
default certificate directories.
|
||||
|
||||
Only use that mode when the Agent does not need public roots for other TLS
|
||||
connections.
|
||||
|
||||
## Validate the live endpoints
|
||||
|
||||
### Strict TLS and identity check
|
||||
|
||||
Use only the specified bundle, without OpenSSL's default CA locations:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-brief \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-verify_ip 10.0.30.11 \
|
||||
-verify_return_error \
|
||||
-CAfile "$HOME/.step/camera/uug-camera-trust-bundle.pem" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
</dev/null
|
||||
```
|
||||
|
||||
Repeat with port `443`. Both must report `Verification: OK`.
|
||||
|
||||
Confirm that identity checking is active by repeating the command with a wrong
|
||||
address, such as `-verify_ip 10.0.30.12`. It must fail with an IP address
|
||||
mismatch.
|
||||
|
||||
### Confirm the live leaf is the generated leaf
|
||||
|
||||
```bash
|
||||
live_fingerprint=$(
|
||||
openssl s_client -connect 10.0.30.11:9554 -showcerts </dev/null 2>/dev/null |
|
||||
openssl x509 -noout -fingerprint -sha256 |
|
||||
cut -d= -f2
|
||||
)
|
||||
|
||||
local_fingerprint=$(
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -fingerprint -sha256 |
|
||||
cut -d= -f2
|
||||
)
|
||||
|
||||
test "$live_fingerprint" = "$local_fingerprint"
|
||||
```
|
||||
|
||||
### Validate the media path
|
||||
|
||||
A successful TLS handshake does not prove that RTSP authentication, DESCRIBE,
|
||||
SETUP, PLAY, and RTP delivery work. Start a fresh Agent with verified TLS and
|
||||
confirm that it connects without an x509 error and receives frames. During the
|
||||
verified setup described here, a gortsplib probe completed all RTSP operations
|
||||
and received an RTP packet over TCP.
|
||||
|
||||
## Why a tampered bundle may still connect
|
||||
|
||||
Editing PEM text is not always a useful negative TLS test.
|
||||
|
||||
### A certificate can still parse after a byte change
|
||||
|
||||
Base64 can remain syntactically valid when one character changes. OpenSSL may
|
||||
still list the certificate subject and issuer even though a signature is now
|
||||
invalid. Parsing and signature verification are different operations.
|
||||
|
||||
### Trust anchors are not validated through a parent
|
||||
|
||||
Every certificate loaded into Go's root pool is a trust anchor, including a
|
||||
non-self-signed intermediate CA. Verification can terminate at that certificate.
|
||||
|
||||
If tampering changes only the intermediate's signature from its parent root,
|
||||
but does not change its public key, that intermediate can still validate the
|
||||
camera leaf when it is trusted directly. Its now-invalid parent signature is
|
||||
not consulted at the trust boundary.
|
||||
|
||||
This is equivalent to OpenSSL's partial-chain behavior:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-verify_ip 10.0.30.11 \
|
||||
-verify_return_error \
|
||||
-partial_chain \
|
||||
-CAfile tampered-bundle.pem \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
</dev/null
|
||||
```
|
||||
|
||||
### `SSL_CERT_FILE` does not isolate Go from CA directories
|
||||
|
||||
On Unix, Go uses `SSL_CERT_FILE` instead of its default aggregate CA file, but it
|
||||
still scans default certificate directories such as `/etc/ssl/certs`. Setting
|
||||
`SSL_CERT_FILE` alone therefore does not remove CA certificates installed with
|
||||
`update-ca-certificates`. `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` is appended
|
||||
after this system pool is loaded; it does not replace the system roots.
|
||||
|
||||
Blank values do not select empty trust sources. Both `SSL_CERT_FILE=` and
|
||||
`SSL_CERT_DIR=` are treated as unset, so Go falls back to its default aggregate
|
||||
CA file and certificate directories. To test with no trusted certificates on
|
||||
Linux, use a non-empty file path that contains no certificates and a non-empty
|
||||
directory path that contains no certificates:
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/empty-ca-dir
|
||||
SSL_CERT_FILE=/dev/null \
|
||||
SSL_CERT_DIR=/tmp/empty-ca-dir \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE= \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
|
||||
GOWORK=off \
|
||||
go run -tags moq . -action run -port 8080
|
||||
```
|
||||
|
||||
That fresh process must fail with `x509: certificate signed by unknown
|
||||
authority`.
|
||||
|
||||
Use exactly one camera trust-distribution approach when possible:
|
||||
|
||||
1. Mount a private trust bundle and set
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE`; or
|
||||
2. Install the CA certificates into the operating-system trust store.
|
||||
|
||||
Using both is valid, but makes isolation tests less obvious.
|
||||
|
||||
### Running processes can retain old roots
|
||||
|
||||
A long-running Go process may already have loaded and cached the trust pool.
|
||||
Always start a new process after changing trust configuration during a negative
|
||||
test.
|
||||
|
||||
## Perform a meaningful negative test
|
||||
|
||||
Do not corrupt only the root or intermediate signature. Instead, give a fresh
|
||||
Agent process a completely unrelated CA and hide the default CA directories.
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/empty-ca-dir
|
||||
|
||||
openssl req \
|
||||
-x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-subj '/CN=Unrelated Test Root' \
|
||||
-keyout /tmp/unrelated-test-root.key \
|
||||
-out /tmp/unrelated-test-root.crt
|
||||
|
||||
SSL_CERT_FILE=/dev/null \
|
||||
SSL_CERT_DIR=/tmp/empty-ca-dir \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/tmp/unrelated-test-root.crt \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
|
||||
GOWORK=off \
|
||||
go run -tags moq . -action run -port 8080
|
||||
```
|
||||
|
||||
The connection must fail with an unknown-authority or chain-building error.
|
||||
Delete the temporary test key and certificate afterward.
|
||||
|
||||
To test bundle integrity rather than client distrust, validate the intermediate
|
||||
against the root explicitly:
|
||||
|
||||
```bash
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
"$HOME/.step/certs/intermediate_ca.crt"
|
||||
```
|
||||
|
||||
Store and compare approved SHA-256 fingerprints when detecting unauthorized
|
||||
certificate-file changes is a requirement.
|
||||
|
||||
Restore an accidentally edited bundle from the protected CA certificates, then
|
||||
restart the Agent:
|
||||
|
||||
```bash
|
||||
step certificate bundle -f \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
"$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
"$HOME/.step/certs/intermediate_ca.crt"
|
||||
```
|
||||
|
||||
## Optional system trust installation
|
||||
|
||||
On Debian, install both public CA certificates when every process in the system
|
||||
should trust this camera PKI:
|
||||
|
||||
```bash
|
||||
sudo install -m 0644 \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
/usr/local/share/ca-certificates/uug-camera-ca.crt
|
||||
|
||||
sudo install -m 0644 \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
/usr/local/share/ca-certificates/uug-camera-intermediate-ca.crt
|
||||
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
This creates links below `/etc/ssl/certs`. Remove those files and rerun
|
||||
`update-ca-certificates` before attempting an isolated trust-bundle test.
|
||||
|
||||
## Renewal and recovery
|
||||
|
||||
- Renew before the leaf or intermediate expires.
|
||||
- Generate a new camera CSR if the firmware cannot renew the existing key.
|
||||
- Sign the new CSR with all required SANs.
|
||||
- Upload and validate the new certificate before deleting the old one.
|
||||
- Preserve an alternate administrative access path while changing HTTPS usage.
|
||||
- Back up the CA certificates, encrypted CA keys, and password separately.
|
||||
- If the CA private keys are lost, create a new CA and redistribute its trust
|
||||
before replacing camera certificates.
|
||||
|
||||
## Production checklist
|
||||
|
||||
- [ ] The Agent URL uses `rtsps://`, not `rtsp://` or `srtsp://`.
|
||||
- [ ] RTSPS is enabled on the camera and the configured port is reachable.
|
||||
- [ ] The certificate SAN exactly matches the Agent URL host.
|
||||
- [ ] The leaf public key matches the camera-generated CSR.
|
||||
- [ ] The certificate has `serverAuth` extended key usage.
|
||||
- [ ] The certificate expires before its issuer.
|
||||
- [ ] HTTPS is assigned to the private-CA certificate.
|
||||
- [ ] CBS client remains assigned to the Bosch device certificate.
|
||||
- [ ] The Agent has the intermediate and root CA certificates it needs.
|
||||
- [ ] `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false`.
|
||||
- [ ] The Agent was restarted after trust changes.
|
||||
- [ ] A strict TLS check reports `Verification: OK`.
|
||||
- [ ] A real Agent connection receives RTP packets.
|
||||
- [ ] CA private keys and passwords are backed up outside the devcontainer.
|
||||
72
README.md
72
README.md
@@ -189,6 +189,21 @@ Next to attaching the configuration file, it is also possible to override the co
|
||||
-e AGENT_CAPTURE_CONTINUOUS=true \
|
||||
-d --restart=always kerberos/agent:latest
|
||||
|
||||
### Secure camera streams (RTSPS)
|
||||
|
||||
The Agent accepts `rtsps://` camera URLs. Do not use `srtsp://`; RTSPS is RTSP over TLS. For a Bosch FLEXIDOME micro 3100i, enable **Secure RTSP** under **Network > Network Services** and use port `9554`:
|
||||
|
||||
```bash
|
||||
AGENT_CAPTURE_IPCAMERA_RTSP='rtsps://username:password@camera.example:9554/?inst=1'
|
||||
AGENT_CAPTURE_IPCAMERA_SUB_RTSP='rtsps://username:password@camera.example:9554/?inst=2'
|
||||
```
|
||||
|
||||
Certificate verification is enabled by default. The URL hostname or IP address must match the camera certificate SAN. On this Bosch firmware, RTSPS presents the certificate assigned to **HTTPS**; there is no separate SRTSP certificate usage. Leave **CBS client** assigned to the Bosch device certificate.
|
||||
|
||||
For a private CA, mount a PEM trust bundle containing every CA certificate needed to build the camera certificate chain and set `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` to its path inside the Agent. The bundle is appended to the system roots for camera RTSPS connections only. This Bosch firmware presents only its leaf certificate, so include both the issuing intermediate and root certificates in the bundle. As a temporary fallback, `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` disables certificate verification for camera streams only.
|
||||
|
||||
See [RTSPS and TLS certificates](README-RTSPS-TLS.md) for the complete Bosch UI, private-CA, deployment, validation, and troubleshooting procedure.
|
||||
|
||||
| Name | Description | Default Value |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
|
||||
@@ -203,12 +218,15 @@ Next to attaching the configuration file, it is also possible to override the co
|
||||
| `AGENT_REMOVE_AFTER_UPLOAD` | When enabled, recordings uploaded successfully to a storage will be removed from disk. | "true" |
|
||||
| `AGENT_OFFLINE` | Makes sure no external connection is made. | "false" |
|
||||
| `AGENT_AUTO_CLEAN` | Cleans up the recordings directory. | "true" |
|
||||
| `AGENT_AUTO_CLEAN_MAX_SIZE` | If `AUTO_CLEAN` enabled, set the max size of the recordings directory (in MB). | "100" |
|
||||
| `AGENT_AUTO_CLEAN_MAX_SIZE` | If `AUTO_CLEAN` enabled, cap the recordings directory at this size (in MB). When unset/0, recordings may use the whole disk instead (see `AGENT_AUTO_CLEAN_MIN_FREE_SPACE`). | "100" |
|
||||
| `AGENT_AUTO_CLEAN_MIN_FREE_SPACE` | When `AUTO_CLEAN` is enabled and no `MAX_SIZE` is set, keep at least this much free space (in MB) on the recordings disk before deleting the oldest (already-uploaded first) recordings. Defaults to 5% of the disk. | "" |
|
||||
| `AGENT_TIME` | Enable the timetable for Kerberos Agent | "false" |
|
||||
| `AGENT_TIMETABLE` | A (weekly) time table to specify when to make recordings "start1,end1,start2,end2;start1.. | "" |
|
||||
| `AGENT_REGION_POLYGON` | A single polygon set for motion detection: "x1,y1;x2,y2;x3,y3;... | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP endpoint to the camera you're targetting. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | Sub-stream RTSP endpoint used for livestreaming (WebRTC). | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP or RTSPS endpoint for the target camera. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | RTSP or RTSPS sub-stream endpoint used for livestreaming (WebRTC). | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` | PEM CA bundle appended to the system roots for RTSPS camera certificate verification. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE` | Disable RTSPS camera certificate verification; use only when a trusted CA cannot be installed. | "false" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_BASE_WIDTH` | Force a specific width resolution for live view processing. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_BASE_HEIGHT` | Force a specific height resolution for live view processing. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF` | Mark as a compliant ONVIF device. | "" |
|
||||
@@ -231,9 +249,9 @@ Next to attaching the configuration file, it is also possible to override the co
|
||||
| `AGENT_MQTT_PASSWORD` | Password of the MQTT broker. | "" |
|
||||
| `AGENT_REALTIME_PROCESSING` | If `AGENT_REALTIME_PROCESSING` set to `true`, the agent will send key frames to the topic | "" |
|
||||
| `AGENT_REALTIME_PROCESSING_TOPIC` | The topic to which keyframes will be sent in base64 encoded format. | "" |
|
||||
| `AGENT_STUN_URI` | When using WebRTC, you'll need to provide a STUN server. | "stun:turn.kerberos.io:8443" |
|
||||
| `AGENT_STUN_URI` | When using WebRTC, you'll need to provide a STUN server. | "stun:turn-fra1.kerberos.io:3478"|
|
||||
| `AGENT_FORCE_TURN` | Force using a TURN server, by generating relay candidates only. | "false" |
|
||||
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn.kerberos.io:8443" |
|
||||
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn-fra1.kerberos.io:3478"|
|
||||
| `AGENT_TURN_USERNAME` | TURN username used for WebRTC. | "username1" |
|
||||
| `AGENT_TURN_PASSWORD` | TURN password used for WebRTC. | "password1" |
|
||||
| `AGENT_CLOUD` | Store recordings in Kerberos Hub (s3), Kerberos Vault (kstorage), or Dropbox (dropbox). | "s3" |
|
||||
@@ -411,13 +429,53 @@ Remember the build step of the `web` part, during build time we move the build d
|
||||
|
||||
## Building for Docker
|
||||
|
||||
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping a **Kerberos Agent**. Important to note is that you start from a prebuilt base image, `kerberos/base:xxx`.
|
||||
This base image already contains a couple of tools, such as Golang, FFmpeg and OpenCV. We do this for faster compilation times.
|
||||
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping a **Kerberos Agent**. It uses Debian Trixie to support the native dependencies used by the Agent, including Media over QUIC.
|
||||
|
||||
By running the `docker build` command, you will create the Kerberos Agent Docker image. After building you can simply run the image as a Docker container.
|
||||
|
||||
docker build -t kerberos/agent .
|
||||
|
||||
### Media over QUIC
|
||||
|
||||
The standard AMD64 and ARM64 images include the optional MoQ publisher. Its Rust
|
||||
FFI archive requires CGO and glibc 2.38 or newer, which is why the standard image
|
||||
uses Debian Trixie. The publisher is disabled unless explicitly enabled at runtime:
|
||||
|
||||
docker run --rm -p 80:80 \
|
||||
-e AGENT_LIVE_MOQ_ENABLED=true \
|
||||
-e AGENT_LIVE_MOQ_URL=https://relay.uug.ai/anon \
|
||||
kerberos/agent
|
||||
|
||||
`AGENT_LIVE_MOQ_BROADCAST_PREFIX` defaults to `devices`. MoQ viewers subscribe to
|
||||
a relay and never negotiate with the Agent, so every quality tier is published as
|
||||
its own broadcast and switching quality is simply a resubscribe:
|
||||
|
||||
| Tier | Broadcast | Source |
|
||||
| ------ | ------------------------------------- | ------------------------------------------ |
|
||||
| `high` | `devices/<agent-key>/live.hang` | highest-resolution camera stream |
|
||||
| `low` | `devices/<agent-key>/live-low.hang` | sub stream (main stream when none is set) |
|
||||
|
||||
Each tier only uploads while it has at least one subscriber, so the tier nobody
|
||||
watches costs virtually no bandwidth. `AGENT_LIVE_MOQ_QUALITY` accepts `high` or
|
||||
`low` to pin the Agent to a single tier; viewers requesting the other tier then
|
||||
find no broadcast. Any other value (including the default) publishes both. The
|
||||
initial implementation publishes H.264 video only.
|
||||
|
||||
The `/anon` relay route is intended for interoperability testing. Production
|
||||
deployments must set `AGENT_LIVE_MOQ_URL` to a short-lived, device-scoped
|
||||
publisher URL issued by Hub API.
|
||||
|
||||
To verify the native SDK in a development container, rebuild the Agent or shared
|
||||
monorepo devcontainer so it uses the Trixie base, then run the VS Code task
|
||||
`agent: moq verify`. The same check is available from a terminal:
|
||||
|
||||
cd machinery
|
||||
bash ./verify-moq-devcontainer.sh
|
||||
|
||||
The check requires glibc 2.38 or newer, runs the tagged package tests, links the
|
||||
complete Agent with `-tags moq`, and executes the resulting binary's version
|
||||
command. Both devcontainers also run this check during their post-create setup.
|
||||
|
||||
## What is new?
|
||||
|
||||
This repository contains the next generation of Kerberos.io, **Kerberos Agent (v3)**, and is the successor of the machinery and web repositories. A switch in technologies and architecture has been made. This version is still under active development and can be followed on the [develop branch](https://github.com/kerberos-io/agent/tree/develop) and [project overview](https://github.com/kerberos-io/agent/projects/1).
|
||||
|
||||
@@ -27,5 +27,12 @@ AGENT_KERBEROSVAULT_SECONDARY_DIRECTORY=
|
||||
AGENT_KERBEROSVAULT_SECONDARY_ACCESS_KEY=
|
||||
AGENT_KERBEROSVAULT_SECONDARY_SECRET_KEY=
|
||||
|
||||
# Resumable (tus) uploads to Kerberos Vault are enabled by default.
|
||||
# Set to true to fall back to the legacy single-shot POST /storage upload.
|
||||
#AGENT_DISABLE_RESUMABLE_UPLOAD=true
|
||||
# Bytes sent per PATCH request (default 1 MiB = 1048576). 0 disables chunking
|
||||
# and sends the whole file in a single PATCH.
|
||||
AGENT_TUS_CHUNK_SIZE_BYTES=1048576
|
||||
|
||||
# Open telemetry tracing endpoint
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
636
machinery/cmd/mp4analyze/main.go
Normal file
636
machinery/cmd/mp4analyze/main.go
Normal file
@@ -0,0 +1,636 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/Eyevinn/mp4ff/avc"
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fromFlag := flag.Int64("from", -1, "start of the detailed inspection window (track timescale units); default auto-detects the largest keyframe gap")
|
||||
toFlag := flag.Int64("to", -1, "end of the detailed inspection window (track timescale units); default auto-detected")
|
||||
flag.Parse()
|
||||
if flag.NArg() < 1 {
|
||||
fmt.Println("usage: mp4analyze [-from N] [-to N] <file.mp4>")
|
||||
os.Exit(1)
|
||||
}
|
||||
f, err := os.Open(flag.Arg(0))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
parsed, err := mp4ff.DecodeFile(f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Movie-level info
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
moov := parsed.Init.Moov
|
||||
fmt.Printf("ftyp/moov present. timescale(mvhd)=%d duration(mvhd)=%d\n",
|
||||
moov.Mvhd.Timescale, moov.Mvhd.Duration)
|
||||
for _, trak := range moov.Traks {
|
||||
ts := trak.Mdia.Mdhd.Timescale
|
||||
fmt.Printf(" trak id=%d handler=%s mdhd.timescale=%d mdhd.duration=%d\n",
|
||||
trak.Tkhd.TrackID, trak.Mdia.Hdlr.HandlerType, ts, trak.Mdia.Mdhd.Duration)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("no Init/Moov (pure fragmented stream?)")
|
||||
}
|
||||
|
||||
// sidx vs actual segment layout. MSE players use sidx to map presentation
|
||||
// time -> byte ranges; if sidx references disagree with the real segment
|
||||
// sizes/durations (e.g. after an early/short flush) the player fetches the
|
||||
// wrong bytes and fails to decode — a failure that "heals" on seek.
|
||||
fmt.Println("=== sidx references vs actual segments ===")
|
||||
var sidxRefs []mp4ff.SidxRef
|
||||
for _, c := range parsed.Children {
|
||||
if s, ok := c.(*mp4ff.SidxBox); ok {
|
||||
fmt.Printf(" sidx: timescale=%d earliestPresTime=%d firstOffset=%d refCount=%d anchor(after sidx)=%d\n",
|
||||
s.Timescale, s.EarliestPresentationTime, s.FirstOffset, len(s.SidxRefs), s.AnchorPoint)
|
||||
sidxRefs = s.SidxRefs
|
||||
}
|
||||
}
|
||||
// Actual segment sizes (styp+moof+mdat) and fragment durations.
|
||||
type segInfo struct {
|
||||
size uint64
|
||||
dur uint64
|
||||
}
|
||||
var actual []segInfo
|
||||
for _, seg := range parsed.Segments {
|
||||
var sz uint64
|
||||
if seg.Styp != nil {
|
||||
sz += seg.Styp.Size()
|
||||
}
|
||||
if seg.Sidx != nil {
|
||||
sz += seg.Sidx.Size()
|
||||
}
|
||||
var dur uint64
|
||||
for _, fr := range seg.Fragments {
|
||||
sz += fr.Moof.Size()
|
||||
if fr.Mdat != nil {
|
||||
sz += fr.Mdat.Size()
|
||||
}
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
dur += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
actual = append(actual, segInfo{size: sz, dur: dur})
|
||||
}
|
||||
for i := range actual {
|
||||
refStr := "(no sidx ref)"
|
||||
if i < len(sidxRefs) {
|
||||
r := sidxRefs[i]
|
||||
mark := ""
|
||||
if uint64(r.ReferencedSize) != actual[i].size {
|
||||
mark += fmt.Sprintf(" SIZE MISMATCH actual=%d", actual[i].size)
|
||||
}
|
||||
if uint64(r.SubSegmentDuration) != actual[i].dur {
|
||||
mark += fmt.Sprintf(" DUR MISMATCH actual=%d", actual[i].dur)
|
||||
}
|
||||
refStr = fmt.Sprintf("sidx.size=%d sidx.dur=%d type=%d sap=%d/%d%s",
|
||||
r.ReferencedSize, r.SubSegmentDuration, r.ReferenceType, r.StartsWithSAP, r.SAPType, mark)
|
||||
}
|
||||
fmt.Printf(" seg%02d actual.size=%d actual.dur=%d | %s\n", i, actual[i].size, actual[i].dur, refStr)
|
||||
}
|
||||
|
||||
fmt.Println("=== fragments ===")
|
||||
fragIdx := 0
|
||||
var allKeyGlobal []uint64 // global keyframe decode times (track timescale units)
|
||||
var prevTfdtEnd = map[uint32]uint64{}
|
||||
for si, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
tid := traf.Tfhd.TrackID
|
||||
tfdt := traf.Tfdt.BaseMediaDecodeTime()
|
||||
offset := uint64(0)
|
||||
var keys []uint64 // keyframe offset-from-tfdt
|
||||
var durs []uint64
|
||||
zeroDur := 0
|
||||
nSamples := 0
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
nSamples++
|
||||
if (s.Flags>>24)&0x03 == 0x02 { // sample_depends_on==2 => IDR/sync
|
||||
keys = append(keys, offset)
|
||||
if tid == 1 {
|
||||
allKeyGlobal = append(allKeyGlobal, tfdt+offset)
|
||||
}
|
||||
}
|
||||
if s.Dur == 0 {
|
||||
zeroDur++
|
||||
}
|
||||
durs = append(durs, uint64(s.Dur))
|
||||
offset += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
cont := ""
|
||||
if pe, ok := prevTfdtEnd[tid]; ok {
|
||||
if tfdt != pe {
|
||||
cont = fmt.Sprintf(" <-- tfdt GAP/JUMP prev_end=%d delta=%d", pe, int64(tfdt)-int64(pe))
|
||||
}
|
||||
}
|
||||
prevTfdtEnd[tid] = tfdt + offset
|
||||
if tid == 1 {
|
||||
// in-fragment keyframe gaps
|
||||
var gaps []int64
|
||||
for i := 1; i < len(keys); i++ {
|
||||
gaps = append(gaps, int64(keys[i])-int64(keys[i-1]))
|
||||
}
|
||||
fmt.Printf("seg%d frag%d trk%d tfdt=%d dur=%d nSamp=%d zeroDur=%d keys=%v inFragKeyGaps=%v%s\n",
|
||||
si, fragIdx, tid, tfdt, offset, nSamples, zeroDur, keys, gaps, cont)
|
||||
}
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== global video keyframe decode times & gaps ===")
|
||||
for i, k := range allKeyGlobal {
|
||||
gap := int64(0)
|
||||
if i > 0 {
|
||||
gap = int64(k) - int64(allKeyGlobal[i-1])
|
||||
}
|
||||
seam := ""
|
||||
if i > 1 {
|
||||
prevGap := int64(allKeyGlobal[i-1]) - int64(allKeyGlobal[i-2])
|
||||
if gap > 0 && prevGap > 0 && gap*2 < prevGap {
|
||||
seam = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, seam)
|
||||
}
|
||||
|
||||
// Choose the detailed-inspection window. By default centre it on the largest
|
||||
// keyframe gap (the most likely artifact location); -from/-to override.
|
||||
winLo, winHi := inspectWindow(allKeyGlobal, *fromFlag, *toFlag)
|
||||
fmt.Printf("=== detailed inspection window: dts %d..%d ===\n", winLo, winHi)
|
||||
|
||||
// Full sample timeline: DTS, CTS (=DTS+cto), composition offset, NAL types,
|
||||
// to detect PTS non-monotonicity / gaps / param-set changes at the seam.
|
||||
fmt.Println("=== per-sample timeline (full) — checking PTS monotonicity & nal types ===")
|
||||
var trex *mp4ff.TrexBox
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil && parsed.Init.Moov.Mvex != nil {
|
||||
for _, t := range parsed.Init.Moov.Mvex.Trexs {
|
||||
if t.TrackID == 1 {
|
||||
trex = t
|
||||
}
|
||||
}
|
||||
}
|
||||
var lastCTS int64 = -1
|
||||
var lastDTS int64 = -1
|
||||
sampIdx := 0
|
||||
fragIdx = 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fmt.Printf(" frag%d GetFullSamples err: %v\n", fragIdx, err)
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
dts := int64(s.DecodeTime)
|
||||
cts := dts + int64(s.CompositionTimeOffset)
|
||||
nals := nalTypes(s.Data)
|
||||
anomaly := ""
|
||||
if lastCTS >= 0 && cts < lastCTS {
|
||||
anomaly += fmt.Sprintf(" <== CTS BACKWARDS (prev=%d)", lastCTS)
|
||||
}
|
||||
if lastDTS >= 0 && dts < lastDTS {
|
||||
anomaly += fmt.Sprintf(" <== DTS BACKWARDS (prev=%d)", lastDTS)
|
||||
}
|
||||
// sample_is_non_sync_sample is bit 16 (0x00010000); a sync sample
|
||||
// has it clear and sample_depends_on==2 (i.e. an I-frame).
|
||||
isSync := s.Flags&0x00010000 == 0 && (s.Flags>>24)&0x03 == 0x02
|
||||
// Only print inside the inspection window and any anomalies, to keep output small.
|
||||
near := dts >= winLo && dts <= winHi
|
||||
if near || anomaly != "" {
|
||||
fmt.Printf(" s%04d frag%d dts=%d cts=%d cto=%d dur=%d size=%d sync=%v nal=%v%s\n",
|
||||
sampIdx, fragIdx, dts, cts, s.CompositionTimeOffset, s.Dur, len(s.Data), isSync, nals, anomaly)
|
||||
}
|
||||
lastCTS = cts
|
||||
lastDTS = dts
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Compare parameter sets: avcC (in moov) vs inline SPS/PPS at every IDR.
|
||||
// A looping source that restarts may re-emit SPS/PPS that differ from the
|
||||
// ones the player configured its decoder with from avcC — a classic cause
|
||||
// of a freeze that "heals" when you seek past the seam.
|
||||
fmt.Println("=== parameter set comparison (avcC vs inline IDR) ===")
|
||||
var avccSPS, avccPPS [][]byte
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
|
||||
continue
|
||||
}
|
||||
stsd := trak.Mdia.Minf.Stbl.Stsd
|
||||
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
|
||||
continue
|
||||
}
|
||||
avccSPS = stsd.AvcX.AvcC.SPSnalus
|
||||
avccPPS = stsd.AvcX.AvcC.PPSnalus
|
||||
}
|
||||
}
|
||||
for i, s := range avccSPS {
|
||||
fmt.Printf(" avcC SPS[%d] = %x\n", i, s)
|
||||
}
|
||||
for i, p := range avccPPS {
|
||||
fmt.Printf(" avcC PPS[%d] = %x\n", i, p)
|
||||
}
|
||||
fragIdx = 0
|
||||
sampIdx = 0
|
||||
var baseSPS, basePPS []byte
|
||||
if len(avccSPS) > 0 {
|
||||
baseSPS = avccSPS[0]
|
||||
}
|
||||
if len(avccPPS) > 0 {
|
||||
basePPS = avccPPS[0]
|
||||
}
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
spsList := nalsByType(s.Data, 7)
|
||||
ppsList := nalsByType(s.Data, 8)
|
||||
if len(spsList) > 0 || len(ppsList) > 0 {
|
||||
dts := int64(s.DecodeTime)
|
||||
note := ""
|
||||
if len(spsList) > 0 {
|
||||
if baseSPS == nil {
|
||||
baseSPS = spsList[0]
|
||||
} else if !bytesEqual(baseSPS, spsList[0]) {
|
||||
note += " <== SPS CHANGED vs base/avcC"
|
||||
}
|
||||
}
|
||||
if len(ppsList) > 0 {
|
||||
if basePPS == nil {
|
||||
basePPS = ppsList[0]
|
||||
} else if !bytesEqual(basePPS, ppsList[0]) {
|
||||
note += " <== PPS CHANGED vs base/avcC"
|
||||
}
|
||||
}
|
||||
var spsHex, ppsHex string
|
||||
if len(spsList) > 0 {
|
||||
spsHex = fmt.Sprintf("%x", spsList[0])
|
||||
}
|
||||
if len(ppsList) > 0 {
|
||||
ppsHex = fmt.Sprintf("%x", ppsList[0])
|
||||
}
|
||||
fmt.Printf(" IDR s%04d frag%d dts=%d SPS=%s PPS=%s%s\n",
|
||||
sampIdx, fragIdx, dts, spsHex, ppsHex, note)
|
||||
}
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
sliceHeaders(parsed, trex, winLo, winHi)
|
||||
|
||||
summary(parsed, trex)
|
||||
}
|
||||
|
||||
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox, winLo, winHi int64) {
|
||||
// Build SPS/PPS maps from avcC.
|
||||
spsMap := map[uint32]*avc.SPS{}
|
||||
ppsMap := map[uint32]*avc.PPS{}
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
|
||||
continue
|
||||
}
|
||||
stsd := trak.Mdia.Minf.Stbl.Stsd
|
||||
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
|
||||
continue
|
||||
}
|
||||
for _, s := range stsd.AvcX.AvcC.SPSnalus {
|
||||
if sps, err := avc.ParseSPSNALUnit(s, true); err == nil {
|
||||
spsMap[uint32(sps.ParameterID)] = sps
|
||||
}
|
||||
}
|
||||
for _, p := range stsd.AvcX.AvcC.PPSnalus {
|
||||
if pps, err := avc.ParsePPSNALUnit(p, spsMap); err == nil {
|
||||
ppsMap[pps.PicParameterSetID] = pps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== slice headers in inspection window (frame_num / poc / idr_pic_id) ===")
|
||||
fragIdx := 0
|
||||
sampIdx := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
dts := int64(s.DecodeTime)
|
||||
if dts < winLo || dts > winHi {
|
||||
sampIdx++
|
||||
continue
|
||||
}
|
||||
for _, nal := range splitAVCC(s.Data) {
|
||||
t := nal[0] & 0x1f
|
||||
if t == 1 || t == 5 { // non-IDR or IDR slice
|
||||
sh, err := avc.ParseSliceHeader(nal, spsMap, ppsMap)
|
||||
if err != nil {
|
||||
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceHeader ERR: %v\n", sampIdx, fragIdx, dts, t, err)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceType=%v frameNum=%d idrPicId=%d pocLsb=%d\n",
|
||||
sampIdx, fragIdx, dts, t, sh.SliceType, sh.FrameNum, sh.IDRPicID, sh.PicOrderCntLsb)
|
||||
break
|
||||
}
|
||||
}
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitAVCC splits a length-prefixed (4-byte) AVCC buffer into NAL units.
|
||||
func splitAVCC(b []byte) [][]byte {
|
||||
var out [][]byte
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
out = append(out, b[i:i+n])
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// nalTypes returns the list of H.264 NAL unit types present in an AVCC
|
||||
// (length-prefixed) sample buffer.
|
||||
func nalTypes(b []byte) []int {
|
||||
var out []int
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
out = append(out, int(b[i]&0x1f))
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nalsByType returns the raw NAL payloads (without length prefix) of the given
|
||||
// type from an AVCC (length-prefixed) sample buffer.
|
||||
func nalsByType(b []byte, want int) [][]byte {
|
||||
var out [][]byte
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
if int(b[i]&0x1f) == want {
|
||||
nal := make([]byte, n)
|
||||
copy(nal, b[i:i+n])
|
||||
out = append(out, nal)
|
||||
}
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inspectWindow returns the [lo,hi] decode-time range (track timescale units)
|
||||
// for which sample-level detail is printed. Explicit -from/-to win; otherwise
|
||||
// the window auto-centres on the largest gap between consecutive video
|
||||
// keyframes — the most likely location of a visible artifact — with a margin on
|
||||
// each side so the frames leading into and out of the gap are shown too.
|
||||
func inspectWindow(keyDecodeTimes []uint64, from, to int64) (int64, int64) {
|
||||
if from >= 0 || to >= 0 {
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if to < 0 {
|
||||
to = from + 2000
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
if len(keyDecodeTimes) < 2 {
|
||||
return 0, 1 << 62
|
||||
}
|
||||
worstIdx, worstGap := 1, uint64(0)
|
||||
for i := 1; i < len(keyDecodeTimes); i++ {
|
||||
if g := keyDecodeTimes[i] - keyDecodeTimes[i-1]; g > worstGap {
|
||||
worstGap = g
|
||||
worstIdx = i
|
||||
}
|
||||
}
|
||||
const margin = 500
|
||||
lo := int64(keyDecodeTimes[worstIdx-1]) - margin
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
return lo, int64(keyDecodeTimes[worstIdx]) + margin
|
||||
}
|
||||
|
||||
// summary prints a compact, generic health report so a recording can be
|
||||
// validated at a glance without reading the full per-sample dump above.
|
||||
func summary(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
|
||||
fmt.Println("=== SUMMARY (health checks) ===")
|
||||
|
||||
videoTracks, audioTracks := 0, 0
|
||||
var videoTimescale uint64 = 1
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
switch trak.Mdia.Hdlr.HandlerType {
|
||||
case "vide":
|
||||
videoTracks++
|
||||
if trak.Mdia.Mdhd.Timescale != 0 {
|
||||
videoTimescale = uint64(trak.Mdia.Mdhd.Timescale)
|
||||
}
|
||||
case "soun":
|
||||
audioTracks++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" tracks: %d video, %d audio\n", videoTracks, audioTracks)
|
||||
if audioTracks == 0 {
|
||||
fmt.Println(" note: no audio track is embedded in this file")
|
||||
}
|
||||
|
||||
type fragStat struct {
|
||||
idx int
|
||||
tfdt uint64
|
||||
dur uint64
|
||||
nSamp int
|
||||
nKeys int
|
||||
zeroDur int
|
||||
fps float64
|
||||
}
|
||||
var stats []fragStat
|
||||
var keyTimes []uint64
|
||||
var fpsArr []float64
|
||||
tfdtGaps := 0
|
||||
var prevEnd uint64
|
||||
havePrev := false
|
||||
fi := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
st := fragStat{idx: fi, tfdt: traf.Tfdt.BaseMediaDecodeTime()}
|
||||
off := uint64(0)
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
st.nSamp++
|
||||
if (s.Flags>>24)&0x03 == 0x02 {
|
||||
st.nKeys++
|
||||
keyTimes = append(keyTimes, st.tfdt+off)
|
||||
}
|
||||
if s.Dur == 0 {
|
||||
st.zeroDur++
|
||||
}
|
||||
off += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
st.dur = off
|
||||
d := st.dur
|
||||
if d == 0 {
|
||||
d = 1
|
||||
}
|
||||
st.fps = float64(st.nSamp) * float64(videoTimescale) / float64(d)
|
||||
fpsArr = append(fpsArr, st.fps)
|
||||
if havePrev && st.tfdt != prevEnd {
|
||||
tfdtGaps++
|
||||
}
|
||||
prevEnd = st.tfdt + st.dur
|
||||
havePrev = true
|
||||
stats = append(stats, st)
|
||||
}
|
||||
fi++
|
||||
}
|
||||
}
|
||||
|
||||
medFps := medianFloat(fpsArr)
|
||||
fmt.Printf(" fragments: %d (video timescale=%d, median %.1f fps)\n", len(stats), videoTimescale, medFps)
|
||||
lowFps := 0
|
||||
totalZero := 0
|
||||
for _, st := range stats {
|
||||
totalZero += st.zeroDur
|
||||
flagStr := ""
|
||||
if medFps > 0 && st.fps < medFps*0.9 {
|
||||
lowFps++
|
||||
flagStr = " <== LOW FRAME RATE — likely dropped frames"
|
||||
}
|
||||
fmt.Printf(" frag%02d tfdt=%-6d dur=%-5d samples=%-3d keyframes=%d zeroDur=%d fps=%.1f%s\n",
|
||||
st.idx, st.tfdt, st.dur, st.nSamp, st.nKeys, st.zeroDur, st.fps, flagStr)
|
||||
}
|
||||
|
||||
var gaps []uint64
|
||||
for i := 1; i < len(keyTimes); i++ {
|
||||
gaps = append(gaps, keyTimes[i]-keyTimes[i-1])
|
||||
}
|
||||
irregular := 0
|
||||
if len(gaps) > 0 {
|
||||
med := medianUint(gaps)
|
||||
mn, mx := gaps[0], gaps[0]
|
||||
for _, g := range gaps {
|
||||
if g < mn {
|
||||
mn = g
|
||||
}
|
||||
if g > mx {
|
||||
mx = g
|
||||
}
|
||||
// Flag intervals that deviate by more than ~50% from the median GOP.
|
||||
if med > 0 && (g*2 > med*3 || g*2 < med) {
|
||||
irregular++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" keyframe gaps: min=%d median=%d max=%d irregular=%d/%d\n", mn, med, mx, irregular, len(gaps))
|
||||
}
|
||||
fmt.Printf(" tfdt discontinuities: %d\n", tfdtGaps)
|
||||
fmt.Printf(" zero-duration samples: %d\n", totalZero)
|
||||
|
||||
fmt.Println(" verdict:")
|
||||
clean := true
|
||||
if audioTracks == 0 {
|
||||
fmt.Println(" - no audio track (expected if this recording is video-only)")
|
||||
}
|
||||
if lowFps > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d fragment(s) have a reduced frame rate (dropped frames) — likely source of the artifacts\n", lowFps)
|
||||
}
|
||||
if irregular > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d irregular keyframe interval(s)\n", irregular)
|
||||
}
|
||||
if tfdtGaps > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d timeline (tfdt) discontinuity(ies)\n", tfdtGaps)
|
||||
}
|
||||
if totalZero > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d zero-duration sample(s)\n", totalZero)
|
||||
}
|
||||
if clean {
|
||||
fmt.Println(" - container structure looks healthy")
|
||||
}
|
||||
}
|
||||
|
||||
func medianUint(v []uint64) uint64 {
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
c := append([]uint64(nil), v...)
|
||||
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
|
||||
return c[len(c)/2]
|
||||
}
|
||||
|
||||
func medianFloat(v []float64) float64 {
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
c := append([]float64(nil), v...)
|
||||
sort.Float64s(c)
|
||||
return c[len(c)/2]
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"offline": "false",
|
||||
"auto_clean": "true",
|
||||
"remove_after_upload": "true",
|
||||
"max_directory_size": 100,
|
||||
"max_directory_size": 0,
|
||||
"timezone": "Africa/Ceuta",
|
||||
"capture": {
|
||||
"name": "",
|
||||
@@ -106,9 +106,9 @@
|
||||
"mqtturi": "tcp://mqtt.kerberos.io:1883",
|
||||
"mqtt_username": "",
|
||||
"mqtt_password": "",
|
||||
"stunuri": "stun:turn.kerberos.io:8443",
|
||||
"turn_force": "false",
|
||||
"turnuri": "turn:turn.kerberos.io:8443",
|
||||
"stunuri": "stun:turn-fra1.kerberos.io:3478",
|
||||
"turnuri": "turn:turn-fra1.kerberos.io:3478",
|
||||
"turn_username": "username1",
|
||||
"turn_password": "password1",
|
||||
"heartbeaturi": "",
|
||||
@@ -122,4 +122,4 @@
|
||||
"signing": {},
|
||||
"realtimeprocessing": "false",
|
||||
"realtimeprocessing_topic": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"upload_url":"https://vault.kerberos.io/api/storage/tus/19e42fbc666a38064904caf8c46d182a","vault_uri":"https://vault.kerberos.io/api/storage/tus/","size":1591581}
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/kerberos-io/agent/machinery
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.0
|
||||
|
||||
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237
|
||||
|
||||
@@ -8,7 +8,7 @@ require (
|
||||
github.com/Eyevinn/mp4ff v0.48.0
|
||||
github.com/InVisionApp/conjungo v1.1.0
|
||||
github.com/appleboy/gin-jwt/v2 v2.10.3
|
||||
github.com/bluenviron/gortsplib/v4 v4.14.1
|
||||
github.com/bluenviron/gortsplib/v5 v5.6.3
|
||||
github.com/bluenviron/mediacommon v1.14.0
|
||||
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6
|
||||
github.com/dromara/carbon/v2 v2.6.8
|
||||
@@ -24,14 +24,16 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/kellydunn/golang-geo v0.7.0
|
||||
github.com/kerberos-io/joy4 v1.0.64
|
||||
github.com/kerberos-io/onvif v1.0.0
|
||||
github.com/kerberos-io/onvif v1.2.2
|
||||
github.com/minio/minio-go/v6 v6.0.57
|
||||
github.com/moq-dev/moq-go v0.5.7
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/pion/interceptor v0.1.40
|
||||
github.com/pion/rtp v1.8.19
|
||||
github.com/pion/webrtc/v4 v4.1.2
|
||||
github.com/pion/interceptor v0.1.47
|
||||
github.com/pion/rtp v1.10.5
|
||||
github.com/pion/webrtc/v4 v4.2.18
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.4
|
||||
@@ -51,13 +53,14 @@ require (
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/beevik/etree v1.2.0 // indirect
|
||||
github.com/bluenviron/mediacommon/v2 v2.2.0 // indirect
|
||||
github.com/bluenviron/mediacommon/v2 v2.9.2 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elgs/gostrgen v0.0.0-20161222160715-9d61ae07eeae // indirect
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
|
||||
@@ -76,7 +79,6 @@ require (
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||
github.com/icholy/digest v0.1.23 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/juju/errors v1.0.0 // indirect
|
||||
@@ -94,21 +96,23 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/moq-dev/moq-go-ffi v0.3.7 // indirect
|
||||
github.com/nxadm/tail v1.4.11 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/logging v0.2.3 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/datachannel v1.6.2 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.5 // indirect
|
||||
github.com/pion/ice/v4 v4.4.0 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
github.com/pion/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.13 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.5 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/pion/rtcp v1.2.17 // indirect
|
||||
github.com/pion/sctp v1.11.1 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.19 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.12 // indirect
|
||||
github.com/pion/stun/v3 v3.1.6 // indirect
|
||||
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/pion/turn/v5 v5.0.12 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
@@ -122,13 +126,14 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/crypto v0.38.0 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.30.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/grpc v1.72.1 // indirect
|
||||
|
||||
111
machinery/go.sum
111
machinery/go.sum
@@ -393,12 +393,12 @@ github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhpl
|
||||
github.com/bazelbuild/rules_go v0.49.0/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs=
|
||||
github.com/beevik/etree v1.2.0 h1:l7WETslUG/T+xOPs47dtd6jov2Ii/8/OjCldk5fYfQw=
|
||||
github.com/beevik/etree v1.2.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc=
|
||||
github.com/bluenviron/gortsplib/v4 v4.14.1 h1:v99NmXeeJFfbrO+ipPzPxYGibQaR5ZOUESOA9UQZhsI=
|
||||
github.com/bluenviron/gortsplib/v4 v4.14.1/go.mod h1:3LaEcg0d47+kfXju5KSlsSxCiZ3IKBI/sqIrBPcsS64=
|
||||
github.com/bluenviron/gortsplib/v5 v5.6.3 h1:OXvHthQZ9fZbLh6r3Go2wuF4XQ4/QW4WTIM2f4bv/W4=
|
||||
github.com/bluenviron/gortsplib/v5 v5.6.3/go.mod h1:kzHgUtvl8NWNsQ5Vsez6Vuugk6ItFT4ByCPm8J/kbSQ=
|
||||
github.com/bluenviron/mediacommon v1.14.0 h1:lWCwOBKNKgqmspRpwpvvg3CidYm+XOc2+z/Jw7LM5dQ=
|
||||
github.com/bluenviron/mediacommon v1.14.0/go.mod h1:z5LP9Tm1ZNfQV5Co54PyOzaIhGMusDfRKmh42nQSnyo=
|
||||
github.com/bluenviron/mediacommon/v2 v2.2.0 h1:fGXEX0OEvv5VhGHOv3Q2ABzOtSkIpl9UbwOHrnKWNTk=
|
||||
github.com/bluenviron/mediacommon/v2 v2.2.0/go.mod h1:a6MbPmXtYda9mKibKVMZlW20GYLLrX2R7ZkUE+1pwV0=
|
||||
github.com/bluenviron/mediacommon/v2 v2.9.2 h1:jvYeBjvhHKFOBRMTMm4hvrSjyOlCelkOkx6708DidQM=
|
||||
github.com/bluenviron/mediacommon/v2 v2.9.2/go.mod h1:jMf/OJDaJl02xRgkLM2zbidUHnDYqLnO1dMMveCmyyU=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
@@ -755,8 +755,6 @@ github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
||||
github.com/icholy/digest v0.1.23 h1:4hX2pIloP0aDx7RJW0JewhPPy3R8kU+vWKdxPsCCGtY=
|
||||
github.com/icholy/digest v0.1.23/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
@@ -776,8 +774,8 @@ github.com/kellydunn/golang-geo v0.7.0 h1:A5j0/BvNgGwY6Yb6inXQxzYwlPHc6WVZR+Mrar
|
||||
github.com/kellydunn/golang-geo v0.7.0/go.mod h1:YYlQPJ+DPEzrHx8kT3oPHC/NjyvCCXE+IuKGKdrjrcU=
|
||||
github.com/kerberos-io/joy4 v1.0.64 h1:gTUSotHSOhp9mNqEecgq88tQHvpj7TjmrvPUsPm0idg=
|
||||
github.com/kerberos-io/joy4 v1.0.64/go.mod h1:nZp4AjvKvTOXRrmDyAIOw+Da+JA5OcSo/JundGfOlFU=
|
||||
github.com/kerberos-io/onvif v1.0.0 h1:pLJrK6skPkK+5Bj4XfqHUkQ2I+p5pwELnp+kQTJWXiQ=
|
||||
github.com/kerberos-io/onvif v1.0.0/go.mod h1:P1kUcCfeotJSlL1jwGseH6NSnCwWiuJLl3gAzafnLbA=
|
||||
github.com/kerberos-io/onvif v1.2.2 h1:QnxITps7xvAVD2abWRsa3+p9QexjKESQQldUFMx/mYA=
|
||||
github.com/kerberos-io/onvif v1.2.2/go.mod h1:XSgEQXmEDjUQTbdXvsaRJt6Az8YPGj7L+j5iXKEGijU=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
@@ -847,6 +845,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/moq-dev/moq-go v0.5.7 h1:LfFpgAU8FRMcnU85L5Lb03HzxhYbQ2+BhT/pJQdjy5U=
|
||||
github.com/moq-dev/moq-go v0.5.7/go.mod h1:5K8zjKKjWe5lzfCtlfxoAfdCr3KX6EBuTOZsz0WBnzw=
|
||||
github.com/moq-dev/moq-go-ffi v0.3.7 h1:+xwPOzTJHvB0tuTnW6znbvvOgT4yLnN7sMqXXZYh02M=
|
||||
github.com/moq-dev/moq-go-ffi v0.3.7/go.mod h1:zxpOlUetvaoxWBnbXTdILtLLUytaoVuLvO36lftwWO0=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
@@ -864,38 +866,40 @@ github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2
|
||||
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
|
||||
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
|
||||
github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc=
|
||||
github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU=
|
||||
github.com/pion/ice/v4 v4.4.0 h1:wvHDDqimaC38Y7MVpD46Y63p246ChvXd87VKoLYS5b4=
|
||||
github.com/pion/ice/v4 v4.4.0/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU=
|
||||
github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo=
|
||||
github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||
github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c=
|
||||
github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
|
||||
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
||||
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
|
||||
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54=
|
||||
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
|
||||
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
|
||||
github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU=
|
||||
github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=
|
||||
github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||
github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA=
|
||||
github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
|
||||
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
|
||||
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
|
||||
github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
|
||||
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
|
||||
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
|
||||
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
|
||||
github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
|
||||
github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI=
|
||||
github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -955,8 +959,9 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/substrait-io/substrait-go v0.4.2/go.mod h1:qhpnLmrcvAnlZsUyPXZRqldiHapPTXC3t7xFgDi3aQg=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
@@ -1164,8 +1169,8 @@ golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@@ -1234,8 +1239,9 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1323,8 +1329,8 @@ golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1403,8 +1409,9 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -1513,8 +1520,8 @@ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -1581,8 +1588,8 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1594,6 +1601,8 @@ golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -1667,8 +1676,9 @@ golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -2109,7 +2119,6 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
@@ -76,12 +77,14 @@ func main() {
|
||||
var name string
|
||||
var port string
|
||||
var timeout string
|
||||
var subnet string
|
||||
|
||||
flag.StringVar(&action, "action", "version", "Tell us what you want do 'run' or 'version'")
|
||||
flag.StringVar(&configDirectory, "config", ".", "Where is the configuration stored")
|
||||
flag.StringVar(&name, "name", "agent", "Provide a name for the agent")
|
||||
flag.StringVar(&port, "port", "80", "On which port should the agent run")
|
||||
flag.StringVar(&timeout, "timeout", "2000", "Number of milliseconds to wait for the ONVIF discovery to complete")
|
||||
flag.StringVar(&subnet, "subnet", "", "Optional subnet(s) to scan for discovery, e.g. '192.168.1.0/24' (comma-separated). Defaults to the local interfaces.")
|
||||
flag.Parse()
|
||||
|
||||
// Specify the level of loggin: "info", "warning", "debug", "error" or "fatal."
|
||||
@@ -112,7 +115,13 @@ func main() {
|
||||
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
|
||||
return
|
||||
}
|
||||
onvif.Discover(timeout)
|
||||
var subnets []string
|
||||
for _, part := range strings.Split(subnet, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
subnets = append(subnets, trimmed)
|
||||
}
|
||||
}
|
||||
onvif.Discover(timeout, subnets...)
|
||||
}
|
||||
case "decrypt":
|
||||
{
|
||||
|
||||
238
machinery/src/capture/cleanup_test.go
Normal file
238
machinery/src/capture/cleanup_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// writeRecording creates a file under recordingsDir and sets its modtime so the
|
||||
// tests can control the "oldest" ordering deterministically.
|
||||
func writeRecording(t *testing.T, recordingsDir, name string, ageMinutes int) {
|
||||
t.Helper()
|
||||
full := filepath.Join(recordingsDir, name)
|
||||
if err := os.WriteFile(full, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write recording %s: %v", name, err)
|
||||
}
|
||||
mod := time.Now().Add(-time.Duration(ageMinutes) * time.Minute)
|
||||
if err := os.Chtimes(full, mod, mod); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// markPending creates the upload marker in cloudDir for the given recording,
|
||||
// marking it as still queued for upload.
|
||||
func markPending(t *testing.T, cloudDir, name string) {
|
||||
t.Helper()
|
||||
markerName := models.RecordingUploadMetadataFileName(name)
|
||||
if err := os.WriteFile(filepath.Join(cloudDir, markerName), nil, 0o644); err != nil {
|
||||
t.Fatalf("write marker %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCleanupDirs(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
recordingsDir := filepath.Join(base, "data", "recordings")
|
||||
cloudDir := filepath.Join(base, "data", "cloud")
|
||||
if err := os.MkdirAll(recordingsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir recordings: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cloudDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud: %v", err)
|
||||
}
|
||||
return recordingsDir, cloudDir
|
||||
}
|
||||
|
||||
// The core regression: when the oldest recording is still pending upload but a
|
||||
// newer one has already been uploaded, cleanup must delete the uploaded (safe)
|
||||
// one and leave the pending recording on disk so it can still be uploaded.
|
||||
func TestPickRecordingToCleanup_PrefersUploaded(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
// oldest is still pending upload (marker present).
|
||||
writeRecording(t, recordingsDir, "oldest_pending.mp4", 30)
|
||||
markPending(t, cloudDir, "oldest_pending.mp4")
|
||||
// newer one has already been uploaded (no marker).
|
||||
writeRecording(t, recordingsDir, "newer_uploaded.mp4", 10)
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending {
|
||||
t.Fatalf("expected a safe (already-uploaded) deletion, got pending=true")
|
||||
}
|
||||
if name != "newer_uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want the uploaded recording newer_uploaded.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRecordingToCleanup_RecognizesLegacyMarkerName(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "legacy_pending.mp4", 30)
|
||||
if err := os.WriteFile(filepath.Join(cloudDir, "legacy_pending.mp4"), nil, 0o644); err != nil {
|
||||
t.Fatalf("write legacy marker: %v", err)
|
||||
}
|
||||
writeRecording(t, recordingsDir, "uploaded.mp4", 10)
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending || name != "uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked name=%q pending=%v, want uploaded.mp4 pending=false", name, pending)
|
||||
}
|
||||
}
|
||||
|
||||
// Among several already-uploaded recordings, the oldest uploaded one is chosen.
|
||||
func TestPickRecordingToCleanup_OldestUploadedFirst(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "old_uploaded.mp4", 40)
|
||||
writeRecording(t, recordingsDir, "mid_uploaded.mp4", 20)
|
||||
// pending one must be ignored even though it is not the oldest.
|
||||
writeRecording(t, recordingsDir, "pending.mp4", 30)
|
||||
markPending(t, cloudDir, "pending.mp4")
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending {
|
||||
t.Fatalf("expected pending=false, got true")
|
||||
}
|
||||
if name != "old_uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want old_uploaded.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: when every recording is still pending upload, cleanup returns the
|
||||
// oldest one with pending=true so the caller can drop it (and its marker) to keep
|
||||
// the disk bounded.
|
||||
func TestPickRecordingToCleanup_AllPendingFallsBackToOldest(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "a_old.mp4", 50)
|
||||
markPending(t, cloudDir, "a_old.mp4")
|
||||
writeRecording(t, recordingsDir, "b_new.mp4", 5)
|
||||
markPending(t, cloudDir, "b_new.mp4")
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !pending {
|
||||
t.Fatalf("expected pending=true when every recording is queued for upload")
|
||||
}
|
||||
if name != "a_old.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want the oldest pending a_old.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty recordings directory yields os.ErrNotExist so the caller does nothing.
|
||||
func TestPickRecordingToCleanup_Empty(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
if _, _, err := pickRecordingToCleanup(recordingsDir, cloudDir); err != os.ErrNotExist {
|
||||
t.Fatalf("expected os.ErrNotExist for an empty directory, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSizedRecording writes a recording of an exact byte size so tests can
|
||||
// exercise the megabyte-based directory-cap threshold.
|
||||
func writeSizedRecording(t *testing.T, dir, name string, size int) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o644); err != nil {
|
||||
t.Fatalf("write sized recording %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// When AGENT_AUTO_CLEAN_MAX_SIZE (MaxDirectorySize) is set, cleanup triggers once
|
||||
// the recordings directory grows past that many megabytes.
|
||||
func TestRecordingsNeedCleanup_FixedCap(t *testing.T) {
|
||||
recordingsDir, _ := newCleanupDirs(t)
|
||||
// ~2 MB of recordings on disk.
|
||||
writeSizedRecording(t, recordingsDir, "big.mp4", 2*1000*1000)
|
||||
|
||||
over := &models.Configuration{Config: models.Config{MaxDirectorySize: 1}}
|
||||
need, err := recordingsNeedCleanup(recordingsDir, over)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !need {
|
||||
t.Fatalf("expected cleanup when 2MB of recordings exceed the 1MB cap")
|
||||
}
|
||||
|
||||
under := &models.Configuration{Config: models.Config{MaxDirectorySize: 100}}
|
||||
need, err = recordingsNeedCleanup(recordingsDir, under)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if need {
|
||||
t.Fatalf("expected no cleanup when 2MB of recordings stay under the 100MB cap")
|
||||
}
|
||||
}
|
||||
|
||||
// With no fixed cap (the default), cleanup is driven by the free space left on
|
||||
// the recordings filesystem versus the reserve.
|
||||
func TestRecordingsNeedCleanup_DefaultDiskReserve(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("disk usage stats are only implemented on linux")
|
||||
}
|
||||
recordingsDir, _ := newCleanupDirs(t)
|
||||
|
||||
totalMB, availableMB, err := diskUsageMB(recordingsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("diskUsageMB: %v", err)
|
||||
}
|
||||
if totalMB <= 0 || availableMB <= 0 {
|
||||
t.Skipf("unexpected disk stats total=%dMB available=%dMB", totalMB, availableMB)
|
||||
}
|
||||
|
||||
// A reserve larger than the whole disk means free space is always below it.
|
||||
over := &models.Configuration{Config: models.Config{MinFreeSpace: totalMB + availableMB}}
|
||||
need, err := recordingsNeedCleanup(recordingsDir, over)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !need {
|
||||
t.Fatalf("expected cleanup when free space (%dMB) is below the reserve", availableMB)
|
||||
}
|
||||
|
||||
// A 1 MB reserve leaves plenty of free space, so nothing should be cleaned.
|
||||
under := &models.Configuration{Config: models.Config{MinFreeSpace: 1}}
|
||||
need, err = recordingsNeedCleanup(recordingsDir, under)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if need {
|
||||
t.Fatalf("expected no cleanup when free space (%dMB) exceeds the 1MB reserve", availableMB)
|
||||
}
|
||||
}
|
||||
|
||||
// The default 5% reserve must never truncate to 0MB on small disks, otherwise
|
||||
// cleanup would only trigger once the disk is completely full.
|
||||
func TestDefaultReserveMB(t *testing.T) {
|
||||
cases := []struct {
|
||||
totalMB int64
|
||||
want int64
|
||||
}{
|
||||
{totalMB: 0, want: 1}, // no/unknown disk size still reserves 1MB
|
||||
{totalMB: 10, want: 1}, // 5% = 0MB -> floored to 1MB
|
||||
{totalMB: 19, want: 1}, // 5% = 0MB -> floored to 1MB
|
||||
{totalMB: 20, want: 1}, // 5% = exactly 1MB
|
||||
{totalMB: 100, want: 5}, // 5% = 5MB
|
||||
{totalMB: 1000, want: 50},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := defaultReserveMB(c.totalMB); got != c.want {
|
||||
t.Errorf("defaultReserveMB(%d) = %d, want %d", c.totalMB, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
machinery/src/capture/disk_linux.go
Normal file
23
machinery/src/capture/disk_linux.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build linux
|
||||
|
||||
package capture
|
||||
|
||||
import "syscall"
|
||||
|
||||
// diskUsageMB returns the total capacity and the currently available space (both
|
||||
// in megabytes, decimal) of the filesystem that contains path. Auto-clean uses
|
||||
// it to default its cleanup threshold to the real disk capacity instead of a
|
||||
// fixed size, so recordings can grow to fill the disk while keeping a reserve
|
||||
// free. Linux is the agent's deployment target (amd64/arm64 containers).
|
||||
func diskUsageMB(path string) (totalMB int64, availableMB int64, err error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err = syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
blockSize := int64(stat.Bsize)
|
||||
totalMB = int64(stat.Blocks) * blockSize / 1000 / 1000
|
||||
// Bavail is the free space available to unprivileged users, which is the
|
||||
// space we can actually keep writing recordings into.
|
||||
availableMB = int64(stat.Bavail) * blockSize / 1000 / 1000
|
||||
return totalMB, availableMB, nil
|
||||
}
|
||||
13
machinery/src/capture/disk_other.go
Normal file
13
machinery/src/capture/disk_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package capture
|
||||
|
||||
import "errors"
|
||||
|
||||
// diskUsageMB is only implemented on Linux (the agent's deployment target). On
|
||||
// other platforms (e.g. local macOS/Windows dev builds) auto-clean falls back to
|
||||
// its historical fixed-size directory cap, so this reports the capability as
|
||||
// unavailable.
|
||||
func diskUsageMB(path string) (totalMB int64, availableMB int64, err error) {
|
||||
return 0, 0, errors.New("disk usage stats are not supported on this platform")
|
||||
}
|
||||
@@ -8,24 +8,27 @@ import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/bluenviron/gortsplib/v4"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/base"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtph264"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtph265"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtplpcm"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtpmpeg4audio"
|
||||
"github.com/bluenviron/gortsplib/v4/pkg/format/rtpsimpleaudio"
|
||||
"github.com/bluenviron/gortsplib/v5"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/base"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/description"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph264"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtph265"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtplpcm"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpmpeg4audio"
|
||||
"github.com/bluenviron/gortsplib/v5/pkg/format/rtpsimpleaudio"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h265"
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/mpeg4audio"
|
||||
@@ -38,6 +41,36 @@ import (
|
||||
|
||||
var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/capture")
|
||||
|
||||
const (
|
||||
rtspsCAFileEnv = "AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE"
|
||||
rtspsInsecureEnv = "AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE"
|
||||
)
|
||||
|
||||
func rtspsTLSConfig() (*tls.Config, error) {
|
||||
if os.Getenv(rtspsInsecureEnv) == "true" {
|
||||
return &tls.Config{InsecureSkipVerify: true}, nil // #nosec G402 -- explicit opt-in for cameras with self-signed certificates
|
||||
}
|
||||
|
||||
caFile := os.Getenv(rtspsCAFileEnv)
|
||||
if caFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rootCAs, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load system CA pool: %w", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read RTSPS CA file %q: %w", caFile, err)
|
||||
}
|
||||
if !rootCAs.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("RTSPS CA file %q contains no certificates", caFile)
|
||||
}
|
||||
|
||||
return &tls.Config{RootCAs: rootCAs}, nil
|
||||
}
|
||||
|
||||
// Implements the RTSPClient interface.
|
||||
type Golibrtsp struct {
|
||||
RTSPClient
|
||||
@@ -95,6 +128,129 @@ type Golibrtsp struct {
|
||||
keyframeBufferSize int
|
||||
keyframeBufferIndex int
|
||||
keyframeMutex sync.Mutex
|
||||
|
||||
// Stream health instrumentation. Used to pinpoint the root cause behind
|
||||
// "RTP packets lost" + watchdog restarts by separating downstream
|
||||
// back-pressure from upstream network/camera stalls.
|
||||
health *streamHealth
|
||||
streamLabel string
|
||||
}
|
||||
|
||||
// streamHealth instruments the RTSP read path. gortsplib delivers every RTP
|
||||
// packet on a single read goroutine; queue.WritePacket() is synchronous, so if
|
||||
// a downstream consumer (recording, muxing, WebRTC) is slow or the process is
|
||||
// CPU-starved, WritePacket() blocks, the TCP socket is not drained, and the
|
||||
// camera advances RTP sequence numbers -> "RTP packets lost". This type makes
|
||||
// the two failure modes distinguishable:
|
||||
// - large writeMax / writeAvg => downstream back-pressure (our side).
|
||||
// - large gapMax with fast writes => upstream network / camera stall.
|
||||
type streamHealth struct {
|
||||
mu sync.Mutex
|
||||
windowStart time.Time
|
||||
lastPacket time.Time
|
||||
frames int64
|
||||
writeSum time.Duration
|
||||
writeMax time.Duration
|
||||
gapMax time.Duration
|
||||
lost uint64
|
||||
decodeErrs int64
|
||||
}
|
||||
|
||||
const (
|
||||
streamHealthWindow = 10 * time.Second
|
||||
streamHealthWriteWarn = 150 * time.Millisecond
|
||||
streamHealthGapWarn = 1500 * time.Millisecond
|
||||
)
|
||||
|
||||
func newStreamHealth() *streamHealth {
|
||||
now := time.Now()
|
||||
return &streamHealth{windowStart: now, lastPacket: now}
|
||||
}
|
||||
|
||||
// observePacket records one processed video frame: the wall-clock gap since the
|
||||
// previous frame (arrival cadence) and how long WritePacket() blocked
|
||||
// (back-pressure). It emits an immediate warning when either side stalls and a
|
||||
// periodic summary every streamHealthWindow.
|
||||
func (h *streamHealth) observePacket(streamType string, writeDur time.Duration) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
now := time.Now()
|
||||
var gap time.Duration
|
||||
if h.frames == 0 {
|
||||
// First frame: initialize timing to avoid counting RTSP setup time as a stall.
|
||||
h.windowStart = now
|
||||
h.lastPacket = now
|
||||
gap = 0
|
||||
} else {
|
||||
gap = now.Sub(h.lastPacket)
|
||||
h.lastPacket = now
|
||||
}
|
||||
h.frames++
|
||||
h.writeSum += writeDur
|
||||
if writeDur > h.writeMax {
|
||||
h.writeMax = writeDur
|
||||
}
|
||||
if gap > h.gapMax {
|
||||
h.gapMax = gap
|
||||
}
|
||||
if writeDur >= streamHealthWriteWarn {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"capture.golibrtsp.health(%s): WritePacket blocked %dms — downstream back-pressure / CPU starvation",
|
||||
streamType, writeDur.Milliseconds()))
|
||||
}
|
||||
if gap >= streamHealthGapWarn {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"capture.golibrtsp.health(%s): %dms since previous frame — upstream network / camera stall",
|
||||
streamType, gap.Milliseconds()))
|
||||
}
|
||||
if now.Sub(h.windowStart) >= streamHealthWindow {
|
||||
elapsed := now.Sub(h.windowStart).Seconds()
|
||||
var avgWriteMs float64
|
||||
if h.frames > 0 {
|
||||
avgWriteMs = float64(h.writeSum.Milliseconds()) / float64(h.frames)
|
||||
}
|
||||
log.Log.Info(fmt.Sprintf(
|
||||
"capture.golibrtsp.health(%s): %.0fs window — frames=%d (%.1f/s) writeAvg=%.1fms writeMax=%dms gapMax=%dms lost=%d decodeErrs=%d",
|
||||
streamType, elapsed, h.frames, float64(h.frames)/elapsed, avgWriteMs,
|
||||
h.writeMax.Milliseconds(), h.gapMax.Milliseconds(), h.lost, h.decodeErrs))
|
||||
h.windowStart = now
|
||||
h.frames = 0
|
||||
h.writeSum = 0
|
||||
h.writeMax = 0
|
||||
h.gapMax = 0
|
||||
h.lost = 0
|
||||
h.decodeErrs = 0
|
||||
}
|
||||
}
|
||||
|
||||
// observeLost is invoked by gortsplib when RTP sequence numbers skip. On a TCP
|
||||
// transport this means the sender (camera) dropped packets because we were not
|
||||
// reading fast enough, not loss on the wire.
|
||||
func (h *streamHealth) observeLost(streamType string, lost uint64) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.lost += lost
|
||||
h.mu.Unlock()
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"capture.golibrtsp.health(%s): %d RTP packet(s) lost — sender-side gap (receiver not draining TCP fast enough)",
|
||||
streamType, lost))
|
||||
}
|
||||
|
||||
// observeDecodeError is invoked by gortsplib on incomplete/invalid access units,
|
||||
// which are a downstream symptom of the loss reported by observeLost.
|
||||
func (h *streamHealth) observeDecodeError(streamType string, err error) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.decodeErrs++
|
||||
h.mu.Unlock()
|
||||
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.health(%s): decode error: %s", streamType, err.Error()))
|
||||
}
|
||||
|
||||
// fpsTracker holds per-stream state for PTS-based FPS calculation.
|
||||
@@ -194,10 +350,27 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
|
||||
_, span := tracer.Start(ctxOtel, "Connect")
|
||||
defer span.End()
|
||||
|
||||
transport := gortsplib.TransportTCP
|
||||
tlsConfig, err := rtspsTLSConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure RTSPS TLS: %w", err)
|
||||
}
|
||||
|
||||
protocol := gortsplib.ProtocolTCP
|
||||
g.health = newStreamHealth()
|
||||
g.Client = gortsplib.Client{
|
||||
RequestBackChannels: false,
|
||||
Transport: &transport,
|
||||
Protocol: &protocol,
|
||||
TLSConfig: tlsConfig,
|
||||
// Route gortsplib's packet-loss / decode-error reporting through our
|
||||
// structured logger with stream context (replaces its plain stdout
|
||||
// logging). These hooks are what let us tell whether the camera is
|
||||
// dropping packets because we can't drain the socket fast enough.
|
||||
OnPacketsLost: func(lost uint64) {
|
||||
g.health.observeLost(g.streamLabel, lost)
|
||||
},
|
||||
OnDecodeError: func(err error) {
|
||||
g.health.observeDecodeError(g.streamLabel, err)
|
||||
},
|
||||
}
|
||||
|
||||
// parse URL
|
||||
@@ -208,7 +381,9 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
|
||||
}
|
||||
|
||||
// connect to the server
|
||||
err = g.Client.Start(u.Scheme, u.Host)
|
||||
g.Client.Scheme = u.Scheme
|
||||
g.Client.Host = u.Host
|
||||
err = g.Client.Start()
|
||||
if err != nil {
|
||||
log.Log.Debug("capture.golibrtsp.Connect(Start): " + err.Error())
|
||||
}
|
||||
@@ -458,11 +633,17 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
|
||||
_, span := tracer.Start(ctxRunAgent, "ConnectBackChannel")
|
||||
defer span.End()
|
||||
|
||||
tlsConfig, err := rtspsTLSConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure RTSPS TLS: %w", err)
|
||||
}
|
||||
|
||||
// Transport TCP
|
||||
transport := gortsplib.TransportTCP
|
||||
protocol := gortsplib.ProtocolTCP
|
||||
g.Client = gortsplib.Client{
|
||||
RequestBackChannels: true,
|
||||
Transport: &transport,
|
||||
Protocol: &protocol,
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
// parse URL
|
||||
u, err := base.ParseURL(g.Url)
|
||||
@@ -472,7 +653,9 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
|
||||
}
|
||||
|
||||
// connect to the server
|
||||
err = g.Client.Start(u.Scheme, u.Host)
|
||||
g.Client.Scheme = u.Scheme
|
||||
g.Client.Host = u.Host
|
||||
err = g.Client.Start()
|
||||
if err != nil {
|
||||
log.Log.Error("capture.golibrtsp.ConnectBackChannel(): " + err.Error())
|
||||
}
|
||||
@@ -517,20 +700,61 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
|
||||
return
|
||||
}
|
||||
|
||||
// dtsExtractor abstracts the codec-specific DTS extractors from mediacommon
|
||||
// (h264.DTSExtractor2 and h265.DTSExtractor2), which expose the same method.
|
||||
type dtsExtractor interface {
|
||||
Extract(au [][]byte, pts int64) (int64, error)
|
||||
}
|
||||
|
||||
// compositionOffsetMs returns the composition time offset (PTS - DTS) in
|
||||
// milliseconds for a coded access unit. Streams that contain B-frames deliver
|
||||
// access units in decode order with non-monotonic PTS; the fragmented MP4
|
||||
// writer needs a monotonic DTS timeline plus a per-sample composition offset
|
||||
// so browsers (Media Source Extensions) can decode the chained segments.
|
||||
//
|
||||
// It returns 0 when the codec has no frame reordering (the common case, e.g.
|
||||
// baseline "IPPP" streams) or when extraction fails, making it a safe no-op.
|
||||
func compositionOffsetMs(ext dtsExtractor, au [][]byte, pts int64, clockRate int) int64 {
|
||||
if ext == nil || clockRate <= 0 {
|
||||
return 0
|
||||
}
|
||||
dts, err := ext.Extract(au, pts)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
offset := pts - dts
|
||||
if offset <= 0 {
|
||||
return 0
|
||||
}
|
||||
return offset * 1000 / int64(clockRate)
|
||||
}
|
||||
|
||||
func ptsToDuration(pts int64, clockRate int) time.Duration {
|
||||
rate := int64(clockRate)
|
||||
return time.Duration(pts/rate)*time.Second +
|
||||
time.Duration(pts%rate)*time.Second/time.Duration(rate)
|
||||
}
|
||||
|
||||
// Start the RTSP client, and start reading packets.
|
||||
func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets.Queue, configuration *models.Configuration, communication *models.Communication) (err error) {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): started")
|
||||
|
||||
// Label this client's loss/decode/health logging with the stream type.
|
||||
g.streamLabel = streamType
|
||||
if g.health == nil {
|
||||
g.health = newStreamHealth()
|
||||
}
|
||||
|
||||
// called when a MULAW audio RTP packet arrives
|
||||
if g.AudioG711Media != nil && g.AudioG711Forma != nil {
|
||||
g.Client.OnPacketRTP(g.AudioG711Media, g.AudioG711Forma, func(rtppkt *rtp.Packet) {
|
||||
pts, ok := g.Client.PacketPTS(g.AudioG711Media, rtppkt)
|
||||
// decode timestamp
|
||||
pts2, ok := g.Client.PacketPTS2(g.AudioG711Media, rtppkt)
|
||||
pts2, ok := g.Client.PacketPTS(g.AudioG711Media, rtppkt)
|
||||
if !ok {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): " + "unable to get PTS")
|
||||
return
|
||||
}
|
||||
pts := ptsToDuration(pts2, g.AudioG711Forma.ClockRate())
|
||||
|
||||
// extract LPCM samples from RTP packets
|
||||
op, err := g.AudioG711Decoder.Decode(rtppkt)
|
||||
@@ -560,12 +784,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
if g.AudioMPEG4Media != nil && g.AudioMPEG4Forma != nil {
|
||||
g.Client.OnPacketRTP(g.AudioMPEG4Media, g.AudioMPEG4Forma, func(rtppkt *rtp.Packet) {
|
||||
// decode timestamp
|
||||
pts, ok := g.Client.PacketPTS(g.AudioMPEG4Media, rtppkt)
|
||||
pts2, ok := g.Client.PacketPTS2(g.AudioMPEG4Media, rtppkt)
|
||||
pts2, ok := g.Client.PacketPTS(g.AudioMPEG4Media, rtppkt)
|
||||
if !ok {
|
||||
log.Log.Error("capture.golibrtsp.Start(): " + "unable to get PTS")
|
||||
return
|
||||
}
|
||||
pts := ptsToDuration(pts2, g.AudioMPEG4Forma.ClockRate())
|
||||
|
||||
// Encode the AAC samples from RTP packets
|
||||
// extract access units from RTP packets
|
||||
@@ -602,7 +826,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
var filteredAU [][]byte
|
||||
if g.VideoH264Media != nil && g.VideoH264Forma != nil {
|
||||
|
||||
//dtsExtractor := h264.NewDTSExtractor2()
|
||||
// Extracts DTS from the bitstream to support B-frame H264 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
h264DTSExtractor := h264.NewDTSExtractor2()
|
||||
|
||||
g.Client.OnPacketRTP(g.VideoH264Media, g.VideoH264Forma, func(rtppkt *rtp.Packet) {
|
||||
|
||||
@@ -617,12 +843,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// decode timestamps — validate each call separately
|
||||
pts, okPTS := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
|
||||
pts2, okPTS2 := g.Client.PacketPTS2(g.VideoH264Media, rtppkt)
|
||||
pts2, okPTS2 := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
|
||||
if !okPTS2 {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS2 from PacketPTS2")
|
||||
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS")
|
||||
return
|
||||
}
|
||||
pts := ptsToDuration(pts2, g.VideoH264Forma.ClockRate())
|
||||
|
||||
// Extract access units from RTP packets.
|
||||
// We need a complete access unit to determine whether
|
||||
@@ -636,15 +862,13 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
}
|
||||
|
||||
// Frame is complete — update per-stream FPS from PTS.
|
||||
if okPTS {
|
||||
ft := g.fpsTrackers[g.VideoH264Index]
|
||||
if ft == nil {
|
||||
ft = newFPSTracker(30)
|
||||
g.fpsTrackers[g.VideoH264Index] = ft
|
||||
}
|
||||
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
|
||||
g.Streams[g.VideoH264Index].FPS = ptsFPS
|
||||
}
|
||||
ft := g.fpsTrackers[g.VideoH264Index]
|
||||
if ft == nil {
|
||||
ft = newFPSTracker(30)
|
||||
g.fpsTrackers[g.VideoH264Index] = ft
|
||||
}
|
||||
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
|
||||
g.Streams[g.VideoH264Index].FPS = ptsFPS
|
||||
}
|
||||
|
||||
// We'll need to read out a few things.
|
||||
@@ -693,6 +917,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
// Get FPS using enhanced method
|
||||
fps := g.getEnhancedFPS(&sps, g.VideoH264Index)
|
||||
g.Streams[g.VideoH264Index].FPS = fps
|
||||
g.persistStreamFPS(configuration, streamType, fps)
|
||||
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Final FPS=%.2f", streamType, fps))
|
||||
g.VideoH264Forma.SPS = nalu
|
||||
if streamType == "main" && len(nalu) > 0 {
|
||||
@@ -742,6 +967,11 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
// Composition time offset (PTS - DTS) in milliseconds. Non-zero
|
||||
// only for streams with B-frames; the MP4 writer uses it to keep a
|
||||
// monotonic decode timeline and present frames in PTS order.
|
||||
compositionOffset := compositionOffsetMs(h264DTSExtractor, au, pts2, g.VideoH264Forma.ClockRate())
|
||||
|
||||
pkt := packets.Packet{
|
||||
IsKeyFrame: idrPresent,
|
||||
Packet: rtppkt,
|
||||
@@ -749,7 +979,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
Time: pts2,
|
||||
TimeLegacy: pts,
|
||||
CurrentTime: time.Now().UnixMilli(),
|
||||
CompositionTime: pts2,
|
||||
CompositionTime: compositionOffset,
|
||||
Idx: g.VideoH264Index,
|
||||
IsVideo: true,
|
||||
IsAudio: false,
|
||||
@@ -777,15 +1007,38 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
|
||||
pkt.Data = pkt.Data[4:]
|
||||
if pkt.IsKeyFrame {
|
||||
annexbNALUStartCode := func() []byte { return []byte{0x00, 0x00, 0x00, 0x01} }
|
||||
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
|
||||
pkt.Data = append(g.VideoH264Forma.PPS, pkt.Data...)
|
||||
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
|
||||
pkt.Data = append(g.VideoH264Forma.SPS, pkt.Data...)
|
||||
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
|
||||
// Prepend SPS/PPS (when available) in front of every keyframe so the
|
||||
// access unit is self-contained. Downstream decoders (and the MP4 writer's
|
||||
// in-band parameter-set recovery) rely on this; a recording whose first
|
||||
// frame lacks SPS/PPS produces an MP4 with an empty avcC, which makes FFmpeg
|
||||
// report "non-existing PPS 0 referenced".
|
||||
//
|
||||
// Build the payload in a freshly allocated buffer. The previous code
|
||||
// did append(g.VideoH264Forma.PPS, pkt.Data...): because the SPS/PPS
|
||||
// slices are sub-slices of the RTP reassembly buffer (spare capacity),
|
||||
// that append wrote into - and corrupted - the shared parameter-set
|
||||
// backing arrays, occasionally poisoning the SPS/PPS stored for the
|
||||
// recording.
|
||||
startCode := []byte{0x00, 0x00, 0x00, 0x01}
|
||||
out := make([]byte, 0, len(g.VideoH264Forma.SPS)+len(g.VideoH264Forma.PPS)+len(pkt.Data)+12)
|
||||
if len(g.VideoH264Forma.SPS) > 0 {
|
||||
out = append(out, startCode...)
|
||||
out = append(out, g.VideoH264Forma.SPS...)
|
||||
}
|
||||
if len(g.VideoH264Forma.PPS) > 0 {
|
||||
out = append(out, startCode...)
|
||||
out = append(out, g.VideoH264Forma.PPS...)
|
||||
}
|
||||
out = append(out, startCode...)
|
||||
out = append(out, pkt.Data...)
|
||||
pkt.Data = out
|
||||
}
|
||||
|
||||
writeStart := time.Now()
|
||||
queue.WritePacket(pkt)
|
||||
// Records WritePacket() blocking time and frame arrival cadence so
|
||||
// we can tell back-pressure from a network/camera stall.
|
||||
g.health.observePacket(streamType, time.Since(writeStart))
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
@@ -817,6 +1070,11 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
|
||||
// called when a video RTP packet arrives for H265
|
||||
if g.VideoH265Media != nil && g.VideoH265Forma != nil {
|
||||
|
||||
// Extracts DTS from the bitstream to support B-frame H265 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
h265DTSExtractor := h265.NewDTSExtractor2()
|
||||
|
||||
g.Client.OnPacketRTP(g.VideoH265Media, g.VideoH265Forma, func(rtppkt *rtp.Packet) {
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
@@ -830,12 +1088,12 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// decode timestamps — validate each call separately
|
||||
pts, okPTS := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
|
||||
pts2, okPTS2 := g.Client.PacketPTS2(g.VideoH265Media, rtppkt)
|
||||
pts2, okPTS2 := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
|
||||
if !okPTS2 {
|
||||
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS")
|
||||
return
|
||||
}
|
||||
pts := ptsToDuration(pts2, g.VideoH265Forma.ClockRate())
|
||||
|
||||
// Extract access units from RTP packets.
|
||||
// We need a complete access unit to determine whether
|
||||
@@ -849,16 +1107,19 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
}
|
||||
|
||||
// Frame is complete — update per-stream FPS from PTS.
|
||||
if okPTS {
|
||||
ft := g.fpsTrackers[g.VideoH265Index]
|
||||
if ft == nil {
|
||||
ft = newFPSTracker(30)
|
||||
g.fpsTrackers[g.VideoH265Index] = ft
|
||||
}
|
||||
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
|
||||
g.Streams[g.VideoH265Index].FPS = ptsFPS
|
||||
}
|
||||
ft := g.fpsTrackers[g.VideoH265Index]
|
||||
if ft == nil {
|
||||
ft = newFPSTracker(30)
|
||||
g.fpsTrackers[g.VideoH265Index] = ft
|
||||
}
|
||||
if ptsFPS := ft.update(pts); ptsFPS > 0 && ptsFPS <= 120 {
|
||||
g.Streams[g.VideoH265Index].FPS = ptsFPS
|
||||
g.persistStreamFPS(configuration, streamType, ptsFPS)
|
||||
}
|
||||
|
||||
// Preserve the decoded access unit (in decode order) for DTS
|
||||
// extraction before we rewrite it into the filtered/annexb form.
|
||||
decodedAU := au
|
||||
|
||||
filteredAU = [][]byte{
|
||||
{byte(h265.NALUType_AUD_NUT) << 1, 1, 0x50},
|
||||
@@ -902,6 +1163,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
// Composition time offset (PTS - DTS) in milliseconds; see H264 handler.
|
||||
compositionOffset := compositionOffsetMs(h265DTSExtractor, decodedAU, pts2, g.VideoH265Forma.ClockRate())
|
||||
|
||||
pkt := packets.Packet{
|
||||
IsKeyFrame: isRandomAccess,
|
||||
Packet: rtppkt,
|
||||
@@ -909,7 +1173,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
Time: pts2,
|
||||
TimeLegacy: pts,
|
||||
CurrentTime: time.Now().UnixMilli(),
|
||||
CompositionTime: pts2,
|
||||
CompositionTime: compositionOffset,
|
||||
Idx: g.VideoH265Index,
|
||||
IsVideo: true,
|
||||
IsAudio: false,
|
||||
@@ -935,7 +1199,11 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
}
|
||||
}
|
||||
|
||||
writeStart := time.Now()
|
||||
queue.WritePacket(pkt)
|
||||
// Records WritePacket() blocking time and frame arrival cadence so
|
||||
// we can tell back-pressure from a network/camera stall.
|
||||
g.health.observePacket(streamType, time.Since(writeStart))
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
@@ -1322,6 +1590,21 @@ func (g *Golibrtsp) initFPSCalculation() {
|
||||
}
|
||||
|
||||
// Get enhanced FPS information from SPS with fallback to PTS-based calculation.
|
||||
// persistStreamFPS stores the computed frame rate into the shared config so it
|
||||
// is reported to the hub/UI (mirrors how width/height are persisted). The value
|
||||
// is rounded to 2 decimals with trailing zeros trimmed (e.g. "25", "29.97").
|
||||
func (g *Golibrtsp) persistStreamFPS(configuration *models.Configuration, streamType string, fps float64) {
|
||||
if fps <= 0 {
|
||||
return
|
||||
}
|
||||
fpsStr := strconv.FormatFloat(float64(int(fps*100+0.5))/100, 'f', -1, 64)
|
||||
if streamType == "main" {
|
||||
configuration.Config.Capture.IPCamera.FPS = fpsStr
|
||||
} else if streamType == "sub" {
|
||||
configuration.Config.Capture.IPCamera.SubFPS = fpsStr
|
||||
}
|
||||
}
|
||||
|
||||
// The PTS-based FPS is computed per completed frame via fpsTracker.update(),
|
||||
// so by the time this is called we already have a good estimate.
|
||||
func (g *Golibrtsp) getEnhancedFPS(sps *h264.SPS, streamIndex int8) float64 {
|
||||
|
||||
79
machinery/src/capture/gortsplib_test.go
Normal file
79
machinery/src/capture/gortsplib_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/pem"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRTSPSTLSConfig(t *testing.T) {
|
||||
t.Run("verifies certificates by default", func(t *testing.T) {
|
||||
t.Setenv(rtspsCAFileEnv, "")
|
||||
t.Setenv(rtspsInsecureEnv, "")
|
||||
|
||||
got, err := rtspsTLSConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("rtspsTLSConfig() error = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("rtspsTLSConfig() = %#v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows explicit insecure mode", func(t *testing.T) {
|
||||
t.Setenv(rtspsCAFileEnv, "/missing/ignored-in-insecure-mode.pem")
|
||||
t.Setenv(rtspsInsecureEnv, "true")
|
||||
|
||||
got, err := rtspsTLSConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("rtspsTLSConfig() error = %v", err)
|
||||
}
|
||||
if got == nil || !got.InsecureSkipVerify {
|
||||
t.Fatalf("rtspsTLSConfig() = %#v, want InsecureSkipVerify enabled", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adds a camera CA to system roots", func(t *testing.T) {
|
||||
t.Setenv(rtspsInsecureEnv, "")
|
||||
server := httptest.NewTLSServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
certificate := server.Certificate()
|
||||
caFile := filepath.Join(t.TempDir(), "camera-ca.pem")
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw})
|
||||
if err := os.WriteFile(caFile, caPEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(rtspsCAFileEnv, caFile)
|
||||
|
||||
got, err := rtspsTLSConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("rtspsTLSConfig() error = %v", err)
|
||||
}
|
||||
if got == nil || got.RootCAs == nil {
|
||||
t.Fatalf("rtspsTLSConfig() = %#v, want custom RootCAs", got)
|
||||
}
|
||||
for _, subject := range got.RootCAs.Subjects() {
|
||||
if bytes.Equal(subject, certificate.RawSubject) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("camera CA was not added to RootCAs")
|
||||
})
|
||||
|
||||
t.Run("rejects an invalid camera CA file", func(t *testing.T) {
|
||||
t.Setenv(rtspsInsecureEnv, "")
|
||||
caFile := filepath.Join(t.TempDir(), "camera-ca.pem")
|
||||
if err := os.WriteFile(caFile, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(rtspsCAFileEnv, caFile)
|
||||
|
||||
if _, err := rtspsTLSConfig(); err == nil {
|
||||
t.Fatal("rtspsTLSConfig() error = nil, want invalid CA error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,15 @@ package capture
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/conditions"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
@@ -20,45 +24,326 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
|
||||
autoClean := configuration.Config.AutoClean
|
||||
if autoClean == "true" {
|
||||
maxSize := configuration.Config.MaxDirectorySize
|
||||
if maxSize == 0 {
|
||||
maxSize = 300
|
||||
}
|
||||
// Total size of the recording directory.
|
||||
recordingsDirectory := configDirectory + "/data/recordings"
|
||||
size, err := utils.DirSize(recordingsDirectory)
|
||||
if err == nil {
|
||||
sizeInMB := size / 1000 / 1000
|
||||
if sizeInMB >= maxSize {
|
||||
// Remove the oldest recording
|
||||
oldestFile, err := utils.FindOldestFile(recordingsDirectory)
|
||||
if err == nil {
|
||||
err := os.Remove(recordingsDirectory + "/" + oldestFile.Name())
|
||||
log.Log.Info("HandleRecordStream: removed oldest file as part of cleanup - " + recordingsDirectory + "/" + oldestFile.Name())
|
||||
if err != nil {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
}
|
||||
|
||||
// publishRecordingState notifies the hub (and ultimately the live-view UI) that
|
||||
// this camera started ("recording": true) or stopped ("recording": false)
|
||||
// recording, so the frontend can show a "recording" indicator while the agent
|
||||
// is recording (e.g. a motion clip triggered manually from the live view or by
|
||||
// motion detection). It is a best-effort broadcast: when no hub/MQTT is
|
||||
// configured (or the agent is offline) it is a no-op, and a missed message is
|
||||
// self-healed by the frontend's safety timeout.
|
||||
func publishRecordingState(mqttClient mqtt.Client, hubKey string, configuration *models.Configuration, recording bool) {
|
||||
if mqttClient == nil || hubKey == "" || configuration.Config.Offline == "true" {
|
||||
return
|
||||
}
|
||||
message := models.Message{
|
||||
Payload: models.Payload{
|
||||
Action: "recording",
|
||||
DeviceId: configuration.Config.Key,
|
||||
Value: map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
"recording": recording,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, err := models.PackageMQTTMessage(configuration, message)
|
||||
if err == nil {
|
||||
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
|
||||
} else {
|
||||
log.Log.Info("HandleRecordStream: Autoclean disabled, nothing to do here.")
|
||||
log.Log.Error("capture.main.publishRecordingState(): failed to package MQTT message: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRecordStream(queue *packets.Queue, configDirectory string, configuration *models.Configuration, communication *models.Communication, rtspClient RTSPClient) {
|
||||
func recordingUploadMetadata(name, deviceKey string, timestamp int64, mp4Video *video.MP4) models.RecordingUploadMetadata {
|
||||
metadata := models.RecordingUploadMetadata{
|
||||
FileName: filepath.Base(name),
|
||||
DeviceKey: deviceKey,
|
||||
Timestamp: timestamp,
|
||||
Duration: mp4Video.VideoTotalDuration,
|
||||
}
|
||||
value := mp4Video.AverageFPS()
|
||||
if value > 0 && value <= 240 && !math.IsInf(value, 0) && !math.IsNaN(value) {
|
||||
metadata.FPS = value
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
// queueRecordingForUpload creates the marker consumed by the upload worker and
|
||||
// stores metadata captured from the finalized recording.
|
||||
func queueRecordingForUpload(configDirectory string, metadata models.RecordingUploadMetadata) {
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
log.Log.Error("capture.main.queueRecordingForUpload(): " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Publish the marker with a same-filesystem rename. Writing directly to the
|
||||
// watched directory would briefly expose an empty file to the upload poller.
|
||||
marker, err := os.CreateTemp(filepath.Join(configDirectory, "data"), ".upload-marker-*")
|
||||
if err == nil {
|
||||
_, err = marker.Write(payload)
|
||||
}
|
||||
if err == nil {
|
||||
err = marker.Chmod(0644)
|
||||
}
|
||||
if marker != nil {
|
||||
if closeErr := marker.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
defer os.Remove(marker.Name())
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Rename(marker.Name(), filepath.Join(configDirectory, "data", "cloud", models.RecordingUploadMetadataFileName(metadata.FileName)))
|
||||
}
|
||||
if err != nil {
|
||||
log.Log.Error("capture.main.queueRecordingForUpload(): " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// manualRecordingHeartbeatTimeout is how long the agent keeps a manual
|
||||
// (live-view / remote) recording alive after the LAST viewer heartbeat. The
|
||||
// frontend re-sends the record command every ~15s while the user stays on the
|
||||
// page; if several heartbeats are missed (the viewer closed the tab, went idle
|
||||
// or lost connectivity) the recorder auto-stops the recording so the camera
|
||||
// doesn't record forever when the "stop" message never arrives.
|
||||
manualRecordingHeartbeatTimeout = 45 * time.Second
|
||||
// manualRecordingMaxDuration caps a single manual recording so a forgotten
|
||||
// record button can't record indefinitely even while the viewer keeps sending
|
||||
// heartbeats. After this the recording auto-stops and the viewer must press
|
||||
// record again to continue.
|
||||
manualRecordingMaxDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
// manualRecordingExpired reports whether an active manual (live-view) recording
|
||||
// has outlived its viewer heartbeat window or the maximum duration cap. When it
|
||||
// has, it clears the manual-recording state (so the motion recorder lets the
|
||||
// current clip close normally and broadcasts recording:false) and returns true.
|
||||
// It is a no-op returning false when no manual recording is active.
|
||||
func manualRecordingExpired(communication *models.Communication, now int64) bool {
|
||||
if communication.IsRecordingManual.IsNotSet() {
|
||||
return false
|
||||
}
|
||||
manualStart := communication.RecordingManualStart.Load()
|
||||
maxDurationReached := manualStart > 0 && now-manualStart > manualRecordingMaxDuration.Milliseconds()
|
||||
// The heartbeat timeout only applies once the viewer has proven it supports
|
||||
// heartbeats (an older frontend that starts a recording but never heartbeats
|
||||
// still records up to the max-duration cap instead of being cut off early).
|
||||
heartbeatExpired := false
|
||||
if communication.RecordingManualHeartbeatSeen.IsSet() {
|
||||
lastHeartbeat := communication.RecordingManualHeartbeat.Load()
|
||||
heartbeatExpired = lastHeartbeat > 0 && now-lastHeartbeat > manualRecordingHeartbeatTimeout.Milliseconds()
|
||||
}
|
||||
if !heartbeatExpired && !maxDurationReached {
|
||||
return false
|
||||
}
|
||||
if heartbeatExpired {
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, no viewer heartbeat within timeout.")
|
||||
} else {
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): auto-stopping manual recording, maximum duration reached.")
|
||||
}
|
||||
communication.IsRecordingManual.UnSet()
|
||||
communication.RecordingManualHeartbeat.Store(0)
|
||||
communication.RecordingManualStart.Store(0)
|
||||
communication.RecordingManualHeartbeatSeen.UnSet()
|
||||
return true
|
||||
}
|
||||
|
||||
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
|
||||
autoClean := configuration.Config.AutoClean
|
||||
if autoClean != "true" {
|
||||
log.Log.Info("HandleRecordStream: Autoclean disabled, nothing to do here.")
|
||||
return
|
||||
}
|
||||
|
||||
recordingsDirectory := configDirectory + "/data/recordings"
|
||||
cloudDirectory := configDirectory + "/data/cloud"
|
||||
|
||||
// Decide whether we still need to free up space. See recordingsNeedCleanup
|
||||
// for the two modes: an explicit fixed directory cap
|
||||
// (AGENT_AUTO_CLEAN_MAX_SIZE) or, by default, letting recordings use the whole
|
||||
// disk while keeping a free-space reserve.
|
||||
needsCleanup, err := recordingsNeedCleanup(recordingsDirectory, configuration)
|
||||
if err != nil {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
return
|
||||
}
|
||||
if !needsCleanup {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the oldest recording, but PREFER recordings that have already been
|
||||
// uploaded (i.e. no longer have a pending marker in data/cloud). This stops
|
||||
// auto-clean from deleting recordings that are still queued for upload. That
|
||||
// previously caused silent data loss: during a network outage the upload
|
||||
// backlog grows, cleanup deletes the oldest (still un-uploaded) recording to
|
||||
// stay under MaxDirectorySize, and when connectivity returns the upload loop
|
||||
// finds the marker but the file is gone -> the recording is dropped and never
|
||||
// reaches the vault.
|
||||
//
|
||||
// Only when EVERY recording on disk is still pending upload do we fall back to
|
||||
// deleting the oldest pending one, as a last resort to keep the disk bounded
|
||||
// (otherwise a long outage would fill the disk and stop new recordings).
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDirectory, cloudDirectory)
|
||||
if err != nil {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Remove(recordingsDirectory + "/" + name); err != nil {
|
||||
log.Log.Info("HandleRecordStream: something went wrong, " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if pending {
|
||||
// Data-loss event: the whole recordings directory is an un-uploaded
|
||||
// backlog (e.g. a prolonged network outage), so we had to drop a recording
|
||||
// that was never uploaded to keep recording new footage. Also remove the
|
||||
// now-dangling upload marker so the upload loop doesn't keep trying to
|
||||
// upload a file that no longer exists.
|
||||
log.Log.Warning("HandleRecordStream: removed oldest recording as part of cleanup, but it was STILL PENDING UPLOAD (disk full of un-uploaded recordings) - " + recordingsDirectory + "/" + name)
|
||||
for _, markerName := range uploadMarkerNames(name) {
|
||||
if err := os.Remove(filepath.Join(cloudDirectory, markerName)); err != nil && !os.IsNotExist(err) {
|
||||
log.Log.Info("HandleRecordStream: could not remove dangling upload marker " + markerName + ", " + err.Error())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("HandleRecordStream: removed oldest file as part of cleanup - " + recordingsDirectory + "/" + name)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingsNeedCleanup reports whether auto-clean should free up space in the
|
||||
// recordings directory. There are two modes:
|
||||
//
|
||||
// - AGENT_AUTO_CLEAN_MAX_SIZE (MaxDirectorySize, MB) set: cap the size of the
|
||||
// recordings directory itself (the historical behaviour).
|
||||
// - MaxDirectorySize == 0 (the default): recordings may use the WHOLE disk.
|
||||
// Cleanup only triggers once the free space on the recordings filesystem
|
||||
// drops to/below a reserve. The reserve is AGENT_AUTO_CLEAN_MIN_FREE_SPACE
|
||||
// (MinFreeSpace, MB) when set, otherwise 5% of the disk's total capacity.
|
||||
//
|
||||
// If disk stats can't be read (e.g. non-Linux dev builds) it falls back to the
|
||||
// historical fixed 300 MB directory cap so behaviour stays bounded.
|
||||
func recordingsNeedCleanup(recordingsDirectory string, configuration *models.Configuration) (bool, error) {
|
||||
maxSize := configuration.Config.MaxDirectorySize
|
||||
|
||||
// Explicit fixed cap on the recordings directory size.
|
||||
if maxSize > 0 {
|
||||
size, err := utils.DirSize(recordingsDirectory)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return size/1000/1000 >= maxSize, nil
|
||||
}
|
||||
|
||||
// Default: allow recordings to use the full disk, keeping a reserve free.
|
||||
totalMB, availableMB, err := diskUsageMB(recordingsDirectory)
|
||||
if err != nil {
|
||||
// Disk stats unavailable: fall back to the historical 300 MB cap.
|
||||
size, derr := utils.DirSize(recordingsDirectory)
|
||||
if derr != nil {
|
||||
return false, derr
|
||||
}
|
||||
return size/1000/1000 >= 300, nil
|
||||
}
|
||||
|
||||
reserveMB := configuration.Config.MinFreeSpace
|
||||
if reserveMB <= 0 {
|
||||
reserveMB = defaultReserveMB(totalMB)
|
||||
}
|
||||
|
||||
return availableMB <= reserveMB, nil
|
||||
}
|
||||
|
||||
// defaultReserveMB returns the free-space reserve (MB) to keep on the recordings
|
||||
// disk when AGENT_AUTO_CLEAN_MIN_FREE_SPACE is not set: 5% of the disk total,
|
||||
// but never below 1MB. On very small disks 5% truncates to 0MB, which would
|
||||
// disable the reserve entirely (cleanup only once availableMB <= 0), so we floor
|
||||
// it at 1MB to preserve the intended "keep some space free" behaviour.
|
||||
func defaultReserveMB(totalMB int64) int64 {
|
||||
reserveMB := totalMB * 5 / 100
|
||||
if reserveMB < 1 {
|
||||
reserveMB = 1
|
||||
}
|
||||
return reserveMB
|
||||
}
|
||||
|
||||
// pickRecordingToCleanup chooses which recording to delete to free space in the
|
||||
// recordings directory. It returns the oldest recording that has already been
|
||||
// uploaded (no pending marker with the same name in cloudDirectory). Only when
|
||||
// every recording is still pending upload does it return the oldest recording
|
||||
// overall with pending=true, signalling the caller that it is about to drop an
|
||||
// un-uploaded recording as a last resort.
|
||||
func pickRecordingToCleanup(recordingsDirectory, cloudDirectory string) (string, bool, error) {
|
||||
entries, err := os.ReadDir(recordingsDirectory)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
var oldestSafeName, oldestAnyName string
|
||||
var oldestSafeTime, oldestAnyTime time.Time
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
modTime := info.ModTime()
|
||||
|
||||
if oldestAnyName == "" || modTime.Before(oldestAnyTime) {
|
||||
oldestAnyName = entry.Name()
|
||||
oldestAnyTime = modTime
|
||||
}
|
||||
|
||||
// A recording is still pending upload if either its current .metadata
|
||||
// marker or a marker created by an older agent exists.
|
||||
if recordingPendingUpload(cloudDirectory, entry.Name()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if oldestSafeName == "" || modTime.Before(oldestSafeTime) {
|
||||
oldestSafeName = entry.Name()
|
||||
oldestSafeTime = modTime
|
||||
}
|
||||
}
|
||||
|
||||
if oldestSafeName != "" {
|
||||
return oldestSafeName, false, nil
|
||||
}
|
||||
if oldestAnyName != "" {
|
||||
return oldestAnyName, true, nil
|
||||
}
|
||||
return "", false, os.ErrNotExist
|
||||
}
|
||||
|
||||
func uploadMarkerNames(recordingName string) []string {
|
||||
return []string{models.RecordingUploadMetadataFileName(recordingName), filepath.Base(recordingName)}
|
||||
}
|
||||
|
||||
func recordingPendingUpload(cloudDirectory, recordingName string) bool {
|
||||
for _, markerName := range uploadMarkerNames(recordingName) {
|
||||
if _, err := os.Stat(filepath.Join(cloudDirectory, markerName)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func HandleRecordStream(queue *packets.Queue, configDirectory string, configuration *models.Configuration, communication *models.Communication, rtspClient RTSPClient, mqttClient mqtt.Client) {
|
||||
|
||||
config := configuration.Config
|
||||
hubKey := config.HubKey
|
||||
loc, _ := time.LoadLocation(config.Timezone)
|
||||
|
||||
// Start each capture session with manual recording off, so a leftover
|
||||
// request from before a restart/reconnect doesn't silently persist.
|
||||
communication.IsRecordingManual.UnSet()
|
||||
communication.RecordingManualHeartbeat.Store(0)
|
||||
communication.RecordingManualStart.Store(0)
|
||||
communication.RecordingManualHeartbeatSeen.UnSet()
|
||||
|
||||
if config.Capture.Recording == "false" {
|
||||
log.Log.Info("capture.main.HandleRecordStream(): disabled, we will not record anything.")
|
||||
} else {
|
||||
@@ -140,23 +425,8 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
if start && // If already recording and current frame is a keyframe and we should stop recording
|
||||
nextPkt.IsKeyFrame && (startRecording+postRecording-now <= 0 || now-startRecording > maxRecordingPeriod-500) {
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
if pkt.IsVideo {
|
||||
// Write the last packet
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
// Write the last packet
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
// Write the last packet before closing the recording.
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
|
||||
// Close mp4
|
||||
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
|
||||
@@ -232,12 +502,13 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
// Create a symbol link.
|
||||
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
|
||||
fc.Close()
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video))
|
||||
|
||||
recordingStatus = "idle"
|
||||
|
||||
// Notify the hub / live-view UI that this camera stopped recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, false)
|
||||
|
||||
// Clean up the recording directory if necessary.
|
||||
CleanupRecordingDirectory(configDirectory, configuration)
|
||||
}
|
||||
@@ -311,43 +582,15 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
if pkt.IsVideo {
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
// We might need to use ffmpeg to transcode the audio to AAC.
|
||||
// For now we will skip the audio track.
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
recordingStatus = "started"
|
||||
|
||||
// Notify the hub / live-view UI that this camera started recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, true)
|
||||
|
||||
} else if start {
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
if pkt.IsVideo {
|
||||
// New method using new mp4 library
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
}
|
||||
pkt = nextPkt
|
||||
}
|
||||
@@ -416,12 +659,13 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
// Create a symbol link.
|
||||
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
|
||||
fc.Close()
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, startRecording, mp4Video))
|
||||
|
||||
recordingStatus = "idle"
|
||||
|
||||
// Notify the hub / live-view UI that this camera stopped recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, false)
|
||||
|
||||
// Clean up the recording directory if necessary.
|
||||
CleanupRecordingDirectory(configDirectory, configuration)
|
||||
}
|
||||
@@ -538,6 +782,17 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
default:
|
||||
}
|
||||
|
||||
// While a manual recording is active, keep it alive: refresh the
|
||||
// motion timestamp every iteration so the post-recording timeout
|
||||
// never fires. The clip still rolls over at maxRecordingPeriod and
|
||||
// is restarted below, until the viewer stops the manual recording.
|
||||
// It also auto-stops when the viewer's heartbeat lapses (closed page
|
||||
// or idle) or the max remote-recording duration is reached, so a
|
||||
// missed "stop" message can't keep the camera recording forever.
|
||||
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, now) {
|
||||
motionTimestamp = now
|
||||
}
|
||||
|
||||
if start && (motionTimestamp+postRecording-now < 0 || now-startRecording > maxRecordingPeriod-500) && nextPkt.IsKeyFrame {
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): timestamp+postRecording-now < 0 - " + strconv.FormatInt(motionTimestamp+postRecording-now, 10) + " < 0")
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): now-startRecording > maxRecordingPeriod-500 - " + strconv.FormatInt(now-startRecording, 10) + " > " + strconv.FormatInt(maxRecordingPeriod-500, 10))
|
||||
@@ -569,31 +824,12 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
start = true
|
||||
|
||||
// Notify the hub / live-view UI that this camera started recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, true)
|
||||
}
|
||||
if start {
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
if pkt.IsVideo {
|
||||
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): add video sample")
|
||||
if mp4Video != nil {
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
|
||||
}
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): add audio sample")
|
||||
if pkt.Codec == "AAC" {
|
||||
if mp4Video != nil {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts); err != nil {
|
||||
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
|
||||
}
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
// We might need to use ffmpeg to transcode the audio to AAC.
|
||||
// For now we will skip the audio track.
|
||||
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
}
|
||||
|
||||
pkt = nextPkt
|
||||
@@ -625,6 +861,21 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
mp4Video.Close(&config)
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): file save: " + name)
|
||||
|
||||
// Notify the hub / live-view UI that this camera stopped recording.
|
||||
publishRecordingState(mqttClient, hubKey, configuration, false)
|
||||
|
||||
// If the viewer still has a manual recording running, this clip just
|
||||
// rolled over at the max length — immediately kick off the next
|
||||
// segment so recording stays continuous until they stop it. Skip the
|
||||
// restart when the recording has expired (heartbeat lapsed or max
|
||||
// duration reached), so it ends here instead of recording forever.
|
||||
if communication.IsRecordingManual.IsSet() && !manualRecordingExpired(communication, time.Now().UnixMilli()) {
|
||||
select {
|
||||
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: time.Now().Unix(), NumberOfChanges: 100000000}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Update the name of the recording with the duration.
|
||||
// We will update the name of the recording with the duration in milliseconds.
|
||||
if mp4Video.VideoTotalDuration > 0 {
|
||||
@@ -678,9 +929,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
}
|
||||
}
|
||||
|
||||
// Create a symbol linc.
|
||||
fc, _ := os.Create(configDirectory + "/data/cloud/" + name)
|
||||
fc.Close()
|
||||
queueRecordingForUpload(configDirectory, recordingUploadMetadata(name, config.Key, displayTime, mp4Video))
|
||||
|
||||
// Clean up the recording directory if necessary.
|
||||
CleanupRecordingDirectory(configDirectory, configuration)
|
||||
@@ -867,6 +1116,41 @@ func convertPTS(v time.Duration) uint64 {
|
||||
return uint64(v.Milliseconds())
|
||||
}
|
||||
|
||||
// writeSampleToMP4 writes a single capture packet to the fragmented MP4.
|
||||
//
|
||||
// For video it derives the decode timestamp (DTS) from the packet PTS using the
|
||||
// per-packet composition offset (PTS - DTS), which is non-zero only for streams
|
||||
// that contain B-frames. Passing the monotonic DTS as the sample timestamp keeps
|
||||
// the fragment timeline (tfdt/sidx) monotonic, while the composition offset is
|
||||
// forwarded so frames are still presented in PTS order.
|
||||
func writeSampleToMP4(mp4Video *video.MP4, videoTrack, audioTrack uint32, pkt packets.Packet) {
|
||||
if mp4Video == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
|
||||
if pkt.IsVideo {
|
||||
compositionOffset := pkt.CompositionTime
|
||||
dts := pts
|
||||
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
|
||||
dts = pts - uint64(compositionOffset)
|
||||
}
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, dts, compositionOffset); err != nil {
|
||||
log.Log.Error("capture.main.writeSampleToMP4(): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts, 0); err != nil {
|
||||
log.Log.Error("capture.main.writeSampleToMP4(): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
log.Log.Debug("capture.main.writeSampleToMP4(): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*func convertPTS2(v int64) uint64 {
|
||||
return uint64(v) / 100
|
||||
}*/
|
||||
|
||||
88
machinery/src/capture/main_test.go
Normal file
88
machinery/src/capture/main_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
func TestPTSToDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pts int64
|
||||
clockRate int
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "one video second", pts: 90_000, clockRate: 90_000, want: time.Second},
|
||||
{name: "one audio frame", pts: 1_024, clockRate: 8_000, want: 128 * time.Millisecond},
|
||||
{name: "fractional millisecond", pts: 45_045, clockRate: 90_000, want: 500*time.Millisecond + 500*time.Microsecond},
|
||||
{name: "negative timestamp", pts: -45_045, clockRate: 90_000, want: -500*time.Millisecond - 500*time.Microsecond},
|
||||
{name: "large timestamp", pts: 90_000 * 60 * 60 * 24, clockRate: 90_000, want: 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := ptsToDuration(test.pts, test.clockRate); got != test.want {
|
||||
t.Fatalf("ptsToDuration(%d, %d) = %s, want %s", test.pts, test.clockRate, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
|
||||
configDirectory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(configDirectory, "data", "cloud"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
|
||||
mp4Video := &video.MP4{VideoTotalDuration: 20452, SampleCount: 613}
|
||||
metadata := recordingUploadMetadata("recording.mp4", "device-key", 1785934709414, mp4Video)
|
||||
queueRecordingForUpload(configDirectory, metadata)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upload marker: %v", err)
|
||||
}
|
||||
var stored models.RecordingUploadMetadata
|
||||
if err := json.Unmarshal(got, &stored); err != nil {
|
||||
t.Fatalf("decode upload marker: %v", err)
|
||||
}
|
||||
expectedFPS := mp4Video.AverageFPS()
|
||||
if stored.FileName != "recording.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 {
|
||||
t.Fatalf("upload marker = %+v", stored)
|
||||
}
|
||||
if stored.FPS == math.Floor(stored.FPS) {
|
||||
t.Fatalf("upload marker FPS = %v, want fractional precision", stored.FPS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueRecordingForUploadKeepsUnknownFPSCompatible(t *testing.T) {
|
||||
for _, fps := range []float64{0, 0.99, -1, math.NaN(), math.Inf(1), 241} {
|
||||
t.Run("invalid FPS", func(t *testing.T) {
|
||||
configDirectory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(configDirectory, "data", "cloud"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
|
||||
metadata := models.RecordingUploadMetadata{FileName: "recording.mp4"}
|
||||
if fps >= 1 && fps <= 240 && !math.IsNaN(fps) && !math.IsInf(fps, 0) {
|
||||
metadata.FPS = fps
|
||||
}
|
||||
queueRecordingForUpload(configDirectory, metadata)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upload marker: %v", err)
|
||||
}
|
||||
if string(got) != `{"filename":"recording.mp4","device_key":"","timestamp":0,"duration":0}` {
|
||||
t.Fatalf("upload marker = %q, want metadata without FPS", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
"github.com/kerberos-io/agent/machinery/src/cloud/livesnapshot"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
@@ -77,7 +78,8 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
default:
|
||||
}
|
||||
|
||||
fileName := f.Name()
|
||||
markerFileName := f.Name()
|
||||
fileName := models.RecordingFileNameFromUploadMarker(markerFileName)
|
||||
uploaded := false
|
||||
configured := false
|
||||
err = nil
|
||||
@@ -112,7 +114,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
// Check if the file is uploaded, if so, remove it.
|
||||
if uploaded {
|
||||
delay = 500 * time.Millisecond // reset
|
||||
err := os.Remove(watchDirectory + fileName)
|
||||
err := os.Remove(watchDirectory + markerFileName)
|
||||
if err != nil {
|
||||
log.Log.Error("HandleUpload: " + err.Error())
|
||||
}
|
||||
@@ -126,7 +128,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
}
|
||||
}
|
||||
} else if !configured {
|
||||
err := os.Remove(watchDirectory + fileName)
|
||||
err := os.Remove(watchDirectory + markerFileName)
|
||||
if err != nil {
|
||||
log.Log.Error("HandleUpload: " + err.Error())
|
||||
}
|
||||
@@ -217,17 +219,81 @@ func GetSystemInfo() (models.System, error) {
|
||||
return system, nil
|
||||
}
|
||||
|
||||
// rawJSONOrEmptyArray returns pre-marshalled JSON bytes as a json.RawMessage.
|
||||
// When the input is empty it falls back to an empty JSON array so the
|
||||
// surrounding payload always stays valid JSON.
|
||||
func rawJSONOrEmptyArray(b []byte) json.RawMessage {
|
||||
if len(b) == 0 {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
|
||||
const heartbeatResponseBodyLogLimit = 4 * 1024
|
||||
|
||||
func readHeartbeatResponseBody(response *http.Response) (string, bool, error) {
|
||||
if response == nil || response.Body == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, heartbeatResponseBodyLogLimit+1))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
truncated := len(body) > heartbeatResponseBodyLogLimit
|
||||
if truncated {
|
||||
body = body[:heartbeatResponseBodyLogLimit]
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(body)), truncated, nil
|
||||
}
|
||||
|
||||
func formatHeartbeatFailureLog(response *http.Response, requestErr error, responseBody string, responseBodyTruncated bool, responseBodyErr error, elapsed time.Duration) string {
|
||||
details := make([]string, 0, 6)
|
||||
if response != nil {
|
||||
details = append(details, "status_code="+strconv.Itoa(response.StatusCode))
|
||||
if response.Status != "" {
|
||||
details = append(details, "status="+strconv.Quote(response.Status))
|
||||
}
|
||||
} else {
|
||||
details = append(details, "status_code=none")
|
||||
}
|
||||
details = append(details, "duration="+elapsed.Round(time.Millisecond).String())
|
||||
if requestErr != nil {
|
||||
details = append(details, "request_error="+strconv.Quote(requestErr.Error()))
|
||||
}
|
||||
if responseBody != "" {
|
||||
details = append(details, "response_body="+strconv.Quote(responseBody))
|
||||
}
|
||||
if responseBodyTruncated {
|
||||
details = append(details, "response_body_truncated=true")
|
||||
}
|
||||
if responseBodyErr != nil {
|
||||
details = append(details, "response_body_error="+strconv.Quote(responseBodyErr.Error()))
|
||||
}
|
||||
|
||||
return "cloud.HandleHeartBeat(): heartbeat request to Kerberos Hub failed: " + strings.Join(details, ", ")
|
||||
}
|
||||
|
||||
func HandleHeartBeat(configuration *models.Configuration, communication *models.Communication, uptimeStart time.Time) {
|
||||
log.Log.Debug("cloud.HandleHeartBeat(): started")
|
||||
|
||||
// Bound every heartbeat POST so a stalled connection (e.g. a saturated uplink
|
||||
// or an unresponsive Hub/Vault) fails fast on this cycle instead of blocking
|
||||
// the whole heartbeat loop indefinitely. A hung POST would otherwise stop all
|
||||
// further heartbeats, and Hub marks a camera offline once its last heartbeat is
|
||||
// older than 180s even while capture is healthy.
|
||||
const heartbeatHTTPTimeout = 30 * time.Second
|
||||
var client *http.Client
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
client = &http.Client{Transport: tr, Timeout: heartbeatHTTPTimeout}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
client = &http.Client{Timeout: heartbeatHTTPTimeout}
|
||||
}
|
||||
|
||||
kerberosAgentVersion := utils.VERSION
|
||||
@@ -470,6 +536,14 @@ loop:
|
||||
hasBackChannel = "true"
|
||||
}
|
||||
|
||||
// Whether this camera records continuously (24/7) rather than on
|
||||
// motion. The Hub live view uses this to disable the manual record
|
||||
// button, which is a no-op in continuous mode (already recording).
|
||||
continuousRecording := "false"
|
||||
if config.Capture.Continuous == "true" {
|
||||
continuousRecording = "true"
|
||||
}
|
||||
|
||||
hub_encryption := "false"
|
||||
if config.HubEncryption == "true" {
|
||||
hub_encryption = "true"
|
||||
@@ -494,47 +568,98 @@ loop:
|
||||
// We need a hub URI and hub public key before we will send a heartbeat
|
||||
if hubURI != "" && key != "" {
|
||||
|
||||
var object = fmt.Sprintf(`{
|
||||
"key" : "%s",
|
||||
"version" : "%s",
|
||||
"hub_encryption": "%s",
|
||||
"e2e_encryption": "%s",
|
||||
"release" : "%s",
|
||||
"cpuid" : "%s",
|
||||
"clouduser" : "%s",
|
||||
"cloudpublickey" : "%s",
|
||||
"cameraname" : "%s",
|
||||
"enterprise" : %t,
|
||||
"hostname" : "%s",
|
||||
"architecture" : "%s",
|
||||
"totalMemory" : "%d",
|
||||
"usedMemory" : "%d",
|
||||
"freeMemory" : "%d",
|
||||
"processMemory" : "%d",
|
||||
"mac_list" : %s,
|
||||
"ip_list" : %s,
|
||||
"board" : "",
|
||||
"disk1size" : "%s",
|
||||
"disk3size" : "%s",
|
||||
"diskvdasize" : "%s",
|
||||
"uptime" : "%s",
|
||||
"boot_time" : "%s",
|
||||
"siteID" : "%s",
|
||||
"onvif" : "%s",
|
||||
"onvif_zoom" : "%s",
|
||||
"onvif_pantilt" : "%s",
|
||||
"onvif_presets": "%s",
|
||||
"onvif_presets_list": %s,
|
||||
"onvif_events_list": %s,
|
||||
"cameraConnected": "%s",
|
||||
"hasBackChannel": "%s",
|
||||
"numberoffiles" : "33",
|
||||
"timestamp" : 1564747908,
|
||||
"cameratype" : "IPCamera",
|
||||
"docker" : true,
|
||||
"kios" : false,
|
||||
"raspberrypi" : false
|
||||
}`, config.Key, kerberosAgentVersion, hub_encryption, e2e_encryption, system.Version, system.CPUId, username, key, name, isEnterprise, system.Hostname, system.Architecture, system.TotalMemory, system.UsedMemory, system.FreeMemory, system.ProcessUsedMemory, macs, ips, "0", "0", "0", uptimeString, boottimeString, config.HubSite, onvifEnabled, onvifZoom, onvifPanTilt, onvifPresets, onvifPresetsList, onvifEventsList, cameraConnected, hasBackChannel)
|
||||
heartbeat := struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
HubEncryption string `json:"hub_encryption"`
|
||||
E2EEncryption string `json:"e2e_encryption"`
|
||||
Release string `json:"release"`
|
||||
CPUId string `json:"cpuid"`
|
||||
CloudUser string `json:"clouduser"`
|
||||
CloudPublicKey string `json:"cloudpublickey"`
|
||||
CameraName string `json:"cameraname"`
|
||||
Enterprise bool `json:"enterprise"`
|
||||
Hostname string `json:"hostname"`
|
||||
Architecture string `json:"architecture"`
|
||||
TotalMemory string `json:"totalMemory"`
|
||||
UsedMemory string `json:"usedMemory"`
|
||||
FreeMemory string `json:"freeMemory"`
|
||||
ProcessMemory string `json:"processMemory"`
|
||||
MacList json.RawMessage `json:"mac_list"`
|
||||
IPList json.RawMessage `json:"ip_list"`
|
||||
Board string `json:"board"`
|
||||
Disk1Size string `json:"disk1size"`
|
||||
Disk3Size string `json:"disk3size"`
|
||||
DiskVdaSize string `json:"diskvdasize"`
|
||||
Uptime string `json:"uptime"`
|
||||
BootTime string `json:"boot_time"`
|
||||
SiteID string `json:"siteID"`
|
||||
Onvif string `json:"onvif"`
|
||||
OnvifZoom string `json:"onvif_zoom"`
|
||||
OnvifPanTilt string `json:"onvif_pantilt"`
|
||||
OnvifPresets string `json:"onvif_presets"`
|
||||
OnvifPresetsList json.RawMessage `json:"onvif_presets_list"`
|
||||
OnvifEventsList json.RawMessage `json:"onvif_events_list"`
|
||||
CameraConnected string `json:"cameraConnected"`
|
||||
HasBackChannel string `json:"hasBackChannel"`
|
||||
ContinuousRecording string `json:"continuousRecording"`
|
||||
LivePreviewHTTP bool `json:"livePreviewHttp"`
|
||||
NumberOfFiles string `json:"numberoffiles"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CameraType string `json:"cameratype"`
|
||||
Docker bool `json:"docker"`
|
||||
Kios bool `json:"kios"`
|
||||
RaspberryPi bool `json:"raspberrypi"`
|
||||
}{
|
||||
Key: config.Key,
|
||||
Version: kerberosAgentVersion,
|
||||
HubEncryption: hub_encryption,
|
||||
E2EEncryption: e2e_encryption,
|
||||
Release: system.Version,
|
||||
CPUId: system.CPUId,
|
||||
CloudUser: username,
|
||||
CloudPublicKey: key,
|
||||
CameraName: name,
|
||||
Enterprise: isEnterprise,
|
||||
Hostname: system.Hostname,
|
||||
Architecture: system.Architecture,
|
||||
TotalMemory: strconv.FormatUint(system.TotalMemory, 10),
|
||||
UsedMemory: strconv.FormatUint(system.UsedMemory, 10),
|
||||
FreeMemory: strconv.FormatUint(system.FreeMemory, 10),
|
||||
ProcessMemory: strconv.FormatUint(system.ProcessUsedMemory, 10),
|
||||
MacList: rawJSONOrEmptyArray(macs),
|
||||
IPList: rawJSONOrEmptyArray(ips),
|
||||
Board: "",
|
||||
Disk1Size: "0",
|
||||
Disk3Size: "0",
|
||||
DiskVdaSize: "0",
|
||||
Uptime: uptimeString,
|
||||
BootTime: boottimeString,
|
||||
SiteID: config.HubSite,
|
||||
Onvif: onvifEnabled,
|
||||
OnvifZoom: onvifZoom,
|
||||
OnvifPanTilt: onvifPanTilt,
|
||||
OnvifPresets: onvifPresets,
|
||||
OnvifPresetsList: rawJSONOrEmptyArray(onvifPresetsList),
|
||||
OnvifEventsList: rawJSONOrEmptyArray(onvifEventsList),
|
||||
CameraConnected: cameraConnected,
|
||||
HasBackChannel: hasBackChannel,
|
||||
ContinuousRecording: continuousRecording,
|
||||
LivePreviewHTTP: true,
|
||||
NumberOfFiles: "33",
|
||||
Timestamp: 1564747908,
|
||||
CameraType: "IPCamera",
|
||||
Docker: true,
|
||||
Kios: false,
|
||||
RaspberryPi: false,
|
||||
}
|
||||
|
||||
objectBytes, err := json.Marshal(heartbeat)
|
||||
if err != nil {
|
||||
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling heartbeat: " + err.Error())
|
||||
objectBytes = []byte("{}")
|
||||
}
|
||||
object := string(objectBytes)
|
||||
|
||||
// Get the private key to encrypt the data using symmetric encryption: AES.
|
||||
privateKey := config.HubPrivateKey
|
||||
@@ -548,29 +673,45 @@ loop:
|
||||
|
||||
// Base64 encode the encrypted data.
|
||||
encryptedBase64 := base64.StdEncoding.EncodeToString(encrypted)
|
||||
object = fmt.Sprintf(`{
|
||||
"cloudpublicKey": "%s",
|
||||
"encrypted" : %t,
|
||||
"encryptedData" : "%s"
|
||||
}`, config.HubKey, true, encryptedBase64)
|
||||
encryptedPayload := struct {
|
||||
CloudPublicKey string `json:"cloudpublicKey"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
EncryptedData string `json:"encryptedData"`
|
||||
}{
|
||||
CloudPublicKey: config.HubKey,
|
||||
Encrypted: true,
|
||||
EncryptedData: encryptedBase64,
|
||||
}
|
||||
encryptedBytes, err := json.Marshal(encryptedPayload)
|
||||
if err != nil {
|
||||
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling encrypted heartbeat: " + err.Error())
|
||||
encryptedBytes = []byte("{}")
|
||||
}
|
||||
object = string(encryptedBytes)
|
||||
}
|
||||
|
||||
var jsonStr = []byte(object)
|
||||
buffy := bytes.NewBuffer(jsonStr)
|
||||
req, _ := http.NewRequest("POST", hubURI, buffy)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
requestStarted := time.Now()
|
||||
req, requestErr := http.NewRequest("POST", hubURI, buffy)
|
||||
var resp *http.Response
|
||||
if requestErr == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, requestErr = client.Do(req)
|
||||
}
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
if requestErr == nil && resp != nil && resp.StatusCode == http.StatusOK {
|
||||
if resp.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
communication.CloudTimestamp.Store(time.Now().Unix())
|
||||
log.Log.Info("cloud.HandleHeartBeat(): (200) Heartbeat received by Kerberos Hub.")
|
||||
} else {
|
||||
responseBody, responseBodyTruncated, responseBodyErr := readHeartbeatResponseBody(resp)
|
||||
if communication.CloudTimestamp != nil && communication.CloudTimestamp.Load() != nil {
|
||||
communication.CloudTimestamp.Store(int64(0))
|
||||
}
|
||||
log.Log.Error("cloud.HandleHeartBeat(): (400) Something went wrong while sending to Kerberos Hub.")
|
||||
log.Log.Error(formatHeartbeatFailureLog(resp, requestErr, responseBody, responseBodyTruncated, responseBodyErr, time.Since(requestStarted)))
|
||||
}
|
||||
} else {
|
||||
log.Log.Error("cloud.HandleHeartBeat(): Disabled as we do not have a public key defined.")
|
||||
@@ -583,43 +724,86 @@ loop:
|
||||
secretAccessKey := config.KStorage.SecretAccessKey
|
||||
if vaultURI != "" && accessKey != "" && secretAccessKey != "" {
|
||||
|
||||
var object = fmt.Sprintf(`{
|
||||
"key" : "%s",
|
||||
"version" : "%s",
|
||||
"release" : "%s",
|
||||
"cpuid" : "%s",
|
||||
"clouduser" : "%s",
|
||||
"cloudpublickey" : "%s",
|
||||
"cameraname" : "%s",
|
||||
"enterprise" : %t,
|
||||
"hostname" : "%s",
|
||||
"architecture" : "%s",
|
||||
"totalMemory" : "%d",
|
||||
"usedMemory" : "%d",
|
||||
"freeMemory" : "%d",
|
||||
"processMemory" : "%d",
|
||||
"mac_list" : %s,
|
||||
"ip_list" : %s,
|
||||
"board" : "",
|
||||
"disk1size" : "%s",
|
||||
"disk3size" : "%s",
|
||||
"diskvdasize" : "%s",
|
||||
"uptime" : "%s",
|
||||
"boot_time" : "%s",
|
||||
"siteID" : "%s",
|
||||
"onvif" : "%s",
|
||||
"onvif_zoom" : "%s",
|
||||
"onvif_pantilt" : "%s",
|
||||
"onvif_presets": "%s",
|
||||
"onvif_presets_list": %s,
|
||||
"cameraConnected": "%s",
|
||||
"numberoffiles" : "33",
|
||||
"timestamp" : 1564747908,
|
||||
"cameratype" : "IPCamera",
|
||||
"docker" : true,
|
||||
"kios" : false,
|
||||
"raspberrypi" : false
|
||||
}`, config.Key, kerberosAgentVersion, system.Version, system.CPUId, username, key, name, isEnterprise, system.Hostname, system.Architecture, system.TotalMemory, system.UsedMemory, system.FreeMemory, system.ProcessUsedMemory, macs, ips, "0", "0", "0", uptimeString, boottimeString, config.HubSite, onvifEnabled, onvifZoom, onvifPanTilt, onvifPresets, onvifPresetsList, cameraConnected)
|
||||
heartbeat := struct {
|
||||
Key string `json:"key"`
|
||||
Version string `json:"version"`
|
||||
Release string `json:"release"`
|
||||
CPUId string `json:"cpuid"`
|
||||
CloudUser string `json:"clouduser"`
|
||||
CloudPublicKey string `json:"cloudpublickey"`
|
||||
CameraName string `json:"cameraname"`
|
||||
Enterprise bool `json:"enterprise"`
|
||||
Hostname string `json:"hostname"`
|
||||
Architecture string `json:"architecture"`
|
||||
TotalMemory string `json:"totalMemory"`
|
||||
UsedMemory string `json:"usedMemory"`
|
||||
FreeMemory string `json:"freeMemory"`
|
||||
ProcessMemory string `json:"processMemory"`
|
||||
MacList json.RawMessage `json:"mac_list"`
|
||||
IPList json.RawMessage `json:"ip_list"`
|
||||
Board string `json:"board"`
|
||||
Disk1Size string `json:"disk1size"`
|
||||
Disk3Size string `json:"disk3size"`
|
||||
DiskVdaSize string `json:"diskvdasize"`
|
||||
Uptime string `json:"uptime"`
|
||||
BootTime string `json:"boot_time"`
|
||||
SiteID string `json:"siteID"`
|
||||
Onvif string `json:"onvif"`
|
||||
OnvifZoom string `json:"onvif_zoom"`
|
||||
OnvifPanTilt string `json:"onvif_pantilt"`
|
||||
OnvifPresets string `json:"onvif_presets"`
|
||||
OnvifPresetsList json.RawMessage `json:"onvif_presets_list"`
|
||||
CameraConnected string `json:"cameraConnected"`
|
||||
NumberOfFiles string `json:"numberoffiles"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CameraType string `json:"cameratype"`
|
||||
Docker bool `json:"docker"`
|
||||
Kios bool `json:"kios"`
|
||||
RaspberryPi bool `json:"raspberrypi"`
|
||||
}{
|
||||
Key: config.Key,
|
||||
Version: kerberosAgentVersion,
|
||||
Release: system.Version,
|
||||
CPUId: system.CPUId,
|
||||
CloudUser: username,
|
||||
CloudPublicKey: key,
|
||||
CameraName: name,
|
||||
Enterprise: isEnterprise,
|
||||
Hostname: system.Hostname,
|
||||
Architecture: system.Architecture,
|
||||
TotalMemory: strconv.FormatUint(system.TotalMemory, 10),
|
||||
UsedMemory: strconv.FormatUint(system.UsedMemory, 10),
|
||||
FreeMemory: strconv.FormatUint(system.FreeMemory, 10),
|
||||
ProcessMemory: strconv.FormatUint(system.ProcessUsedMemory, 10),
|
||||
MacList: rawJSONOrEmptyArray(macs),
|
||||
IPList: rawJSONOrEmptyArray(ips),
|
||||
Board: "",
|
||||
Disk1Size: "0",
|
||||
Disk3Size: "0",
|
||||
DiskVdaSize: "0",
|
||||
Uptime: uptimeString,
|
||||
BootTime: boottimeString,
|
||||
SiteID: config.HubSite,
|
||||
Onvif: onvifEnabled,
|
||||
OnvifZoom: onvifZoom,
|
||||
OnvifPanTilt: onvifPanTilt,
|
||||
OnvifPresets: onvifPresets,
|
||||
OnvifPresetsList: rawJSONOrEmptyArray(onvifPresetsList),
|
||||
CameraConnected: cameraConnected,
|
||||
NumberOfFiles: "33",
|
||||
Timestamp: 1564747908,
|
||||
CameraType: "IPCamera",
|
||||
Docker: true,
|
||||
Kios: false,
|
||||
RaspberryPi: false,
|
||||
}
|
||||
|
||||
objectBytes, err := json.Marshal(heartbeat)
|
||||
if err != nil {
|
||||
log.Log.Error("cloud.HandleHeartBeat(): error while marshalling vault heartbeat: " + err.Error())
|
||||
objectBytes = []byte("{}")
|
||||
}
|
||||
object := string(objectBytes)
|
||||
|
||||
var jsonStr = []byte(object)
|
||||
buffy := bytes.NewBuffer(jsonStr)
|
||||
@@ -684,7 +868,35 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
|
||||
hubKey = config.HubKey
|
||||
}
|
||||
|
||||
lastLivestreamRequest := int64(0)
|
||||
lastLivestreamRequestMQTT := int64(0)
|
||||
lastLivestreamRequestHTTP := int64(0)
|
||||
|
||||
// HTTP transport (preferred when this agent is paired with a Kerberos
|
||||
// Hub): ship preview frames to hub-api over HTTPS instead of pushing
|
||||
// (large, base64) images through the MQTT broker. Viewers opt in per
|
||||
// session via the "http" transport on their keepalive; the legacy MQTT
|
||||
// push is kept for viewers (older frontends) that don't, and as a fallback.
|
||||
region := ""
|
||||
if config.S3 != nil {
|
||||
region = config.S3.Region
|
||||
}
|
||||
var snapshotPublisher *livesnapshot.Publisher
|
||||
if config.HubURI != "" && config.HubKey != "" {
|
||||
snapshotPublisher = livesnapshot.NewPublisher(livesnapshot.PublisherConfig{
|
||||
HubURI: config.HubURI,
|
||||
HubKey: config.HubKey,
|
||||
HubPrivateKey: config.HubPrivateKey,
|
||||
Region: region,
|
||||
DeviceKey: deviceId,
|
||||
})
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): HTTP preview transport ENABLED; frames go to " + strings.TrimRight(config.HubURI, "/") + "/storage/snapshot when a viewer requests it (kept off MQTT).")
|
||||
} else {
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): HTTP preview transport DISABLED (Hub not configured: HubURI/HubKey empty); preview frames are pushed over MQTT.")
|
||||
}
|
||||
|
||||
// Track the transport actually used so we log only when it changes; the
|
||||
// loop runs once per keyframe and logging every frame would be noise.
|
||||
lastTransport := ""
|
||||
|
||||
var cursorError error
|
||||
var pkt packets.Packet
|
||||
@@ -695,20 +907,74 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
|
||||
continue
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
// Drain both viewer keepalive channels (non-blocking): one for the
|
||||
// HTTP transport, one for the legacy MQTT push.
|
||||
select {
|
||||
case <-communication.HandleLiveSD:
|
||||
lastLivestreamRequest = now
|
||||
lastLivestreamRequestMQTT = now
|
||||
default:
|
||||
}
|
||||
if now-lastLivestreamRequest > 3 {
|
||||
select {
|
||||
case <-communication.HandleLiveSDHTTP:
|
||||
lastLivestreamRequestHTTP = now
|
||||
default:
|
||||
}
|
||||
|
||||
mqttViewerActive := now-lastLivestreamRequestMQTT <= 3
|
||||
httpViewerActive := now-lastLivestreamRequestHTTP <= 3
|
||||
if !mqttViewerActive && !httpViewerActive {
|
||||
continue
|
||||
}
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): Sending base64 encoded images to MQTT.")
|
||||
img, err := rtspClient.DecodePacket(pkt)
|
||||
if err == nil {
|
||||
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
|
||||
bytes, _ := utils.ImageToBytes(imageResized)
|
||||
|
||||
img, err := rtspClient.DecodePacket(pkt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
|
||||
bytes, _ := utils.ImageToBytes(imageResized)
|
||||
|
||||
// Prefer HTTP for viewers that asked for it. Only if that did not
|
||||
// deliver (Hub not configured, or the upload failed) do we also push
|
||||
// over MQTT, so a new frontend can still fall back to its MQTT path.
|
||||
httpPushed := false
|
||||
var httpErr error
|
||||
if httpViewerActive && snapshotPublisher != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
httpErr = snapshotPublisher.PublishSnapshot(ctx, bytes)
|
||||
if httpErr == nil {
|
||||
httpPushed = true
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
pushMQTT := mqttViewerActive || (httpViewerActive && !httpPushed)
|
||||
|
||||
// Log only when the effective transport changes, so an operator can
|
||||
// tell at a glance whether a device's preview travels over HTTP or
|
||||
// MQTT (and why it fell back) without per-frame log spam.
|
||||
transport := ""
|
||||
if httpPushed {
|
||||
transport = "http"
|
||||
} else if pushMQTT {
|
||||
transport = "mqtt"
|
||||
}
|
||||
if transport != "" && transport != lastTransport {
|
||||
if transport == "http" {
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): delivering preview frames over HTTP for device " + deviceId + ".")
|
||||
} else {
|
||||
reason := "viewer requested MQTT (older frontend)"
|
||||
if httpViewerActive && snapshotPublisher == nil {
|
||||
reason = "viewer asked for HTTP but Hub is not configured"
|
||||
} else if httpViewerActive && httpErr != nil {
|
||||
reason = "HTTP upload failed, falling back: " + httpErr.Error()
|
||||
}
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): delivering preview frames over MQTT for device " + deviceId + " (" + reason + ").")
|
||||
}
|
||||
lastTransport = transport
|
||||
}
|
||||
|
||||
if pushMQTT {
|
||||
log.Log.Debug("cloud.HandleLiveStreamSD(): Sending base64 encoded images to MQTT.")
|
||||
chunking := config.Capture.LiveviewChunking
|
||||
|
||||
if chunking == "true" {
|
||||
@@ -789,7 +1055,7 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
|
||||
log.Log.Debug("cloud.HandleLiveStreamSD(): finished")
|
||||
}
|
||||
|
||||
func HandleLiveStreamHD(livestreamCursor *packets.QueueCursor, configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, rtspClient capture.RTSPClient) {
|
||||
func HandleLiveStreamHD(configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, rtspClient capture.RTSPClient, rtspSubClient capture.RTSPClient, subStreamEnabled bool) {
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
@@ -803,23 +1069,51 @@ func HandleLiveStreamHD(livestreamCursor *packets.QueueCursor, configuration *mo
|
||||
// Create per-peer broadcasters instead of shared tracks.
|
||||
// Each viewer gets its own track with independent, non-blocking writes
|
||||
// so a slow/congested peer cannot stall the others.
|
||||
streams, _ := rtspClient.GetStreams()
|
||||
videoBroadcaster := webrtc.NewVideoBroadcaster(streams)
|
||||
audioBroadcaster := webrtc.NewAudioBroadcaster(streams)
|
||||
//
|
||||
// Both the main (high-resolution) and sub (low-resolution) streams are
|
||||
// exposed as separate broadcasters that are always forwarding, so a
|
||||
// viewer can pick the resolution it needs per peer connection without
|
||||
// the agent re-negotiating the RTSP source.
|
||||
mainStreams, _ := rtspClient.GetStreams()
|
||||
mainVideoBroadcaster := webrtc.NewVideoBroadcaster(mainStreams)
|
||||
mainAudioBroadcaster := webrtc.NewAudioBroadcaster(mainStreams)
|
||||
|
||||
if videoBroadcaster == nil && audioBroadcaster == nil {
|
||||
log.Log.Error("cloud.HandleLiveStreamHD(): failed to create both video and audio broadcasters")
|
||||
if mainVideoBroadcaster == nil && mainAudioBroadcaster == nil {
|
||||
log.Log.Error("cloud.HandleLiveStreamHD(): failed to create both video and audio broadcasters for the main stream")
|
||||
return
|
||||
}
|
||||
|
||||
go webrtc.WriteToTrack(livestreamCursor, configuration, communication, mqttClient, videoBroadcaster, audioBroadcaster, rtspClient)
|
||||
go webrtc.WriteToTrack(communication.Queue.Latest(), configuration, communication, mqttClient, mainVideoBroadcaster, mainAudioBroadcaster, rtspClient)
|
||||
|
||||
// Sub stream broadcasters, only when a distinct sub stream is available.
|
||||
var subVideoBroadcaster *webrtc.TrackBroadcaster
|
||||
var subAudioBroadcaster *webrtc.TrackBroadcaster
|
||||
if subStreamEnabled && rtspSubClient != nil && communication.SubQueue != nil {
|
||||
subStreams, _ := rtspSubClient.GetStreams()
|
||||
subVideoBroadcaster = webrtc.NewVideoBroadcaster(subStreams)
|
||||
subAudioBroadcaster = webrtc.NewAudioBroadcaster(subStreams)
|
||||
go webrtc.WriteToTrack(communication.SubQueue.Latest(), configuration, communication, mqttClient, subVideoBroadcaster, subAudioBroadcaster, rtspSubClient)
|
||||
}
|
||||
subBroadcastersReady := subVideoBroadcaster != nil || subAudioBroadcaster != nil
|
||||
|
||||
if config.Capture.ForwardWebRTC == "true" {
|
||||
|
||||
} else {
|
||||
log.Log.Info("cloud.HandleLiveStreamHD(): Waiting for peer connections.")
|
||||
for handshake := range communication.HandleLiveHDHandshake {
|
||||
log.Log.Info("cloud.HandleLiveStreamHD(): setting up a peer connection.")
|
||||
// Route each viewer to the main or sub broadcasters based on the
|
||||
// quality it requested; "auto" prefers the sub stream when one is
|
||||
// available, matching the historical default.
|
||||
useSub := models.SelectSubStreamForQuality(config, handshake.Payload.Quality, subStreamEnabled && subBroadcastersReady)
|
||||
videoBroadcaster := mainVideoBroadcaster
|
||||
audioBroadcaster := mainAudioBroadcaster
|
||||
streamLabel := "main"
|
||||
if useSub {
|
||||
videoBroadcaster = subVideoBroadcaster
|
||||
audioBroadcaster = subAudioBroadcaster
|
||||
streamLabel = "sub"
|
||||
}
|
||||
log.Log.Info("cloud.HandleLiveStreamHD(): setting up a peer connection on the " + streamLabel + " stream (quality=" + handshake.Payload.Quality + ").")
|
||||
go webrtc.InitializeWebRTCConnection(configuration, communication, mqttClient, videoBroadcaster, audioBroadcaster, handshake)
|
||||
}
|
||||
}
|
||||
51
machinery/src/cloud/cloud_test.go
Normal file
51
machinery/src/cloud/cloud_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHeartbeatFailureLogIncludesHubResponse(t *testing.T) {
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Status: "400 Bad Request",
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"invalid heartbeat"}`)),
|
||||
}
|
||||
|
||||
responseBody, truncated, err := readHeartbeatResponseBody(response)
|
||||
if err != nil {
|
||||
t.Fatalf("readHeartbeatResponseBody() error = %v", err)
|
||||
}
|
||||
message := formatHeartbeatFailureLog(response, nil, responseBody, truncated, nil, 125*time.Millisecond)
|
||||
|
||||
for _, expected := range []string{
|
||||
"status_code=400",
|
||||
`status="400 Bad Request"`,
|
||||
"duration=125ms",
|
||||
`response_body="{\"error\":\"invalid heartbeat\"}"`,
|
||||
} {
|
||||
if !strings.Contains(message, expected) {
|
||||
t.Errorf("formatHeartbeatFailureLog() = %q, want it to contain %q", message, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeartbeatResponseBodyTruncatesLargeBody(t *testing.T) {
|
||||
response := &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader(strings.Repeat("x", heartbeatResponseBodyLogLimit+1))),
|
||||
}
|
||||
|
||||
body, truncated, err := readHeartbeatResponseBody(response)
|
||||
if err != nil {
|
||||
t.Fatalf("readHeartbeatResponseBody() error = %v", err)
|
||||
}
|
||||
if !truncated {
|
||||
t.Fatal("readHeartbeatResponseBody() truncated = false, want true")
|
||||
}
|
||||
if len(body) != heartbeatResponseBodyLogLimit {
|
||||
t.Fatalf("len(body) = %d, want %d", len(body), heartbeatResponseBodyLogLimit)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,11 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
log.Log.Error("UploadDropbox: Error closing file: " + cerr.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
@@ -34,6 +34,29 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
|
||||
log.Log.Info("UploadKerberosHub: Uploading to Kerberos Hub (" + config.HubURI + ")")
|
||||
log.Log.Info("UploadKerberosHub: Upload started for " + fileName)
|
||||
|
||||
// Prefer the resumable (tus) upload when enabled (the default). Kerberos Hub
|
||||
// authenticates the agent with its Hub public/private key and proxies the
|
||||
// resumable upload to the Kerberos Vault. When Hub does not expose a tus
|
||||
// endpoint (older deployments) we transparently fall back to the legacy
|
||||
// single-POST upload below.
|
||||
if resumableUploadsEnabled() {
|
||||
uploaded, _, supported, body, rerr := uploadHubResumable(&config, fileName, "UploadKerberosHub", "hub")
|
||||
if supported {
|
||||
if uploaded {
|
||||
log.Log.Info("UploadKerberosHub: Upload Finished (resumable), " + body)
|
||||
return true, true, nil
|
||||
}
|
||||
if rerr != nil {
|
||||
log.Log.Info("UploadKerberosHub: resumable upload failed, " + rerr.Error())
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosHub: resumable upload incomplete, " + body)
|
||||
}
|
||||
return false, true, rerr
|
||||
}
|
||||
log.Log.Info("UploadKerberosHub: resumable (tus) endpoint not available, falling back to legacy upload")
|
||||
}
|
||||
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
// Check if we still have the file otherwise we abort the request.
|
||||
@@ -62,15 +85,16 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
req.Header.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
req.Header.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
req.Header.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
|
||||
var client *http.Client
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
client = &http.Client{Transport: tr, CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
client = &http.Client{CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -105,6 +129,7 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
req.Header.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
req.Header.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
req.Header.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
resp, err = client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
@@ -129,3 +154,20 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
log.Log.Info(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
// stripHubCredentialsOnCrossHostRedirect removes the custom Kerberos Hub
|
||||
// credential headers on a redirect that crosses to a different host. net/http
|
||||
// already strips the standard sensitive headers (Authorization, Cookie,
|
||||
// WWW-Authenticate) on a cross-host redirect, but it does NOT strip
|
||||
// custom-named headers, so without this the Hub private/public keys would be
|
||||
// forwarded to any host the configured HubURI redirects to.
|
||||
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del("X-Kerberos-Hub-PrivateKey")
|
||||
req.Header.Del("X-Kerberos-Hub-PublicKey")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
@@ -30,6 +31,15 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
|
||||
return false, false, errors.New(err)
|
||||
}
|
||||
|
||||
// If the recording no longer exists on disk there is nothing to upload.
|
||||
// This can happen when the file was already removed (e.g. cleanup, or an
|
||||
// earlier successful upload). Skip it so the watcher drops the marker
|
||||
// instead of retrying indefinitely.
|
||||
if _, err := os.Stat("data/recordings/" + fileName); err != nil {
|
||||
log.Log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
@@ -41,17 +51,6 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
|
||||
// KerberosCloud, this means storage is disabled and proxy enabled.
|
||||
log.Log.Info("UploadKerberosVault: Uploading to Kerberos Vault (" + config.KStorage.URI + ")")
|
||||
log.Log.Info("UploadKerberosVault: Upload started for " + fileName)
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if err != nil {
|
||||
err := "UploadKerberosVault: Upload Failed, file doesn't exists anymore"
|
||||
log.Log.Info(err)
|
||||
return false, false, errors.New(err)
|
||||
}
|
||||
|
||||
publicKey := config.KStorage.CloudKey
|
||||
if config.HubKey != "" {
|
||||
@@ -60,62 +59,30 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
|
||||
|
||||
// We need to check if we are in a retry timeout.
|
||||
if kstorageRetryTimeout <= time.Now().Unix() {
|
||||
uploaded, responded, body, err := sendToVault(*config.KStorage, publicKey, config.Key, fileName, "UploadKerberosVault", "primary")
|
||||
if uploaded {
|
||||
kstorageRetryCount = 0
|
||||
log.Log.Info("UploadKerberosVault: Upload Finished, " + body)
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", config.KStorage.URI+"/storage", file)
|
||||
if err != nil {
|
||||
errorMessage := "UploadKerberosVault: error reading request, " + config.KStorage.URI + "/storage: " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
req.Header.Set("Content-Type", "video/mp4")
|
||||
req.Header.Set("X-Kerberos-Storage-CloudKey", publicKey)
|
||||
req.Header.Set("X-Kerberos-Storage-AccessKey", config.KStorage.AccessKey)
|
||||
req.Header.Set("X-Kerberos-Storage-SecretAccessKey", config.KStorage.SecretAccessKey)
|
||||
req.Header.Set("X-Kerberos-Storage-Provider", config.KStorage.Provider)
|
||||
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
req.Header.Set("X-Kerberos-Storage-Device", config.Key)
|
||||
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
req.Header.Set("X-Kerberos-Storage-Directory", config.KStorage.Directory)
|
||||
|
||||
var client *http.Client
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if resp != nil {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
if resp.StatusCode == 200 {
|
||||
kstorageRetryCount = 0
|
||||
log.Log.Info("UploadKerberosVault: Upload Finished, " + resp.Status + ", " + string(body))
|
||||
return true, true, nil
|
||||
} else {
|
||||
// We increase the retry count, and set the timeout.
|
||||
// If we have reached the retry policy, we set the timeout.
|
||||
// This means we will not retry for the next 5 minutes.
|
||||
if kstorageRetryCount < config.KStorage.MaxRetries {
|
||||
kstorageRetryCount = (kstorageRetryCount + 1)
|
||||
}
|
||||
if kstorageRetryCount == config.KStorage.MaxRetries {
|
||||
kstorageRetryTimeout = time.Now().Add(time.Duration(config.KStorage.Timeout) * time.Second).Unix()
|
||||
}
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + resp.Status + ", " + string(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + err.Error())
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + body)
|
||||
}
|
||||
|
||||
// We only advance the retry policy when the vault gave a definitive
|
||||
// response (mirroring the original behaviour where transient network
|
||||
// errors did not consume retries). When the retry count reaches the
|
||||
// configured maximum we back off for the configured timeout.
|
||||
if responded {
|
||||
if kstorageRetryCount < config.KStorage.MaxRetries {
|
||||
kstorageRetryCount = (kstorageRetryCount + 1)
|
||||
}
|
||||
if kstorageRetryCount == config.KStorage.MaxRetries {
|
||||
kstorageRetryTimeout = time.Now().Add(time.Duration(config.KStorage.Timeout) * time.Second).Unix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,61 +101,159 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
|
||||
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Uploading to Secondary Kerberos Vault (" + config.KStorageSecondary.URI + ")")
|
||||
|
||||
file, err = os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if err != nil {
|
||||
err := "UploadKerberosVault (Secondary): Upload Failed, file doesn't exists anymore"
|
||||
log.Log.Info(err)
|
||||
return false, false, errors.New(err)
|
||||
uploaded, _, body, err := sendToVault(*config.KStorageSecondary, publicKey, config.Key, fileName, "UploadKerberosVault (Secondary)", "secondary")
|
||||
if uploaded {
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Upload Finished to secondary, " + body)
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", config.KStorageSecondary.URI+"/storage", file)
|
||||
if err != nil {
|
||||
errorMessage := "UploadKerberosVault (Secondary): error reading request, " + config.KStorageSecondary.URI + "/storage: " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
req.Header.Set("Content-Type", "video/mp4")
|
||||
req.Header.Set("X-Kerberos-Storage-CloudKey", publicKey)
|
||||
req.Header.Set("X-Kerberos-Storage-AccessKey", config.KStorageSecondary.AccessKey)
|
||||
req.Header.Set("X-Kerberos-Storage-SecretAccessKey", config.KStorageSecondary.SecretAccessKey)
|
||||
req.Header.Set("X-Kerberos-Storage-Provider", config.KStorageSecondary.Provider)
|
||||
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
req.Header.Set("X-Kerberos-Storage-Device", config.Key)
|
||||
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
req.Header.Set("X-Kerberos-Storage-Directory", config.KStorageSecondary.Directory)
|
||||
|
||||
var client *http.Client
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Upload Failed to secondary, " + err.Error())
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if resp != nil {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
if resp.StatusCode == 200 {
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Upload Finished to secondary, " + resp.Status + ", " + string(body))
|
||||
return true, true, nil
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Upload Failed to secondary, " + resp.Status + ", " + string(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Upload Failed to secondary, " + body)
|
||||
}
|
||||
}
|
||||
|
||||
return false, true, nil
|
||||
}
|
||||
|
||||
// sendToVault uploads a single recording to one Kerberos Vault. When resumable
|
||||
// uploads are enabled (the default) it attempts the tus protocol first and, if
|
||||
// the vault does not expose a tus endpoint (older deployments), transparently
|
||||
// falls back to the legacy single-shot POST.
|
||||
//
|
||||
// It returns whether the upload succeeded, whether the vault gave a definitive
|
||||
// HTTP response (so the caller can advance its retry policy), a short message
|
||||
// for logging, and a transport error if any.
|
||||
func sendToVault(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, string, error) {
|
||||
if resumableUploadsEnabled() {
|
||||
uploaded, responded, supported, body, err := uploadVaultResumable(vault, publicKey, deviceKey, fileName, label, slot)
|
||||
if supported {
|
||||
return uploaded, responded, body, err
|
||||
}
|
||||
log.Log.Info(label + ": resumable (tus) endpoint not available, falling back to legacy upload")
|
||||
}
|
||||
return uploadVaultLegacy(vault, publicKey, deviceKey, fileName, label)
|
||||
}
|
||||
|
||||
// uploadVaultLegacy performs the original single-request upload: the whole file
|
||||
// is sent as the body of a POST to {URI}/storage. Kept for backwards
|
||||
// compatibility with vault deployments that do not support resumable uploads.
|
||||
func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, label string) (bool, bool, string, error) {
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.Open(fullname)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if err != nil {
|
||||
msg := label + ": Upload Failed, file doesn't exists anymore"
|
||||
log.Log.Info(msg)
|
||||
return false, false, "", errors.New(msg)
|
||||
}
|
||||
|
||||
uri := vault.URI
|
||||
for len(uri) > 0 && uri[len(uri)-1] == '/' {
|
||||
uri = uri[:len(uri)-1]
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", uri+"/storage", file)
|
||||
if err != nil {
|
||||
errorMessage := label + ": error reading request, " + uri + "/storage: " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, false, "", errors.New(errorMessage)
|
||||
}
|
||||
req.Header.Set("Content-Type", "video/mp4")
|
||||
setVaultHeaders(req.Header, vault, publicKey, deviceKey, fileName)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
|
||||
client := newVaultHTTPClient(0)
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
|
||||
body, rerr := io.ReadAll(resp.Body)
|
||||
if rerr != nil {
|
||||
return false, false, "", rerr
|
||||
}
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
return true, true, resp.Status + ", " + string(body), nil
|
||||
}
|
||||
return false, true, resp.Status + ", " + string(body), nil
|
||||
}
|
||||
|
||||
// setVaultHeaders sets the standard Kerberos Vault headers used by the legacy
|
||||
// single-POST upload.
|
||||
func setVaultHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
|
||||
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
|
||||
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
|
||||
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
|
||||
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
h.Set("X-Kerberos-Storage-Device", deviceKey)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
|
||||
}
|
||||
|
||||
// newVaultHTTPClient builds an HTTP client honouring the AGENT_TLS_INSECURE
|
||||
// escape hatch. A timeout of 0 disables the *overall* client timeout, which is
|
||||
// required for streaming large upload bodies without capping the total transfer
|
||||
// time. Transport-level timeouts are still applied so that a lost network
|
||||
// connection (for example the internet being disconnected) fails reasonably
|
||||
// fast and the upload is retried, instead of the request hanging until the OS
|
||||
// TCP timeout (which can be many minutes) and blocking the whole upload loop.
|
||||
func newVaultHTTPClient(timeout time.Duration) *http.Client {
|
||||
// Start from a clone of the default transport so we keep its sane dial and
|
||||
// TLS-handshake timeouts, connection pooling and HTTP/2 support even when the
|
||||
// AGENT_TLS_INSECURE escape hatch is enabled (a bare http.Transport would have
|
||||
// no dial/handshake timeouts at all).
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// ResponseHeaderTimeout bounds how long we wait for the vault's response
|
||||
// headers *after* the request body has been fully written. It does not limit
|
||||
// the time spent streaming the (potentially large) upload body, so big
|
||||
// recordings still upload fine, but a vault/network that disappears while we
|
||||
// wait for the acknowledgement is detected and the upload is retried instead
|
||||
// of hanging indefinitely.
|
||||
transport.ResponseHeaderTimeout = vaultResponseHeaderTimeout()
|
||||
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
if transport.TLSClientConfig == nil {
|
||||
transport.TLSClientConfig = &tls.Config{}
|
||||
}
|
||||
transport.TLSClientConfig.InsecureSkipVerify = true
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
if timeout > 0 {
|
||||
client.Timeout = timeout
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// vaultResponseHeaderTimeout returns the maximum time to wait for a vault's
|
||||
// response headers after the request body has been written. It defaults to 5
|
||||
// minutes — generous enough for the vault to persist/finalize a chunk or a full
|
||||
// recording to its storage provider — and can be tuned with the
|
||||
// AGENT_VAULT_RESPONSE_HEADER_TIMEOUT_SECONDS environment variable. A value of 0
|
||||
// (or a negative/invalid value) disables the timeout.
|
||||
func vaultResponseHeaderTimeout() time.Duration {
|
||||
const def = 5 * time.Minute
|
||||
v := os.Getenv("AGENT_VAULT_RESPONSE_HEADER_TIMEOUT_SECONDS")
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
|
||||
327
machinery/src/cloud/livehls.go
Normal file
327
machinery/src/cloud/livehls.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/cloud/livehls"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
)
|
||||
|
||||
// hlsViewerTimeoutSeconds is how long the agent keeps shipping live HLS segments
|
||||
// after the last viewer keepalive. It is a few seconds longer than the segment
|
||||
// duration so a viewer whose keepalive is briefly delayed does not cause the
|
||||
// session to flap. When it lapses the session is torn down to stop wasting
|
||||
// upload bandwidth when nobody is watching.
|
||||
const hlsViewerTimeoutSeconds = 8
|
||||
|
||||
// hlsReadyReannounceSeconds throttles how often the agent re-announces an
|
||||
// already-ready session over MQTT in response to viewer keepalives. The initial
|
||||
// "receive-hls-ready" is a one-shot fired when the first segment lands; a viewer
|
||||
// that connects or hard-refreshes after that (while the session is still alive)
|
||||
// missed it, so we re-announce on subsequent keepalives. Viewers dedupe by
|
||||
// session id, so a re-announce for a session they already play is a no-op. ~2s
|
||||
// gets a refreshed viewer playing well within its connection timeout without
|
||||
// spamming the control plane.
|
||||
const hlsReadyReannounceSeconds = 2
|
||||
|
||||
// HandleLiveStreamHLS drives the live HLS producer. It mirrors HandleLiveStreamSD:
|
||||
// it reads the camera's packet stream from a Latest() cursor, and while a viewer
|
||||
// is active (kept alive via communication.HandleLiveHLS) it muxes the packets
|
||||
// into CMAF segments and ships them to hub-api, which stores each segment in an
|
||||
// ephemeral, short-TTL live window and serves the rolling playlist to viewers.
|
||||
//
|
||||
// A session is created lazily on the first keyframe seen while a viewer is active
|
||||
// and torn down once viewers go away, so an idle camera produces no live traffic.
|
||||
//
|
||||
// By default (AGENT_LIVE_HLS_PREWARM unset or != "false") the agent instead keeps
|
||||
// one long-lived session muxing continuously into a small in-memory ring buffer
|
||||
// while idle (uploading nothing) and, the moment a viewer arrives, flushes the
|
||||
// already-encoded init + most-recent segment(s) and starts uploading live. This
|
||||
// trades a little idle CPU for a near-instant "requesting stream", so viewers no
|
||||
// longer wait a full GOP for the first segment to be cut. Set
|
||||
// AGENT_LIVE_HLS_PREWARM=false to fall back to the lazy on-demand path above.
|
||||
func HandleLiveStreamHLS(configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, subStreamEnabled bool) {
|
||||
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): started")
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
if config.Offline == "true" {
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as Offline is enabled.")
|
||||
return
|
||||
}
|
||||
if config.Capture.Liveview == "false" {
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as Liveview is disabled.")
|
||||
return
|
||||
}
|
||||
if config.HubURI == "" || config.HubKey == "" {
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as the Hub is not configured (HubURI/HubKey).")
|
||||
return
|
||||
}
|
||||
|
||||
hubKey := config.HubKey
|
||||
deviceId := config.Key
|
||||
|
||||
region := ""
|
||||
if config.S3 != nil {
|
||||
region = config.S3.Region
|
||||
}
|
||||
|
||||
publisher := livehls.NewPublisher(livehls.PublisherConfig{
|
||||
HubURI: config.HubURI,
|
||||
HubKey: config.HubKey,
|
||||
HubPrivateKey: config.HubPrivateKey,
|
||||
Region: region,
|
||||
DeviceKey: deviceId,
|
||||
})
|
||||
|
||||
// The live session can be served from the main (high-resolution) or sub
|
||||
// (low-resolution) stream and switched on demand. requestedQuality tracks the
|
||||
// latest tier asked for over the keepalive; source holds the cursor plus the
|
||||
// encoded parameter sets/dimensions for the stream currently being muxed.
|
||||
// Encoded dimensions are only needed for the avcC fallback path (an SPS that
|
||||
// mp4ff's strict parser rejects).
|
||||
requestedQuality := models.StreamQualityAuto
|
||||
useSub := models.SelectSubStreamForQuality(config, requestedQuality, subStreamEnabled)
|
||||
source := buildHLSSource(config, communication, useSub)
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): serving live HLS from the " + source.label + " stream")
|
||||
|
||||
// prewarm keeps a single long-lived session muxing into an in-memory ring
|
||||
// buffer while idle and flushes it the instant a viewer arrives, eliminating
|
||||
// the per-request GOP wait. Enabled by default; set AGENT_LIVE_HLS_PREWARM=false
|
||||
// to fall back to the lazy on-demand path.
|
||||
prewarm := os.Getenv("AGENT_LIVE_HLS_PREWARM") != "false"
|
||||
if prewarm {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS prewarm ENABLED (set AGENT_LIVE_HLS_PREWARM=false to disable)")
|
||||
} else {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS prewarm DISABLED (AGENT_LIVE_HLS_PREWARM=false)")
|
||||
}
|
||||
|
||||
// lowLatency enables LL-HLS: each segment is sliced into CMAF parts shipped the
|
||||
// instant they close and advertised via #EXT-X-PART, taking glass-to-glass HLS
|
||||
// latency from ~4-6s down to ~1-2s. Enabled by default; set
|
||||
// AGENT_LIVE_HLS_LOW_LATENCY=false to fall back to whole-segment HLS.
|
||||
partTargetMs := uint64(0)
|
||||
if os.Getenv("AGENT_LIVE_HLS_LOW_LATENCY") != "false" {
|
||||
partTargetMs = livehls.DefaultPartTargetMs
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS low-latency (LL-HLS) ENABLED (set AGENT_LIVE_HLS_LOW_LATENCY=false to disable)")
|
||||
} else {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS low-latency (LL-HLS) DISABLED (AGENT_LIVE_HLS_LOW_LATENCY=false)")
|
||||
}
|
||||
|
||||
var session *livehls.Session
|
||||
lastViewerRequest := int64(0)
|
||||
lastReadyAnnounce := int64(0)
|
||||
|
||||
var cursorError error
|
||||
var pkt packets.Packet
|
||||
|
||||
for cursorError == nil {
|
||||
pkt, cursorError = source.cursor.ReadPacket()
|
||||
|
||||
now := time.Now().Unix()
|
||||
select {
|
||||
case q := <-communication.HandleLiveHLS:
|
||||
lastViewerRequest = now
|
||||
if q != "" {
|
||||
requestedQuality = q
|
||||
}
|
||||
// A keepalive may come from a viewer that just connected or hard-
|
||||
// refreshed and therefore missed the one-shot readiness announcement
|
||||
// fired when this session's first segment landed. Re-announce (throttled)
|
||||
// so late/refreshed viewers learn the active session id; the frontend
|
||||
// dedupes by session id, so this is a no-op for viewers already playing.
|
||||
// UploadsActive() is always true for the on-demand path; for prewarm it
|
||||
// suppresses a stale re-announce while idle (the flush-on-arrival path
|
||||
// below announces once the buffer has actually been shipped).
|
||||
if session != nil && session.IsReady() && session.UploadsActive() && now-lastReadyAnnounce >= hlsReadyReannounceSeconds {
|
||||
publishHLSReady(configuration, mqttClient, hubKey, deviceId, session.SessionID())
|
||||
lastReadyAnnounce = now
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
// Switch the source stream when the requested quality now maps to the other
|
||||
// stream. Tearing the current session down makes the producer rebuild the
|
||||
// init segment and announce a fresh session id from the new stream, which the
|
||||
// viewer re-attaches to.
|
||||
if wantSub := models.SelectSubStreamForQuality(config, requestedQuality, subStreamEnabled); wantSub != useSub {
|
||||
useSub = wantSub
|
||||
if session != nil {
|
||||
_ = session.Close()
|
||||
session = nil
|
||||
}
|
||||
source = buildHLSSource(config, communication, useSub)
|
||||
lastReadyAnnounce = 0
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): switched live HLS to the " + source.label + " stream (quality=" + requestedQuality + ")")
|
||||
continue
|
||||
}
|
||||
|
||||
viewerActive := now-lastViewerRequest <= hlsViewerTimeoutSeconds
|
||||
|
||||
if prewarm {
|
||||
// Keep one long-lived session muxing into the ring buffer. Create it on
|
||||
// the first keyframe (so the buffer opens on a random-access point) and
|
||||
// never tear it down for idleness; uploads, not muxing, are what we gate
|
||||
// on viewer presence.
|
||||
if session == nil {
|
||||
if len(pkt.Data) == 0 || !pkt.IsVideo || !pkt.IsKeyFrame {
|
||||
continue
|
||||
}
|
||||
session = livehls.NewSession(publisher, livehls.SessionOptions{
|
||||
Codec: pkt.Codec,
|
||||
SPSNALUs: source.sps,
|
||||
PPSNALUs: source.pps,
|
||||
VPSNALUs: source.vps,
|
||||
Width: source.width,
|
||||
Height: source.height,
|
||||
PartTargetMs: partTargetMs,
|
||||
StartBuffering: true,
|
||||
})
|
||||
session.SetOnReady(func(sessionID string) {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS session ready, announcing " + sessionID)
|
||||
publishHLSReady(configuration, mqttClient, hubKey, deviceId, sessionID)
|
||||
lastReadyAnnounce = time.Now().Unix()
|
||||
})
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): prewarming live HLS session " + session.SessionID())
|
||||
}
|
||||
|
||||
if viewerActive {
|
||||
// Activating flushes the cached init + buffered segment(s). onReady
|
||||
// announces the first-ever readiness; on a later re-activation it has
|
||||
// already fired, so announce here (throttled, so the first activation
|
||||
// does not double up) once the buffer has actually been shipped.
|
||||
if session.SetUploadsActive(true) && session.IsReady() && now-lastReadyAnnounce >= hlsReadyReannounceSeconds {
|
||||
publishHLSReady(configuration, mqttClient, hubKey, deviceId, session.SessionID())
|
||||
lastReadyAnnounce = now
|
||||
}
|
||||
} else {
|
||||
// No viewer: keep muxing into the buffer but stop uploading.
|
||||
session.SetUploadsActive(false)
|
||||
}
|
||||
|
||||
if len(pkt.Data) > 0 && pkt.IsVideo {
|
||||
if err := session.WritePacket(pkt); err != nil {
|
||||
log.Log.Error("cloud.HandleLiveStreamHLS(): " + err.Error())
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerActive {
|
||||
// No viewer: stop and discard the session so we stop shipping segments.
|
||||
if session != nil {
|
||||
_ = session.Close()
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): no active viewers, stopped live HLS session " + session.SessionID())
|
||||
session = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(pkt.Data) == 0 || !pkt.IsVideo {
|
||||
continue
|
||||
}
|
||||
|
||||
// Start a session lazily, but only on a keyframe so the first segment opens
|
||||
// on a random-access point.
|
||||
if session == nil {
|
||||
if !pkt.IsKeyFrame {
|
||||
continue
|
||||
}
|
||||
session = livehls.NewSession(publisher, livehls.SessionOptions{
|
||||
Codec: pkt.Codec,
|
||||
SPSNALUs: source.sps,
|
||||
PPSNALUs: source.pps,
|
||||
VPSNALUs: source.vps,
|
||||
Width: source.width,
|
||||
Height: source.height,
|
||||
PartTargetMs: partTargetMs,
|
||||
})
|
||||
session.SetOnReady(func(sessionID string) {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS session ready, announcing " + sessionID)
|
||||
publishHLSReady(configuration, mqttClient, hubKey, deviceId, sessionID)
|
||||
lastReadyAnnounce = time.Now().Unix()
|
||||
})
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): started live HLS session " + session.SessionID())
|
||||
}
|
||||
|
||||
if err := session.WritePacket(pkt); err != nil {
|
||||
log.Log.Error("cloud.HandleLiveStreamHLS(): " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if session != nil {
|
||||
_ = session.Close()
|
||||
}
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): finished")
|
||||
}
|
||||
|
||||
// publishHLSReady announces, over MQTT, that a live HLS session is available so
|
||||
// viewers can load the rolling playlist hub-api serves for {device}/{session}.
|
||||
func publishHLSReady(configuration *models.Configuration, mqttClient mqtt.Client, hubKey, deviceId, sessionID string) {
|
||||
valueMap := map[string]interface{}{
|
||||
"session": sessionID,
|
||||
"device": deviceId,
|
||||
}
|
||||
message := models.Message{
|
||||
Payload: models.Payload{
|
||||
Action: "receive-hls-ready",
|
||||
DeviceId: deviceId,
|
||||
Value: valueMap,
|
||||
},
|
||||
}
|
||||
payload, err := models.PackageMQTTMessage(configuration, message)
|
||||
if err == nil {
|
||||
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): announced live HLS session " + sessionID)
|
||||
} else {
|
||||
log.Log.Error("cloud.HandleLiveStreamHLS(): failed to package receive-hls-ready message: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// hlsStreamSource bundles everything the live HLS producer needs to mux one of
|
||||
// the camera's streams: the packet cursor it reads from plus the encoded
|
||||
// parameter sets and dimensions used to build that stream's init segment.
|
||||
type hlsStreamSource struct {
|
||||
cursor *packets.QueueCursor
|
||||
sps [][]byte
|
||||
pps [][]byte
|
||||
vps [][]byte
|
||||
width uint16
|
||||
height uint16
|
||||
label string
|
||||
}
|
||||
|
||||
// buildHLSSource resolves the packet cursor and encoded parameter sets/dimensions
|
||||
// for the selected stream. useSub picks the sub (low-resolution) stream when one
|
||||
// is available; otherwise the main (high-resolution) stream is used. A fresh
|
||||
// Latest() cursor is created so muxing resumes from the live edge of the chosen
|
||||
// stream after a switch.
|
||||
func buildHLSSource(config models.Config, communication *models.Communication, useSub bool) hlsStreamSource {
|
||||
cam := config.Capture.IPCamera
|
||||
if useSub && communication.SubQueue != nil {
|
||||
return hlsStreamSource{
|
||||
cursor: communication.SubQueue.Latest(),
|
||||
sps: cam.SubSPSNALUs,
|
||||
pps: cam.SubPPSNALUs,
|
||||
vps: cam.SubVPSNALUs,
|
||||
width: uint16(cam.SubWidth),
|
||||
height: uint16(cam.SubHeight),
|
||||
label: "sub",
|
||||
}
|
||||
}
|
||||
return hlsStreamSource{
|
||||
cursor: communication.Queue.Latest(),
|
||||
sps: cam.SPSNALUs,
|
||||
pps: cam.PPSNALUs,
|
||||
vps: cam.VPSNALUs,
|
||||
width: uint16(cam.Width),
|
||||
height: uint16(cam.Height),
|
||||
label: "main",
|
||||
}
|
||||
}
|
||||
236
machinery/src/cloud/livehls/publisher.go
Normal file
236
machinery/src/cloud/livehls/publisher.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Package livehls implements the agent-side producer for live HLS streaming.
|
||||
//
|
||||
// It complements the recording pipeline: where recordings are muxed into one
|
||||
// fragmented MP4 and uploaded resumably (TUS) when complete, live HLS ships a
|
||||
// continuous series of small, independently-decodable CMAF segments to hub-api
|
||||
// the instant each is produced, so a browser can play a near-live HLS stream
|
||||
// without WebRTC/TURN (outbound HTTPS only).
|
||||
//
|
||||
// The wire contract (agent -> hub-api) intentionally mirrors the existing
|
||||
// header-based storage convention (X-Kerberos-Storage-Device / -FileName, plus
|
||||
// the Hub public/private key auth headers). hub-api authenticates the agent and
|
||||
// stores each segment in an ephemeral, short-TTL live window keyed by
|
||||
// {device}/{session}, which it serves straight back to the browser. The live
|
||||
// window is deliberately kept out of the vault and the recordings collection;
|
||||
// durable archival/DVR is a separate, later concern.
|
||||
//
|
||||
// Unlike recordings, live segments are NOT uploaded resumably: a 1-2s segment
|
||||
// that fails to upload is stale by the time a retry would land, so the publisher
|
||||
// is fire-and-forget and drops on failure (logged) rather than blocking the live
|
||||
// pipeline behind a retry/handshake.
|
||||
package livehls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
const (
|
||||
// liveIngestPath is the hub-api endpoint that accepts a single live segment
|
||||
// (or the init segment) and stores it in the ephemeral live window. hub-api
|
||||
// distinguishes init vs media segment and the object name via the
|
||||
// X-Kerberos-Live-* headers below, keeping a single route (mirrors the
|
||||
// existing /storage/upload convention).
|
||||
liveIngestPath = "/storage/live"
|
||||
|
||||
// Object names within a session. The init segment (ftyp+moov) is uploaded
|
||||
// once per session; media segments are seg-<sequence>.m4s.
|
||||
initObjectName = "init.mp4"
|
||||
|
||||
contentTypeInit = "video/mp4"
|
||||
contentTypeSegment = "video/iso.segment"
|
||||
|
||||
// Header names for the live ingest contract.
|
||||
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
|
||||
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
|
||||
headerHubRegion = "X-Kerberos-Hub-Region"
|
||||
headerStorageDevice = "X-Kerberos-Storage-Device"
|
||||
headerLiveSession = "X-Kerberos-Live-Session"
|
||||
headerLiveName = "X-Kerberos-Live-Name"
|
||||
headerLiveSequence = "X-Kerberos-Live-Sequence"
|
||||
headerLiveDuration = "X-Kerberos-Live-Duration"
|
||||
// Low-latency (LL-HLS) part headers. A part belongs to media segment
|
||||
// X-Kerberos-Live-Sequence and is the X-Kerberos-Live-Part-th chunk within it;
|
||||
// X-Kerberos-Live-Part-Independent flags a part that starts on a keyframe.
|
||||
headerLivePart = "X-Kerberos-Live-Part"
|
||||
headerLivePartIndependent = "X-Kerberos-Live-Part-Independent"
|
||||
|
||||
// defaultPublishTimeout bounds a single segment upload. A live segment that
|
||||
// cannot be delivered within roughly its own duration is stale, so the upload
|
||||
// is abandoned (dropped) rather than allowed to back up the pipeline.
|
||||
defaultPublishTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// PublisherConfig carries the hub endpoint and credentials needed to ship live
|
||||
// segments. It is populated from the agent's models.Config (HubURI/HubKey/...).
|
||||
type PublisherConfig struct {
|
||||
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
|
||||
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
|
||||
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
|
||||
Region string // storage region (X-Kerberos-Hub-Region), may be empty
|
||||
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
|
||||
|
||||
// Timeout optionally overrides defaultPublishTimeout (used by tests).
|
||||
Timeout time.Duration
|
||||
// HTTPClient optionally injects a client (used by tests). When nil a
|
||||
// redirect-credential-stripping client is created.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Publisher ships init and media segments to hub-api over plain HTTP POST.
|
||||
//
|
||||
// It is safe for sequential use from a single live-stream goroutine. Methods are
|
||||
// fire-and-forget: they return an error for the caller to log, but the caller is
|
||||
// expected to continue (drop-on-fail) rather than retry.
|
||||
type Publisher struct {
|
||||
cfg PublisherConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
|
||||
// headers on a cross-host redirect (net/http does this for standard auth headers
|
||||
// but not custom-named ones), matching the recording upload client.
|
||||
func NewPublisher(cfg PublisherConfig) *Publisher {
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultPublishTimeout
|
||||
}
|
||||
client = &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
|
||||
}
|
||||
}
|
||||
return &Publisher{cfg: cfg, client: client}
|
||||
}
|
||||
|
||||
// PublishInit uploads the session's init segment (ftyp+moov). It must be called
|
||||
// (and succeed) before the player can use any media segment, so the caller
|
||||
// should treat a failure here as "session not yet established" and retry on the
|
||||
// next init opportunity rather than shipping media segments blindly.
|
||||
func (p *Publisher) PublishInit(ctx context.Context, sessionID string, data []byte) error {
|
||||
return p.post(ctx, postParams{
|
||||
sessionID: sessionID,
|
||||
name: initObjectName,
|
||||
contentType: contentTypeInit,
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
// PublishSegment uploads one media segment (styp+moof+mdat). The segment's
|
||||
// sequence number and duration travel in headers so hub-api can update the
|
||||
// rolling playlist window without parsing the box structure.
|
||||
func (p *Publisher) PublishSegment(ctx context.Context, sessionID string, seg video.LiveSegment) error {
|
||||
return p.post(ctx, postParams{
|
||||
sessionID: sessionID,
|
||||
name: fmt.Sprintf("seg-%d.m4s", seg.SequenceNumber),
|
||||
sequence: seg.SequenceNumber,
|
||||
durationMs: seg.DurationMs,
|
||||
hasSegment: true,
|
||||
contentType: contentTypeSegment,
|
||||
body: seg.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// PublishPart uploads one CMAF partial segment (LL-HLS). The part is named
|
||||
// seg-<segment>.<part>.m4s and carries its segment sequence, part index,
|
||||
// independence flag and duration in headers so hub-api can advertise it via
|
||||
// #EXT-X-PART and reconstruct the full segment by concatenating its parts.
|
||||
func (p *Publisher) PublishPart(ctx context.Context, sessionID string, part video.LivePart) error {
|
||||
return p.post(ctx, postParams{
|
||||
sessionID: sessionID,
|
||||
name: fmt.Sprintf("seg-%d.%d.m4s", part.SegmentSeq, part.PartIndex),
|
||||
sequence: part.SegmentSeq,
|
||||
durationMs: part.DurationMs,
|
||||
partIndex: part.PartIndex,
|
||||
independent: part.Independent,
|
||||
hasPart: true,
|
||||
contentType: contentTypeSegment,
|
||||
body: part.Data,
|
||||
})
|
||||
}
|
||||
|
||||
type postParams struct {
|
||||
sessionID string
|
||||
name string
|
||||
sequence uint32
|
||||
durationMs uint64
|
||||
hasSegment bool
|
||||
partIndex uint32
|
||||
independent bool
|
||||
hasPart bool
|
||||
contentType string
|
||||
body []byte
|
||||
}
|
||||
|
||||
// post performs a single fire-and-forget upload to the live ingest endpoint.
|
||||
func (p *Publisher) post(ctx context.Context, params postParams) error {
|
||||
if p.cfg.HubURI == "" {
|
||||
return fmt.Errorf("livehls: HubURI not configured")
|
||||
}
|
||||
if params.sessionID == "" {
|
||||
return fmt.Errorf("livehls: empty session id")
|
||||
}
|
||||
|
||||
url := strings.TrimRight(p.cfg.HubURI, "/") + liveIngestPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(params.body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("livehls: build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", params.contentType)
|
||||
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
|
||||
req.Header.Set(headerLiveSession, params.sessionID)
|
||||
req.Header.Set(headerLiveName, params.name)
|
||||
if params.hasSegment || params.hasPart {
|
||||
req.Header.Set(headerLiveSequence, strconv.FormatUint(uint64(params.sequence), 10))
|
||||
req.Header.Set(headerLiveDuration, strconv.FormatUint(params.durationMs, 10))
|
||||
}
|
||||
if params.hasPart {
|
||||
req.Header.Set(headerLivePart, strconv.FormatUint(uint64(params.partIndex), 10))
|
||||
independent := "0"
|
||||
if params.independent {
|
||||
independent = "1"
|
||||
}
|
||||
req.Header.Set(headerLivePartIndependent, independent)
|
||||
}
|
||||
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
|
||||
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
|
||||
req.Header.Set(headerHubRegion, p.cfg.Region)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("livehls: upload %s: %w", params.name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("livehls: upload %s rejected: %s", params.name, resp.Status)
|
||||
}
|
||||
log.Log.Debug("livehls.Publisher.post(): shipped " + params.name + " for session " + params.sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
|
||||
// a redirect crosses to a different host. net/http strips standard sensitive
|
||||
// headers on a cross-host redirect but not custom-named ones, so without this the
|
||||
// Hub keys could leak to a redirect target.
|
||||
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del(headerHubPrivateKey)
|
||||
req.Header.Del(headerHubPublicKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
312
machinery/src/cloud/livehls/publisher_test.go
Normal file
312
machinery/src/cloud/livehls/publisher_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package livehls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
// captured records one received upload for assertions.
|
||||
type captured struct {
|
||||
path string
|
||||
method string
|
||||
contentType string
|
||||
device string
|
||||
session string
|
||||
name string
|
||||
sequence string
|
||||
duration string
|
||||
hubPublic string
|
||||
hubPrivate string
|
||||
region string
|
||||
body []byte
|
||||
}
|
||||
|
||||
// newCapturingServer returns an httptest server that records every upload and
|
||||
// replies with the given status code.
|
||||
func newCapturingServer(t *testing.T, status int) (*httptest.Server, *[]captured, *sync.Mutex) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var got []captured
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
got = append(got, captured{
|
||||
path: r.URL.Path,
|
||||
method: r.Method,
|
||||
contentType: r.Header.Get("Content-Type"),
|
||||
device: r.Header.Get(headerStorageDevice),
|
||||
session: r.Header.Get(headerLiveSession),
|
||||
name: r.Header.Get(headerLiveName),
|
||||
sequence: r.Header.Get(headerLiveSequence),
|
||||
duration: r.Header.Get(headerLiveDuration),
|
||||
hubPublic: r.Header.Get(headerHubPublicKey),
|
||||
hubPrivate: r.Header.Get(headerHubPrivateKey),
|
||||
region: r.Header.Get(headerHubRegion),
|
||||
body: body,
|
||||
})
|
||||
mu.Unlock()
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &got, &mu
|
||||
}
|
||||
|
||||
func testPublisher(hubURI string) *Publisher {
|
||||
return NewPublisher(PublisherConfig{
|
||||
HubURI: hubURI,
|
||||
HubKey: "pub-key",
|
||||
HubPrivateKey: "priv-key",
|
||||
Region: "eu-west",
|
||||
DeviceKey: "cam-1",
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublisherPublishInitSendsContractHeaders(t *testing.T) {
|
||||
srv, got, mu := newCapturingServer(t, http.StatusOK)
|
||||
p := testPublisher(srv.URL)
|
||||
|
||||
if err := p.PublishInit(context.Background(), "sess-1", []byte("INITBYTES")); err != nil {
|
||||
t.Fatalf("PublishInit: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(*got) != 1 {
|
||||
t.Fatalf("server received %d requests, want 1", len(*got))
|
||||
}
|
||||
c := (*got)[0]
|
||||
if c.method != http.MethodPost {
|
||||
t.Errorf("method=%s, want POST", c.method)
|
||||
}
|
||||
if c.path != liveIngestPath {
|
||||
t.Errorf("path=%s, want %s", c.path, liveIngestPath)
|
||||
}
|
||||
if c.contentType != contentTypeInit {
|
||||
t.Errorf("content-type=%s, want %s", c.contentType, contentTypeInit)
|
||||
}
|
||||
if c.device != "cam-1" {
|
||||
t.Errorf("device=%s, want cam-1", c.device)
|
||||
}
|
||||
if c.session != "sess-1" {
|
||||
t.Errorf("session=%s, want sess-1", c.session)
|
||||
}
|
||||
if c.name != initObjectName {
|
||||
t.Errorf("name=%s, want %s", c.name, initObjectName)
|
||||
}
|
||||
if c.hubPublic != "pub-key" || c.hubPrivate != "priv-key" || c.region != "eu-west" {
|
||||
t.Errorf("auth headers wrong: pub=%q priv=%q region=%q", c.hubPublic, c.hubPrivate, c.region)
|
||||
}
|
||||
if string(c.body) != "INITBYTES" {
|
||||
t.Errorf("body=%q, want INITBYTES", string(c.body))
|
||||
}
|
||||
// init must NOT carry segment-only headers.
|
||||
if c.sequence != "" || c.duration != "" {
|
||||
t.Errorf("init should not send sequence/duration, got seq=%q dur=%q", c.sequence, c.duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublisherPublishSegmentSendsSequenceAndDuration(t *testing.T) {
|
||||
srv, got, mu := newCapturingServer(t, http.StatusOK)
|
||||
p := testPublisher(srv.URL)
|
||||
|
||||
seg := video.LiveSegment{SequenceNumber: 7, DurationMs: 1960, Data: []byte("SEGMENT")}
|
||||
if err := p.PublishSegment(context.Background(), "sess-9", seg); err != nil {
|
||||
t.Fatalf("PublishSegment: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
c := (*got)[0]
|
||||
if c.contentType != contentTypeSegment {
|
||||
t.Errorf("content-type=%s, want %s", c.contentType, contentTypeSegment)
|
||||
}
|
||||
if c.name != "seg-7.m4s" {
|
||||
t.Errorf("name=%s, want seg-7.m4s", c.name)
|
||||
}
|
||||
if c.sequence != "7" {
|
||||
t.Errorf("sequence=%s, want 7", c.sequence)
|
||||
}
|
||||
if c.duration != "1960" {
|
||||
t.Errorf("duration=%s, want 1960", c.duration)
|
||||
}
|
||||
if string(c.body) != "SEGMENT" {
|
||||
t.Errorf("body=%q, want SEGMENT", string(c.body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublisherReturnsErrorOnNon2xx(t *testing.T) {
|
||||
srv, _, _ := newCapturingServer(t, http.StatusInternalServerError)
|
||||
p := testPublisher(srv.URL)
|
||||
|
||||
err := p.PublishSegment(context.Background(), "s", video.LiveSegment{SequenceNumber: 1, Data: []byte("x")})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error on 500 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublisherErrorsWithoutHubURI(t *testing.T) {
|
||||
p := NewPublisher(PublisherConfig{DeviceKey: "cam"})
|
||||
if err := p.PublishInit(context.Background(), "s", []byte("x")); err == nil {
|
||||
t.Fatal("expected error when HubURI is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// makeAnnexBVideoPacket builds a synthetic capture packet carrying one Annex B
|
||||
// H.264 access unit at the given decode time (ms).
|
||||
func makeAnnexBVideoPacket(isKey bool, timeMs int64) packets.Packet {
|
||||
nalType := byte(0x01)
|
||||
if isKey {
|
||||
nalType = 0x65
|
||||
}
|
||||
data := []byte{0x00, 0x00, 0x00, 0x01, nalType}
|
||||
for i := 0; i < 80; i++ {
|
||||
data = append(data, byte(i))
|
||||
}
|
||||
return packets.Packet{
|
||||
IsVideo: true,
|
||||
IsKeyFrame: isKey,
|
||||
Codec: "H264",
|
||||
Data: data,
|
||||
TimeLegacy: time.Duration(timeMs) * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionShipsInitThenSegmentsAndFiresReady(t *testing.T) {
|
||||
srv, got, mu := newCapturingServer(t, http.StatusOK)
|
||||
p := testPublisher(srv.URL)
|
||||
|
||||
sess := NewSession(p, SessionOptions{
|
||||
Codec: "H264",
|
||||
SPSNALUs: [][]byte{liveTestSPSForSession()},
|
||||
PPSNALUs: [][]byte{{0x68, 0xce, 0x38, 0x80}},
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
TargetSegmentMs: 2000,
|
||||
})
|
||||
|
||||
var readyCalls int
|
||||
var readySession string
|
||||
sess.SetOnReady(func(id string) {
|
||||
readyCalls++
|
||||
readySession = id
|
||||
})
|
||||
|
||||
// 4 GOPs of 25 frames @ 40ms = 1s GOPs => with 2s target, 2 segments emitted
|
||||
// during streaming and a final one on Close.
|
||||
const gopFrames, gops = 25, 4
|
||||
for i := 0; i < gopFrames*gops; i++ {
|
||||
isKey := i%gopFrames == 0
|
||||
pkt := makeAnnexBVideoPacket(isKey, int64(i*40))
|
||||
if err := sess.WritePacket(pkt); err != nil {
|
||||
t.Fatalf("WritePacket(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
// A non-video packet must be ignored.
|
||||
if err := sess.WritePacket(packets.Packet{IsAudio: true, Data: []byte{1, 2, 3}}); err != nil {
|
||||
t.Fatalf("WritePacket(audio): %v", err)
|
||||
}
|
||||
if err := sess.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var initCount, segCount int
|
||||
for _, c := range *got {
|
||||
if c.name == initObjectName {
|
||||
initCount++
|
||||
if string(c.body[4:8]) != "ftyp" {
|
||||
t.Errorf("init body is not an ftyp box: % x", c.body[:12])
|
||||
}
|
||||
} else {
|
||||
segCount++
|
||||
if c.session != sess.SessionID() {
|
||||
t.Errorf("segment session=%s, want %s", c.session, sess.SessionID())
|
||||
}
|
||||
}
|
||||
}
|
||||
if initCount != 1 {
|
||||
t.Errorf("init uploaded %d times, want exactly 1", initCount)
|
||||
}
|
||||
if segCount < 2 {
|
||||
t.Errorf("got %d segment uploads, want >= 2", segCount)
|
||||
}
|
||||
if readyCalls != 1 {
|
||||
t.Errorf("OnReady fired %d times, want exactly 1", readyCalls)
|
||||
}
|
||||
if readySession != sess.SessionID() {
|
||||
t.Errorf("OnReady session=%s, want %s", readySession, sess.SessionID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRetriesInitWhenFirstAttemptFails(t *testing.T) {
|
||||
// Server fails the first N requests, then succeeds. This proves init is
|
||||
// re-attempted (not dropped) so the session can still establish.
|
||||
var mu sync.Mutex
|
||||
var inits, segs int
|
||||
failFirst := 1
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
name := r.Header.Get(headerLiveName)
|
||||
if name == initObjectName {
|
||||
inits++
|
||||
if inits <= failFirst {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
segs++
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
sess := NewSession(testPublisher(srv.URL), SessionOptions{
|
||||
Codec: "H264",
|
||||
SPSNALUs: [][]byte{liveTestSPSForSession()},
|
||||
PPSNALUs: [][]byte{{0x68, 0xce, 0x38, 0x80}},
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
})
|
||||
|
||||
var ready int
|
||||
sess.SetOnReady(func(string) { ready++ })
|
||||
|
||||
for i := 0; i < 60; i++ {
|
||||
isKey := i%25 == 0
|
||||
if err := sess.WritePacket(makeAnnexBVideoPacket(isKey, int64(i*40))); err != nil {
|
||||
t.Fatalf("WritePacket(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := sess.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if inits < 2 {
|
||||
t.Errorf("init attempted %d times, want >= 2 (first failed then retried)", inits)
|
||||
}
|
||||
if segs < 1 {
|
||||
t.Errorf("no segments delivered after init recovered (segs=%d)", segs)
|
||||
}
|
||||
if ready != 1 {
|
||||
t.Errorf("OnReady fired %d times, want 1", ready)
|
||||
}
|
||||
}
|
||||
|
||||
// liveTestSPSForSession is the known-good baseline SPS reused across tests.
|
||||
func liveTestSPSForSession() []byte {
|
||||
return []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
|
||||
}
|
||||
457
machinery/src/cloud/livehls/session.go
Normal file
457
machinery/src/cloud/livehls/session.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package livehls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
// DefaultTargetSegmentMs is the nominal live segment length. ~2s keeps standard
|
||||
// HLS latency reasonable (a player typically buffers ~3 segments) while staying
|
||||
// large enough that per-segment HTTP overhead is negligible.
|
||||
const DefaultTargetSegmentMs = 2000
|
||||
|
||||
// DefaultPartTargetMs is the nominal LL-HLS part length used when low latency is
|
||||
// enabled. ~300ms parts yield ~6-7 parts per 2s segment; with the playlist's
|
||||
// PART-HOLD-BACK at ~3x the part target this lands glass-to-glass latency around
|
||||
// 1-2s (versus ~4-6s for whole-segment HLS).
|
||||
const DefaultPartTargetMs = 300
|
||||
|
||||
// Session ties a video.LiveSegmenter to a Publisher: it converts capture packets
|
||||
// into CMAF segments and ships each one to hub-api. Exactly one init segment is
|
||||
// delivered per session (re-attempted until it lands), after which media
|
||||
// segments are published and the OnReady signal fires once so the control plane
|
||||
// (MQTT) can tell viewers the live playlist exists.
|
||||
//
|
||||
// A Session is driven from a single goroutine (the live-stream loop); its methods
|
||||
// are not safe for concurrent use except SessionID, which is immutable.
|
||||
type Session struct {
|
||||
id string
|
||||
publisher *Publisher
|
||||
segmenter *video.LiveSegmenter
|
||||
|
||||
// newContext produces the per-upload context (timeout). Overridable in tests.
|
||||
newContext func() (context.Context, context.CancelFunc)
|
||||
|
||||
mu sync.Mutex
|
||||
initBytes []byte
|
||||
initPublished bool
|
||||
// lastInitAt is when the init segment was last (re)uploaded. The init is
|
||||
// re-sent periodically so its short TTL in the hub live window never lapses
|
||||
// mid-session; see refreshInitIfStale.
|
||||
lastInitAt time.Time
|
||||
readyFired bool
|
||||
onReady func(sessionID string)
|
||||
|
||||
// uploadsActive gates whether the init and completed segments are shipped to
|
||||
// hub-api. It is true for the default on-demand path. The prewarm path starts
|
||||
// it false so the session keeps muxing into bufferedSegments without producing
|
||||
// any live traffic until a viewer actually arrives; see SetUploadsActive.
|
||||
uploadsActive bool
|
||||
// bufferedSegments is the in-memory ring buffer (the most recent
|
||||
// prewarmMaxBufferedSegments segments) kept while uploadsActive is false, so a
|
||||
// viewer that arrives can be served an already-encoded segment immediately
|
||||
// instead of waiting a full GOP for the next one to be cut.
|
||||
bufferedSegments []video.LiveSegment
|
||||
// bufferedParts is the LL-HLS counterpart of bufferedSegments: while idle it
|
||||
// retains the parts of the most recent (prewarmMaxBufferedSegments+1) segments,
|
||||
// pruned a WHOLE segment at a time so a flushed segment is never partial.
|
||||
bufferedParts []video.LivePart
|
||||
}
|
||||
|
||||
// SessionOptions configures a live HLS session.
|
||||
type SessionOptions struct {
|
||||
Codec string // "H264" or "H265"
|
||||
SPSNALUs [][]byte // parameter sets (raw or Annex B)
|
||||
PPSNALUs [][]byte //
|
||||
VPSNALUs [][]byte // H.265 only
|
||||
Width uint16 // encoded width (for the avcC fallback path)
|
||||
Height uint16 // encoded height
|
||||
TargetSegmentMs uint64 // 0 => DefaultTargetSegmentMs
|
||||
// PartTargetMs, when > 0, enables LL-HLS: each segment is additionally sliced
|
||||
// into ~PartTargetMs CMAF parts that are published (and advertised via
|
||||
// #EXT-X-PART) the instant they close, for ~1-2s glass-to-glass latency. 0
|
||||
// keeps the classic whole-segment path.
|
||||
PartTargetMs uint64
|
||||
// StartBuffering starts the session in prewarm (buffer-only) mode: it muxes
|
||||
// segments into an in-memory ring buffer but uploads nothing until
|
||||
// SetUploadsActive(true) is called. Default false => uploads are live
|
||||
// immediately (the on-demand path's behaviour).
|
||||
StartBuffering bool
|
||||
}
|
||||
|
||||
// NewSession builds a session with a fresh random id and wires the segmenter's
|
||||
// init/segment callbacks to the publisher.
|
||||
func NewSession(publisher *Publisher, opts SessionOptions) *Session {
|
||||
target := opts.TargetSegmentMs
|
||||
if target == 0 {
|
||||
target = DefaultTargetSegmentMs
|
||||
}
|
||||
seg := video.NewLiveSegmenter(opts.Codec, opts.SPSNALUs, opts.PPSNALUs, opts.VPSNALUs, target)
|
||||
seg.SetDimensions(opts.Width, opts.Height)
|
||||
if opts.PartTargetMs > 0 {
|
||||
seg.EnableLowLatency(opts.PartTargetMs)
|
||||
}
|
||||
|
||||
s := &Session{
|
||||
id: newSessionID(),
|
||||
publisher: publisher,
|
||||
segmenter: seg,
|
||||
// Uploads are live by default; the prewarm path opts into buffer-only mode.
|
||||
uploadsActive: !opts.StartBuffering,
|
||||
newContext: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), defaultPublishTimeout)
|
||||
},
|
||||
}
|
||||
|
||||
// The segmenter emits the init segment exactly once; capture it and try to
|
||||
// ship it. Failures here are non-fatal - publishInitIfNeeded re-attempts
|
||||
// before the next media segment so a transient hub hiccup at startup does not
|
||||
// permanently break the session.
|
||||
seg.OnInit = func(initBytes []byte) error {
|
||||
s.mu.Lock()
|
||||
s.initBytes = append([]byte(nil), initBytes...)
|
||||
active := s.uploadsActive
|
||||
s.mu.Unlock()
|
||||
// While prewarming we cache the init in memory but ship nothing; it is
|
||||
// uploaded on the first SetUploadsActive(true) flush.
|
||||
if active {
|
||||
s.publishInitIfNeeded()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Each completed media segment is shipped. We only publish a segment once the
|
||||
// init segment has landed (a media segment is useless without it), and we fire
|
||||
// OnReady after the first successfully shipped segment.
|
||||
seg.OnSegment = func(segment video.LiveSegment) error {
|
||||
s.mu.Lock()
|
||||
active := s.uploadsActive
|
||||
s.mu.Unlock()
|
||||
if !active {
|
||||
// Prewarm: retain the most recent segments in memory but upload nothing
|
||||
// until a viewer arrives (SetUploadsActive flushes them).
|
||||
s.bufferSegment(segment)
|
||||
return nil
|
||||
}
|
||||
if !s.publishInitIfNeeded() {
|
||||
log.Log.Warning("livehls.Session: dropping segment " +
|
||||
fmt.Sprintf("%d", segment.SequenceNumber) + " because init has not been delivered yet")
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := s.newContext()
|
||||
defer cancel()
|
||||
if err := s.publisher.PublishSegment(ctx, s.id, segment); err != nil {
|
||||
log.Log.Warning("livehls.Session: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
s.fireReadyOnce()
|
||||
// Keep the (write-once) init segment from ageing out of the live window
|
||||
// while the session is still producing media.
|
||||
s.refreshInitIfStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// In LL-HLS mode the segmenter emits parts (not whole segments); ship each one
|
||||
// the instant it closes. Mirrors OnSegment: buffer while prewarming, otherwise
|
||||
// publish after the init has landed and fire OnReady on the first part.
|
||||
if opts.PartTargetMs > 0 {
|
||||
seg.OnPart = func(part video.LivePart) error {
|
||||
s.mu.Lock()
|
||||
active := s.uploadsActive
|
||||
s.mu.Unlock()
|
||||
if !active {
|
||||
s.bufferPart(part)
|
||||
return nil
|
||||
}
|
||||
if !s.publishInitIfNeeded() {
|
||||
log.Log.Warning("livehls.Session: dropping part " +
|
||||
fmt.Sprintf("%d.%d", part.SegmentSeq, part.PartIndex) +
|
||||
" because init has not been delivered yet")
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := s.newContext()
|
||||
defer cancel()
|
||||
if err := s.publisher.PublishPart(ctx, s.id, part); err != nil {
|
||||
log.Log.Warning("livehls.Session: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
s.fireReadyOnce()
|
||||
s.refreshInitIfStale()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// SessionID returns the immutable session identifier used in object keys and the
|
||||
// MQTT ready signal.
|
||||
func (s *Session) SessionID() string { return s.id }
|
||||
|
||||
// IsReady reports whether the session has delivered its init segment and at
|
||||
// least one media segment, i.e. the playlist hub-api serves is now playable. It
|
||||
// lets the live-stream loop re-announce "receive-hls-ready" to viewers that join
|
||||
// or hard-refresh after the initial one-shot signal (which they would otherwise
|
||||
// never receive, leaving the stream blank until the session is recreated).
|
||||
func (s *Session) IsReady() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readyFired
|
||||
}
|
||||
|
||||
// SetOnReady registers a callback fired exactly once, after the first media
|
||||
// segment has been successfully delivered. Used to publish the MQTT
|
||||
// "receive-hls-ready" signal so viewers can load the playlist.
|
||||
func (s *Session) SetOnReady(fn func(sessionID string)) {
|
||||
s.mu.Lock()
|
||||
s.onReady = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// prewarmMaxBufferedSegments is how many of the most recent completed segments
|
||||
// the prewarm path keeps in memory while idle and flushes to a viewer on arrival.
|
||||
// One segment keeps startup instant (the viewer immediately gets a playable
|
||||
// segment) while starting as close to the live edge as possible, so the HLS view
|
||||
// tracks the WebRTC/live edge instead of opening several seconds behind; hls.js
|
||||
// then converges to the edge via maxLiveSyncPlaybackRate. Raising it trades
|
||||
// latency-from-live for a little more startup cushion.
|
||||
const prewarmMaxBufferedSegments = 1
|
||||
|
||||
// SetUploadsActive toggles whether the session ships its init and segments to
|
||||
// hub-api, and reports whether this call flipped it from inactive to active.
|
||||
//
|
||||
// While uploads are inactive the session keeps muxing capture packets into an
|
||||
// in-memory ring buffer (the cached init plus the most recent
|
||||
// prewarmMaxBufferedSegments segments) but uploads nothing, so an idle camera
|
||||
// produces no live traffic. Switching from inactive to active immediately
|
||||
// flushes the cached init and buffered segments so a viewer can start almost
|
||||
// instantly instead of waiting a full GOP for the next segment to be cut.
|
||||
// Switching from active to inactive resets the init-published flag so the next
|
||||
// activation re-uploads the init (it may have aged out of the hub's short-TTL
|
||||
// live window while idle). All other transitions are no-ops. Driven from the
|
||||
// live-stream goroutine; not safe for concurrent use.
|
||||
func (s *Session) SetUploadsActive(active bool) bool {
|
||||
s.mu.Lock()
|
||||
if s.uploadsActive == active {
|
||||
s.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
s.uploadsActive = active
|
||||
if !active {
|
||||
// Going idle: force the next activation to re-deliver the init segment,
|
||||
// which may have expired from the hub live window while nobody was watching.
|
||||
s.initPublished = false
|
||||
s.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
// Inactive -> active: take the cached buffered segments/parts and flush them
|
||||
// outside the lock (the publish calls take their own time and re-acquire the
|
||||
// mutex).
|
||||
buffered := s.bufferedSegments
|
||||
bufferedParts := s.bufferedParts
|
||||
s.bufferedSegments = nil
|
||||
s.bufferedParts = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
// Deliver the init first; media segments are useless without it.
|
||||
for i := range buffered {
|
||||
if !s.publishInitIfNeeded() {
|
||||
break
|
||||
}
|
||||
ctx, cancel := s.newContext()
|
||||
if err := s.publisher.PublishSegment(ctx, s.id, buffered[i]); err != nil {
|
||||
log.Log.Warning("livehls.Session: prewarm flush: " + err.Error())
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
cancel()
|
||||
s.fireReadyOnce()
|
||||
s.refreshInitIfStale()
|
||||
}
|
||||
// LL-HLS: flush the buffered parts in order (oldest first) so the viewer gets a
|
||||
// playable, near-live window immediately.
|
||||
for i := range bufferedParts {
|
||||
if !s.publishInitIfNeeded() {
|
||||
break
|
||||
}
|
||||
ctx, cancel := s.newContext()
|
||||
if err := s.publisher.PublishPart(ctx, s.id, bufferedParts[i]); err != nil {
|
||||
log.Log.Warning("livehls.Session: prewarm flush (part): " + err.Error())
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
cancel()
|
||||
s.fireReadyOnce()
|
||||
s.refreshInitIfStale()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UploadsActive reports whether the session is currently shipping segments (as
|
||||
// opposed to buffering them while prewarming). Always true for the on-demand
|
||||
// path.
|
||||
func (s *Session) UploadsActive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.uploadsActive
|
||||
}
|
||||
|
||||
// bufferSegment appends a completed segment to the in-memory prewarm ring buffer,
|
||||
// discarding the oldest so at most prewarmMaxBufferedSegments are retained.
|
||||
func (s *Session) bufferSegment(seg video.LiveSegment) {
|
||||
s.mu.Lock()
|
||||
s.bufferedSegments = append(s.bufferedSegments, seg)
|
||||
if overflow := len(s.bufferedSegments) - prewarmMaxBufferedSegments; overflow > 0 {
|
||||
// Drop the oldest segment(s) and shrink the backing array so retained bytes
|
||||
// stay bounded.
|
||||
s.bufferedSegments = append([]video.LiveSegment(nil), s.bufferedSegments[overflow:]...)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// bufferPart appends a part to the LL-HLS prewarm ring buffer, pruning whole
|
||||
// older segments (never individual parts) so the retained window always consists
|
||||
// of complete segments plus the in-progress one. Pruning on a part-0 boundary
|
||||
// keeps at most prewarmMaxBufferedSegments fully-buffered segments behind the
|
||||
// current one, which guarantees a flushed segment can be reconstructed in full.
|
||||
func (s *Session) bufferPart(part video.LivePart) {
|
||||
s.mu.Lock()
|
||||
s.bufferedParts = append(s.bufferedParts, part)
|
||||
if part.PartIndex == 0 && part.SegmentSeq > uint32(prewarmMaxBufferedSegments) {
|
||||
minSeg := part.SegmentSeq - uint32(prewarmMaxBufferedSegments)
|
||||
kept := make([]video.LivePart, 0, len(s.bufferedParts))
|
||||
for _, p := range s.bufferedParts {
|
||||
if p.SegmentSeq >= minSeg {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
s.bufferedParts = kept
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// WritePacket feeds one capture packet into the segmenter. Non-video packets are
|
||||
// ignored (the spike is video-only). The decode timestamp is derived exactly as
|
||||
// the recording muxer does: DTS = PTS - compositionOffset, with the composition
|
||||
// offset forwarded for correct B-frame presentation order.
|
||||
func (s *Session) WritePacket(pkt packets.Packet) error {
|
||||
if !pkt.IsVideo {
|
||||
return nil
|
||||
}
|
||||
pts := uint64(pkt.TimeLegacy.Milliseconds())
|
||||
compositionOffset := pkt.CompositionTime
|
||||
dts := pts
|
||||
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
|
||||
dts = pts - uint64(compositionOffset)
|
||||
} else if compositionOffset < 0 || uint64(compositionOffset) > pts {
|
||||
// Guard against invalid offsets to avoid producing a CTS (DTS+CTO) jump.
|
||||
compositionOffset = 0
|
||||
}
|
||||
return s.segmenter.WriteSample(pkt.IsKeyFrame, pkt.Data, dts, int32(compositionOffset))
|
||||
}
|
||||
|
||||
// Close flushes any buffered sample and ships the final segment.
|
||||
func (s *Session) Close() error {
|
||||
return s.segmenter.Close()
|
||||
}
|
||||
|
||||
// publishInitIfNeeded ensures the init segment has been delivered, attempting an
|
||||
// upload if it has not. Returns true once init is known to be published.
|
||||
func (s *Session) publishInitIfNeeded() bool {
|
||||
s.mu.Lock()
|
||||
if s.initPublished {
|
||||
s.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
initBytes := s.initBytes
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(initBytes) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := s.newContext()
|
||||
defer cancel()
|
||||
if err := s.publisher.PublishInit(ctx, s.id, initBytes); err != nil {
|
||||
log.Log.Warning("livehls.Session: init upload failed, will retry: " + err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.initPublished = true
|
||||
s.lastInitAt = time.Now()
|
||||
s.mu.Unlock()
|
||||
log.Log.Info("livehls.Session: init segment delivered for session " + s.id)
|
||||
return true
|
||||
}
|
||||
|
||||
// initRefreshInterval is how often the init segment is re-uploaded so its TTL in
|
||||
// the hub-api live window never lapses mid-session. The init segment is otherwise
|
||||
// written only once per session; because the live window expires objects after a
|
||||
// short TTL (LiveSegmentTTLSeconds, 45s on the hub) the init would age out after
|
||||
// ~1 minute and the playlist's #EXT-X-MAP would start 404ing, stalling playback.
|
||||
// Re-uploading well inside that TTL keeps the init alive for the life of the
|
||||
// session while still letting it expire naturally once the session ends.
|
||||
const initRefreshInterval = 15 * time.Second
|
||||
|
||||
// refreshInitIfStale re-uploads the init segment if it has not been refreshed
|
||||
// within initRefreshInterval, keeping its created_at (and thus its TTL) current
|
||||
// for as long as the session is producing segments. It is a no-op until the init
|
||||
// has first been published. Failures are non-fatal: the next segment retries.
|
||||
func (s *Session) refreshInitIfStale() {
|
||||
s.mu.Lock()
|
||||
if !s.initPublished || time.Since(s.lastInitAt) < initRefreshInterval {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
initBytes := s.initBytes
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(initBytes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := s.newContext()
|
||||
defer cancel()
|
||||
if err := s.publisher.PublishInit(ctx, s.id, initBytes); err != nil {
|
||||
log.Log.Warning("livehls.Session: init refresh failed, will retry: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.lastInitAt = time.Now()
|
||||
s.mu.Unlock()
|
||||
log.Log.Debug("livehls.Session: refreshed init segment TTL for session " + s.id)
|
||||
}
|
||||
|
||||
// fireReadyOnce invokes the OnReady callback the first time it is called.
|
||||
func (s *Session) fireReadyOnce() {
|
||||
s.mu.Lock()
|
||||
if s.readyFired || s.onReady == nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.readyFired = true
|
||||
fn := s.onReady
|
||||
s.mu.Unlock()
|
||||
fn(s.id)
|
||||
}
|
||||
|
||||
// newSessionID returns a short, unique, URL-safe session identifier of the form
|
||||
// <unix-seconds>-<random-hex>.
|
||||
func newSessionID() string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// rand.Read essentially never fails; fall back to a time-only id.
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("%d-%s", time.Now().Unix(), hex.EncodeToString(b))
|
||||
}
|
||||
103
machinery/src/cloud/livemoq/annexb.go
Normal file
103
machinery/src/cloud/livemoq/annexb.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
var annexBStartCode = []byte{0x00, 0x00, 0x00, 0x01}
|
||||
|
||||
// H264NormalizationStats reports malformed duplication removed from an access unit.
|
||||
type H264NormalizationStats struct {
|
||||
DuplicateIDRNALUs int
|
||||
}
|
||||
|
||||
// EnsureAnnexB restores the start code stripped by the Agent capture queue.
|
||||
func EnsureAnnexB(payload []byte) []byte {
|
||||
if hasAnnexBStartCode(payload) {
|
||||
return payload
|
||||
}
|
||||
|
||||
framed := make([]byte, 0, len(annexBStartCode)+len(payload))
|
||||
framed = append(framed, annexBStartCode...)
|
||||
return append(framed, payload...)
|
||||
}
|
||||
|
||||
// NormalizeH264AccessUnit removes delimiters and exact duplicate parameter-set
|
||||
// or IDR NALUs that can confuse older MoQ splitters and decoders.
|
||||
func NormalizeH264AccessUnit(payload []byte) ([]byte, error) {
|
||||
normalized, _, err := NormalizeH264AccessUnitWithStats(payload)
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
// NormalizeH264AccessUnitWithStats also reports exact duplicate IDR NALUs.
|
||||
func NormalizeH264AccessUnitWithStats(payload []byte) ([]byte, H264NormalizationStats, error) {
|
||||
nalus, err := h264.AnnexBUnmarshal(EnsureAnnexB(payload))
|
||||
if err != nil {
|
||||
return nil, H264NormalizationStats{}, err
|
||||
}
|
||||
|
||||
stats := H264NormalizationStats{}
|
||||
normalized := make([][]byte, 0, len(nalus))
|
||||
for _, nalu := range nalus {
|
||||
if len(nalu) == 0 || nalu[0]&0x1f == 9 {
|
||||
continue
|
||||
}
|
||||
naluType := nalu[0] & 0x1f
|
||||
if naluType == 7 || naluType == 8 || naluType == 5 {
|
||||
duplicate := false
|
||||
for _, existing := range normalized {
|
||||
if bytes.Equal(existing, nalu) {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if duplicate {
|
||||
if naluType == 5 {
|
||||
stats.DuplicateIDRNALUs++
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
normalized = append(normalized, nalu)
|
||||
}
|
||||
|
||||
result, err := h264.AnnexBMarshal(normalized)
|
||||
return result, stats, err
|
||||
}
|
||||
|
||||
// BroadcastPath returns the relay path a quality tier is published on. Every
|
||||
// tier gets its own broadcast so a viewer switches between the camera's main and
|
||||
// sub stream by resubscribing to another path, without any control channel back
|
||||
// to the Agent. The high tier keeps the historical ".../live.hang" path so
|
||||
// existing viewers keep working; the low tier lives next to it on
|
||||
// ".../live-low.hang".
|
||||
func BroadcastPath(prefix string, deviceKey string, quality string) string {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix == "" {
|
||||
prefix = "devices"
|
||||
}
|
||||
name := "live.hang"
|
||||
if quality == models.StreamQualityLow {
|
||||
name = "live-low.hang"
|
||||
}
|
||||
return prefix + "/" + strings.Trim(deviceKey, "/") + "/" + name
|
||||
}
|
||||
|
||||
// TimestampUs converts the capture presentation timestamp from milliseconds.
|
||||
// CompositionTime must not be added: it is already represented in the PTS and
|
||||
// is only used by muxers to derive DTS for streams containing B-frames.
|
||||
func TimestampUs(presentationTimeMs int64) uint64 {
|
||||
if presentationTimeMs < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(presentationTimeMs) * 1000
|
||||
}
|
||||
|
||||
func hasAnnexBStartCode(payload []byte) bool {
|
||||
return len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 &&
|
||||
((payload[2] == 0 && payload[3] == 1) || payload[2] == 1)
|
||||
}
|
||||
116
machinery/src/cloud/livemoq/annexb_test.go
Normal file
116
machinery/src/cloud/livemoq/annexb_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestEnsureAnnexB(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
want []byte
|
||||
}{
|
||||
{
|
||||
name: "missing start code",
|
||||
payload: []byte{0x41, 0x01},
|
||||
want: []byte{0x00, 0x00, 0x00, 0x01, 0x41, 0x01},
|
||||
},
|
||||
{
|
||||
name: "four byte start code",
|
||||
payload: []byte{0x00, 0x00, 0x00, 0x01, 0x65},
|
||||
want: []byte{0x00, 0x00, 0x00, 0x01, 0x65},
|
||||
},
|
||||
{
|
||||
name: "three byte start code",
|
||||
payload: []byte{0x00, 0x00, 0x01, 0x41},
|
||||
want: []byte{0x00, 0x00, 0x01, 0x41},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := EnsureAnnexB(test.payload); !bytes.Equal(got, test.want) {
|
||||
t.Fatalf("EnsureAnnexB() = %x, want %x", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeH264AccessUnit(t *testing.T) {
|
||||
startCode := []byte{0x00, 0x00, 0x00, 0x01}
|
||||
sps := []byte{0x67, 0x42, 0x00, 0x1f}
|
||||
pps := []byte{0x68, 0xce, 0x06, 0xe2}
|
||||
aud := []byte{0x09, 0xf0}
|
||||
idr := []byte{0x65, 0x88, 0x84}
|
||||
|
||||
payload := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{sps, pps, aud, sps, pps, idr} {
|
||||
payload = append(payload, startCode...)
|
||||
payload = append(payload, nalu...)
|
||||
}
|
||||
|
||||
got, err := NormalizeH264AccessUnit(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{sps, pps, idr} {
|
||||
want = append(want, startCode...)
|
||||
want = append(want, nalu...)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("NormalizeH264AccessUnit() = %x, want %x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeH264AccessUnitRemovesOnlyExactDuplicateIDRSlices(t *testing.T) {
|
||||
startCode := []byte{0x00, 0x00, 0x00, 0x01}
|
||||
idrSlice1 := []byte{0x65, 0x88, 0x84}
|
||||
idrSlice2 := []byte{0x65, 0x44, 0x22}
|
||||
|
||||
payload := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{idrSlice1, idrSlice1, idrSlice2} {
|
||||
payload = append(payload, startCode...)
|
||||
payload = append(payload, nalu...)
|
||||
}
|
||||
|
||||
got, stats, err := NormalizeH264AccessUnitWithStats(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{idrSlice1, idrSlice2} {
|
||||
want = append(want, startCode...)
|
||||
want = append(want, nalu...)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("NormalizeH264AccessUnitWithStats() = %x, want %x", got, want)
|
||||
}
|
||||
if stats.DuplicateIDRNALUs != 1 {
|
||||
t.Fatalf("DuplicateIDRNALUs = %d, want 1", stats.DuplicateIDRNALUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastPath(t *testing.T) {
|
||||
if got := BroadcastPath("/devices/", "/camera-1/", models.StreamQualityHigh); got != "devices/camera-1/live.hang" {
|
||||
t.Fatalf("BroadcastPath() high = %q", got)
|
||||
}
|
||||
if got := BroadcastPath("", "camera-1", models.StreamQualityHigh); got != "devices/camera-1/live.hang" {
|
||||
t.Fatalf("BroadcastPath() default = %q", got)
|
||||
}
|
||||
if got := BroadcastPath("", "camera-1", models.StreamQualityLow); got != "devices/camera-1/live-low.hang" {
|
||||
t.Fatalf("BroadcastPath() low = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampUs(t *testing.T) {
|
||||
if got := TimestampUs(1234); got != 1_234_000 {
|
||||
t.Fatalf("TimestampUs() = %d, want 1234000", got)
|
||||
}
|
||||
if got := TimestampUs(-1); got != 0 {
|
||||
t.Fatalf("TimestampUs() negative = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
41
machinery/src/cloud/livemoq/dedup.go
Normal file
41
machinery/src/cloud/livemoq/dedup.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KeyframeDeduplicator struct {
|
||||
hasPrevious bool
|
||||
timestampMs int64
|
||||
capturedAtMs int64
|
||||
observedAt time.Time
|
||||
digest [sha256.Size]byte
|
||||
}
|
||||
|
||||
func (d *KeyframeDeduplicator) Reset() {
|
||||
*d = KeyframeDeduplicator{}
|
||||
}
|
||||
|
||||
// IsDuplicate reports exact repeated keyframe access units observed close
|
||||
// together. Distinct IDR slices within one access unit remain untouched.
|
||||
func (d *KeyframeDeduplicator) IsDuplicate(timestampMs int64, capturedAtMs int64, payload []byte, observedAt time.Time, window time.Duration) bool {
|
||||
digest := sha256.Sum256(payload)
|
||||
duplicate := d.hasPrevious && d.timestampMs == timestampMs && d.digest == digest
|
||||
if duplicate {
|
||||
if capturedAtMs > 0 && d.capturedAtMs > 0 {
|
||||
gap := time.Duration(capturedAtMs-d.capturedAtMs) * time.Millisecond
|
||||
duplicate = gap >= 0 && gap <= window
|
||||
} else {
|
||||
gap := observedAt.Sub(d.observedAt)
|
||||
duplicate = gap >= 0 && gap <= window
|
||||
}
|
||||
}
|
||||
|
||||
d.hasPrevious = true
|
||||
d.timestampMs = timestampMs
|
||||
d.capturedAtMs = capturedAtMs
|
||||
d.observedAt = observedAt
|
||||
d.digest = digest
|
||||
return duplicate
|
||||
}
|
||||
60
machinery/src/cloud/livemoq/dedup_test.go
Normal file
60
machinery/src/cloud/livemoq/dedup_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKeyframeDeduplicator(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
window := 500 * time.Millisecond
|
||||
payload := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
if deduplicator.IsDuplicate(1_000, 10_000, payload, now, window) {
|
||||
t.Fatal("first keyframe reported as duplicate")
|
||||
}
|
||||
if !deduplicator.IsDuplicate(1_000, 10_020, payload, now.Add(20*time.Millisecond), window) {
|
||||
t.Fatal("exact repeated keyframe was not reported as duplicate")
|
||||
}
|
||||
if deduplicator.IsDuplicate(2_000, 11_000, payload, now.Add(time.Second), window) {
|
||||
t.Fatal("same payload with a new timestamp reported as duplicate")
|
||||
}
|
||||
if deduplicator.IsDuplicate(2_000, 11_020, append(payload, 0x01), now.Add(1020*time.Millisecond), window) {
|
||||
t.Fatal("different payload with the same timestamp reported as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorAllowsTimestampReuseOutsideWindow(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 10_000, payload, now, 500*time.Millisecond)
|
||||
if deduplicator.IsDuplicate(1_000, 20_000, payload, now.Add(10*time.Second), 500*time.Millisecond) {
|
||||
t.Fatal("later keyframe after timestamp reset reported as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorFallsBackToObservationTime(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 0, payload, now, 500*time.Millisecond)
|
||||
if !deduplicator.IsDuplicate(1_000, 0, payload, now.Add(20*time.Millisecond), 500*time.Millisecond) {
|
||||
t.Fatal("duplicate without capture time was not reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorReset(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 10_000, payload, now, 500*time.Millisecond)
|
||||
deduplicator.Reset()
|
||||
if deduplicator.IsDuplicate(1_000, 10_020, payload, now.Add(20*time.Millisecond), 500*time.Millisecond) {
|
||||
t.Fatal("first keyframe after reset reported as duplicate")
|
||||
}
|
||||
}
|
||||
55
machinery/src/cloud/livemoq/recovery.go
Normal file
55
machinery/src/cloud/livemoq/recovery.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package livemoq
|
||||
|
||||
import "time"
|
||||
|
||||
type FrameGateEvent uint8
|
||||
|
||||
const (
|
||||
FrameGateEventNone FrameGateEvent = iota
|
||||
FrameGateEventStarted
|
||||
FrameGateEventLagging
|
||||
FrameGateEventRecovered
|
||||
)
|
||||
|
||||
// FrameGate keeps publication on a decodable, recent GOP.
|
||||
type FrameGate struct {
|
||||
started bool
|
||||
recovering bool
|
||||
}
|
||||
|
||||
// Reset closes the gate so publication resumes on the next keyframe. It is used
|
||||
// when the publisher stopped writing for a reason unrelated to the stream health
|
||||
// (no subscribers), so the next viewer never receives a partial GOP.
|
||||
func (g *FrameGate) Reset() {
|
||||
g.started = false
|
||||
g.recovering = false
|
||||
}
|
||||
|
||||
// Allow rejects stale frames and waits for a fresh keyframe before reopening.
|
||||
func (g *FrameGate) Allow(isKeyFrame bool, capturedAtMs int64, now time.Time, maxAge time.Duration) (bool, FrameGateEvent) {
|
||||
if capturedAtMs > 0 && now.Sub(time.UnixMilli(capturedAtMs)) > maxAge {
|
||||
event := FrameGateEventNone
|
||||
if g.started {
|
||||
if !g.recovering {
|
||||
event = FrameGateEventLagging
|
||||
}
|
||||
g.started = false
|
||||
g.recovering = true
|
||||
}
|
||||
return false, event
|
||||
}
|
||||
|
||||
if !g.started {
|
||||
if !isKeyFrame {
|
||||
return false, FrameGateEventNone
|
||||
}
|
||||
g.started = true
|
||||
if g.recovering {
|
||||
g.recovering = false
|
||||
return true, FrameGateEventRecovered
|
||||
}
|
||||
return true, FrameGateEventStarted
|
||||
}
|
||||
|
||||
return true, FrameGateEventNone
|
||||
}
|
||||
49
machinery/src/cloud/livemoq/recovery_test.go
Normal file
49
machinery/src/cloud/livemoq/recovery_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFrameGateRecoversAtFreshKeyframe(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
maxAge := 1500 * time.Millisecond
|
||||
gate := FrameGate{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isKeyFrame bool
|
||||
capturedAtMs int64
|
||||
wantAllowed bool
|
||||
wantEvent FrameGateEvent
|
||||
}{
|
||||
{name: "waits for initial keyframe", capturedAtMs: 10_000},
|
||||
{name: "starts at initial keyframe", isKeyFrame: true, capturedAtMs: 10_000, wantAllowed: true, wantEvent: FrameGateEventStarted},
|
||||
{name: "publishes fresh delta", capturedAtMs: 10_020, wantAllowed: true},
|
||||
{name: "detects stale packet", capturedAtMs: 8_000, wantEvent: FrameGateEventLagging},
|
||||
{name: "rejects fresh delta while recovering", capturedAtMs: 10_040},
|
||||
{name: "rejects stale keyframe without duplicate event", isKeyFrame: true, capturedAtMs: 8_000},
|
||||
{name: "recovers at fresh keyframe", isKeyFrame: true, capturedAtMs: 10_060, wantAllowed: true, wantEvent: FrameGateEventRecovered},
|
||||
{name: "publishes delta after recovery", capturedAtMs: 10_080, wantAllowed: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
allowed, event := gate.Allow(test.isKeyFrame, test.capturedAtMs, now, maxAge)
|
||||
if allowed != test.wantAllowed {
|
||||
t.Fatalf("Allow() allowed = %t, want %t", allowed, test.wantAllowed)
|
||||
}
|
||||
if event != test.wantEvent {
|
||||
t.Fatalf("Allow() event = %d, want %d", event, test.wantEvent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameGateAllowsMissingCaptureTime(t *testing.T) {
|
||||
gate := FrameGate{}
|
||||
allowed, event := gate.Allow(true, 0, time.Now(), time.Second)
|
||||
if !allowed || event != FrameGateEventStarted {
|
||||
t.Fatalf("Allow() = (%t, %d), want (true, %d)", allowed, event, FrameGateEventStarted)
|
||||
}
|
||||
}
|
||||
8
machinery/src/cloud/livemoq_disabled.go
Normal file
8
machinery/src/cloud/livemoq_disabled.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !moq
|
||||
|
||||
package cloud
|
||||
|
||||
import "github.com/kerberos-io/agent/machinery/src/models"
|
||||
|
||||
// StartLiveStreamMoQ is disabled in the standard Agent build.
|
||||
func StartLiveStreamMoQ(_ *models.Configuration, _ *models.Communication, _ bool) {}
|
||||
298
machinery/src/cloud/livemoq_enabled.go
Normal file
298
machinery/src/cloud/livemoq_enabled.go
Normal file
@@ -0,0 +1,298 @@
|
||||
//go:build moq
|
||||
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/cloud/livemoq"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/moq-dev/moq-go/moq"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMoQRelayURL = "https://relay.uug.ai/anon"
|
||||
minMoQRetryDelay = time.Second
|
||||
maxMoQRetryDelay = 30 * time.Second
|
||||
maxMoQLivePacketAge = 1500 * time.Millisecond
|
||||
slowMoQWriteThreshold = 100 * time.Millisecond
|
||||
moQWriteWarningInterval = 10 * time.Second
|
||||
duplicateKeyframeWindow = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type liveMoQConfig struct {
|
||||
relayURL string
|
||||
broadcast string
|
||||
quality string
|
||||
sourceLabel string
|
||||
queue *packets.Queue
|
||||
}
|
||||
|
||||
// label identifies the tier in log lines, since one Agent runs a publisher per
|
||||
// quality tier.
|
||||
func (c liveMoQConfig) label() string {
|
||||
return c.quality + " (" + c.sourceLabel + " stream)"
|
||||
}
|
||||
|
||||
// StartLiveStreamMoQ starts the publisher only in the dedicated MoQ build and
|
||||
// only when explicitly enabled by the deployment.
|
||||
//
|
||||
// Unlike WebRTC and HLS — where a viewer negotiates a session with the Agent and
|
||||
// can therefore ask for another quality on the fly — MoQ viewers subscribe to a
|
||||
// relay and never talk to the Agent. The quality selector is honoured by
|
||||
// publishing each tier as its OWN broadcast (see livemoq.BroadcastPath): the
|
||||
// high tier from the camera's highest-resolution stream and the low tier from
|
||||
// its sub stream, so switching quality in the frontend is a resubscribe to the
|
||||
// other path. Each tier only uploads while it actually has subscribers, so the
|
||||
// second broadcast is close to free when nobody watches it.
|
||||
func StartLiveStreamMoQ(configuration *models.Configuration, communication *models.Communication, subStreamEnabled bool) {
|
||||
if os.Getenv("AGENT_LIVE_MOQ_ENABLED") != "true" {
|
||||
return
|
||||
}
|
||||
|
||||
config := configuration.Config
|
||||
if config.Offline == "true" || config.Capture.Liveview == "false" {
|
||||
log.Log.Info("cloud.StartLiveStreamMoQ(): disabled by Agent live-view configuration")
|
||||
return
|
||||
}
|
||||
if config.Key == "" {
|
||||
log.Log.Warning("cloud.StartLiveStreamMoQ(): AGENT_KEY is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Both tiers are published by default. AGENT_LIVE_MOQ_QUALITY pins the Agent
|
||||
// to a single tier for deployments that must never publish the other one
|
||||
// (viewers asking for the pinned-away tier then find no broadcast).
|
||||
qualities := []string{models.StreamQualityHigh, models.StreamQualityLow}
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("AGENT_LIVE_MOQ_QUALITY"))) {
|
||||
case models.StreamQualityHigh:
|
||||
qualities = []string{models.StreamQualityHigh}
|
||||
case models.StreamQualityLow:
|
||||
qualities = []string{models.StreamQualityLow}
|
||||
}
|
||||
|
||||
relayURL := os.Getenv("AGENT_LIVE_MOQ_URL")
|
||||
if relayURL == "" {
|
||||
relayURL = defaultMoQRelayURL
|
||||
}
|
||||
broadcastPrefix := os.Getenv("AGENT_LIVE_MOQ_BROADCAST_PREFIX")
|
||||
|
||||
ctx := context.Background()
|
||||
if communication.Context != nil {
|
||||
ctx = *communication.Context
|
||||
}
|
||||
|
||||
for _, quality := range qualities {
|
||||
queue := communication.Queue
|
||||
sourceLabel := "main"
|
||||
if models.SelectSubStreamForQuality(config, quality, subStreamEnabled) && communication.SubQueue != nil {
|
||||
queue = communication.SubQueue
|
||||
sourceLabel = "sub"
|
||||
}
|
||||
if queue == nil {
|
||||
log.Log.Warning("cloud.StartLiveStreamMoQ(): packet queue for the " + quality + " tier is unavailable")
|
||||
continue
|
||||
}
|
||||
go runLiveStreamMoQ(ctx, liveMoQConfig{
|
||||
relayURL: relayURL,
|
||||
broadcast: livemoq.BroadcastPath(broadcastPrefix, config.Key, quality),
|
||||
quality: quality,
|
||||
sourceLabel: sourceLabel,
|
||||
queue: queue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runLiveStreamMoQ(ctx context.Context, config liveMoQConfig) {
|
||||
log.Log.Info(fmt.Sprintf(
|
||||
"cloud.runLiveStreamMoQ(): publishing %s stream (quality=%s) to %s/%s",
|
||||
config.sourceLabel, config.quality, strings.TrimRight(config.relayURL, "/"), config.broadcast,
|
||||
))
|
||||
|
||||
retryDelay := minMoQRetryDelay
|
||||
for ctx.Err() == nil {
|
||||
connectedAt := time.Now()
|
||||
err := publishLiveStreamMoQ(ctx, config)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Log.Warning("cloud.runLiveStreamMoQ(): publisher stopped: " + err.Error())
|
||||
if time.Since(connectedAt) >= time.Minute {
|
||||
retryDelay = minMoQRetryDelay
|
||||
}
|
||||
|
||||
timer := time.NewTimer(retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if retryDelay < maxMoQRetryDelay {
|
||||
retryDelay *= 2
|
||||
if retryDelay > maxMoQRetryDelay {
|
||||
retryDelay = maxMoQRetryDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishLiveStreamMoQ(ctx context.Context, config liveMoQConfig) error {
|
||||
client, err := moq.Dial(ctx, config.relayURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to relay: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
sessionCtx, cancelSessionWatch := context.WithCancel(ctx)
|
||||
defer cancelSessionWatch()
|
||||
sessionClosed := make(chan error, 1)
|
||||
go func() {
|
||||
sessionClosed <- client.Session().Closed(sessionCtx)
|
||||
}()
|
||||
|
||||
broadcast, err := client.CreateBroadcast(config.broadcast)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create broadcast: %w", err)
|
||||
}
|
||||
defer broadcast.Finish()
|
||||
|
||||
stream, err := broadcast.PublishMedia("avc3", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create H.264 media stream: %w", err)
|
||||
}
|
||||
defer stream.Finish()
|
||||
|
||||
// Only upload while this tier is actually being watched. `publishing` starts
|
||||
// true so the track becomes discoverable on the relay even before the first
|
||||
// subscriber ever arrives; from the moment a viewer has attached once, the
|
||||
// subscriber watcher takes over and idles the tier again when everybody left.
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
publishing := &atomic.Bool{}
|
||||
publishing.Store(true)
|
||||
go watchLiveStreamMoQSubscribers(watchCtx, stream, publishing, config)
|
||||
|
||||
cursor := config.queue.Latest()
|
||||
gate := livemoq.FrameGate{}
|
||||
deduplicator := livemoq.KeyframeDeduplicator{}
|
||||
var lastSlowWriteWarning time.Time
|
||||
var lastDuplicateKeyframeWarning time.Time
|
||||
idle := false
|
||||
for {
|
||||
select {
|
||||
case err := <-sessionClosed:
|
||||
return fmt.Errorf("relay session closed: %w", err)
|
||||
default:
|
||||
}
|
||||
|
||||
packet, err := cursor.ReadPacket()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read packet: %w", err)
|
||||
}
|
||||
if !publishing.Load() {
|
||||
// Keep draining the cursor so we stay at the live edge, but publish
|
||||
// nothing. The gate is closed so the next viewer resumes on a keyframe.
|
||||
if !idle {
|
||||
gate.Reset()
|
||||
deduplicator.Reset()
|
||||
idle = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
idle = false
|
||||
if !packet.IsVideo || len(packet.Data) == 0 || !strings.EqualFold(packet.Codec, "H264") {
|
||||
continue
|
||||
}
|
||||
allowed, event := gate.Allow(packet.IsKeyFrame, packet.CurrentTime, time.Now(), maxMoQLivePacketAge)
|
||||
switch event {
|
||||
case livemoq.FrameGateEventStarted:
|
||||
log.Log.Info("cloud.publishLiveStreamMoQ(): first H.264 keyframe received; " + config.label() + " broadcast is live")
|
||||
case livemoq.FrameGateEventLagging:
|
||||
log.Log.Warning("cloud.publishLiveStreamMoQ(): " + config.label() + " stream is lagging; dropping packets until a recent keyframe")
|
||||
case livemoq.FrameGateEventRecovered:
|
||||
log.Log.Info("cloud.publishLiveStreamMoQ(): caught up with the " + config.label() + " live stream at a recent keyframe")
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
payload, normalizationStats, err := livemoq.NormalizeH264AccessUnitWithStats(packet.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("normalize H.264 access unit: %w", err)
|
||||
}
|
||||
if normalizationStats.DuplicateIDRNALUs > 0 && time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s removed %d duplicate IDR NALU(s) from H.264 keyframe (timestamp_ms=%d)",
|
||||
config.label(), normalizationStats.DuplicateIDRNALUs, packet.Time,
|
||||
))
|
||||
lastDuplicateKeyframeWarning = time.Now()
|
||||
}
|
||||
if packet.IsKeyFrame && deduplicator.IsDuplicate(packet.Time, packet.CurrentTime, payload, time.Now(), duplicateKeyframeWindow) {
|
||||
if time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s dropping duplicate H.264 keyframe (timestamp_ms=%d)",
|
||||
config.label(), packet.Time,
|
||||
))
|
||||
lastDuplicateKeyframeWarning = time.Now()
|
||||
}
|
||||
continue
|
||||
}
|
||||
frame := moq.Frame{
|
||||
Payload: payload,
|
||||
TimestampUs: livemoq.TimestampUs(packet.Time),
|
||||
}
|
||||
writeStartedAt := time.Now()
|
||||
if err := stream.WriteFrame(frame); err != nil {
|
||||
return fmt.Errorf("write H.264 access unit: %w", err)
|
||||
}
|
||||
writeDuration := time.Since(writeStartedAt)
|
||||
if writeDuration >= slowMoQWriteThreshold && time.Since(lastSlowWriteWarning) >= moQWriteWarningInterval {
|
||||
packetAge := time.Duration(0)
|
||||
if packet.CurrentTime > 0 {
|
||||
packetAge = time.Since(time.UnixMilli(packet.CurrentTime))
|
||||
if packetAge < 0 {
|
||||
packetAge = 0
|
||||
}
|
||||
}
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s WriteFrame blocked for %s (packet_age=%s keyframe=%t)",
|
||||
config.label(), writeDuration.Round(time.Millisecond), packetAge.Round(time.Millisecond), packet.IsKeyFrame,
|
||||
))
|
||||
lastSlowWriteWarning = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchLiveStreamMoQSubscribers flips the publisher between uploading and idling
|
||||
// as viewers subscribe to and leave this tier's broadcast. Used and Unused both
|
||||
// block, so they are followed from their own goroutine.
|
||||
//
|
||||
// It deliberately never turns publishing off before the first subscriber has
|
||||
// been observed: the relay catalog is only complete once media has flowed, so
|
||||
// going idle up front could keep the tier undiscoverable. On any error it fails
|
||||
// open (keeps publishing) — a stalled watcher must never take the live view down.
|
||||
func watchLiveStreamMoQSubscribers(ctx context.Context, stream *moq.MediaProducer, publishing *atomic.Bool, config liveMoQConfig) {
|
||||
for ctx.Err() == nil {
|
||||
if err := stream.Used(ctx); err != nil {
|
||||
publishing.Store(true)
|
||||
return
|
||||
}
|
||||
if publishing.CompareAndSwap(false, true) {
|
||||
log.Log.Info("cloud.watchLiveStreamMoQSubscribers(): viewer subscribed, resuming the " + config.label() + " broadcast")
|
||||
}
|
||||
|
||||
if err := stream.Unused(ctx); err != nil {
|
||||
publishing.Store(true)
|
||||
return
|
||||
}
|
||||
publishing.Store(false)
|
||||
log.Log.Info("cloud.watchLiveStreamMoQSubscribers(): no viewers left, idling the " + config.label() + " broadcast")
|
||||
}
|
||||
}
|
||||
151
machinery/src/cloud/livesnapshot/publisher.go
Normal file
151
machinery/src/cloud/livesnapshot/publisher.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Package livesnapshot implements the agent-side producer for the live-view
|
||||
// "preview" (SD) mode over HTTP.
|
||||
//
|
||||
// Historically the preview pipeline shipped each resized keyframe (a base64
|
||||
// JPEG, often chunked) to viewers over the MQTT broker. MQTT is a control plane
|
||||
// for small messages, so pushing ~1 image/second of base64 image data per
|
||||
// watched camera congests the broker and delays genuine control traffic. This
|
||||
// package moves those frames off MQTT: the agent POSTs the latest resized JPEG
|
||||
// straight to hub-api over plain HTTPS (outbound only), and viewers fetch it
|
||||
// back with their session token. Only the tiny "a viewer is watching" keepalive
|
||||
// stays on MQTT.
|
||||
//
|
||||
// The wire contract (agent -> hub-api) deliberately mirrors the live HLS ingest
|
||||
// and the existing storage-upload convention (X-Kerberos-Storage-Device plus the
|
||||
// Hub public/private key auth headers). hub-api authenticates the agent and
|
||||
// stores the frame in an ephemeral, short-TTL per-device slot which it serves
|
||||
// straight back to authorized viewers; the frame never enters the vault or the
|
||||
// recordings collection.
|
||||
//
|
||||
// Like live HLS segments, a preview frame is worthless once stale: a frame that
|
||||
// fails to upload is superseded by the next one a second later, so the publisher
|
||||
// is fire-and-forget and drops on failure (logged) rather than retrying.
|
||||
package livesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// snapshotIngestPath is the hub-api endpoint that accepts the latest preview
|
||||
// frame and stores it in the device's ephemeral snapshot slot (mirrors the
|
||||
// /storage/live live-HLS ingest convention).
|
||||
snapshotIngestPath = "/storage/snapshot"
|
||||
|
||||
contentTypeJPEG = "image/jpeg"
|
||||
|
||||
// Header names for the snapshot ingest contract (shared with live HLS / storage).
|
||||
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
|
||||
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
|
||||
headerHubRegion = "X-Kerberos-Hub-Region"
|
||||
headerStorageDevice = "X-Kerberos-Storage-Device"
|
||||
|
||||
// defaultPublishTimeout bounds a single snapshot upload. Preview frames are
|
||||
// produced roughly once a second from a single goroutine, so an upload that
|
||||
// cannot land in a few seconds is abandoned rather than allowed to back up the
|
||||
// preview loop behind a slow request.
|
||||
defaultPublishTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// PublisherConfig carries the hub endpoint and credentials needed to ship
|
||||
// preview frames. It is populated from the agent's models.Config (the same
|
||||
// HubURI/HubKey/HubPrivateKey used by recordings and live HLS).
|
||||
type PublisherConfig struct {
|
||||
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
|
||||
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
|
||||
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
|
||||
Region string // storage region (X-Kerberos-Hub-Region), may be empty
|
||||
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
|
||||
|
||||
// Timeout optionally overrides defaultPublishTimeout (used by tests).
|
||||
Timeout time.Duration
|
||||
// HTTPClient optionally injects a client (used by tests). When nil a
|
||||
// redirect-credential-stripping client is created.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Publisher ships the latest preview frame to hub-api over plain HTTP POST.
|
||||
//
|
||||
// It is safe for sequential use from a single live-stream goroutine. PublishSnapshot
|
||||
// is fire-and-forget: it returns an error for the caller to log, but the caller is
|
||||
// expected to continue (drop-on-fail) rather than retry.
|
||||
type Publisher struct {
|
||||
cfg PublisherConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
|
||||
// headers on a cross-host redirect (net/http does this for standard auth headers
|
||||
// but not custom-named ones), matching the recording/live-HLS upload clients.
|
||||
func NewPublisher(cfg PublisherConfig) *Publisher {
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultPublishTimeout
|
||||
}
|
||||
client = &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
|
||||
}
|
||||
}
|
||||
return &Publisher{cfg: cfg, client: client}
|
||||
}
|
||||
|
||||
// PublishSnapshot uploads a single resized preview frame (JPEG) as the device's
|
||||
// latest snapshot. It overwrites whatever frame was there before, so viewers
|
||||
// always fetch the most recent frame.
|
||||
func (p *Publisher) PublishSnapshot(ctx context.Context, jpeg []byte) error {
|
||||
if p.cfg.HubURI == "" {
|
||||
return fmt.Errorf("livesnapshot: HubURI not configured")
|
||||
}
|
||||
if len(jpeg) == 0 {
|
||||
return fmt.Errorf("livesnapshot: empty snapshot body")
|
||||
}
|
||||
|
||||
url := strings.TrimRight(p.cfg.HubURI, "/") + snapshotIngestPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jpeg))
|
||||
if err != nil {
|
||||
return fmt.Errorf("livesnapshot: build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", contentTypeJPEG)
|
||||
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
|
||||
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
|
||||
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
|
||||
req.Header.Set(headerHubRegion, p.cfg.Region)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("livesnapshot: upload snapshot: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("livesnapshot: upload snapshot rejected: %s", resp.Status)
|
||||
}
|
||||
log.Log.Debug("livesnapshot.Publisher.PublishSnapshot(): shipped preview frame for device " + p.cfg.DeviceKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
|
||||
// a redirect crosses to a different host. net/http strips standard sensitive
|
||||
// headers on a cross-host redirect but not custom-named ones, so without this the
|
||||
// Hub keys could leak to a redirect target.
|
||||
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del(headerHubPrivateKey)
|
||||
req.Header.Del(headerHubPublicKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
88
machinery/src/cloud/recording_metadata.go
Normal file
88
machinery/src/cloud/recording_metadata.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
const recordingFPSHeader = "X-Kerberos-Storage-Fps"
|
||||
const recordingDurationHeader = "X-Kerberos-Storage-Duration"
|
||||
const recordingTimestampHeader = "X-Kerberos-Storage-Timestamp"
|
||||
|
||||
// queuedRecordingFPS reads the FPS snapshot written into the upload marker
|
||||
// when the recording was finalized. Historical empty markers intentionally
|
||||
// return no value so receivers can retain their existing MP4-derived fallback.
|
||||
func queuedRecordingFPS(fileName string) string {
|
||||
value, ok := readRecordingUploadMetadata(fileName)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
marker := strings.TrimSpace(string(value))
|
||||
if strings.HasPrefix(marker, "{") {
|
||||
metadata, ok := decodeRecordingUploadMetadata(value)
|
||||
if !ok || metadata.FPS <= 0 || metadata.FPS > 240 || math.IsInf(metadata.FPS, 0) || math.IsNaN(metadata.FPS) {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(metadata.FPS, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Compatibility with markers created before upload metadata used JSON.
|
||||
fps := marker
|
||||
parsed, err := strconv.ParseFloat(fps, 64)
|
||||
if err != nil || parsed <= 0 || parsed > 240 || math.IsInf(parsed, 0) || math.IsNaN(parsed) {
|
||||
return ""
|
||||
}
|
||||
return fps
|
||||
}
|
||||
|
||||
func queuedRecordingMetadata(fileName string) (models.RecordingUploadMetadata, bool) {
|
||||
value, ok := readRecordingUploadMetadata(fileName)
|
||||
if !ok || !strings.HasPrefix(strings.TrimSpace(string(value)), "{") {
|
||||
return models.RecordingUploadMetadata{}, false
|
||||
}
|
||||
return decodeRecordingUploadMetadata(value)
|
||||
}
|
||||
|
||||
func decodeRecordingUploadMetadata(value []byte) (models.RecordingUploadMetadata, bool) {
|
||||
var metadata models.RecordingUploadMetadata
|
||||
if err := json.Unmarshal(value, &metadata); err != nil {
|
||||
return models.RecordingUploadMetadata{}, false
|
||||
}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
|
||||
markerNames := []string{
|
||||
models.RecordingUploadMetadataFileName(fileName),
|
||||
filepath.Base(fileName),
|
||||
}
|
||||
for _, markerName := range markerNames {
|
||||
value, err := os.ReadFile(filepath.Join("data", "cloud", markerName))
|
||||
if err == nil {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
|
||||
if fps := queuedRecordingFPS(fileName); fps != "" {
|
||||
header.Set(recordingFPSHeader, fps)
|
||||
}
|
||||
if metadata, ok := queuedRecordingMetadata(fileName); ok {
|
||||
if metadata.Duration > 0 {
|
||||
header.Set(recordingDurationHeader, strconv.FormatUint(metadata.Duration, 10))
|
||||
}
|
||||
if metadata.Timestamp > 0 {
|
||||
header.Set(recordingTimestampHeader, strconv.FormatInt(metadata.Timestamp, 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
608
machinery/src/cloud/tus_client.go
Normal file
608
machinery/src/cloud/tus_client.go
Normal file
@@ -0,0 +1,608 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// tusResumableVersion is the tus protocol version implemented by this client.
|
||||
const tusResumableVersion = "1.0.0"
|
||||
|
||||
// tusUploadPath is appended to the configured Kerberos Vault URI to reach the
|
||||
// resumable upload endpoint. It mirrors how the legacy uploader appends
|
||||
// "/storage".
|
||||
const tusUploadPath = "/storage/tus/"
|
||||
|
||||
// tusResumeState is persisted in a sidecar file next to the agent data so an
|
||||
// interrupted upload can be resumed across retries and even agent restarts.
|
||||
type tusResumeState struct {
|
||||
UploadURL string `json:"upload_url"`
|
||||
VaultURI string `json:"vault_uri"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// resumableUploadsEnabled reports whether the resumable (tus) upload path should
|
||||
// be attempted. It is enabled by default and can be disabled (falling back to
|
||||
// the legacy single POST) by setting AGENT_DISABLE_RESUMABLE_UPLOAD=true.
|
||||
func resumableUploadsEnabled() bool {
|
||||
return os.Getenv("AGENT_DISABLE_RESUMABLE_UPLOAD") != "true"
|
||||
}
|
||||
|
||||
// tusDefaultChunkSize is the number of bytes uploaded per PATCH request when no
|
||||
// explicit size is configured. Splitting the upload into chunks keeps each HTTP
|
||||
// request small enough for intermediary proxies/load balancers and checkpoints
|
||||
// progress frequently, so an interruption resumes with minimal re-upload.
|
||||
const tusDefaultChunkSize int64 = 8 << 20 // 8 MiB (>= S3 multipart minimum part size)
|
||||
|
||||
const tusProgressBucketPercent int64 = 10
|
||||
|
||||
// tusChunkSize returns the number of bytes to send per PATCH request. It
|
||||
// defaults to tusDefaultChunkSize (8 MiB) and can be overridden with the
|
||||
// AGENT_TUS_CHUNK_SIZE_BYTES environment variable. A value of 0 (or negative)
|
||||
// disables chunking and sends the remaining bytes in a single PATCH.
|
||||
func tusChunkSize() int64 {
|
||||
v := os.Getenv("AGENT_TUS_CHUNK_SIZE_BYTES")
|
||||
if v == "" {
|
||||
return tusDefaultChunkSize
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return tusDefaultChunkSize
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0 // chunking disabled: send everything in one PATCH
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func tusProgressBucket(offset, size int64) int64 {
|
||||
if size <= 0 {
|
||||
return 100
|
||||
}
|
||||
percent := (offset * 100) / size
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
return percent / tusProgressBucketPercent
|
||||
}
|
||||
|
||||
func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64) {
|
||||
bucket := tusProgressBucket(offset, size)
|
||||
if bucket <= *loggedBucket {
|
||||
return
|
||||
}
|
||||
*loggedBucket = bucket
|
||||
percent := bucket * tusProgressBucketPercent
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
log.Log.Infof("%s: resumable upload progress %d%% (%d/%d bytes)", label, percent, offset, size)
|
||||
}
|
||||
|
||||
// tusHeaderFunc sets the authentication and routing headers required on every
|
||||
// tus request for a particular upload target (Kerberos Vault directly, or
|
||||
// Kerberos Hub which proxies to a vault). fileName is only meaningful on the
|
||||
// creation request; it is empty on HEAD/PATCH/DELETE.
|
||||
type tusHeaderFunc func(h http.Header, fileName string)
|
||||
|
||||
// runTusUpload performs a resumable (tus) upload of data/recordings/<fileName>
|
||||
// to baseURL, sending target-specific authentication/routing headers via
|
||||
// setHeaders on every request. It encapsulates the create/resume/chunk/finalize
|
||||
// state machine shared by the Kerberos Vault (direct) and Kerberos Hub (proxied)
|
||||
// upload paths.
|
||||
//
|
||||
// Return values:
|
||||
// - uploaded: the recording was fully received and persisted by the server.
|
||||
// - responded: the server returned a definitive HTTP response (used by the
|
||||
// caller to advance its retry/secondary-failover policy).
|
||||
// - supported: the server exposes a tus endpoint. When false, the caller
|
||||
// should fall back to the legacy single-POST upload (older deployments).
|
||||
// - body: a short message for logging.
|
||||
func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tusHeaderFunc) (uploaded bool, responded bool, supported bool, body string, err error) {
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, ferr := os.Open(fullname)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if ferr != nil {
|
||||
msg := label + ": resumable upload failed, file doesn't exist anymore"
|
||||
log.Log.Info(msg)
|
||||
// The file is gone, so the legacy path cannot help either. Report it as
|
||||
// "supported" to avoid a pointless fallback attempt.
|
||||
return false, false, true, "", errors.New(msg)
|
||||
}
|
||||
|
||||
info, serr := file.Stat()
|
||||
if serr != nil {
|
||||
return false, false, true, "", serr
|
||||
}
|
||||
size := info.Size()
|
||||
|
||||
client := newVaultHTTPClient(0)
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
for k := range req.Header {
|
||||
if strings.HasPrefix(http.CanonicalHeaderKey(k), "X-Kerberos-") {
|
||||
req.Header.Del(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
sidecar := tusSidecarPath(fileName, slot)
|
||||
uploadURL := loadTusResumeState(sidecar, baseURL)
|
||||
|
||||
const maxAttempts = 4
|
||||
restartedAfterComplete := false
|
||||
|
||||
// lastStatus holds the HTTP status code of the most recent tus request. A
|
||||
// value of 0 means the request failed at the transport level (no HTTP
|
||||
// response at all, e.g. the vault was unreachable or the connection dropped
|
||||
// because the internet went down). It lets the final "gave up" return report
|
||||
// whether the vault actually answered, so the caller only advances its
|
||||
// retry/back-off policy on a definitive response and transient network errors
|
||||
// never consume the retry budget (matching the legacy single-POST behaviour).
|
||||
lastStatus := 0
|
||||
|
||||
// highWaterOffset is the furthest server-acknowledged offset observed across
|
||||
// all attempts (via HEAD or PATCH). It lets the retry budget be refreshed only
|
||||
// on GENUINE net forward progress. Without it, a server that keeps resetting the
|
||||
// offset — e.g. a persistent 409 ERR_MISMATCHED_OFFSET where HEAD reports 0 again
|
||||
// while the first chunk still "succeeds" — would refresh the budget every attempt
|
||||
// and loop forever, wedging the upload worker on one recording and saturating the
|
||||
// uplink.
|
||||
highWaterOffset := int64(0)
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// (1) Ensure we have an active upload URL, creating one if needed.
|
||||
if uploadURL == "" {
|
||||
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
|
||||
lastStatus = status
|
||||
if cerr != nil {
|
||||
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
|
||||
// The vault does not implement tus; let the caller fall back.
|
||||
return false, false, false, "", cerr
|
||||
}
|
||||
log.Log.Info(label + ": resumable create failed, " + cerr.Error())
|
||||
tusBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
uploadURL = created
|
||||
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
|
||||
}
|
||||
|
||||
// (2) Query the current server-side offset.
|
||||
offset, status, herr := tusHead(client, uploadURL, setHeaders)
|
||||
lastStatus = status
|
||||
if herr != nil {
|
||||
if status == http.StatusNotFound || status == http.StatusGone {
|
||||
// The upload expired/was removed server-side; start over.
|
||||
removeTusResumeState(sidecar)
|
||||
uploadURL = ""
|
||||
continue
|
||||
}
|
||||
log.Log.Info(label + ": resumable head failed, " + herr.Error())
|
||||
tusBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
|
||||
// The furthest offset any previous attempt reached. If this attempt pushes
|
||||
// past it (via HEAD showing server-side progress or a successful PATCH) we made
|
||||
// genuine net progress and may refresh the retry budget; if not, a repeated
|
||||
// failure at the same spot must count against maxAttempts.
|
||||
startHighWater := highWaterOffset
|
||||
if offset > highWaterOffset {
|
||||
highWaterOffset = offset
|
||||
}
|
||||
|
||||
// (3) All bytes are present but the upload was not finalized (e.g. the
|
||||
// completion hook failed). A completed tus upload cannot be re-finalized
|
||||
// with another PATCH, so delete it and re-upload to force a clean finalize.
|
||||
if offset >= size {
|
||||
if restartedAfterComplete {
|
||||
return false, true, true, "resumable finalize did not complete", errors.New(label + ": resumable finalize did not complete")
|
||||
}
|
||||
tusTerminate(client, uploadURL, setHeaders)
|
||||
removeTusResumeState(sidecar)
|
||||
uploadURL = ""
|
||||
restartedAfterComplete = true
|
||||
continue
|
||||
}
|
||||
|
||||
// (4) Stream the remaining bytes to the vault via PATCH, reading directly
|
||||
// from disk so the recording is never fully buffered in memory. When a chunk
|
||||
// size is configured the data is sent across several PATCH requests,
|
||||
// checkpointing the offset after each one so an interruption resumes from the
|
||||
// last completed chunk instead of re-uploading everything.
|
||||
chunkSize := tusChunkSize()
|
||||
patchFailed := false
|
||||
var lastBody string
|
||||
loggedProgressBucket := tusProgressBucket(offset, size)
|
||||
for offset < size {
|
||||
// Re-seek every chunk so the on-disk position always matches the
|
||||
// server-acknowledged offset, even if a PATCH was partially accepted.
|
||||
if _, sErr := file.Seek(offset, io.SeekStart); sErr != nil {
|
||||
return false, false, true, "", sErr
|
||||
}
|
||||
patchLen := size - offset
|
||||
if chunkSize > 0 && chunkSize < patchLen {
|
||||
patchLen = chunkSize
|
||||
}
|
||||
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
|
||||
lastStatus = status
|
||||
if perr != nil {
|
||||
if status >= 400 {
|
||||
// Definitive rejection (e.g. provider push failed during finalize).
|
||||
// Re-evaluate via HEAD on the next iteration to decide retry/restart.
|
||||
log.Log.Info(label + ": resumable patch rejected, " + perr.Error())
|
||||
} else {
|
||||
log.Log.Info(label + ": resumable patch failed, " + perr.Error())
|
||||
}
|
||||
tusBackoff(attempt)
|
||||
patchFailed = true
|
||||
break
|
||||
}
|
||||
offset = newOffset
|
||||
if offset > highWaterOffset {
|
||||
highWaterOffset = offset
|
||||
}
|
||||
lastBody = respBody
|
||||
logTusUploadProgress(label, offset, size, &loggedProgressBucket)
|
||||
if offset < size {
|
||||
// Partial progress: persist so a later retry resumes from here.
|
||||
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
|
||||
}
|
||||
}
|
||||
if patchFailed {
|
||||
if highWaterOffset > startHighWater {
|
||||
// Genuine net progress (we advanced past the furthest point any previous
|
||||
// attempt reached) refreshes the retry budget: maxAttempts bounds the
|
||||
// number of consecutive *non-progressing* failures, not the number of
|
||||
// chunks needed for a large recording. A server that keeps rejecting the
|
||||
// same offset (no net progress, e.g. a persistent ERR_MISMATCHED_OFFSET)
|
||||
// therefore gives up after maxAttempts instead of retrying forever.
|
||||
attempt = -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// All declared bytes have been sent and acknowledged: the upload is done.
|
||||
removeTusResumeState(sidecar)
|
||||
return true, true, true, lastBody, nil
|
||||
}
|
||||
|
||||
// Every attempt failed. Only report responded=true when the vault actually
|
||||
// answered on the last attempt (lastStatus > 0). If every attempt failed at
|
||||
// the transport level (lastStatus == 0, e.g. the internet was disconnected),
|
||||
// report responded=false so the caller keeps the recording queued and retries
|
||||
// later instead of consuming its retry budget and entering the long back-off
|
||||
// timeout.
|
||||
return false, lastStatus > 0, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
|
||||
}
|
||||
|
||||
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
|
||||
// the tus resumable upload protocol. Credentials travel in the
|
||||
// X-Kerberos-Storage-* headers on every request and routing (directory/provider)
|
||||
// is additionally carried in the tus Upload-Metadata.
|
||||
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, bool, string, error) {
|
||||
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
|
||||
metadataValues := map[string]string{
|
||||
"filename": fileName,
|
||||
"device": deviceKey,
|
||||
"directory": vault.Directory,
|
||||
"provider": vault.Provider,
|
||||
"capture": "IPCamera",
|
||||
"cloudkey": publicKey,
|
||||
"fps": queuedRecordingFPS(fileName),
|
||||
}
|
||||
addRecordingTusMetadata(metadataValues, fileName)
|
||||
metadata := encodeTusMetadata(metadataValues)
|
||||
setHeaders := func(h http.Header, fn string) {
|
||||
setVaultTusHeaders(h, vault, publicKey, deviceKey, fn)
|
||||
}
|
||||
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
|
||||
}
|
||||
|
||||
// uploadHubResumable uploads a recording to Kerberos Hub's tus endpoint, which
|
||||
// authenticates the agent with its Hub public/private key and proxies the
|
||||
// resumable upload to the Kerberos Vault on the agent's behalf. The vault
|
||||
// directory and provider are resolved and injected by Kerberos Hub, so they are
|
||||
// intentionally omitted from the metadata here.
|
||||
func uploadHubResumable(config *models.Config, fileName, label, slot string) (bool, bool, bool, string, error) {
|
||||
baseURL := strings.TrimRight(config.HubURI, "/") + tusUploadPath
|
||||
metadataValues := map[string]string{
|
||||
"filename": fileName,
|
||||
"device": config.Key,
|
||||
"capture": "IPCamera",
|
||||
"fps": queuedRecordingFPS(fileName),
|
||||
}
|
||||
addRecordingTusMetadata(metadataValues, fileName)
|
||||
metadata := encodeTusMetadata(metadataValues)
|
||||
setHeaders := func(h http.Header, fn string) {
|
||||
setHubTusHeaders(h, config, fn)
|
||||
}
|
||||
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
|
||||
}
|
||||
|
||||
func addRecordingTusMetadata(values map[string]string, fileName string) {
|
||||
metadata, ok := queuedRecordingMetadata(fileName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if metadata.Duration > 0 {
|
||||
values["duration"] = strconv.FormatUint(metadata.Duration, 10)
|
||||
}
|
||||
if metadata.Timestamp > 0 {
|
||||
values["timestamp"] = strconv.FormatInt(metadata.Timestamp, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// tusCreate performs the tus "creation" request (POST). On success it returns
|
||||
// the resolved upload URL the agent should use for subsequent HEAD/PATCH calls.
|
||||
func tusCreate(client *http.Client, baseURL string, size int64, metadata string, setHeaders tusHeaderFunc, fileName string) (string, int, error) {
|
||||
req, err := http.NewRequest("POST", baseURL, nil)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
req.Header.Set("Upload-Length", strconv.FormatInt(size, 10))
|
||||
if metadata != "" {
|
||||
req.Header.Set("Upload-Metadata", metadata)
|
||||
}
|
||||
setHeaders(req.Header, fileName)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s", resp.Status)
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return "", resp.StatusCode, errors.New("missing Location header in create response")
|
||||
}
|
||||
return resolveTusLocation(baseURL, location), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// tusHead performs the tus "offset" request (HEAD) and returns the current
|
||||
// server-side upload offset.
|
||||
func tusHead(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) (int64, int, error) {
|
||||
req, err := http.NewRequest("HEAD", uploadURL, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return 0, resp.StatusCode, fmt.Errorf("unexpected status on HEAD: %s", resp.Status)
|
||||
}
|
||||
offsetStr := resp.Header.Get("Upload-Offset")
|
||||
offset, perr := strconv.ParseInt(offsetStr, 10, 64)
|
||||
if perr != nil {
|
||||
return 0, resp.StatusCode, fmt.Errorf("invalid Upload-Offset header: %q", offsetStr)
|
||||
}
|
||||
return offset, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// tusPatch streams up to length bytes of the file (starting at offset) to the
|
||||
// upload URL using a single PATCH request. The body is read straight from the
|
||||
// *os.File, so the recording is never fully buffered in memory.
|
||||
func tusPatch(client *http.Client, uploadURL string, offset, length int64, file io.Reader, setHeaders tusHeaderFunc) (int64, int, string, error) {
|
||||
req, err := http.NewRequest("PATCH", uploadURL, io.LimitReader(file, length))
|
||||
if err != nil {
|
||||
return offset, 0, "", err
|
||||
}
|
||||
req.ContentLength = length
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
req.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return offset, 0, "", err
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
respBody := string(bodyBytes)
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return offset, resp.StatusCode, respBody, fmt.Errorf("unexpected status on PATCH: %s, %s", resp.Status, respBody)
|
||||
}
|
||||
newOffsetStr := resp.Header.Get("Upload-Offset")
|
||||
newOffset, perr := strconv.ParseInt(newOffsetStr, 10, 64)
|
||||
if perr != nil {
|
||||
// A 204 without a parseable offset means this PATCH was fully accepted.
|
||||
return offset + length, resp.StatusCode, respBody, nil
|
||||
}
|
||||
return newOffset, resp.StatusCode, respBody, nil
|
||||
}
|
||||
|
||||
// tusTerminate best-effort deletes an upload server-side (DELETE).
|
||||
func tusTerminate(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) {
|
||||
req, err := http.NewRequest("DELETE", uploadURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, derr := client.Do(req)
|
||||
if resp != nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
_ = derr
|
||||
}
|
||||
|
||||
// setVaultTusHeaders sets the Kerberos Vault authentication and routing headers
|
||||
// on every tus request. Credentials are sent on each request (and never stored
|
||||
// server-side in the upload metadata). When fileName is empty it is omitted, as
|
||||
// it is only useful on the creation request (routing also travels in the tus
|
||||
// Upload-Metadata).
|
||||
func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
|
||||
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
|
||||
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
|
||||
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
|
||||
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
|
||||
h.Set("X-Kerberos-Storage-Device", deviceKey)
|
||||
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// setHubTusHeaders sets the Kerberos Hub authentication headers on every tus
|
||||
// request of a hub-proxied resumable upload. The agent authenticates with its
|
||||
// Hub public/private key (exactly as the legacy single-POST hub upload does);
|
||||
// Kerberos Hub validates the subscription and injects the vault credentials and
|
||||
// directory/provider on the agent's behalf.
|
||||
func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
|
||||
h.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
h.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
h.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
h.Set("X-Kerberos-Storage-Device", config.Key)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeTusMetadata serializes a map into the tus Upload-Metadata header format:
|
||||
// a comma separated list of "key base64(value)" pairs. Keys are sorted for a
|
||||
// deterministic header value. Empty values are skipped.
|
||||
func encodeTusMetadata(pairs map[string]string) string {
|
||||
parts := make([]string, 0, len(pairs))
|
||||
for k, v := range pairs {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, k+" "+base64.StdEncoding.EncodeToString([]byte(v)))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// resolveTusLocation turns the Location header returned by the create request
|
||||
// into an absolute URL. To keep talking to the agent's configured vault host
|
||||
// (and avoid issues when the vault sits behind a proxy that rewrites the host),
|
||||
// it keeps the configured base URL and only appends the server-assigned upload
|
||||
// id taken from the Location.
|
||||
func resolveTusLocation(baseURL, location string) string {
|
||||
if ref, err := url.Parse(location); err == nil {
|
||||
trimmed := strings.Trim(ref.Path, "/")
|
||||
if trimmed != "" {
|
||||
segments := strings.Split(trimmed, "/")
|
||||
id := segments[len(segments)-1]
|
||||
if id != "" {
|
||||
return strings.TrimRight(baseURL, "/") + "/" + id
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: resolve the reference against the base URL as-is.
|
||||
if base, err := url.Parse(baseURL); err == nil {
|
||||
if ref, err := url.Parse(location); err == nil {
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
// tusSidecarDir is the directory where resume state files are kept. It is
|
||||
// intentionally separate from data/cloud (which is scanned for recordings to
|
||||
// upload) so the sidecar files are never mistaken for recordings.
|
||||
func tusSidecarDir() string {
|
||||
return "data/tus"
|
||||
}
|
||||
|
||||
func tusSidecarPath(fileName, slot string) string {
|
||||
safe := strings.ReplaceAll(fileName, "/", "_")
|
||||
safe = strings.ReplaceAll(safe, string(os.PathSeparator), "_")
|
||||
return filepath.Join(tusSidecarDir(), safe+"."+slot+".json")
|
||||
}
|
||||
|
||||
// loadTusResumeState returns a previously stored upload URL for the given
|
||||
// sidecar, but only if it was created against the same vault base URL. Any
|
||||
// mismatch or read/parse error yields an empty string (start fresh).
|
||||
func loadTusResumeState(path, baseURL string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var state tusResumeState
|
||||
if err := json.Unmarshal(b, &state); err != nil {
|
||||
return ""
|
||||
}
|
||||
if state.UploadURL == "" || state.VaultURI != baseURL {
|
||||
return ""
|
||||
}
|
||||
return state.UploadURL
|
||||
}
|
||||
|
||||
func saveTusResumeState(path string, state tusResumeState) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
func removeTusResumeState(path string) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
// tusBackoffBaseDelay is the base delay used by tusBackoff for the exponential
|
||||
// back-off between resume attempts. It is a package variable (rather than a
|
||||
// constant) so tests can shrink it to keep them fast.
|
||||
var tusBackoffBaseDelay = 500 * time.Millisecond
|
||||
|
||||
// tusBackoff sleeps for an exponentially increasing duration (capped) between
|
||||
// resume attempts to avoid hammering a temporarily unavailable vault.
|
||||
func tusBackoff(attempt int) {
|
||||
delay := tusBackoffBaseDelay * time.Duration(1<<uint(attempt))
|
||||
if delay > 3*time.Second {
|
||||
delay = 3 * time.Second
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
852
machinery/src/cloud/tus_client_test.go
Normal file
852
machinery/src/cloud/tus_client_test.go
Normal file
@@ -0,0 +1,852 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// fakeUpload tracks the state of a single resumable upload on the fake server.
|
||||
type fakeUpload struct {
|
||||
size int64
|
||||
offset int64
|
||||
}
|
||||
|
||||
// recordedRequest captures the method and headers of a request received by the
|
||||
// fake tus server, so tests can assert the client's per-method auth headers.
|
||||
type recordedRequest struct {
|
||||
method string
|
||||
header http.Header
|
||||
}
|
||||
|
||||
// fakeTus is a tiny in-memory implementation of the tus 1.0.0 server protocol,
|
||||
// sufficient to exercise the agent's resumable client.
|
||||
type fakeTus struct {
|
||||
mu sync.Mutex
|
||||
uploads map[string]*fakeUpload
|
||||
counter int
|
||||
creates int
|
||||
lastPatchBytes int64
|
||||
patchSizes []int64
|
||||
|
||||
// unsupported makes the creation endpoint return 404, simulating an older
|
||||
// vault without a tus endpoint.
|
||||
unsupported bool
|
||||
// failFinalize causes the next N completing PATCH requests to return 502
|
||||
// after storing the bytes, simulating a failed completion hook.
|
||||
failFinalize int
|
||||
|
||||
// loseProgress simulates a vault that never durably retains the in-progress
|
||||
// upload: every PATCH is acknowledged (the response advertises the advanced
|
||||
// offset) but the stored offset is immediately reset to 0. HEAD therefore
|
||||
// keeps reporting 0 and the next chunk — sent at the advanced offset — is
|
||||
// rejected with 409, reproducing the cross-replica ERR_MISMATCHED_OFFSET
|
||||
// loop that previously wedged the agent's upload worker forever.
|
||||
loseProgress bool
|
||||
|
||||
// requests records the headers of every received request (in order) so
|
||||
// tests can assert which auth/routing headers the client sent per method.
|
||||
requests []recordedRequest
|
||||
}
|
||||
|
||||
func newFakeTus() *fakeTus {
|
||||
return &fakeTus{uploads: map[string]*fakeUpload{}}
|
||||
}
|
||||
|
||||
func (s *fakeTus) seed(size, offset int64) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.counter++
|
||||
id := fmt.Sprintf("seed-%d", s.counter)
|
||||
s.uploads[id] = &fakeUpload{size: size, offset: offset}
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *fakeTus) totalBytes() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var total int64
|
||||
for _, u := range s.uploads {
|
||||
total += u.offset
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *fakeTus) lastPatch() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastPatchBytes
|
||||
}
|
||||
|
||||
// patchCounts returns the number of PATCH requests received and the size of each.
|
||||
func (s *fakeTus) patchCounts() (int, []int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sizes := make([]int64, len(s.patchSizes))
|
||||
copy(sizes, s.patchSizes)
|
||||
return len(s.patchSizes), sizes
|
||||
}
|
||||
|
||||
func (s *fakeTus) createCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.creates
|
||||
}
|
||||
|
||||
// requestsForMethod returns the recorded requests for the given HTTP method.
|
||||
func (s *fakeTus) requestsForMethod(method string) []recordedRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []recordedRequest
|
||||
for _, req := range s.requests {
|
||||
if req.method == method {
|
||||
out = append(out, req)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *fakeTus) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, tusUploadPath)
|
||||
w.Header().Set("Tus-Resumable", tusResumableVersion)
|
||||
|
||||
s.mu.Lock()
|
||||
s.requests = append(s.requests, recordedRequest{method: r.Method, header: r.Header.Clone()})
|
||||
s.mu.Unlock()
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
if s.unsupported {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
length, _ := strconv.ParseInt(r.Header.Get("Upload-Length"), 10, 64)
|
||||
s.mu.Lock()
|
||||
s.counter++
|
||||
s.creates++
|
||||
newID := fmt.Sprintf("up-%d", s.counter)
|
||||
s.uploads[newID] = &fakeUpload{size: length}
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Location", tusUploadPath+newID)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
case http.MethodHead:
|
||||
s.mu.Lock()
|
||||
u, ok := s.uploads[id]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(u.offset, 10))
|
||||
w.Header().Set("Upload-Length", strconv.FormatInt(u.size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
case http.MethodPatch:
|
||||
s.mu.Lock()
|
||||
u, ok := s.uploads[id]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if s.loseProgress {
|
||||
reqOffset, _ := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
|
||||
s.mu.Lock()
|
||||
cur := u.offset
|
||||
if reqOffset != cur {
|
||||
// The offset the client resumes from no longer matches what this
|
||||
// "replica" retained, so reject like a vault returning
|
||||
// ERR_MISMATCHED_OFFSET.
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(cur, 10))
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, r.Body)
|
||||
s.lastPatchBytes = n
|
||||
s.patchSizes = append(s.patchSizes, n)
|
||||
// Advertise progress to the client, then immediately forget it so the
|
||||
// next chunk (sent at the advanced offset) mismatches again.
|
||||
reported := cur + n
|
||||
u.offset = 0
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(reported, 10))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, r.Body)
|
||||
s.mu.Lock()
|
||||
u.offset += n
|
||||
s.lastPatchBytes = n
|
||||
s.patchSizes = append(s.patchSizes, n)
|
||||
complete := u.offset >= u.size
|
||||
failNow := complete && s.failFinalize > 0
|
||||
if failNow {
|
||||
s.failFinalize--
|
||||
}
|
||||
offset := u.offset
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||||
if failNow {
|
||||
// Bytes are stored but the (simulated) completion hook failed.
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case http.MethodDelete:
|
||||
s.mu.Lock()
|
||||
delete(s.uploads, id)
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// withRecording switches into a fresh temp working directory containing a
|
||||
// recording at data/recordings/<fileName>. The working directory is restored on
|
||||
// cleanup. Tests using this helper must not run in parallel.
|
||||
func withRecording(t *testing.T, fileName string, payload []byte) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
if err := os.MkdirAll("data/recordings", 0o755); err != nil {
|
||||
t.Fatalf("mkdir recordings: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("data/recordings", fileName), payload, 0o644); err != nil {
|
||||
t.Fatalf("write recording: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withQueuedRecordingFPS(t *testing.T, fileName, fps string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll("data/cloud", 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
markerName := models.RecordingUploadMetadataFileName(fileName)
|
||||
if err := os.WriteFile(filepath.Join("data/cloud", markerName), []byte(fps), 0o644); err != nil {
|
||||
t.Fatalf("write cloud queue marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testVault(uri string) models.KStorage {
|
||||
return models.KStorage{
|
||||
URI: uri,
|
||||
AccessKey: "ak",
|
||||
SecretAccessKey: "sk",
|
||||
Provider: "gcp",
|
||||
Directory: "dir",
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_HappyPath(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("x"), 4096)
|
||||
withRecording(t, fileName, payload)
|
||||
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":29.97}`)
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !responded || !supported {
|
||||
t.Fatalf("uploaded/responded/supported = %v/%v/%v, want all true", uploaded, responded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected sidecar to be removed after success, stat err = %v", err)
|
||||
}
|
||||
posts := srv.requestsForMethod(http.MethodPost)
|
||||
metadata := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
|
||||
if got := metadata["fps"]; got != "29.97" {
|
||||
t.Fatalf("POST metadata fps = %q, want %q", got, "29.97")
|
||||
}
|
||||
if got := metadata["duration"]; got != "20452" {
|
||||
t.Fatalf("POST metadata duration = %q, want %q", got, "20452")
|
||||
}
|
||||
if got := metadata["timestamp"]; got != "1785934709414" {
|
||||
t.Fatalf("POST metadata timestamp = %q, want %q", got, "1785934709414")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
fps string
|
||||
want string
|
||||
}{
|
||||
{name: "json", fps: `{"fps":29}`, want: "29"},
|
||||
{name: "json fractional", fps: `{"fps":17.35}`, want: "17.35"},
|
||||
{name: "json with future field", fps: `{"fps":29,"codec":"h264"}`, want: "29"},
|
||||
{name: "json without fps", fps: `{}`},
|
||||
{name: "json invalid fps", fps: `{"fps":241}`},
|
||||
{name: "legacy fractional", fps: "29.97", want: "29.97"},
|
||||
{name: "legacy trimmed", fps: " 25 \n", want: "25"},
|
||||
{name: "empty"},
|
||||
{name: "invalid", fps: "invalid"},
|
||||
{name: "zero", fps: "0"},
|
||||
{name: "negative", fps: "-1"},
|
||||
{name: "nan", fps: "NaN"},
|
||||
{name: "infinite", fps: "+Inf"},
|
||||
{name: "unreasonable", fps: "241"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
withQueuedRecordingFPS(t, fileName, test.fps)
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != test.want {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want %q", got, test.want)
|
||||
}
|
||||
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != test.want {
|
||||
t.Fatalf("legacy FPS header = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSAllowsMissingHistoricalMarker(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != "" {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want empty for missing marker", got)
|
||||
}
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != "" {
|
||||
t.Fatalf("legacy FPS header = %q, want empty for missing marker", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingMetadataHeaders(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":25}`)
|
||||
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != "25" {
|
||||
t.Fatalf("FPS header = %q", got)
|
||||
}
|
||||
if got := header.Get(recordingDurationHeader); got != "20452" {
|
||||
t.Fatalf("duration header = %q", got)
|
||||
}
|
||||
if got := header.Get(recordingTimestampHeader); got != "1785934709414" {
|
||||
t.Fatalf("timestamp header = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSAllowsLegacyMarkerFileName(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
if err := os.MkdirAll("data/cloud", 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("data/cloud", fileName), []byte("25"), 0o644); err != nil {
|
||||
t.Fatalf("write legacy cloud queue marker: %v", err)
|
||||
}
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != "25" {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want legacy marker FPS", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_Chunked(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
// 10 KiB payload uploaded in 4 KiB chunks => 3 PATCH requests (4096+4096+2048).
|
||||
payload := bytes.Repeat([]byte("c"), 10240)
|
||||
withRecording(t, fileName, payload)
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected chunked upload success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
count, sizes := srv.patchCounts()
|
||||
if count != 3 {
|
||||
t.Fatalf("expected 3 chunked PATCH requests, got %d (sizes=%v)", count, sizes)
|
||||
}
|
||||
want := []int64{4096, 4096, 2048}
|
||||
for i, w := range want {
|
||||
if sizes[i] != w {
|
||||
t.Fatalf("chunk %d size = %d, want %d (sizes=%v)", i, sizes[i], w, sizes)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected sidecar removed after success, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_ChunkingDisabled(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("d"), 10240)
|
||||
withRecording(t, fileName, payload)
|
||||
// 0 disables chunking: the whole file should go out in a single PATCH.
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "0")
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
count, sizes := srv.patchCounts()
|
||||
if count != 1 {
|
||||
t.Fatalf("expected a single PATCH when chunking is disabled, got %d (sizes=%v)", count, sizes)
|
||||
}
|
||||
if sizes[0] != int64(len(payload)) {
|
||||
t.Fatalf("single PATCH size = %d, want %d", sizes[0], len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusChunkSize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string
|
||||
set bool
|
||||
want int64
|
||||
}{
|
||||
{name: "default when unset", set: false, want: tusDefaultChunkSize},
|
||||
{name: "default on invalid", env: "notanumber", set: true, want: tusDefaultChunkSize},
|
||||
{name: "explicit value", env: "65536", set: true, want: 65536},
|
||||
{name: "zero disables", env: "0", set: true, want: 0},
|
||||
{name: "negative disables", env: "-5", set: true, want: 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.set {
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", tc.env)
|
||||
} else {
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "")
|
||||
}
|
||||
if got := tusChunkSize(); got != tc.want {
|
||||
t.Fatalf("tusChunkSize() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_Unsupported(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.unsupported = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "f.mp4"
|
||||
withRecording(t, fileName, []byte("hello"))
|
||||
|
||||
uploaded, _, supported, _, _ := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false against a vault without a tus endpoint")
|
||||
}
|
||||
if supported {
|
||||
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadVaultResumable_NetworkErrorKeepsRetryBudget verifies that when the
|
||||
// vault is unreachable (mimicking the internet being disconnected) the resumable
|
||||
// upload reports responded=false. That is what stops the caller
|
||||
// (UploadKerberosVault) from consuming its retry budget and entering the long
|
||||
// back-off timeout on a transient network outage, so the recording keeps being
|
||||
// retried until connectivity returns.
|
||||
func TestUploadVaultResumable_NetworkErrorKeepsRetryBudget(t *testing.T) {
|
||||
// Bind then immediately release a loopback port so every connection to it is
|
||||
// refused, producing a transport-level error (no HTTP response).
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
if cerr := ln.Close(); cerr != nil {
|
||||
t.Fatalf("close listener: %v", cerr)
|
||||
}
|
||||
|
||||
// Keep the between-attempt back-off tiny so the test stays fast.
|
||||
oldDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = time.Millisecond
|
||||
defer func() { tusBackoffBaseDelay = oldDelay }()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
withRecording(t, fileName, bytes.Repeat([]byte("n"), 2048))
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault("http://"+addr), "pk", "dev", fileName, "test", "primary")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false when the vault is unreachable")
|
||||
}
|
||||
if !supported {
|
||||
t.Fatal("a transport error is not a missing tus endpoint; expected supported=true")
|
||||
}
|
||||
if responded {
|
||||
t.Fatal("expected responded=false for a pure network error so the retry budget is preserved")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the vault is unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_MismatchedOffsetGivesUp(t *testing.T) {
|
||||
// A vault that never durably retains the in-progress upload (offset resets to
|
||||
// 0 between chunks) makes every resume "progress" by one chunk and then fail
|
||||
// the next chunk with 409. Before the high-water gating fix this refreshed the
|
||||
// retry budget every attempt and looped forever, wedging the upload worker and
|
||||
// saturating the uplink (which starved heartbeats and reported the camera
|
||||
// offline). The loop must now be bounded: give up after a fixed number of
|
||||
// non-progressing attempts and report responded=true so the caller re-queues.
|
||||
srv := newFakeTus()
|
||||
srv.loseProgress = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
// Keep the between-attempt back-off tiny so the test stays fast.
|
||||
oldDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = time.Millisecond
|
||||
defer func() { tusBackoffBaseDelay = oldDelay }()
|
||||
|
||||
// Force multiple chunks so there is always a second chunk to be rejected.
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
withRecording(t, fileName, bytes.Repeat([]byte("m"), 12288))
|
||||
|
||||
done := make(chan struct{})
|
||||
var uploaded, responded bool
|
||||
var upErr error
|
||||
go func() {
|
||||
uploaded, responded, _, _, upErr = uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("resumable upload did not terminate: the retry loop is unbounded on a persistent mismatched offset")
|
||||
}
|
||||
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false when the vault never retains the offset")
|
||||
}
|
||||
if !responded {
|
||||
t.Fatal("expected responded=true (the vault answered) so the caller re-queues the recording")
|
||||
}
|
||||
if upErr == nil {
|
||||
t.Fatal("expected an error when the upload cannot complete")
|
||||
}
|
||||
|
||||
// The bounded retry budget must cap the number of PATCH requests. Two PATCHes
|
||||
// per attempt across a handful of attempts stays comfortably below this.
|
||||
if count, _ := srv.patchCounts(); count > 50 {
|
||||
t.Fatalf("expected a bounded number of PATCH requests, got %d (retry loop not bounded)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_FinalizeRetry(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.failFinalize = 1
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("y"), 2048)
|
||||
withRecording(t, fileName, payload)
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected success after a failed finalize + restart, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.createCount(); got < 2 {
|
||||
t.Fatalf("expected at least 2 create requests (restart after failed finalize), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_ResumeFromSidecar(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
total := 8192
|
||||
half := 4096
|
||||
payload := bytes.Repeat([]byte("z"), total)
|
||||
withRecording(t, fileName, payload)
|
||||
|
||||
// Simulate a previous run that uploaded half the file before being interrupted.
|
||||
id := srv.seed(int64(total), int64(half))
|
||||
baseURL := strings.TrimRight(ts.URL, "/") + tusUploadPath
|
||||
saveTusResumeState(tusSidecarPath(fileName, "primary"), tusResumeState{
|
||||
UploadURL: strings.TrimRight(baseURL, "/") + "/" + id,
|
||||
VaultURI: baseURL,
|
||||
Size: int64(total),
|
||||
})
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected resume success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.lastPatch(); got != int64(total-half) {
|
||||
t.Fatalf("resume should only send the remaining %d bytes, sent %d", total-half, got)
|
||||
}
|
||||
if srv.createCount() != 0 {
|
||||
t.Fatalf("resume should not create a new upload, got %d creates", srv.createCount())
|
||||
}
|
||||
}
|
||||
|
||||
func testHubConfig(hubURI string) *models.Config {
|
||||
return &models.Config{
|
||||
Key: "device-key",
|
||||
HubURI: hubURI,
|
||||
HubKey: "hubpub",
|
||||
HubPrivateKey: "hubpriv",
|
||||
S3: &models.S3{Region: "eu-west"},
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTusMetadata parses a tus Upload-Metadata header value ("key b64,key b64")
|
||||
// back into a map of decoded key/value pairs.
|
||||
func decodeTusMetadata(meta string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if meta == "" {
|
||||
return out
|
||||
}
|
||||
for _, pair := range strings.Split(meta, ",") {
|
||||
parts := strings.SplitN(strings.TrimSpace(pair), " ", 2)
|
||||
if parts[0] == "" {
|
||||
continue
|
||||
}
|
||||
val := ""
|
||||
if len(parts) == 2 {
|
||||
if b, err := base64.StdEncoding.DecodeString(parts[1]); err == nil {
|
||||
val = string(b)
|
||||
}
|
||||
}
|
||||
out[parts[0]] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestUploadHubResumable_HappyPath(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("h"), 4096)
|
||||
withRecording(t, fileName, payload)
|
||||
withQueuedRecordingFPS(t, fileName, "29.97")
|
||||
|
||||
uploaded, _, supported, _, err := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("uploaded/supported = %v/%v, want both true", uploaded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
|
||||
// The Hub auth headers must be present on every request type (POST/HEAD/PATCH),
|
||||
// because Kerberos Hub validates them on each proxied request. Conversely the
|
||||
// vault credentials/routing are injected by Kerberos Hub on the agent's behalf
|
||||
// and must never be sent by the agent on the hub path.
|
||||
for _, method := range []string{http.MethodPost, http.MethodHead, http.MethodPatch} {
|
||||
reqs := srv.requestsForMethod(method)
|
||||
if len(reqs) == 0 {
|
||||
t.Fatalf("expected at least one %s request", method)
|
||||
}
|
||||
for _, req := range reqs {
|
||||
if got := req.header.Get("X-Kerberos-Hub-PublicKey"); got != "hubpub" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-PublicKey = %q, want %q", method, got, "hubpub")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Hub-PrivateKey"); got != "hubpriv" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-PrivateKey = %q, want %q", method, got, "hubpriv")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Hub-Region"); got != "eu-west" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-Region = %q, want %q", method, got, "eu-west")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Storage-Device"); got != "device-key" {
|
||||
t.Errorf("%s: X-Kerberos-Storage-Device = %q, want %q", method, got, "device-key")
|
||||
}
|
||||
for _, h := range []string{
|
||||
"X-Kerberos-Storage-AccessKey",
|
||||
"X-Kerberos-Storage-SecretAccessKey",
|
||||
"X-Kerberos-Storage-CloudKey",
|
||||
"X-Kerberos-Storage-Provider",
|
||||
"X-Kerberos-Storage-Directory",
|
||||
} {
|
||||
if got := req.header.Get(h); got != "" {
|
||||
t.Errorf("%s: %s should be empty on the hub path, got %q", method, h, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The creation request carries the upload metadata; on the hub path it must
|
||||
// omit directory/provider/cloudkey (Hub resolves those) but include
|
||||
// filename/device/capture. The filename header is also set on create.
|
||||
posts := srv.requestsForMethod(http.MethodPost)
|
||||
if got := posts[0].header.Get("X-Kerberos-Storage-FileName"); got != fileName {
|
||||
t.Errorf("POST X-Kerberos-Storage-FileName = %q, want %q", got, fileName)
|
||||
}
|
||||
meta := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
|
||||
for _, omitted := range []string{"directory", "provider", "cloudkey"} {
|
||||
if _, ok := meta[omitted]; ok {
|
||||
t.Errorf("hub metadata must omit %q, got %v", omitted, meta)
|
||||
}
|
||||
}
|
||||
if meta["filename"] != fileName {
|
||||
t.Errorf("hub metadata filename = %q, want %q", meta["filename"], fileName)
|
||||
}
|
||||
if meta["device"] != "device-key" {
|
||||
t.Errorf("hub metadata device = %q, want %q", meta["device"], "device-key")
|
||||
}
|
||||
if meta["capture"] != "IPCamera" {
|
||||
t.Errorf("hub metadata capture = %q, want %q", meta["capture"], "IPCamera")
|
||||
}
|
||||
if meta["fps"] != "29.97" {
|
||||
t.Errorf("hub metadata fps = %q, want %q", meta["fps"], "29.97")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadHubResumable_Unsupported(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.unsupported = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "f.mp4"
|
||||
withRecording(t, fileName, []byte("hello"))
|
||||
|
||||
uploaded, _, supported, _, _ := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false against a hub without a tus endpoint")
|
||||
}
|
||||
if supported {
|
||||
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTusMetadata(t *testing.T) {
|
||||
got := encodeTusMetadata(map[string]string{
|
||||
"b": "2",
|
||||
"a": "1",
|
||||
"empty": "",
|
||||
})
|
||||
// keys sorted, empty values skipped, values base64-encoded.
|
||||
want := "a MQ==,b Mg=="
|
||||
if got != want {
|
||||
t.Fatalf("encodeTusMetadata = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTusLocation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
location string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "absolute path location",
|
||||
base: "http://host/storage/tus/",
|
||||
location: "/storage/tus/abc",
|
||||
want: "http://host/storage/tus/abc",
|
||||
},
|
||||
{
|
||||
name: "absolute url keeps configured host",
|
||||
base: "http://host/storage/tus/",
|
||||
location: "http://internal:8080/storage/tus/xyz",
|
||||
want: "http://host/storage/tus/xyz",
|
||||
},
|
||||
{
|
||||
name: "relative id",
|
||||
base: "http://host/api/storage/tus/",
|
||||
location: "abc",
|
||||
want: "http://host/api/storage/tus/abc",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := resolveTusLocation(tc.base, tc.location); got != tc.want {
|
||||
t.Fatalf("resolveTusLocation(%q, %q) = %q, want %q", tc.base, tc.location, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusResumeStateRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(old)
|
||||
|
||||
path := tusSidecarPath("file.mp4", "primary")
|
||||
state := tusResumeState{UploadURL: "http://host/storage/tus/abc", VaultURI: "http://host/storage/tus/", Size: 123}
|
||||
saveTusResumeState(path, state)
|
||||
|
||||
if got := loadTusResumeState(path, state.VaultURI); got != state.UploadURL {
|
||||
t.Fatalf("loadTusResumeState = %q, want %q", got, state.UploadURL)
|
||||
}
|
||||
// A mismatched vault URI must not be reused.
|
||||
if got := loadTusResumeState(path, "http://other/storage/tus/"); got != "" {
|
||||
t.Fatalf("loadTusResumeState with mismatched vault = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package components
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +17,62 @@ import (
|
||||
"github.com/zaf/g711"
|
||||
)
|
||||
|
||||
const (
|
||||
backchannelSampleRate = 8000
|
||||
backchannelTalkspurtGap = 500 * time.Millisecond
|
||||
backchannelReconnectInitial = time.Second
|
||||
backchannelReconnectMax = 30 * time.Second
|
||||
)
|
||||
|
||||
type backchannelClient interface {
|
||||
ConnectBackChannel(ctx context.Context, otelContext context.Context) error
|
||||
StartBackChannel(ctx context.Context, otelContext context.Context) error
|
||||
WritePacket(pkt packets.Packet) error
|
||||
Close(otelContext context.Context) error
|
||||
}
|
||||
|
||||
type backchannelPacketizer struct {
|
||||
sequenceNumber uint16
|
||||
timestamp uint32
|
||||
ssrc uint32
|
||||
lastPacketAt time.Time
|
||||
}
|
||||
|
||||
func newBackchannelPacketizer() backchannelPacketizer {
|
||||
return backchannelPacketizer{
|
||||
sequenceNumber: uint16(rand.Uint32()),
|
||||
timestamp: rand.Uint32(),
|
||||
ssrc: rand.Uint32(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *backchannelPacketizer) packet(audio models.AudioDataPartial, now time.Time) packets.Packet {
|
||||
bufferUlaw := make([]byte, len(audio.Data))
|
||||
for index, sample := range audio.Data {
|
||||
bufferUlaw[index] = g711.EncodeUlawFrame(sample)
|
||||
}
|
||||
|
||||
pkt := packets.Packet{
|
||||
Packet: &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: p.lastPacketAt.IsZero() || now.Sub(p.lastPacketAt) >= backchannelTalkspurtGap,
|
||||
PayloadType: 0,
|
||||
SequenceNumber: p.sequenceNumber,
|
||||
Timestamp: p.timestamp,
|
||||
SSRC: p.ssrc,
|
||||
},
|
||||
Payload: bufferUlaw,
|
||||
},
|
||||
}
|
||||
|
||||
p.timestamp += uint32(len(bufferUlaw))
|
||||
p.sequenceNumber++
|
||||
p.lastPacketAt = now
|
||||
|
||||
return pkt
|
||||
}
|
||||
|
||||
func GetBackChannelAudioCodec(streams []av.CodecData, communication *models.Communication) av.AudioCodecData {
|
||||
for _, stream := range streams {
|
||||
if stream.Type().IsAudio() {
|
||||
@@ -31,41 +89,115 @@ func GetBackChannelAudioCodec(streams []av.CodecData, communication *models.Comm
|
||||
}
|
||||
|
||||
func WriteAudioToBackchannel(communication *models.Communication, rtspClient capture.RTSPClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): writing to backchannel audio codec")
|
||||
length := uint32(0)
|
||||
sequenceNumber := uint16(0)
|
||||
for audio := range communication.HandleAudio {
|
||||
// Encode PCM to MULAW
|
||||
var bufferUlaw []byte
|
||||
for _, v := range audio.Data {
|
||||
b := g711.EncodeUlawFrame(v)
|
||||
bufferUlaw = append(bufferUlaw, b)
|
||||
}
|
||||
|
||||
pkt := packets.Packet{
|
||||
Packet: &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: true, // should be true
|
||||
PayloadType: 0, //packet.PayloadType, // will be owerwriten
|
||||
SequenceNumber: sequenceNumber,
|
||||
Timestamp: uint32(length),
|
||||
SSRC: 1293847657,
|
||||
},
|
||||
Payload: bufferUlaw,
|
||||
},
|
||||
}
|
||||
err := rtspClient.WritePacket(pkt)
|
||||
if err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error writing packet to backchannel")
|
||||
}
|
||||
|
||||
length = (length + uint32(len(bufferUlaw))) % 65536
|
||||
sequenceNumber = (sequenceNumber + 1) % 65535
|
||||
time.Sleep(128 * time.Millisecond)
|
||||
ctx := context.Background()
|
||||
if communication.Context != nil {
|
||||
ctx = *communication.Context
|
||||
}
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): finished")
|
||||
|
||||
writeAudioToBackchannel(ctx, ctx, communication.HandleAudio, rtspClient)
|
||||
}
|
||||
|
||||
func writeAudioToBackchannel(ctx context.Context, otelContext context.Context, audioChannel <-chan models.AudioDataPartial, rtspClient backchannelClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): writing to backchannel audio codec")
|
||||
|
||||
if err := rtspClient.StartBackChannel(ctx, otelContext); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error starting backchannel: " + err.Error())
|
||||
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
packetizer := newBackchannelPacketizer()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped")
|
||||
return
|
||||
case audio, ok := <-audioChannel:
|
||||
if !ok {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): finished")
|
||||
return
|
||||
}
|
||||
|
||||
audio = latestBackchannelAudio(audio, audioChannel)
|
||||
if len(audio.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
pkt := packetizer.packet(audio, time.Now())
|
||||
if err := rtspClient.WritePacket(pkt); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error writing packet to backchannel: " + err.Error())
|
||||
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
|
||||
return
|
||||
}
|
||||
packetizer = newBackchannelPacketizer()
|
||||
continue
|
||||
}
|
||||
|
||||
if !waitForBackchannel(ctx, time.Duration(len(audio.Data))*time.Second/backchannelSampleRate) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func latestBackchannelAudio(audio models.AudioDataPartial, audioChannel <-chan models.AudioDataPartial) models.AudioDataPartial {
|
||||
for {
|
||||
select {
|
||||
case next, ok := <-audioChannel:
|
||||
if !ok {
|
||||
return audio
|
||||
}
|
||||
audio = next
|
||||
default:
|
||||
return audio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reconnectBackchannel(ctx context.Context, otelContext context.Context, rtspClient backchannelClient) bool {
|
||||
backoff := backchannelReconnectInitial
|
||||
for {
|
||||
if err := rtspClient.Close(otelContext); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error closing failed backchannel: " + err.Error())
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err := rtspClient.ConnectBackChannel(ctx, otelContext)
|
||||
if err == nil {
|
||||
err = rtspClient.StartBackChannel(ctx, otelContext)
|
||||
}
|
||||
if err == nil {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): reconnected backchannel")
|
||||
return true
|
||||
}
|
||||
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error reconnecting backchannel: " + err.Error())
|
||||
if !waitForBackchannel(ctx, backoff) {
|
||||
return false
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > backchannelReconnectMax {
|
||||
backoff = backchannelReconnectMax
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForBackchannel(ctx context.Context, duration time.Duration) bool {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func WriteFileToBackChannel(infile av.DemuxCloser) {
|
||||
|
||||
209
machinery/src/components/backchannel_test.go
Normal file
209
machinery/src/components/backchannel_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
)
|
||||
|
||||
type fakeBackchannelClient struct {
|
||||
mutex sync.Mutex
|
||||
startErrors []error
|
||||
connectError error
|
||||
writeErrors []error
|
||||
startCalls int
|
||||
connectCalls int
|
||||
closeCalls int
|
||||
writeCalls int
|
||||
connectAttempt chan struct{}
|
||||
successfulWrite chan packets.Packet
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) ConnectBackChannel(context.Context, context.Context) error {
|
||||
f.mutex.Lock()
|
||||
f.connectCalls++
|
||||
err := f.connectError
|
||||
f.mutex.Unlock()
|
||||
|
||||
select {
|
||||
case f.connectAttempt <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) StartBackChannel(context.Context, context.Context) error {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
f.startCalls++
|
||||
if len(f.startErrors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := f.startErrors[0]
|
||||
f.startErrors = f.startErrors[1:]
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) WritePacket(pkt packets.Packet) error {
|
||||
f.mutex.Lock()
|
||||
f.writeCalls++
|
||||
var err error
|
||||
if len(f.writeErrors) != 0 {
|
||||
err = f.writeErrors[0]
|
||||
f.writeErrors = f.writeErrors[1:]
|
||||
}
|
||||
f.mutex.Unlock()
|
||||
|
||||
if err == nil {
|
||||
select {
|
||||
case f.successfulWrite <- pkt:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) Close(context.Context) error {
|
||||
f.mutex.Lock()
|
||||
f.closeCalls++
|
||||
f.mutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBackchannelClient) callCounts() (start, connect, close, write int) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
return f.startCalls, f.connectCalls, f.closeCalls, f.writeCalls
|
||||
}
|
||||
|
||||
func TestBackchannelPacketizerUsesFullRTPClock(t *testing.T) {
|
||||
packetizer := backchannelPacketizer{ssrc: 1}
|
||||
audio := models.AudioDataPartial{Data: make([]int16, 1024)}
|
||||
startedAt := time.Unix(1, 0)
|
||||
|
||||
var timestamp uint32
|
||||
for index := 0; index <= 64; index++ {
|
||||
pkt := packetizer.packet(audio, startedAt.Add(time.Duration(index)*128*time.Millisecond))
|
||||
timestamp = pkt.Packet.Timestamp
|
||||
}
|
||||
|
||||
if timestamp != 65536 {
|
||||
t.Fatalf("timestamp after 64 frames = %d, want 65536", timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackchannelPacketizerUsesNaturalSequenceRollover(t *testing.T) {
|
||||
packetizer := backchannelPacketizer{sequenceNumber: ^uint16(0), ssrc: 1}
|
||||
audio := models.AudioDataPartial{Data: []int16{0}}
|
||||
startedAt := time.Unix(1, 0)
|
||||
|
||||
last := packetizer.packet(audio, startedAt)
|
||||
firstAfterRollover := packetizer.packet(audio, startedAt.Add(time.Millisecond))
|
||||
|
||||
if last.Packet.SequenceNumber != ^uint16(0) {
|
||||
t.Fatalf("last sequence number = %d, want %d", last.Packet.SequenceNumber, ^uint16(0))
|
||||
}
|
||||
if firstAfterRollover.Packet.SequenceNumber != 0 {
|
||||
t.Fatalf("first sequence number after rollover = %d, want 0", firstAfterRollover.Packet.SequenceNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackchannelPacketizerMarksTalkspurtStart(t *testing.T) {
|
||||
packetizer := backchannelPacketizer{ssrc: 1}
|
||||
audio := models.AudioDataPartial{Data: []int16{0}}
|
||||
startedAt := time.Unix(1, 0)
|
||||
|
||||
first := packetizer.packet(audio, startedAt)
|
||||
continuous := packetizer.packet(audio, startedAt.Add(128*time.Millisecond))
|
||||
afterGap := packetizer.packet(audio, startedAt.Add(backchannelTalkspurtGap+128*time.Millisecond))
|
||||
|
||||
if !first.Packet.Marker {
|
||||
t.Fatal("first packet must mark the start of a talkspurt")
|
||||
}
|
||||
if continuous.Packet.Marker {
|
||||
t.Fatal("continuous packet must not carry the marker bit")
|
||||
}
|
||||
if !afterGap.Packet.Marker {
|
||||
t.Fatal("packet after an audio gap must mark a new talkspurt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAudioToBackchannelReconnectsAfterWriteFailure(t *testing.T) {
|
||||
writeFailure := errors.New("EOF")
|
||||
client := &fakeBackchannelClient{
|
||||
writeErrors: []error{writeFailure, nil},
|
||||
connectAttempt: make(chan struct{}, 1),
|
||||
successfulWrite: make(chan packets.Packet, 1),
|
||||
}
|
||||
audioChannel := make(chan models.AudioDataPartial, 2)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
writeAudioToBackchannel(ctx, ctx, audioChannel, client)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
|
||||
select {
|
||||
case <-client.connectAttempt:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backchannel was not reconnected after the write failure")
|
||||
}
|
||||
|
||||
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
|
||||
select {
|
||||
case pkt := <-client.successfulWrite:
|
||||
if !pkt.Packet.Marker {
|
||||
t.Fatal("first packet after reconnect must mark a new talkspurt")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("fresh audio was not written after reconnect")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("backchannel writer did not stop after cancellation")
|
||||
}
|
||||
|
||||
startCalls, connectCalls, closeCalls, writeCalls := client.callCounts()
|
||||
if startCalls != 2 || connectCalls != 1 || closeCalls != 1 || writeCalls != 2 {
|
||||
t.Fatalf("calls (start, connect, close, write) = (%d, %d, %d, %d), want (2, 1, 1, 2)", startCalls, connectCalls, closeCalls, writeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAudioToBackchannelCancellationStopsReconnect(t *testing.T) {
|
||||
client := &fakeBackchannelClient{
|
||||
connectError: errors.New("camera unavailable"),
|
||||
writeErrors: []error{errors.New("EOF")},
|
||||
connectAttempt: make(chan struct{}, 1),
|
||||
successfulWrite: make(chan packets.Packet, 1),
|
||||
}
|
||||
audioChannel := make(chan models.AudioDataPartial, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
writeAudioToBackchannel(ctx, ctx, audioChannel, client)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
audioChannel <- models.AudioDataPartial{Data: make([]int16, 1024)}
|
||||
select {
|
||||
case <-client.connectAttempt:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected a reconnect attempt")
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
t.Fatal("cancellation did not interrupt reconnect backoff")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
@@ -69,9 +70,16 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
|
||||
communication.HandleUpload = make(chan string, 1)
|
||||
communication.HandleHeartBeat = make(chan string, 1)
|
||||
communication.HandleLiveSD = make(chan int64, 1)
|
||||
communication.HandleLiveSDHTTP = make(chan int64, 1)
|
||||
communication.HandleLiveHDKeepalive = make(chan string, 1)
|
||||
communication.HandleLiveHDPeers = make(chan string, 1)
|
||||
communication.HandleLiveHLS = make(chan string, 1)
|
||||
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
|
||||
communication.IsConfiguring = abool.New()
|
||||
communication.IsRecordingManual = abool.New()
|
||||
communication.RecordingManualHeartbeat = &atomic.Int64{}
|
||||
communication.RecordingManualStart = &atomic.Int64{}
|
||||
communication.RecordingManualHeartbeatSeen = abool.New()
|
||||
|
||||
cameraSettings := &models.Camera{}
|
||||
|
||||
@@ -175,19 +183,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
configuration.Config.Capture.IPCamera.Height = height
|
||||
|
||||
// Set the liveview width and height, this is used for the liveview and motion regions (drawing on the hub).
|
||||
baseWidth := config.Capture.IPCamera.BaseWidth
|
||||
baseHeight := config.Capture.IPCamera.BaseHeight
|
||||
// If the liveview height is not set, we will calculate it based on the width and aspect ratio of the camera.
|
||||
if baseWidth > 0 && baseHeight == 0 {
|
||||
widthAspectRatio := float64(baseWidth) / float64(width)
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = int(float64(height) * widthAspectRatio)
|
||||
} else if baseHeight > 0 && baseWidth > 0 {
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = baseHeight
|
||||
configuration.Config.Capture.IPCamera.BaseWidth = baseWidth
|
||||
} else {
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = height
|
||||
configuration.Config.Capture.IPCamera.BaseWidth = width
|
||||
}
|
||||
// ResolveBaseDimensions gates the aspect-ratio compute on width/height > 0
|
||||
// so a not-yet-probed stream can't poison the dimensions and crash resize.
|
||||
configuration.Config.Capture.IPCamera.BaseWidth, configuration.Config.Capture.IPCamera.BaseHeight =
|
||||
utils.ResolveBaseDimensions(config.Capture.IPCamera.BaseWidth, config.Capture.IPCamera.BaseHeight, width, height)
|
||||
|
||||
// Set the SPS and PPS values in the configuration.
|
||||
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{videoStream.SPS}
|
||||
@@ -243,21 +242,17 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
configuration.Config.Capture.IPCamera.SubWidth = width
|
||||
configuration.Config.Capture.IPCamera.SubHeight = height
|
||||
|
||||
// Capture the sub stream parameter sets separately from the main stream so
|
||||
// the live HLS muxer can build a correct init segment when a viewer asks for
|
||||
// the sub (low-resolution) stream on demand.
|
||||
configuration.Config.Capture.IPCamera.SubSPSNALUs = [][]byte{videoSubStream.SPS}
|
||||
configuration.Config.Capture.IPCamera.SubPPSNALUs = [][]byte{videoSubStream.PPS}
|
||||
configuration.Config.Capture.IPCamera.SubVPSNALUs = [][]byte{videoSubStream.VPS}
|
||||
|
||||
// If we have a substream, we need to set the width and height of the substream. (so we will override above information)
|
||||
// Set the liveview width and height, this is used for the liveview and motion regions (drawing on the hub).
|
||||
baseWidth := config.Capture.IPCamera.BaseWidth
|
||||
baseHeight := config.Capture.IPCamera.BaseHeight
|
||||
// If the liveview height is not set, we will calculate it based on the width and aspect ratio of the camera.
|
||||
if baseWidth > 0 && baseHeight == 0 {
|
||||
widthAspectRatio := float64(baseWidth) / float64(width)
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = int(float64(height) * widthAspectRatio)
|
||||
} else if baseHeight > 0 && baseWidth > 0 {
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = baseHeight
|
||||
configuration.Config.Capture.IPCamera.BaseWidth = baseWidth
|
||||
} else {
|
||||
configuration.Config.Capture.IPCamera.BaseHeight = height
|
||||
configuration.Config.Capture.IPCamera.BaseWidth = width
|
||||
}
|
||||
configuration.Config.Capture.IPCamera.BaseWidth, configuration.Config.Capture.IPCamera.BaseHeight =
|
||||
utils.ResolveBaseDimensions(config.Capture.IPCamera.BaseWidth, config.Capture.IPCamera.BaseHeight, width, height)
|
||||
}
|
||||
|
||||
// We are creating a queue to store the RTSP frames in, these frames will be
|
||||
@@ -275,11 +270,11 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
communication.MainStreamConnected = true
|
||||
|
||||
// Try to create backchannel
|
||||
communication.HasBackChannel = false
|
||||
rtspBackChannelClient := captureDevice.SetBackChannelClient(rtspUrl)
|
||||
err = rtspBackChannelClient.ConnectBackChannel(ctx, ctxRunAgent)
|
||||
if err == nil {
|
||||
log.Log.Info("components.Kerberos.RunAgent(): opened RTSP backchannel stream: " + rtspUrl)
|
||||
go rtspBackChannelClient.StartBackChannel(ctx, ctxRunAgent)
|
||||
}
|
||||
|
||||
rtspSubClient := captureDevice.RTSPSubClient
|
||||
@@ -303,18 +298,27 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
go cloud.HandleLiveStreamSD(livestreamCursor, configuration, communication, mqttClient, rtspClient)
|
||||
}
|
||||
|
||||
// Handle livestream HD (high resolution over WEBRTC)
|
||||
// Handle livestream HLS (adaptive segments over HTTP via hub-api -> vault).
|
||||
// The producer can serve either the main (high-resolution) or sub
|
||||
// (low-resolution) stream and switches between them on demand based on the
|
||||
// quality the viewer requests; "auto" prefers the sub stream when available.
|
||||
// Like SD it is viewer-keepalive gated and produces no traffic while nobody is
|
||||
// watching.
|
||||
go cloud.HandleLiveStreamHLS(configuration, communication, mqttClient, subStreamEnabled)
|
||||
|
||||
// MoQ is available only in the dedicated CGO/glibc build. The standard
|
||||
// static Alpine build resolves this hook to a no-op.
|
||||
cloud.StartLiveStreamMoQ(configuration, communication, subStreamEnabled)
|
||||
|
||||
// Handle livestream HD (high resolution over WEBRTC). Both the main and sub
|
||||
// stream are exposed as separate broadcasters so a viewer can request the
|
||||
// high (main) or low (sub) resolution per peer connection; "auto" prefers the
|
||||
// sub stream when available.
|
||||
communication.HandleLiveHDHandshake = make(chan models.LiveHDHandshake, 100)
|
||||
if subStreamEnabled {
|
||||
livestreamHDCursor := subQueue.Latest()
|
||||
go cloud.HandleLiveStreamHD(livestreamHDCursor, configuration, communication, mqttClient, rtspSubClient)
|
||||
} else {
|
||||
livestreamHDCursor := queue.Latest()
|
||||
go cloud.HandleLiveStreamHD(livestreamHDCursor, configuration, communication, mqttClient, rtspClient)
|
||||
}
|
||||
go cloud.HandleLiveStreamHD(configuration, communication, mqttClient, rtspClient, rtspSubClient, subStreamEnabled)
|
||||
|
||||
// Handle recording, will write an mp4 to disk.
|
||||
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient)
|
||||
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient, mqttClient)
|
||||
|
||||
// Handle processing of motion
|
||||
communication.HandleMotion = make(chan models.MotionDataPartial, 10)
|
||||
@@ -342,7 +346,11 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
communication.HandleONVIF = make(chan models.OnvifAction, 10)
|
||||
go onvif.HandleONVIFActions(configuration, communication)
|
||||
|
||||
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
|
||||
// Handle ONVIF event stream — opt-in via Capture.ONVIFMotion="true".
|
||||
// Stops when the agent's shared context is cancelled. The function
|
||||
// is a no-op if ONVIFMotion is not enabled.
|
||||
go onvif.HandleONVIFEventStream(*communication.Context, configuration, communication)
|
||||
|
||||
if rtspBackChannelClient.HasBackChannel {
|
||||
communication.HasBackChannel = true
|
||||
go WriteAudioToBackchannel(communication, rtspBackChannelClient)
|
||||
@@ -433,9 +441,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
close(communication.HandleMotion)
|
||||
communication.HandleMotion = nil
|
||||
|
||||
close(communication.HandleAudio)
|
||||
communication.HandleAudio = nil
|
||||
|
||||
close(communication.HandleONVIF)
|
||||
communication.HandleONVIF = nil
|
||||
|
||||
@@ -446,6 +451,37 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
return status
|
||||
}
|
||||
|
||||
// packetAgeString returns a human readable age (e.g. "12s") since the last
|
||||
// packet timestamp stored in the given atomic.Value, or "unknown" when no
|
||||
// packet has been received yet. Used to add context to watchdog restart logs.
|
||||
func packetAgeString(timer *atomic.Value) string {
|
||||
if timer == nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// atomic.Value panics on Load() if it was never initialized via Store().
|
||||
var v any
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
v = nil
|
||||
}
|
||||
}()
|
||||
v = timer.Load()
|
||||
}()
|
||||
|
||||
last, ok := v.(int64)
|
||||
if !ok || last == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
age := time.Now().Unix() - last
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return strconv.FormatInt(age, 10) + "s"
|
||||
}
|
||||
|
||||
// ControlAgent will check if the camera is still connected, if not it will restart the agent.
|
||||
// In the other thread we are keeping track of the number of packets received, and particular the keyframe packets.
|
||||
// Once we are not receiving any packets anymore, we will restart the agent.
|
||||
@@ -480,7 +516,8 @@ func ControlAgent(communication *models.Communication) {
|
||||
|
||||
// After 15 seconds without activity this is thrown..
|
||||
if occurence == 3 {
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream.")
|
||||
log.Log.Info(fmt.Sprintf("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream. (stalledKeyframeCounter=%d, lastPacket=%s ago, isConfiguring=%t)",
|
||||
packetsR, packetAgeString(communication.LastPacketTimer), communication.IsConfiguring.IsSet()))
|
||||
select {
|
||||
case communication.HandleBootstrap <- "restart":
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
|
||||
@@ -507,6 +544,8 @@ func ControlAgent(communication *models.Communication) {
|
||||
|
||||
// After 15 seconds without activity this is thrown..
|
||||
if occurenceSub == 3 {
|
||||
log.Log.Info(fmt.Sprintf("components.Kerberos.ControlAgent(): substream stalled (stalledKeyframeCounter=%d, lastPacket=%s ago, isConfiguring=%t)",
|
||||
packetsSubR, packetAgeString(communication.LastPacketTimerSub), communication.IsConfiguring.IsSet()))
|
||||
select {
|
||||
case communication.HandleBootstrap <- "restart":
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
|
||||
@@ -752,10 +791,24 @@ func GetSnapshotRaw(c *gin.Context, captureDevice *capture.Capture, configuratio
|
||||
// @Description Get the current configuration.
|
||||
// @Success 200
|
||||
func GetConfig(c *gin.Context, captureDevice *capture.Capture, configuration *models.Configuration, communication *models.Communication) {
|
||||
// We'll try to get a snapshot from the camera.
|
||||
base64Image := capture.Base64Image(captureDevice, communication, configuration)
|
||||
if base64Image != "" {
|
||||
communication.Image = base64Image
|
||||
// We'll try to get a fresh snapshot from the camera. Capturing a snapshot
|
||||
// reads a keyframe from the live stream, which blocks until one arrives.
|
||||
// When the camera is offline or the stream is stalled (no packets being
|
||||
// received) this would block the /config endpoint indefinitely, making the
|
||||
// agent appear unreachable even though its HTTP server is healthy. We
|
||||
// therefore bound the snapshot fetch with a short timeout and fall back to
|
||||
// the last cached snapshot, so /config always responds promptly.
|
||||
snapshot := make(chan string, 1)
|
||||
go func() {
|
||||
snapshot <- capture.Base64Image(captureDevice, communication, configuration)
|
||||
}()
|
||||
select {
|
||||
case base64Image := <-snapshot:
|
||||
if base64Image != "" {
|
||||
communication.Image = base64Image
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
log.Log.Info("components.Kerberos.GetConfig(): snapshot timed out (stream stalled or camera offline), returning configuration with the last cached snapshot.")
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
@@ -22,20 +22,35 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
var isPixelChangeThresholdReached = false
|
||||
var changesToReturn = 0
|
||||
var motionRectangle models.MotionRectangle
|
||||
var motionRectangles []models.MotionRectangle
|
||||
|
||||
pixelThreshold := config.Capture.PixelChangeThreshold
|
||||
// Might not be set in the config file, so set it to 150
|
||||
if pixelThreshold == 0 {
|
||||
pixelThreshold = 150
|
||||
// Resolve the motion sensitivity (pixel-change threshold). Nil, zero, and
|
||||
// negative values use the historical default so older configurations keep
|
||||
// recording after an upgrade.
|
||||
pixelThreshold := 150
|
||||
if config.Capture.PixelChangeThreshold != nil && *config.Capture.PixelChangeThreshold > 0 {
|
||||
pixelThreshold = *config.Capture.PixelChangeThreshold
|
||||
}
|
||||
// In motion mode we always run detection. In CONTINUOUS mode recording is
|
||||
// 24/7 so motion detection is normally skipped, BUT if a motion region is
|
||||
// configured we still run it so the live view can visualise the motion boxes
|
||||
// + region. In that case we only emit the motion EVENT — no motion-triggered
|
||||
// recording (continuous already records, and the recorder's motion branch
|
||||
// isn't draining HandleMotion in continuous mode).
|
||||
continuousMode := config.Capture.Continuous == "true"
|
||||
hasMotionRegion := config.Region != nil && len(config.Region.Polygon) > 0
|
||||
|
||||
if config.Capture.Continuous == "true" {
|
||||
if continuousMode && !hasMotionRegion {
|
||||
|
||||
log.Log.Info("computervision.main.ProcessMotion(): you've enabled continuous recording, so no motion detection required.")
|
||||
log.Log.Info("computervision.main.ProcessMotion(): continuous recording enabled and no motion region configured, so no motion detection required.")
|
||||
|
||||
} else {
|
||||
|
||||
log.Log.Info("computervision.main.ProcessMotion(): motion detected is enabled, so starting the motion detection.")
|
||||
if continuousMode {
|
||||
log.Log.Info("computervision.main.ProcessMotion(): continuous recording enabled with a motion region, running motion detection for live-view visualisation only (no motion-triggered recording).")
|
||||
} else {
|
||||
log.Log.Info("computervision.main.ProcessMotion(): motion detected is enabled, so starting the motion detection.")
|
||||
}
|
||||
|
||||
hubKey := config.HubKey
|
||||
deviceKey := config.Key
|
||||
@@ -100,20 +115,47 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
}
|
||||
}
|
||||
|
||||
// Frame dimensions + the motion region polygon(s) in image space, shipped
|
||||
// with each motion event so the live view can draw a motion-debug overlay
|
||||
// (the boxes below + the detection region).
|
||||
var imageCols, imageRows int
|
||||
var regionPolygons [][]map[string]int
|
||||
if config.Region != nil {
|
||||
for _, polygon := range config.Region.Polygon {
|
||||
var pts []map[string]int
|
||||
for _, c := range polygon.Coordinates {
|
||||
pts = append(pts, map[string]int{
|
||||
"x": int(c.X * baseWidthRatio),
|
||||
"y": int(c.Y * baseHeightRatio),
|
||||
})
|
||||
}
|
||||
if len(pts) > 0 {
|
||||
regionPolygons = append(regionPolygons, pts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img := imageArray[0]
|
||||
var coordinatesToCheck []int
|
||||
var coordinatesPerRegion [][]int
|
||||
totalCoordinates := 0
|
||||
if img != nil {
|
||||
bounds := img.Bounds()
|
||||
rows := bounds.Dy()
|
||||
cols := bounds.Dx()
|
||||
imageCols = cols
|
||||
imageRows = rows
|
||||
|
||||
// Make fixed size array of uinty8
|
||||
// Build a SEPARATE coordinate list per region. Motion is evaluated
|
||||
// independently per region: pixels are NOT shared between regions, so
|
||||
// the threshold must be exceeded within a single region to trigger.
|
||||
coordinatesPerRegion = make([][]int, len(polyObjects))
|
||||
for y := 0; y < rows; y++ {
|
||||
for x := 0; x < cols; x++ {
|
||||
for _, poly := range polyObjects {
|
||||
point := geo.NewPoint(float64(x), float64(y))
|
||||
point := geo.NewPoint(float64(x), float64(y))
|
||||
for idx, poly := range polyObjects {
|
||||
if poly.Contains(point) {
|
||||
coordinatesToCheck = append(coordinatesToCheck, y*cols+x)
|
||||
coordinatesPerRegion[idx] = append(coordinatesPerRegion[idx], y*cols+x)
|
||||
totalCoordinates++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +163,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
}
|
||||
|
||||
// If no region is set, we'll skip the motion detection
|
||||
if len(coordinatesToCheck) > 0 {
|
||||
if totalCoordinates > 0 {
|
||||
|
||||
// Start the motion detection
|
||||
i := 0
|
||||
@@ -146,12 +188,16 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
log.Log.Debug("computervision.main.ProcessMotion(): " + err.Error() + ".")
|
||||
}
|
||||
|
||||
if config.Capture.Motion != "false" {
|
||||
// Run detection when motion is enabled, OR when we're in continuous
|
||||
// mode with a region: there config.Capture.Motion (the motion-RECORDING
|
||||
// switch) is irrelevant, so the configured region alone is enough to
|
||||
// emit motion events for the live-view overlay.
|
||||
if config.Capture.Motion != "false" || continuousMode {
|
||||
|
||||
if detectMotion {
|
||||
|
||||
// Remember additional information about the result of findmotion
|
||||
isPixelChangeThresholdReached, changesToReturn, motionRectangle = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
|
||||
isPixelChangeThresholdReached, changesToReturn, motionRectangle, motionRectangles = FindMotion(imageArray, coordinatesPerRegion, pixelThreshold)
|
||||
if isPixelChangeThresholdReached {
|
||||
|
||||
// If offline mode is disabled, send a message to the hub
|
||||
@@ -164,6 +210,24 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
DeviceId: configuration.Config.Key,
|
||||
Value: map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
// Live-view motion-debug overlay data. The boxes/region
|
||||
// are in the MOTION frame's pixel space (width/height =
|
||||
// the stream motion ran on, i.e. the sub stream when
|
||||
// set). mainWidth/mainHeight are the MAIN stream's
|
||||
// dimensions so the live view can extrapolate the
|
||||
// boxes/region onto the high-res main view it shows —
|
||||
// we know both, so no guessing from the <video> element.
|
||||
"width": imageCols,
|
||||
"height": imageRows,
|
||||
"mainWidth": configuration.Config.Capture.IPCamera.Width,
|
||||
"mainHeight": configuration.Config.Capture.IPCamera.Height,
|
||||
"regions": motionRectangles,
|
||||
"polygon": regionPolygons, // Motion sensitivity = the pixel-change threshold that must
|
||||
// be exceeded before motion triggers. The live view renders
|
||||
// a reference square of sqrt(threshold) px (in this MOTION
|
||||
// frame's pixel space) so the user can visually gauge how
|
||||
// large a moving object must be before it is detected.
|
||||
"pixelChangeThreshold": pixelThreshold,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -179,7 +243,12 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
}
|
||||
}
|
||||
|
||||
if config.Capture.Recording != "false" {
|
||||
// Trigger motion-based recording — but NOT in continuous mode:
|
||||
// there the recorder runs the continuous branch and does not
|
||||
// drain HandleMotion, so a (blocking) send would hang the motion
|
||||
// loop. In continuous mode we only publish the motion event above
|
||||
// for the live-view overlay.
|
||||
if config.Capture.Recording != "false" && !continuousMode {
|
||||
dataToPass := models.MotionDataPartial{
|
||||
Timestamp: time.Now().Unix(),
|
||||
NumberOfChanges: changesToReturn,
|
||||
@@ -205,17 +274,73 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
log.Log.Debug("computervision.main.ProcessMotion(): stop the motion detection.")
|
||||
}
|
||||
|
||||
func FindMotion(imageArray [3]*image.Gray, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle) {
|
||||
func FindMotion(imageArray [3]*image.Gray, coordinatesPerRegion [][]int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle, motionRectangles []models.MotionRectangle) {
|
||||
image1 := imageArray[0]
|
||||
image2 := imageArray[1]
|
||||
image3 := imageArray[2]
|
||||
threshold := 60
|
||||
changes, motionRectangle := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
|
||||
return changes > pixelChangeThreshold, changes, motionRectangle
|
||||
|
||||
// Evaluate each region INDEPENDENTLY — pixels are not shared between regions,
|
||||
// so the threshold must be exceeded within a single region to trigger. The
|
||||
// overall rectangle (recording metadata) and the per-cluster rectangles
|
||||
// (live-view overlay) are aggregated across all regions.
|
||||
var combinedRectangles []models.MotionRectangle
|
||||
var overall models.MotionRectangle
|
||||
haveOverall := false
|
||||
totalChanges := 0
|
||||
for _, coordinatesToCheck := range coordinatesPerRegion {
|
||||
if len(coordinatesToCheck) == 0 {
|
||||
continue
|
||||
}
|
||||
changes, rect, rects := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
|
||||
totalChanges += changes
|
||||
if changes > pixelChangeThreshold {
|
||||
thresholdReached = true
|
||||
}
|
||||
combinedRectangles = append(combinedRectangles, rects...)
|
||||
if changes > 0 {
|
||||
if !haveOverall {
|
||||
overall = rect
|
||||
haveOverall = true
|
||||
} else {
|
||||
overall = unionMotionRectangle(overall, rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return thresholdReached, totalChanges, overall, combinedRectangles
|
||||
}
|
||||
|
||||
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle) {
|
||||
// unionMotionRectangle returns the smallest rectangle that contains both a and b.
|
||||
func unionMotionRectangle(a, b models.MotionRectangle) models.MotionRectangle {
|
||||
minX := a.X
|
||||
if b.X < minX {
|
||||
minX = b.X
|
||||
}
|
||||
minY := a.Y
|
||||
if b.Y < minY {
|
||||
minY = b.Y
|
||||
}
|
||||
maxX := a.X + a.Width
|
||||
if b.X+b.Width > maxX {
|
||||
maxX = b.X + b.Width
|
||||
}
|
||||
maxY := a.Y + a.Height
|
||||
if b.Y+b.Height > maxY {
|
||||
maxY = b.Y + b.Height
|
||||
}
|
||||
return models.MotionRectangle{
|
||||
X: minX,
|
||||
Y: minY,
|
||||
Width: maxX - minX,
|
||||
Height: maxY - minY,
|
||||
}
|
||||
}
|
||||
|
||||
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle, []models.MotionRectangle) {
|
||||
changes := 0
|
||||
cols := img1.Bounds().Dx()
|
||||
rows := img1.Bounds().Dy()
|
||||
var pixelList [][]int
|
||||
for i := 0; i < len(coordinatesToCheck); i++ {
|
||||
pixel := coordinatesToCheck[i]
|
||||
@@ -224,7 +349,7 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
|
||||
if (diff > threshold || diff < -threshold) && (diff2 > threshold || diff2 < -threshold) {
|
||||
changes++
|
||||
// Store the pixel coordinates where the change is detected
|
||||
pixelList = append(pixelList, []int{pixel % img1.Bounds().Dx(), pixel / img1.Bounds().Dx()})
|
||||
pixelList = append(pixelList, []int{pixel % cols, pixel / cols})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,5 +383,118 @@ func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.
|
||||
}
|
||||
log.Log.Debugf("Motion rectangle: %+v", motionRectangle)
|
||||
}
|
||||
return changes, motionRectangle
|
||||
|
||||
// Cluster the changed pixels into separate bounding boxes so the live view can
|
||||
// visualise WHERE motion happened (a single overall rectangle is useless when
|
||||
// two objects move in opposite corners). Cheap grid-based connected components.
|
||||
motionRectangles := clusterMotionRectangles(pixelList, cols, rows)
|
||||
|
||||
return changes, motionRectangle, motionRectangles
|
||||
}
|
||||
|
||||
// clusterMotionRectangles groups the changed-pixel coordinates into a handful of
|
||||
// bounding boxes using connected-components on a coarse grid (8-connectivity).
|
||||
// It is intentionally lightweight — it runs only when the motion threshold is
|
||||
// reached and the boxes are meant for a debug overlay, not precise detection.
|
||||
func clusterMotionRectangles(pixelList [][]int, cols, rows int) []models.MotionRectangle {
|
||||
if len(pixelList) == 0 || cols <= 0 || rows <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ~40 cells across the longest side keeps the grid small (cheap to cluster)
|
||||
// while still separating distinct motion blobs.
|
||||
const gridDim = 40
|
||||
cellW := cols / gridDim
|
||||
if cellW < 1 {
|
||||
cellW = 1
|
||||
}
|
||||
cellH := rows / gridDim
|
||||
if cellH < 1 {
|
||||
cellH = 1
|
||||
}
|
||||
gCols := (cols + cellW - 1) / cellW
|
||||
gRows := (rows + cellH - 1) / cellH
|
||||
|
||||
grid := make([]bool, gCols*gRows)
|
||||
for _, p := range pixelList {
|
||||
cx := p[0] / cellW
|
||||
cy := p[1] / cellH
|
||||
if cx >= 0 && cx < gCols && cy >= 0 && cy < gRows {
|
||||
grid[cy*gCols+cx] = true
|
||||
}
|
||||
}
|
||||
|
||||
visited := make([]bool, gCols*gRows)
|
||||
var rectangles []models.MotionRectangle
|
||||
const maxBoxes = 12
|
||||
stack := make([][2]int, 0, 64)
|
||||
|
||||
for cy := 0; cy < gRows; cy++ {
|
||||
for cx := 0; cx < gCols; cx++ {
|
||||
idx := cy*gCols + cx
|
||||
if !grid[idx] || visited[idx] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Flood-fill this component (8-connectivity) and track its extent.
|
||||
minX, minY, maxX, maxY := cx, cy, cx, cy
|
||||
cellCount := 0
|
||||
stack = stack[:0]
|
||||
stack = append(stack, [2]int{cx, cy})
|
||||
visited[idx] = true
|
||||
for len(stack) > 0 {
|
||||
cur := stack[len(stack)-1]
|
||||
stack = stack[:len(stack)-1]
|
||||
ccx, ccy := cur[0], cur[1]
|
||||
cellCount++
|
||||
if ccx < minX {
|
||||
minX = ccx
|
||||
}
|
||||
if ccy < minY {
|
||||
minY = ccy
|
||||
}
|
||||
if ccx > maxX {
|
||||
maxX = ccx
|
||||
}
|
||||
if ccy > maxY {
|
||||
maxY = ccy
|
||||
}
|
||||
for dy := -1; dy <= 1; dy++ {
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
nx, ny := ccx+dx, ccy+dy
|
||||
if nx < 0 || ny < 0 || nx >= gCols || ny >= gRows {
|
||||
continue
|
||||
}
|
||||
nIdx := ny*gCols + nx
|
||||
if grid[nIdx] && !visited[nIdx] {
|
||||
visited[nIdx] = true
|
||||
stack = append(stack, [2]int{nx, ny})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip single-cell specks (sensor noise) unless it's the only motion.
|
||||
if cellCount < 2 && len(pixelList) > 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
x := minX * cellW
|
||||
y := minY * cellH
|
||||
w := (maxX - minX + 1) * cellW
|
||||
h := (maxY - minY + 1) * cellH
|
||||
if x+w > cols {
|
||||
w = cols - x
|
||||
}
|
||||
if y+h > rows {
|
||||
h = rows - y
|
||||
}
|
||||
rectangles = append(rectangles, models.MotionRectangle{X: x, Y: y, Width: w, Height: h})
|
||||
if len(rectangles) >= maxBoxes {
|
||||
return rectangles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rectangles
|
||||
}
|
||||
|
||||
@@ -193,8 +193,104 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
|
||||
return
|
||||
}
|
||||
|
||||
// This function will override the configuration with environment variables.
|
||||
// OverrideWithEnvironmentVariables builds the effective configuration from the
|
||||
// environment variables.
|
||||
//
|
||||
// In ConfigMap/standalone mode (DEPLOYMENT empty or "agent") the global
|
||||
// configuration is delivered as GLOBAL_AGENT_* environment variables and the
|
||||
// per-agent configuration as AGENT_* environment variables. We parse them into
|
||||
// the separate global and custom configurations and build the effective
|
||||
// configuration as "global overridden by custom", mirroring the MongoDB-backed
|
||||
// factory behaviour. This keeps the global and per-agent (custom) configuration
|
||||
// separated so the factory edit page can distinguish inherited global settings
|
||||
// from per-agent overrides.
|
||||
func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
if os.Getenv("DEPLOYMENT") == "" || os.Getenv("DEPLOYMENT") == "agent" {
|
||||
initConfigPointers(&configuration.Config)
|
||||
|
||||
// Parse the global configuration from the GLOBAL_AGENT_* variables.
|
||||
globalWrap := &models.Configuration{Config: configuration.GlobalConfig}
|
||||
initConfigPointers(&globalWrap.Config)
|
||||
applyAgentEnvVars(globalWrap, "GLOBAL_", false)
|
||||
configuration.GlobalConfig = globalWrap.Config
|
||||
|
||||
// Parse the per-agent (custom) configuration from the AGENT_* variables.
|
||||
// In ConfigMap mode the per-agent overrides are delivered exclusively
|
||||
// through AGENT_* environment variables, so we must start from an empty
|
||||
// configuration rather than the bundled config.json that OpenConfig loaded
|
||||
// into CustomConfig. Otherwise defaults from that file (e.g. cloud="s3")
|
||||
// would leak into the custom config and be mistaken for explicit per-agent
|
||||
// overrides, hiding inherited global settings (the factory edit page would
|
||||
// show the local default instead of the inherited global persistence).
|
||||
customBase := configuration.CustomConfig
|
||||
if isConfigMapMode() {
|
||||
customBase = models.Config{}
|
||||
}
|
||||
customWrap := &models.Configuration{Config: customBase}
|
||||
initConfigPointers(&customWrap.Config)
|
||||
applyAgentEnvVars(customWrap, "", false)
|
||||
configuration.CustomConfig = customWrap.Config
|
||||
|
||||
// Build the effective configuration: global base, then per-agent
|
||||
// overrides on top. Defaults (e.g. signing) are applied on the last
|
||||
// pass only.
|
||||
applyAgentEnvVars(configuration, "GLOBAL_", false)
|
||||
applyAgentEnvVars(configuration, "", true)
|
||||
} else {
|
||||
// Factory/MongoDB mode: the global and custom configurations are already
|
||||
// loaded and merged from MongoDB; we only override the effective
|
||||
// configuration with any AGENT_* environment variables.
|
||||
applyAgentEnvVars(configuration, "", true)
|
||||
}
|
||||
}
|
||||
|
||||
// isConfigMapMode reports whether the agent is running in ConfigMap mode, i.e.
|
||||
// whether a global configuration layer is delivered separately through
|
||||
// GLOBAL_AGENT_* environment variables. In that mode the per-agent (custom)
|
||||
// configuration must be built solely from the AGENT_* overrides and must not be
|
||||
// seeded with the bundled config.json defaults, so that inherited global
|
||||
// settings remain distinguishable from explicit per-agent overrides.
|
||||
func isConfigMapMode() bool {
|
||||
for _, env := range os.Environ() {
|
||||
if strings.HasPrefix(env, "GLOBAL_AGENT_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// initConfigPointers ensures all pointer sub-structs are non-nil so that the
|
||||
// environment-variable parsing can assign into them without dereferencing a nil
|
||||
// pointer.
|
||||
func initConfigPointers(config *models.Config) {
|
||||
if config.KStorage == nil {
|
||||
config.KStorage = &models.KStorage{}
|
||||
}
|
||||
if config.KStorageSecondary == nil {
|
||||
config.KStorageSecondary = &models.KStorage{}
|
||||
}
|
||||
if config.S3 == nil {
|
||||
config.S3 = &models.S3{}
|
||||
}
|
||||
if config.Encryption == nil {
|
||||
config.Encryption = &models.Encryption{}
|
||||
}
|
||||
if config.Signing == nil {
|
||||
config.Signing = &models.Signing{}
|
||||
}
|
||||
if config.Dropbox == nil {
|
||||
config.Dropbox = &models.Dropbox{}
|
||||
}
|
||||
if config.Region == nil {
|
||||
config.Region = &models.Region{}
|
||||
}
|
||||
}
|
||||
|
||||
// applyAgentEnvVars applies the AGENT_* environment variables (optionally
|
||||
// carrying the given prefix, e.g. "GLOBAL_") onto configuration.Config. When
|
||||
// applyDefaults is true, defaults (such as the signing key) are applied after
|
||||
// parsing; this should only be done for the effective configuration.
|
||||
func applyAgentEnvVars(configuration *models.Configuration, prefix string, applyDefaults bool) {
|
||||
environmentVariables := os.Environ()
|
||||
|
||||
// Initialize the configuration for some new fields.
|
||||
@@ -203,9 +299,10 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
}
|
||||
|
||||
for _, env := range environmentVariables {
|
||||
if strings.Contains(env, "AGENT_") {
|
||||
key := strings.Split(env, "=")[0]
|
||||
value := os.Getenv(key)
|
||||
fullKey := strings.SplitN(env, "=", 2)[0]
|
||||
if strings.HasPrefix(fullKey, prefix+"AGENT_") && !(prefix == "" && strings.HasPrefix(fullKey, "GLOBAL_AGENT_")) {
|
||||
key := strings.TrimPrefix(fullKey, prefix)
|
||||
value := os.Getenv(fullKey)
|
||||
switch key {
|
||||
|
||||
/* General configuration */
|
||||
@@ -230,6 +327,12 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
configuration.Config.MaxDirectorySize = size
|
||||
}
|
||||
break
|
||||
case "AGENT_AUTO_CLEAN_MIN_FREE_SPACE":
|
||||
size, err := strconv.ParseInt(value, 10, 64)
|
||||
if err == nil {
|
||||
configuration.Config.MinFreeSpace = size
|
||||
}
|
||||
break
|
||||
|
||||
/* Camera configuration */
|
||||
case "AGENT_CAPTURE_IPCAMERA_RTSP":
|
||||
@@ -274,6 +377,9 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
case "AGENT_CAPTURE_MOTION":
|
||||
configuration.Config.Capture.Motion = value
|
||||
break
|
||||
case "AGENT_CAPTURE_ONVIF_MOTION":
|
||||
configuration.Config.Capture.ONVIFMotion = value
|
||||
break
|
||||
case "AGENT_CAPTURE_SNAPSHOTS":
|
||||
configuration.Config.Capture.Snapshots = value
|
||||
break
|
||||
@@ -298,7 +404,7 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
case "AGENT_CAPTURE_PIXEL_CHANGE":
|
||||
count, err := strconv.Atoi(value)
|
||||
if err == nil {
|
||||
configuration.Config.Capture.PixelChangeThreshold = count
|
||||
configuration.Config.Capture.PixelChangeThreshold = &count
|
||||
}
|
||||
break
|
||||
case "AGENT_CAPTURE_FRAGMENTED":
|
||||
@@ -545,13 +651,30 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
}
|
||||
}
|
||||
|
||||
// Signing is a new feature, so if empty we set default values.
|
||||
if configuration.Config.Signing == nil || configuration.Config.Signing.PrivateKey == "" {
|
||||
// Motion sensitivity historically used 0 to mean "use the default". Preserve
|
||||
// that behaviour for configurations created before this field became a
|
||||
// pointer, and also recover invalid negative values. Only apply this to the
|
||||
// effective configuration so missing values can still be inherited between
|
||||
// the separate global and custom layers.
|
||||
if applyDefaults && (configuration.Config.Capture.PixelChangeThreshold == nil || *configuration.Config.Capture.PixelChangeThreshold <= 0) {
|
||||
defaultPixelChangeThreshold := 150
|
||||
configuration.Config.Capture.PixelChangeThreshold = &defaultPixelChangeThreshold
|
||||
}
|
||||
|
||||
// Signing is a new feature, so if empty we set default values. Only applied
|
||||
// for the effective configuration (applyDefaults), not for the separate
|
||||
// global/custom views.
|
||||
if applyDefaults && (configuration.Config.Signing == nil || configuration.Config.Signing.PrivateKey == "") {
|
||||
configuration.Config.Signing = &models.Signing{
|
||||
Enabled: "true",
|
||||
PrivateKey: "-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQDoSxjyw08lRxF4Yoqmcaewjq3XjB55dMy4tlN5MGLdr8aAPuNR9Mwh3jlh1bDpwQXNgZkHDV/q9bpdPGGi7SQo2xw+rDuo5Y1f3wdzz+iuCTPbzoGFalE+1PZlU5TEtUtlbt7MRc4pxTaLP3u0P3EtW3KnzcUarcJWZJYxzv7gqVNCA/47BN+1ptqjwz3LAlah5yaftEvVjkaANOsafUswbS4VT44XfSlbKgebORCKDuNgQiyhuV5gU+J0TOaqRWwwMAWV0UoScyJLfhHRBCrUwrCUTwqH9jfkB7pgRFsYoZJd4MKMeHJjFSum+QXCBqInSnwu8c2kJChiLMWqJ+mhpTdfUAmSkeUSStfbbcavIPbDABvMgzOcmYMIVXXe57twU0xdu3AqWLtc9kw1BkUgZblM9pSSpYrIDheEyMs2/hiLgXsIaM0nVQtqwrA7rbeEGuPblzA6hvHgwN9K6HaBqdlGSlpYZ0v3SWIMwmxRB+kIojlyuggm8Qa4mqL97GFDGl6gOBGlNUFTBUVEa3EaJ7NJpGobRGsh/9dXzcW4aYmT9WxlzTlIKksI1ro6KdRfuVWfEs4AnG8bVEJmofK8EUrueB9IdXlcJZB49xolnOZPFohtMe/0U7evQOQP3sZnX+KotCsE7OXJvL09oF58JKoqmK9lPp0+pFBU4g6NjQIDAQABAoICAA+RSWph1t+q5R3nxUxFTYMrhv5IjQe2mDxJpF3B409zolC9OHxgGUisobTY3pBqs0DtKbxUeH2A0ehUH/axEosWHcz3cmIbgxHE9kdlJ9B3Lmss6j/uw+PWutu1sgm5phaIFIvuNNRWhPB6yXUwU4sLRat1+Z9vTmIQiKdtLIrtJz/n2VDvrJxn1N+yAsE20fnrksFKyZuxVsJaZPiX/t5Yv1/z0LjFjVoL7GUA5/Si7csN4ftqEhUrkNr2BvcZlTyffrF4lZCXrtl76RNUaxhqIu3H0gFbV2UfBpuckkfAhNRpXJ4iFSxm4nQbk4ojV8+l21RFOBeDN2Z7Ocu6auP5MnzpopR66vmDCmPoid498VGgDzFQEVkOar8WAa4v9h85QgLKrth6FunmaWJUT6OggQD3yY58GSwp5+ARMETMBP2x6Eld+PGgqoJvPT1+l/e9gOw7/SJ+Wz6hRXZAm/eiXMppHtB7sfea5rscNanPjJkK9NvPM0MX9cq/iA6QjXuETkMbubjo+Cxk3ydZiIQmWQDAx/OgxTyHbeRCVhLPcAphX0clykCuHZpI9Mvvj643/LoE0mjTByWJXf/WuGJA8ElHkjSdokVJ7jumz8OZZHfq0+V7+la2opsObeQANHW5MLWrnHlRVzTGV0IRZDXh7h1ptUJ4ubdvw/GJ2NeTAoIBAQD0lXXdjYKWC4uZ4YlgydP8b1CGda9cBV5RcPt7q9Ya1R2E4ieYyohmzltopvdaOXdsTZzhtdzOzKF+2qNcbBKhBTleYZ8GN5RKbo7HwXWpzfCTjseKHOD/QPwvBKXzLVWNtXn1NrLR79Rv0wbkYF6DtoqpEPf5kMs4bx79yW+mz8FUgdEeMjKphx6Jd5RYlTUxS64K6bnK7gjHNCF2cwdxsh4B6EB649GKeNz4JXi+oQBmOcX5ncXnkJrbju+IjtCkQ40HINVNdX7XeEaaw6KGaImVjw61toPUuDaioYUojufayoyXaUJnDbHQ2tNekEpq5iwnenZCbUKWmSeRe7dLAoIBAQDzIscYujsrmPxiTj2prhG0v36NRNP99mShnnJGowiIs+UBS0EMdOmBFa2sC9uFs/VnreQNYPDJdfr7O5VK9kfbH/PSiiKJ+wVebfdAlWkJYH27JN2Kl2l/OsvRVelNvF3BWIYF46qzGxIM0axaz3T2ZAJ9SrUgeAYhak6uyM4fbexEWXxDgPGu6C0jB6IAzmHJnnh+j5+4ZXqjVyUxBYtUsWXF/TXomVcT9jxj7aUmS2/Us0XTVOVNpALqqYcekrzsX/wX0OEi5HkivYXHcNaDHx3NuUf6KdYof5DwPUM76qe+5/kWlSIHP3M6rIFK3pYFUnkHn2E8jNWcO97Aio+HAoIBAA+bcff/TbPxbKkXIUMR3fsfx02tONFwbkJYKVQM9Q6lRsrx+4Dee7HDvUWCUgpp3FsG4NnuVvbDTBLiNMZzBwVLZgvFwvYMmePeBjJs/+sj/xQLamQ/z4O6S91cOJK589mlGPEy2lpXKYExQCFWnPFetp5vPMOqH62sOZgMQJmubDHOTt/UaDM1Mhenj8nPS6OnpqV/oKF4awr7Ip+CW5k/unZ4sZSl8PsbF06mZXwUngfn6+Av1y8dpSQZjONz6ZBx1w/7YmEc/EkXnbnGfhqBlTX7+P5TdTofvyzFjc+2vsjRYANRbjFRSGWBcTd5kaYcpfim8eDvQ+6EO2gnMt0CggEAH2ln1Y8B5AEQ4lZ/avOdP//ZhsDUrqPtnl/NHckkahzrwj4JumVEYbP+SxMBGoYEd4+kvgG/OhfvBBRPlm65G9tF8fZ8vdzbdba5UfO7rUV1GP+LS8OCErjy6imySaPDbR5Vul8Oh7NAor1YCidxUf/bvnovanF3QUvtvHEfCDp4YuA4yLPZBaLjaforePUw9w5tPNSravRZYs74dBvmQ1vj7S9ojpN5B5AxfyuNwaPPX+iFZec69MvywISEe3Ozysof1Kfc3lgsOkvIA9tVK32SqSh93xkWnQbWH+OaUxxe7bAko0FDMzKEXZk53wVg1nEwR8bUljEPy+6EOdXs8wKCAQEAsEOWYMY5m7HkeG2XTTvX7ECmmdGl/c4ZDVwzB4IPxqUG7XfLmtsON8YoKOEUpJoc4ANafLXzmU+esUGbH4Ph22IWgP9jzws7jxaN/Zoku64qrSjgEZFTRIpKyhFk/ImWbS9laBW4l+m0tqTTRqoE0QEJf/2uv/04q65zrA70X9z2+KTrAtqOiRQPWl/IxRe9U4OEeGL+oD+YlXKCDsnJ3rwUIOZgJx0HWZg7K35DKwqs1nVi56FBdljiTRKAjVLRedjgDCSfGS1yUZ3krHzpaPt1qgnT3rdtYcIdbYDr66V2/gEEaz6XMGHuTk/ewjzUJxq9UTVeXOCbkRPXgVJg1w==\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
}
|
||||
|
||||
// When the agent is configured through environment variables the global and
|
||||
// custom configurations were already parsed separately (see
|
||||
// OverrideWithEnvironmentVariables), so there is no need to mirror the
|
||||
// effective configuration into CustomConfig anymore.
|
||||
}
|
||||
|
||||
func SaveConfig(configDirectory string, config models.Config, configuration *models.Configuration, communication *models.Communication) error {
|
||||
|
||||
40
machinery/src/config/main_test.go
Normal file
40
machinery/src/config/main_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestApplyAgentEnvVarsPixelChangeThresholdDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
threshold *int
|
||||
want int
|
||||
}{
|
||||
{name: "missing", want: 150},
|
||||
{name: "legacy zero", threshold: intPointer(0), want: 150},
|
||||
{name: "negative", threshold: intPointer(-1), want: 150},
|
||||
{name: "positive", threshold: intPointer(275), want: 275},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
configuration := &models.Configuration{}
|
||||
configuration.Config.Capture.PixelChangeThreshold = test.threshold
|
||||
|
||||
applyAgentEnvVars(configuration, "TEST_", true)
|
||||
|
||||
if configuration.Config.Capture.PixelChangeThreshold == nil {
|
||||
t.Fatal("PixelChangeThreshold is nil after applying defaults")
|
||||
}
|
||||
if got := *configuration.Config.Capture.PixelChangeThreshold; got != test.want {
|
||||
t.Fatalf("PixelChangeThreshold = %d, want %d", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int {
|
||||
return &value
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/tevino/abool"
|
||||
)
|
||||
|
||||
type LiveHDSignalingCallbacks struct {
|
||||
SendAnswer func(sessionID string, sdp string) error
|
||||
SendCandidate func(sessionID string, candidate string) error
|
||||
SendError func(sessionID string, message string) error
|
||||
}
|
||||
|
||||
type LiveHDHandshake struct {
|
||||
Payload RequestHDStreamPayload
|
||||
Signaling *LiveHDSignalingCallbacks
|
||||
}
|
||||
|
||||
// The communication struct that is managing
|
||||
// all the communication between the different goroutines.
|
||||
type Communication struct {
|
||||
Context *context.Context
|
||||
CancelContext *context.CancelFunc
|
||||
PackageCounter *atomic.Value
|
||||
LastPacketTimer *atomic.Value
|
||||
PackageCounterSub *atomic.Value
|
||||
LastPacketTimerSub *atomic.Value
|
||||
CloudTimestamp *atomic.Value
|
||||
HandleBootstrap chan string
|
||||
HandleStream chan string
|
||||
HandleSubStream chan string
|
||||
HandleMotion chan MotionDataPartial
|
||||
HandleAudio chan AudioDataPartial
|
||||
HandleUpload chan string
|
||||
HandleHeartBeat chan string
|
||||
HandleLiveSD chan int64
|
||||
HandleLiveHDKeepalive chan string
|
||||
HandleLiveHDHandshake chan LiveHDHandshake
|
||||
HandleLiveHDPeers chan string
|
||||
HandleONVIF chan OnvifAction
|
||||
IsConfiguring *abool.AtomicBool
|
||||
Queue *packets.Queue
|
||||
SubQueue *packets.Queue
|
||||
Image string
|
||||
CameraConnected bool
|
||||
MainStreamConnected bool
|
||||
SubStreamConnected bool
|
||||
HasBackChannel bool
|
||||
}
|
||||
@@ -19,6 +19,44 @@ type CameraStreams struct {
|
||||
SubRTSP string `json:"sub_rtsp"`
|
||||
}
|
||||
|
||||
// DiscoveredDevice describes a device found on the local network during a
|
||||
// discovery scan (fing/wifiman-style). It combines ONVIF WS-Discovery results
|
||||
// with an active port scan and MAC/vendor lookup so cameras can be
|
||||
// auto-detected and pre-filled in the configuration UI.
|
||||
type DiscoveredDevice struct {
|
||||
IP string `json:"ip" bson:"ip"`
|
||||
Hostname string `json:"hostname,omitempty" bson:"hostname"`
|
||||
MAC string `json:"mac,omitempty" bson:"mac"`
|
||||
Vendor string `json:"vendor,omitempty" bson:"vendor"`
|
||||
Manufacturer string `json:"manufacturer,omitempty" bson:"manufacturer"`
|
||||
Model string `json:"model,omitempty" bson:"model"`
|
||||
Type string `json:"type,omitempty" bson:"type"`
|
||||
Server string `json:"server,omitempty" bson:"server"`
|
||||
OpenPorts []int `json:"open_ports,omitempty" bson:"open_ports"`
|
||||
Services []string `json:"services,omitempty" bson:"services"`
|
||||
ONVIF bool `json:"onvif" bson:"onvif"`
|
||||
ONVIFXAddr string `json:"onvif_xaddr,omitempty" bson:"onvif_xaddr"`
|
||||
RTSPURL string `json:"rtsp_url,omitempty" bson:"rtsp_url"`
|
||||
RTSPStreams []RTSPStream `json:"rtsp_streams,omitempty" bson:"rtsp_streams"`
|
||||
IsCamera bool `json:"is_camera" bson:"is_camera"`
|
||||
// IsAudio marks audio-only devices (e.g. IP speakers / intercoms such as
|
||||
// TOA) that expose RTSP to receive/stream audio rather than video.
|
||||
IsAudio bool `json:"is_audio" bson:"is_audio"`
|
||||
}
|
||||
|
||||
// RTSPStream is a candidate RTSP stream URL for a discovered camera, derived
|
||||
// from a built-in brand -> RTSP path mapping. When Verified is true the path was
|
||||
// confirmed to exist on the device via an unauthenticated RTSP DESCRIBE probe
|
||||
// (a 200 OK or a 401/403 "auth required" both prove the path is valid).
|
||||
type RTSPStream struct {
|
||||
Brand string `json:"brand,omitempty" bson:"brand"`
|
||||
Stream string `json:"stream,omitempty" bson:"stream"` // "main" or "sub"
|
||||
Path string `json:"path" bson:"path"`
|
||||
URL string `json:"url" bson:"url"`
|
||||
Verified bool `json:"verified" bson:"verified"`
|
||||
RequiresAuth bool `json:"requires_auth,omitempty" bson:"requires_auth"`
|
||||
}
|
||||
|
||||
type OnvifPanTilt struct {
|
||||
OnvifCredentials OnvifCredentials `json:"onvif_credentials,omitempty" bson:"onvif_credentials"`
|
||||
Pan float64 `json:"pan,omitempty" bson:"pan"`
|
||||
|
||||
81
machinery/src/models/communication.go
Normal file
81
machinery/src/models/communication.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/tevino/abool"
|
||||
)
|
||||
|
||||
type LiveHDSignalingCallbacks struct {
|
||||
SendAnswer func(sessionID string, sdp string) error
|
||||
SendCandidate func(sessionID string, candidate string) error
|
||||
SendError func(sessionID string, message string) error
|
||||
}
|
||||
|
||||
type LiveHDHandshake struct {
|
||||
Payload RequestHDStreamPayload
|
||||
Signaling *LiveHDSignalingCallbacks
|
||||
}
|
||||
|
||||
// The communication struct that is managing
|
||||
// all the communication between the different goroutines.
|
||||
type Communication struct {
|
||||
Context *context.Context
|
||||
CancelContext *context.CancelFunc
|
||||
PackageCounter *atomic.Value
|
||||
LastPacketTimer *atomic.Value
|
||||
PackageCounterSub *atomic.Value
|
||||
LastPacketTimerSub *atomic.Value
|
||||
CloudTimestamp *atomic.Value
|
||||
HandleBootstrap chan string
|
||||
HandleStream chan string
|
||||
HandleSubStream chan string
|
||||
HandleMotion chan MotionDataPartial
|
||||
HandleAudio chan AudioDataPartial
|
||||
HandleUpload chan string
|
||||
HandleHeartBeat chan string
|
||||
HandleLiveSD chan int64
|
||||
HandleLiveSDHTTP chan int64
|
||||
HandleLiveHDKeepalive chan string
|
||||
HandleLiveHDHandshake chan LiveHDHandshake
|
||||
HandleLiveHDPeers chan string
|
||||
// HandleLiveHLS is the live HLS viewer keepalive. It carries the requested
|
||||
// quality tier ("auto"|"high"|"low"; empty => auto) so the producer can switch
|
||||
// the live session between the main and sub stream on demand.
|
||||
HandleLiveHLS chan string
|
||||
HandleONVIF chan OnvifAction
|
||||
IsConfiguring *abool.AtomicBool
|
||||
// IsRecordingManual is set while a viewer has requested a manual recording
|
||||
// from the live view (the record button). While set, the motion-based
|
||||
// recorder keeps recording (it does not auto-close on the post-recording
|
||||
// timeout) until the viewer stops it again. It is independent of motion
|
||||
// detection so it also works when nothing is moving.
|
||||
IsRecordingManual *abool.AtomicBool
|
||||
// RecordingManualHeartbeat holds the unix-milliseconds timestamp of the last
|
||||
// heartbeat received from the live view while a manual recording is active.
|
||||
// The frontend re-sends the record command every few seconds while the user
|
||||
// stays on the page; if the heartbeats stop (the viewer closed the tab, went
|
||||
// idle or lost connectivity) the recorder auto-stops the manual recording so
|
||||
// it can't record forever when the "stop" message never arrives.
|
||||
RecordingManualHeartbeat *atomic.Int64
|
||||
// RecordingManualStart holds the unix-milliseconds timestamp at which the
|
||||
// current manual recording started. It bounds a manual recording to a maximum
|
||||
// duration (see capture.manualRecordingMaxDuration) so a forgotten record
|
||||
// button can't record indefinitely even while the viewer stays active.
|
||||
RecordingManualStart *atomic.Int64
|
||||
// RecordingManualHeartbeatSeen is set once the current manual recording has
|
||||
// received at least one heartbeat, i.e. the viewer proved it supports
|
||||
// heartbeating. Only then does the recorder enforce the heartbeat timeout; a
|
||||
// viewer that starts a recording but never heartbeats (an older frontend)
|
||||
// still records up to the max-duration cap instead of being cut off early.
|
||||
RecordingManualHeartbeatSeen *abool.AtomicBool
|
||||
Queue *packets.Queue
|
||||
SubQueue *packets.Queue
|
||||
Image string
|
||||
CameraConnected bool
|
||||
MainStreamConnected bool
|
||||
SubStreamConnected bool
|
||||
HasBackChannel bool
|
||||
}
|
||||
@@ -21,6 +21,7 @@ type Config struct {
|
||||
AutoClean string `json:"auto_clean"`
|
||||
RemoveAfterUpload string `json:"remove_after_upload"`
|
||||
MaxDirectorySize int64 `json:"max_directory_size"`
|
||||
MinFreeSpace int64 `json:"min_free_space,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
Capture Capture `json:"capture"`
|
||||
Timetable []*Timetable `json:"timetable"`
|
||||
@@ -73,7 +74,15 @@ type Capture struct {
|
||||
ForwardWebRTC string `json:"forwardwebrtc"`
|
||||
Fragmented string `json:"fragmented,omitempty" bson:"fragmented,omitempty"`
|
||||
FragmentedDuration int64 `json:"fragmentedduration,omitempty" bson:"fragmentedduration,omitempty"`
|
||||
PixelChangeThreshold int `json:"pixelChangeThreshold,omitempty"`
|
||||
PixelChangeThreshold *int `json:"pixelChangeThreshold,omitempty"`
|
||||
// ONVIFMotion routes the camera's ONVIF motion events into the
|
||||
// agent's motion-triggered recording pipeline. When "true" the
|
||||
// agent opens an event/stream against the configured ONVIF
|
||||
// endpoint and forwards Motion+Active events to HandleMotion.
|
||||
// Requires Capture.IPCamera.ONVIFXAddr / ONVIFUsername /
|
||||
// ONVIFPassword to be set. Default empty (disabled) keeps the
|
||||
// existing pixel-diff motion detection as the only source.
|
||||
ONVIFMotion string `json:"onvif_motion,omitempty" bson:"onvif_motion,omitempty"`
|
||||
}
|
||||
|
||||
// IPCamera configuration, such as the RTSP url of the IPCamera and the FPS.
|
||||
@@ -99,8 +108,14 @@ type IPCamera struct {
|
||||
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"`
|
||||
SampleRate int `json:"sample_rate,omitempty" bson:"sample_rate,omitempty"`
|
||||
Channels int `json:"channels,omitempty" bson:"channels,omitempty"`
|
||||
// Sub stream parameter sets, captured separately from the main stream so the
|
||||
// live HLS muxer can build a correct init segment when a viewer switches the
|
||||
// live view to the sub (low-resolution) stream.
|
||||
SubSPSNALUs [][]byte `json:"sub_sps_nalus,omitempty" bson:"sub_sps_nalus,omitempty"`
|
||||
SubPPSNALUs [][]byte `json:"sub_pps_nalus,omitempty" bson:"sub_pps_nalus,omitempty"`
|
||||
SubVPSNALUs [][]byte `json:"sub_vps_nalus,omitempty" bson:"sub_vps_nalus,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty" bson:"sample_rate,omitempty"`
|
||||
Channels int `json:"channels,omitempty" bson:"channels,omitempty"`
|
||||
}
|
||||
|
||||
// USBCamera configuration, such as the device path (/dev/video*)
|
||||
@@ -150,6 +150,19 @@ type AudioPayload struct {
|
||||
// We received a recording request, we'll send it to the motion handler.
|
||||
type RecordPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp of the recording request.
|
||||
// Recording toggles a manual recording from the live view: true starts a
|
||||
// recording (and keeps it running), false stops it. Older clients that only
|
||||
// send a timestamp default to false; the live view always sets it explicitly.
|
||||
Recording bool `json:"recording"`
|
||||
// Heartbeat marks a keep-alive re-send (with Recording=true) from a viewer
|
||||
// that supports heartbeating, as opposed to the initial start (the record
|
||||
// button). While a user stays on the page the live view re-sends the record
|
||||
// command every few seconds; the agent uses this flag to (a) refresh the
|
||||
// recording's keep-alive without restarting an already auto-stopped clip from
|
||||
// a stray heartbeat, and (b) only enable the heartbeat-timeout auto-stop once
|
||||
// it has actually seen a heartbeat — so older viewers that never heartbeat
|
||||
// still record up to the max-duration cap instead of being cut off early.
|
||||
Heartbeat bool `json:"heartbeat"`
|
||||
}
|
||||
|
||||
// We received a preset position request, we'll request it through onvif and send it back.
|
||||
@@ -162,6 +175,15 @@ type RequestConfigPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp of the preset request.
|
||||
}
|
||||
|
||||
// We received a verify-stream request: probe the given (or configured) RTSP
|
||||
// stream and report whether it can be connected/decoded, along with the
|
||||
// discovered codec/resolution/fps. Responds with action "verify-stream-result".
|
||||
type VerifyStreamPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp of the verify request.
|
||||
Stream string `json:"stream"` // "main" or "sub".
|
||||
RTSP string `json:"rtsp"` // optional RTSP url to verify; falls back to the configured one.
|
||||
}
|
||||
|
||||
// We received a update config request, we'll update the current config and send a confirmation back.
|
||||
type UpdateConfigPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp of the preset request.
|
||||
@@ -171,6 +193,34 @@ type UpdateConfigPayload struct {
|
||||
// We received a request SD stream request
|
||||
type RequestSDStreamPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp
|
||||
// Transport selects how the agent should deliver the preview frames for this
|
||||
// viewer. "http" asks the agent to POST frames to hub-api (keeping them off
|
||||
// MQTT); empty/absent means the legacy MQTT image push. Older agents simply
|
||||
// ignore this unknown field and keep doing MQTT, and older frontends never set
|
||||
// it — so new/old agents and frontends interoperate in every combination.
|
||||
Transport string `json:"transport,omitempty"`
|
||||
}
|
||||
|
||||
// Stream quality tiers a viewer can request for the live (HD) view. The agent
|
||||
// maps these onto the camera's main (high-resolution) or sub (low-resolution)
|
||||
// RTSP stream, so a viewer can pick the resolution it needs instead of the agent
|
||||
// always preferring the sub stream. Empty/unknown values are treated as "auto"
|
||||
// for backward compatibility: older frontends that never set a quality keep the
|
||||
// previous behaviour (sub stream when available, otherwise main).
|
||||
const (
|
||||
StreamQualityAuto = "auto" // agent decides based on availability/resolution
|
||||
StreamQualityHigh = "high" // main stream (highest resolution)
|
||||
StreamQualityLow = "low" // sub stream (lowest resolution)
|
||||
)
|
||||
|
||||
// We received a live HLS stream request. Like SD it is a simple viewer
|
||||
// keepalive: the agent owns the live HLS session, so the request only needs to
|
||||
// signal "a viewer is watching" to keep the segment pipeline alive. Quality lets
|
||||
// the viewer ask for the main (high) or sub (low) stream on demand; the agent
|
||||
// switches the live session's source stream when it changes.
|
||||
type RequestHLSStreamPayload struct {
|
||||
Timestamp int64 `json:"timestamp"` // timestamp
|
||||
Quality string `json:"quality,omitempty"` // "auto" | "high" | "low" (empty => auto)
|
||||
}
|
||||
|
||||
// We received a request HD stream request
|
||||
@@ -179,6 +229,7 @@ type RequestHDStreamPayload struct {
|
||||
HubKey string `json:"hub_key"` // hub key
|
||||
SessionID string `json:"session_id"` // session id
|
||||
SessionDescription string `json:"session_description"` // session description
|
||||
Quality string `json:"quality,omitempty"` // "auto" | "high" | "low" (empty => auto)
|
||||
}
|
||||
|
||||
// We received a receive HD candidates request
|
||||
37
machinery/src/models/recording_upload_metadata.go
Normal file
37
machinery/src/models/recording_upload_metadata.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const RecordingUploadMetadataExtension = ".metadata"
|
||||
|
||||
// RecordingUploadMetadata is persisted in the upload queue marker associated
|
||||
// with a recording. New optional fields can be added without changing the queue
|
||||
// mechanism or breaking older agents.
|
||||
type RecordingUploadMetadata struct {
|
||||
FileName string `json:"filename"`
|
||||
DeviceKey string `json:"device_key"`
|
||||
Timestamp int64 `json:"timestamp"` // Unix milliseconds.
|
||||
Duration uint64 `json:"duration"` // Milliseconds.
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
}
|
||||
|
||||
// RecordingUploadMetadataFileName returns the queue marker name associated
|
||||
// with a recording, replacing the recording extension with .metadata.
|
||||
func RecordingUploadMetadataFileName(recordingFileName string) string {
|
||||
name := filepath.Base(recordingFileName)
|
||||
extension := filepath.Ext(name)
|
||||
return strings.TrimSuffix(name, extension) + RecordingUploadMetadataExtension
|
||||
}
|
||||
|
||||
// RecordingFileNameFromUploadMarker resolves a queue entry to its recording.
|
||||
// Markers created by older agents used the recording filename directly.
|
||||
func RecordingFileNameFromUploadMarker(markerFileName string) string {
|
||||
name := filepath.Base(markerFileName)
|
||||
if strings.HasSuffix(name, RecordingUploadMetadataExtension) {
|
||||
return strings.TrimSuffix(name, RecordingUploadMetadataExtension) + ".mp4"
|
||||
}
|
||||
return name
|
||||
}
|
||||
15
machinery/src/models/recording_upload_metadata_test.go
Normal file
15
machinery/src/models/recording_upload_metadata_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRecordingUploadMetadataFileNames(t *testing.T) {
|
||||
if got := RecordingUploadMetadataFileName("141245_x_x_.mp4"); got != "141245_x_x_.metadata" {
|
||||
t.Fatalf("RecordingUploadMetadataFileName() = %q", got)
|
||||
}
|
||||
if got := RecordingFileNameFromUploadMarker("141245_x_x_.metadata"); got != "141245_x_x_.mp4" {
|
||||
t.Fatalf("RecordingFileNameFromUploadMarker() = %q", got)
|
||||
}
|
||||
if got := RecordingFileNameFromUploadMarker("legacy.mp4"); got != "legacy.mp4" {
|
||||
t.Fatalf("legacy RecordingFileNameFromUploadMarker() = %q", got)
|
||||
}
|
||||
}
|
||||
40
machinery/src/models/stream.go
Normal file
40
machinery/src/models/stream.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
// SelectSubStreamForQuality decides whether the live (HD) view should be served
|
||||
// from the sub (secondary) RTSP stream for the requested quality tier.
|
||||
//
|
||||
// It is resolution-aware: "high" picks whichever configured stream has the
|
||||
// higher resolution and "low" whichever has the lower resolution, regardless of
|
||||
// which one is wired as main vs sub. "auto" — the default, also used for the
|
||||
// empty/unknown value sent by older frontends that never set a quality — keeps
|
||||
// the historical behaviour of preferring the sub stream when one is available
|
||||
// (lower bitrate, browser friendly), falling back to the main stream otherwise.
|
||||
//
|
||||
// When no sub stream is configured the main stream is always used.
|
||||
func SelectSubStreamForQuality(config Config, quality string, subStreamEnabled bool) bool {
|
||||
if !subStreamEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
cam := config.Capture.IPCamera
|
||||
mainPixels := cam.Width * cam.Height
|
||||
subPixels := cam.SubWidth * cam.SubHeight
|
||||
|
||||
switch quality {
|
||||
case StreamQualityHigh:
|
||||
// Highest resolution available. If the sub stream is (unusually) larger,
|
||||
// use it; otherwise use the main stream. When dimensions are not yet known
|
||||
// (0), default to the main stream for "high".
|
||||
return subPixels > mainPixels
|
||||
case StreamQualityLow:
|
||||
// Lowest resolution available. If the main stream is (unusually) the
|
||||
// smaller of the two, use it; otherwise use the sub stream. When the sub
|
||||
// dimensions are unknown, still prefer the sub stream for "low".
|
||||
if mainPixels > 0 && subPixels > 0 && mainPixels < subPixels {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default: // StreamQualityAuto, empty, or any unknown value
|
||||
return true
|
||||
}
|
||||
}
|
||||
53
machinery/src/models/stream_test.go
Normal file
53
machinery/src/models/stream_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
func cfgWithDims(mainW, mainH, subW, subH int) Config {
|
||||
c := Config{}
|
||||
c.Capture.IPCamera.Width = mainW
|
||||
c.Capture.IPCamera.Height = mainH
|
||||
c.Capture.IPCamera.SubWidth = subW
|
||||
c.Capture.IPCamera.SubHeight = subH
|
||||
return c
|
||||
}
|
||||
|
||||
func TestSelectSubStreamForQuality(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
quality string
|
||||
subStreamEnabled bool
|
||||
wantSub bool
|
||||
}{
|
||||
// No sub stream configured -> always the main stream.
|
||||
{"no sub, auto", cfgWithDims(1920, 1080, 0, 0), StreamQualityAuto, false, false},
|
||||
{"no sub, high", cfgWithDims(1920, 1080, 0, 0), StreamQualityHigh, false, false},
|
||||
{"no sub, low", cfgWithDims(1920, 1080, 0, 0), StreamQualityLow, false, false},
|
||||
|
||||
// Typical config: main is the bigger stream, sub the smaller one.
|
||||
{"auto prefers sub", cfgWithDims(1920, 1080, 640, 480), StreamQualityAuto, true, true},
|
||||
{"empty prefers sub", cfgWithDims(1920, 1080, 640, 480), "", true, true},
|
||||
{"unknown prefers sub", cfgWithDims(1920, 1080, 640, 480), "potato", true, true},
|
||||
{"high picks main", cfgWithDims(1920, 1080, 640, 480), StreamQualityHigh, true, false},
|
||||
{"low picks sub", cfgWithDims(1920, 1080, 640, 480), StreamQualityLow, true, true},
|
||||
|
||||
// Dimensions not probed yet (0): high defaults to main, low/auto to sub.
|
||||
{"unknown dims, high", cfgWithDims(0, 0, 0, 0), StreamQualityHigh, true, false},
|
||||
{"unknown dims, low", cfgWithDims(0, 0, 0, 0), StreamQualityLow, true, true},
|
||||
{"unknown dims, auto", cfgWithDims(0, 0, 0, 0), StreamQualityAuto, true, true},
|
||||
|
||||
// Inverted config: sub is (unusually) the higher-resolution stream.
|
||||
{"inverted high picks sub", cfgWithDims(640, 480, 1920, 1080), StreamQualityHigh, true, true},
|
||||
{"inverted low picks main", cfgWithDims(640, 480, 1920, 1080), StreamQualityLow, true, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := SelectSubStreamForQuality(tt.config, tt.quality, tt.subStreamEnabled)
|
||||
if got != tt.wantSub {
|
||||
t.Errorf("SelectSubStreamForQuality(quality=%q, subEnabled=%v) = %v, want %v",
|
||||
tt.quality, tt.subStreamEnabled, got, tt.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
523
machinery/src/onvif/brands.go
Normal file
523
machinery/src/onvif/brands.go
Normal file
@@ -0,0 +1,523 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// brandProfile describes a camera brand together with the RTSP URL path
|
||||
// templates it exposes for its main (high quality) and sub (low quality)
|
||||
// streams. The paths are the well-known, widely documented defaults for each
|
||||
// vendor and are used both to identify the brand (by probing which path the
|
||||
// device recognises) and to pre-fill a working RTSP URL for the user.
|
||||
//
|
||||
// The order of the list matters: more specific / more common brands come first
|
||||
// so that when we actively probe a device the first matching profile wins.
|
||||
type brandProfile struct {
|
||||
Brand string
|
||||
// aliases are lower-cased tokens that, when seen in a banner/realm/MAC
|
||||
// vendor, map onto this brand.
|
||||
Aliases []string
|
||||
MainPath string
|
||||
SubPath string
|
||||
// extraMainPaths are alternative main-stream paths tried during active
|
||||
// probing when the primary MainPath is not recognised.
|
||||
extraMainPaths []string
|
||||
}
|
||||
|
||||
// brandProfiles is the built-in brand -> RTSP path mapping. It mirrors the
|
||||
// tables used by tools such as ONVIF Device Manager, iSpy/Agent DVR and
|
||||
// Blue Iris.
|
||||
var brandProfiles = []brandProfile{
|
||||
{
|
||||
Brand: "Hikvision",
|
||||
Aliases: []string{"hikvision", "dvrdvs", "ds-", "hik"},
|
||||
MainPath: "/Streaming/Channels/101",
|
||||
SubPath: "/Streaming/Channels/102",
|
||||
extraMainPaths: []string{"/h264/ch1/main/av_stream", "/ISAPI/Streaming/Channels/101"},
|
||||
},
|
||||
{
|
||||
Brand: "Dahua",
|
||||
Aliases: []string{"dahua", "dh-"},
|
||||
MainPath: "/cam/realmonitor?channel=1&subtype=0",
|
||||
SubPath: "/cam/realmonitor?channel=1&subtype=1",
|
||||
extraMainPaths: []string{"/live"},
|
||||
},
|
||||
{
|
||||
Brand: "Amcrest",
|
||||
Aliases: []string{"amcrest"},
|
||||
MainPath: "/cam/realmonitor?channel=1&subtype=0",
|
||||
SubPath: "/cam/realmonitor?channel=1&subtype=1",
|
||||
},
|
||||
{
|
||||
Brand: "Axis",
|
||||
Aliases: []string{"axis"},
|
||||
MainPath: "/axis-media/media.amp",
|
||||
SubPath: "/axis-media/media.amp?videocodec=h264&resolution=640x480",
|
||||
extraMainPaths: []string{"/mpeg4/media.amp"},
|
||||
},
|
||||
{
|
||||
Brand: "Reolink",
|
||||
Aliases: []string{"reolink", "rlc", "rln", "rlc-", "rln-", "trackmix", "duo"},
|
||||
MainPath: "/h264Preview_01_main",
|
||||
SubPath: "/h264Preview_01_sub",
|
||||
extraMainPaths: []string{"/Preview_01_main"},
|
||||
},
|
||||
{
|
||||
Brand: "Hanwha",
|
||||
Aliases: []string{"hanwha", "wisenet", "samsung techwin"},
|
||||
MainPath: "/profile2/media.smp",
|
||||
SubPath: "/profile3/media.smp",
|
||||
extraMainPaths: []string{"/profile1/media.smp", "/onvif/profile2/media.smp"},
|
||||
},
|
||||
{
|
||||
Brand: "Bosch",
|
||||
Aliases: []string{"bosch"},
|
||||
MainPath: "/rtsp_tunnel",
|
||||
SubPath: "/rtsp_tunnel?inst=2",
|
||||
extraMainPaths: []string{"/rtsp_tunnel?inst=1", "/?inst=1"},
|
||||
},
|
||||
{
|
||||
Brand: "Vivotek",
|
||||
Aliases: []string{"vivotek"},
|
||||
MainPath: "/live.sdp",
|
||||
SubPath: "/live2.sdp",
|
||||
extraMainPaths: []string{"/live1s1.sdp"},
|
||||
},
|
||||
{
|
||||
Brand: "Foscam",
|
||||
Aliases: []string{"foscam"},
|
||||
MainPath: "/videoMain",
|
||||
SubPath: "/videoSub",
|
||||
},
|
||||
{
|
||||
Brand: "Uniview",
|
||||
Aliases: []string{"uniview", "unv"},
|
||||
MainPath: "/media/video1",
|
||||
SubPath: "/media/video2",
|
||||
extraMainPaths: []string{"/unicast/c1/s0/live", "/unicast/c1/s1/live"},
|
||||
},
|
||||
{
|
||||
Brand: "TP-Link",
|
||||
Aliases: []string{"tp-link", "tplink", "tapo"},
|
||||
MainPath: "/stream1",
|
||||
SubPath: "/stream2",
|
||||
},
|
||||
{
|
||||
Brand: "Mobotix",
|
||||
Aliases: []string{"mobotix"},
|
||||
MainPath: "/cam0/mjpeg",
|
||||
SubPath: "/cam1/mjpeg",
|
||||
extraMainPaths: []string{"/live.sdp"},
|
||||
},
|
||||
{
|
||||
Brand: "Ubiquiti",
|
||||
Aliases: []string{"ubiquiti", "unifi"},
|
||||
MainPath: "/s0",
|
||||
SubPath: "/s1",
|
||||
extraMainPaths: []string{"/live/ch00_0"},
|
||||
},
|
||||
{
|
||||
Brand: "Panasonic",
|
||||
Aliases: []string{"panasonic", "i-pro", "ipro"},
|
||||
MainPath: "/MediaInput/h264",
|
||||
SubPath: "/MediaInput/h264/stream_2",
|
||||
},
|
||||
{
|
||||
Brand: "Sony",
|
||||
Aliases: []string{"sony"},
|
||||
MainPath: "/media/video1",
|
||||
SubPath: "/media/video2",
|
||||
},
|
||||
{
|
||||
// D-Link mydlink IP cameras. Older models stream MJPEG over HTTP; the
|
||||
// RTSP-capable ones expose SDP-named streams, newer DCS models use
|
||||
// "/live/profile.0".
|
||||
Brand: "D-Link",
|
||||
Aliases: []string{"d-link", "dlink", "dcs-", "dcs"},
|
||||
MainPath: "/live1.sdp",
|
||||
SubPath: "/live2.sdp",
|
||||
extraMainPaths: []string{"/live.sdp", "/live/profile.0", "/play1.sdp"},
|
||||
},
|
||||
{
|
||||
// TRENDnet. Newer PoE bullet/dome models (TV-IPxxxPI) use a
|
||||
// Hikvision-style path; older ones expose SDP streams.
|
||||
Brand: "Trendnet",
|
||||
Aliases: []string{"trendnet", "tv-ip"},
|
||||
MainPath: "/Streaming/Channels/101",
|
||||
SubPath: "/Streaming/Channels/102",
|
||||
extraMainPaths: []string{"/play1.sdp", "/play2.sdp", "/ch0_0.h264", "/live/av0"},
|
||||
},
|
||||
{
|
||||
// Lorex is built largely on Dahua hardware, so it shares Dahua's
|
||||
// realmonitor path scheme.
|
||||
Brand: "Lorex",
|
||||
Aliases: []string{"lorex"},
|
||||
MainPath: "/cam/realmonitor?channel=1&subtype=0",
|
||||
SubPath: "/cam/realmonitor?channel=1&subtype=1",
|
||||
extraMainPaths: []string{"/ch01/0"},
|
||||
},
|
||||
{
|
||||
// Honeywell ships both Dahua-OEM models (realmonitor) and in-house
|
||||
// firmwares exposing "/h264" or "/media".
|
||||
Brand: "Honeywell",
|
||||
Aliases: []string{"honeywell"},
|
||||
MainPath: "/cam/realmonitor?channel=1&subtype=0",
|
||||
SubPath: "/cam/realmonitor?channel=1&subtype=1",
|
||||
extraMainPaths: []string{"/h264", "/media", "/live.sdp"},
|
||||
},
|
||||
{
|
||||
Brand: "Pelco",
|
||||
Aliases: []string{"pelco"},
|
||||
MainPath: "/stream1",
|
||||
SubPath: "/stream2",
|
||||
extraMainPaths: []string{"/1/stream1"},
|
||||
},
|
||||
{
|
||||
// TOA network audio devices (IP horn speakers / intercoms, banner
|
||||
// "TOA rtsp server") expose their stream through ONVIF rather than a
|
||||
// documented fixed RTSP path. These ONVIF-style paths are a best-effort
|
||||
// default; the authoritative URL should come from an ONVIF GetStreamUri
|
||||
// query with credentials.
|
||||
Brand: "TOA",
|
||||
Aliases: []string{"toa"},
|
||||
MainPath: "/ONVIF/channel1",
|
||||
SubPath: "/ONVIF/channel2",
|
||||
extraMainPaths: []string{"/media/video1", "/live"},
|
||||
},
|
||||
{
|
||||
// Linksys/Cisco IP cameras (e.g. LCAD03FLN, LCAB03VLNOD, LCAM0336OD)
|
||||
// run a mini_httpd server and expose ONVIF-style stream paths with a
|
||||
// capitalised "ONVIF" segment (distinct from the generic "/onvif1").
|
||||
Brand: "Linksys",
|
||||
Aliases: []string{"linksys", "lcad", "lcab", "lcam", "lcae"},
|
||||
MainPath: "/ONVIF/channel1",
|
||||
SubPath: "/ONVIF/channel2",
|
||||
extraMainPaths: []string{"/img/media.sav", "/live"},
|
||||
},
|
||||
}
|
||||
|
||||
// genericRTSPPaths are last-resort, vendor-neutral RTSP paths used when the
|
||||
// brand is unknown. Many ONVIF/embedded cameras answer on one of these.
|
||||
var genericRTSPPaths = []string{
|
||||
"/ONVIF/channel1", "/ONVIF/channel2", "/onvif1", "/live", "/live/ch0", "/11", "/12",
|
||||
"/stream0", "/stream1", "/h264", "/media/video1", "/ch0_0.h264",
|
||||
}
|
||||
|
||||
// brandProfileFor returns the profile whose aliases best match the given brand
|
||||
// hint (from a banner, realm or MAC vendor). It returns nil when nothing
|
||||
// matches.
|
||||
func brandProfileFor(hint string) *brandProfile {
|
||||
hint = strings.ToLower(strings.TrimSpace(hint))
|
||||
if hint == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range brandProfiles {
|
||||
for _, alias := range brandProfiles[i].Aliases {
|
||||
if strings.Contains(hint, alias) {
|
||||
return &brandProfiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// realmBrands maps a lower-cased substring of an RTSP/HTTP WWW-Authenticate
|
||||
// realm to a manufacturer. The auth realm is one of the most reliable brand
|
||||
// signals because a camera advertises it even when it refuses every
|
||||
// unauthenticated request (e.g. Hikvision realm "IP Camera(E3669)", Dahua realm
|
||||
// "Login to <serial>"). Ordered so the most specific matches win.
|
||||
var realmBrands = []struct {
|
||||
Match string
|
||||
Vendor string
|
||||
}{
|
||||
{"login to", "Dahua"},
|
||||
{"surveillance server", "Dahua"},
|
||||
{"real time streaming", "Dahua"},
|
||||
{"dahua", "Dahua"},
|
||||
{"ip camera(", "Hikvision"},
|
||||
{"hikvision", "Hikvision"},
|
||||
{"ds-", "Hikvision"},
|
||||
{"axis", "Axis"},
|
||||
{"reolink", "Reolink"},
|
||||
{"amcrest", "Amcrest"},
|
||||
{"wisenet", "Hanwha"},
|
||||
{"hanwha", "Hanwha"},
|
||||
{"uniview", "Uniview"},
|
||||
{"tp-link", "TP-Link"},
|
||||
{"tapo", "TP-Link"},
|
||||
{"foscam", "Foscam"},
|
||||
{"vivotek", "Vivotek"},
|
||||
{"mobotix", "Mobotix"},
|
||||
{"bosch", "Bosch"},
|
||||
{"please log in with a valid username", "Bosch"},
|
||||
{"d-link", "D-Link"},
|
||||
{"dcs-", "D-Link"},
|
||||
{"trendnet", "Trendnet"},
|
||||
{"lorex", "Lorex"},
|
||||
{"honeywell", "Honeywell"},
|
||||
{"pelco", "Pelco"},
|
||||
{"linksys", "Linksys"},
|
||||
{"lcad", "Linksys"},
|
||||
{"lcab", "Linksys"},
|
||||
{"lcam", "Linksys"},
|
||||
}
|
||||
|
||||
// brandFromRealm resolves a manufacturer from an auth realm string.
|
||||
func brandFromRealm(realm string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(realm))
|
||||
if r == "" {
|
||||
return ""
|
||||
}
|
||||
for _, entry := range realmBrands {
|
||||
if strings.Contains(r, entry.Match) {
|
||||
return entry.Vendor
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// modelFromRealm extracts a model/device code embedded in an auth realm, e.g.
|
||||
// Hikvision's realm="IP Camera(E3669)" -> "E3669".
|
||||
func modelFromRealm(realm string) string {
|
||||
open := strings.Index(realm, "(")
|
||||
closeIdx := strings.Index(realm, ")")
|
||||
if open >= 0 && closeIdx > open+1 {
|
||||
return strings.TrimSpace(realm[open+1 : closeIdx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank value.
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// guessRTSPStreams determines the most likely RTSP stream URLs for a camera. It
|
||||
// combines the brand hint discovered from banners/MAC with an active,
|
||||
// unauthenticated RTSP DESCRIBE probe and the auth realm advertised by the
|
||||
// device.
|
||||
//
|
||||
// Detection strategy (most reliable first):
|
||||
// 1. Send a control DESCRIBE for a random, non-existent path. Its 401 response
|
||||
// usually carries a WWW-Authenticate realm that reveals the brand
|
||||
// (Hikvision "IP Camera(...)", Dahua "Login to ..."). The realm is the
|
||||
// strongest signal and works even when the device challenges auth for every
|
||||
// request. The control also tells us whether the device distinguishes valid
|
||||
// from invalid paths.
|
||||
// 2. If the device discriminates paths, probe each brand's main path (realm
|
||||
// brand first); the first the device recognises (200 or 401/403) confirms a
|
||||
// working URL.
|
||||
// 3. Otherwise fall back to the realm / hint / port brand's default paths and
|
||||
// return them as unverified suggestions.
|
||||
//
|
||||
// It returns the detected brand, an optional model code parsed from the realm,
|
||||
// and the ordered list of candidate streams (verified first).
|
||||
func guessRTSPStreams(ip string, port int, brandHint string, openPorts []int, timeout time.Duration) (brand string, model string, streams []models.RTSPStream) {
|
||||
base := "rtsp://" + net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
|
||||
build := func(profileBrand, stream, path string, verified, requiresAuth bool) models.RTSPStream {
|
||||
return models.RTSPStream{
|
||||
Brand: profileBrand,
|
||||
Stream: stream,
|
||||
Path: path,
|
||||
URL: base + path,
|
||||
Verified: verified,
|
||||
RequiresAuth: requiresAuth,
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Control probe: distinguish behaviour + capture the auth realm.
|
||||
bogusPath := "/kerberos-probe-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
controlStatus, controlRealm, _ := rtspDescribe(ip, port, bogusPath, timeout)
|
||||
controlExists := controlStatus == 200 || controlStatus == 401 || controlStatus == 403
|
||||
controlAuth := controlStatus == 401 || controlStatus == 403
|
||||
discriminates := !controlExists
|
||||
|
||||
realmBrand := brandFromRealm(controlRealm)
|
||||
model = modelFromRealm(controlRealm)
|
||||
|
||||
// The realm brand (when present) is authoritative and probed first.
|
||||
primaryHint := firstNonEmpty(realmBrand, brandHint)
|
||||
|
||||
var verified []models.RTSPStream
|
||||
var unverified []models.RTSPStream
|
||||
detected := ""
|
||||
|
||||
// 2) Trustworthy active per-brand probing (device discriminates paths).
|
||||
if discriminates {
|
||||
for _, profile := range orderedProfiles(primaryHint) {
|
||||
mainCandidates := append([]string{profile.MainPath}, profile.extraMainPaths...)
|
||||
matchedMain := ""
|
||||
matchedAuth := false
|
||||
for _, path := range mainCandidates {
|
||||
ok, requiresAuth := rtspPathExists(ip, port, path, timeout)
|
||||
if ok {
|
||||
matchedMain = path
|
||||
matchedAuth = requiresAuth
|
||||
break
|
||||
}
|
||||
}
|
||||
if matchedMain == "" {
|
||||
continue
|
||||
}
|
||||
detected = profile.Brand
|
||||
verified = append(verified, build(profile.Brand, "main", matchedMain, true, matchedAuth))
|
||||
if profile.SubPath != "" {
|
||||
subOK, subAuth := rtspPathExists(ip, port, profile.SubPath, timeout)
|
||||
verified = append(verified, build(profile.Brand, "sub", profile.SubPath, subOK, subAuth || matchedAuth))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Fall back to unverified suggestions from realm / hint / port signals.
|
||||
if len(verified) == 0 {
|
||||
profile := brandProfileFor(primaryHint)
|
||||
if profile == nil {
|
||||
profile = brandProfileForPorts(openPorts)
|
||||
}
|
||||
if profile != nil {
|
||||
detected = profile.Brand
|
||||
unverified = append(unverified, build(profile.Brand, "main", profile.MainPath, false, controlAuth))
|
||||
if profile.SubPath != "" {
|
||||
unverified = append(unverified, build(profile.Brand, "sub", profile.SubPath, false, controlAuth))
|
||||
}
|
||||
} else {
|
||||
for _, path := range genericRTSPPaths {
|
||||
unverified = append(unverified, build("Generic", "main", path, false, controlAuth))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The realm brand always wins for the manufacturer name.
|
||||
if realmBrand != "" {
|
||||
detected = realmBrand
|
||||
}
|
||||
|
||||
return detected, model, append(verified, unverified...)
|
||||
}
|
||||
|
||||
// brandProfileForPorts derives a brand from vendor-specific control ports that
|
||||
// were found open during the scan (used when banners give no hint).
|
||||
func brandProfileForPorts(openPorts []int) *brandProfile {
|
||||
if containsInt(openPorts, 37777) {
|
||||
return brandProfileByName("Dahua")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// brandProfileByName returns the profile with the given brand name (nil when
|
||||
// absent).
|
||||
func brandProfileByName(name string) *brandProfile {
|
||||
for i := range brandProfiles {
|
||||
if brandProfiles[i].Brand == name {
|
||||
return &brandProfiles[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// orderedProfiles returns the brand profiles with the profile matching the
|
||||
// brand hint (if any) moved to the front so it is probed first.
|
||||
func orderedProfiles(brandHint string) []brandProfile {
|
||||
match := brandProfileFor(brandHint)
|
||||
if match == nil {
|
||||
return brandProfiles
|
||||
}
|
||||
ordered := make([]brandProfile, 0, len(brandProfiles))
|
||||
ordered = append(ordered, *match)
|
||||
for i := range brandProfiles {
|
||||
if brandProfiles[i].Brand != match.Brand {
|
||||
ordered = append(ordered, brandProfiles[i])
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// rtspDescribe sends an unauthenticated RTSP DESCRIBE for the given path and
|
||||
// returns the response status code together with the WWW-Authenticate realm and
|
||||
// Server header (when present). status is 0 when the device does not answer.
|
||||
func rtspDescribe(ip string, port int, path string, timeout time.Duration) (status int, realm string, server string) {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return 0, "", ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
request := "DESCRIBE rtsp://" + address + path + " RTSP/1.0\r\n" +
|
||||
"CSeq: 1\r\n" +
|
||||
"User-Agent: KerberosDiscovery\r\n" +
|
||||
"Accept: application/sdp\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return 0, "", ""
|
||||
}
|
||||
|
||||
status, headers := readRTSPResponse(conn)
|
||||
return status, parseRealm(headers["www-authenticate"]), headers["server"]
|
||||
}
|
||||
|
||||
// rtspPathExists reports whether the device recognises the given RTSP path. A
|
||||
// 200 OK means the path is publicly accessible; a 401/403 means the path is
|
||||
// valid but requires credentials (still a positive match). Any other status
|
||||
// (404, 400, 455, ...) means the path is not recognised.
|
||||
func rtspPathExists(ip string, port int, path string, timeout time.Duration) (exists bool, requiresAuth bool) {
|
||||
status, _, _ := rtspDescribe(ip, port, path, timeout)
|
||||
switch status {
|
||||
case 200:
|
||||
return true, false
|
||||
case 401, 403:
|
||||
return true, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
// readRTSPResponse reads and parses the status code and headers of an RTSP
|
||||
// response. Only the first occurrence of each header is kept.
|
||||
func readRTSPResponse(conn net.Conn) (status int, headers map[string]string) {
|
||||
headers = make(map[string]string)
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return 0, headers
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && strings.HasPrefix(strings.ToUpper(fields[0]), "RTSP/") {
|
||||
status, _ = strconv.Atoi(fields[1])
|
||||
}
|
||||
|
||||
for {
|
||||
hline, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
hline = strings.TrimRight(hline, "\r\n")
|
||||
if hline == "" {
|
||||
break
|
||||
}
|
||||
idx := strings.Index(hline, ":")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(hline[:idx]))
|
||||
value := strings.TrimSpace(hline[idx+1:])
|
||||
if _, exists := headers[key]; !exists {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
return status, headers
|
||||
}
|
||||
171
machinery/src/onvif/brands_test.go
Normal file
171
machinery/src/onvif/brands_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockRTSPServer starts a TCP listener that answers RTSP DESCRIBE requests. For
|
||||
// each incoming request it extracts the path and calls respond(path) to obtain
|
||||
// the numeric status code and optional auth realm to return. It returns the
|
||||
// listener host, port and a cleanup function.
|
||||
func mockRTSPServer(t *testing.T, respond func(path string) (int, string)) (string, int, func()) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start mock RTSP server: %v", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
reader := bufio.NewReader(c)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := ""
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
url := fields[1]
|
||||
url = strings.TrimPrefix(url, "rtsp://")
|
||||
if idx := strings.Index(url, "/"); idx >= 0 {
|
||||
path = url[idx:]
|
||||
}
|
||||
}
|
||||
status, realm := respond(path)
|
||||
reason := map[int]string{200: "OK", 401: "Unauthorized", 404: "Not Found"}[status]
|
||||
response := "RTSP/1.0 " + strconv.Itoa(status) + " " + reason + "\r\nCSeq: 1\r\n"
|
||||
if realm != "" {
|
||||
response += "WWW-Authenticate: Digest realm=\"" + realm + "\", nonce=\"abc\"\r\n"
|
||||
}
|
||||
response += "\r\n"
|
||||
_, _ = c.Write([]byte(response))
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(listener.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
return host, port, func() { listener.Close() }
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_DiscriminatingHikvision verifies that a device which
|
||||
// distinguishes valid from invalid paths (returning 401 only for the Hikvision
|
||||
// path) is correctly identified as Hikvision with a confirmed main/sub stream.
|
||||
func TestGuessRTSPStreams_DiscriminatingHikvision(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
if strings.HasPrefix(path, "/Streaming/Channels/") {
|
||||
return 401, "" // valid path, needs auth
|
||||
}
|
||||
return 404, "" // everything else is unknown -> device discriminates
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
|
||||
if brand != "Hikvision" {
|
||||
t.Fatalf("expected brand Hikvision, got %q", brand)
|
||||
}
|
||||
if len(streams) == 0 || !streams[0].Verified {
|
||||
t.Fatalf("expected a verified main stream, got %+v", streams)
|
||||
}
|
||||
if !streams[0].RequiresAuth {
|
||||
t.Errorf("expected main stream to require auth")
|
||||
}
|
||||
if streams[0].Path != "/Streaming/Channels/101" {
|
||||
t.Errorf("expected main path /Streaming/Channels/101, got %q", streams[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_ChallengesEverything verifies that a device which returns
|
||||
// 401 for *any* path (including a bogus one) does NOT get mis-detected via path
|
||||
// probing, and instead falls back to the port hint (Dahua control port 37777)
|
||||
// with unverified suggestions.
|
||||
func TestGuessRTSPStreams_ChallengesEverything(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
return 401, "" // challenges auth before checking the path, no realm
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, _, streams := guessRTSPStreams(host, port, "", []int{37777}, 2*time.Second)
|
||||
if brand != "Dahua" {
|
||||
t.Fatalf("expected fallback brand Dahua from port hint, got %q", brand)
|
||||
}
|
||||
if len(streams) == 0 {
|
||||
t.Fatalf("expected suggested streams, got none")
|
||||
}
|
||||
if streams[0].Verified {
|
||||
t.Errorf("expected unverified suggestion for a non-discriminating device")
|
||||
}
|
||||
if streams[0].Path != "/cam/realmonitor?channel=1&subtype=0" {
|
||||
t.Errorf("expected Dahua main path, got %q", streams[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_RealmDetectsHikvision verifies that a device which
|
||||
// challenges auth for every path (so path probing cannot help) is still
|
||||
// identified from its RTSP auth realm, and the model code is extracted.
|
||||
func TestGuessRTSPStreams_RealmDetectsHikvision(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
return 401, "IP Camera(E3669)" // Hikvision realm signature, 401 for all paths
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, model, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
|
||||
if brand != "Hikvision" {
|
||||
t.Fatalf("expected brand Hikvision from realm, got %q", brand)
|
||||
}
|
||||
if model != "E3669" {
|
||||
t.Errorf("expected model E3669 from realm, got %q", model)
|
||||
}
|
||||
if len(streams) == 0 || streams[0].Path != "/Streaming/Channels/101" {
|
||||
t.Fatalf("expected Hikvision default main path, got %+v", streams)
|
||||
}
|
||||
if !streams[0].RequiresAuth {
|
||||
t.Errorf("expected the suggestion to be marked auth-required")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_RealmDetectsDahua verifies Dahua detection from its
|
||||
// "Login to ..." realm.
|
||||
func TestGuessRTSPStreams_RealmDetectsDahua(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
return 401, "Login to 5df61a6057b10cc99d471769516d3c11"
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
|
||||
if brand != "Dahua" {
|
||||
t.Fatalf("expected brand Dahua from realm, got %q", brand)
|
||||
}
|
||||
if len(streams) == 0 || streams[0].Path != "/cam/realmonitor?channel=1&subtype=0" {
|
||||
t.Fatalf("expected Dahua default main path, got %+v", streams)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuessRTSPStreams_UnknownFallsBackToGeneric verifies that an unknown device
|
||||
// (discriminating but matching no brand) yields generic suggestions.
|
||||
func TestGuessRTSPStreams_UnknownFallsBackToGeneric(t *testing.T) {
|
||||
host, port, cleanup := mockRTSPServer(t, func(path string) (int, string) {
|
||||
return 404, "" // discriminates, but nothing matches
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
brand, _, streams := guessRTSPStreams(host, port, "", nil, 2*time.Second)
|
||||
if brand != "" {
|
||||
t.Fatalf("expected no detected brand, got %q", brand)
|
||||
}
|
||||
if len(streams) == 0 || streams[0].Brand != "Generic" {
|
||||
t.Fatalf("expected generic suggestions, got %+v", streams)
|
||||
}
|
||||
}
|
||||
557
machinery/src/onvif/discovery.go
Normal file
557
machinery/src/onvif/discovery.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
onvifc "github.com/cedricve/go-onvif"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// scanPort describes a TCP port we probe while scanning the local network,
|
||||
// together with a human readable service name.
|
||||
type scanPort struct {
|
||||
Port int
|
||||
Service string
|
||||
// rtsp marks RTSP ports we can fingerprint via an OPTIONS request.
|
||||
rtsp bool
|
||||
// http marks HTTP ports we can fingerprint via a banner grab.
|
||||
http bool
|
||||
// camera marks ports that strongly hint the device is an IP camera or NVR
|
||||
// (RTSP, dedicated ONVIF ports and well-known DVR/NVR control ports).
|
||||
camera bool
|
||||
}
|
||||
|
||||
// commonCameraPorts is the list of TCP ports we probe on every host. These are
|
||||
// the ports most commonly exposed by IP cameras (RTSP, HTTP(S) and ONVIF).
|
||||
var commonCameraPorts = []scanPort{
|
||||
{Port: 554, Service: "RTSP", rtsp: true, camera: true},
|
||||
{Port: 8554, Service: "RTSP (alt)", rtsp: true, camera: true},
|
||||
{Port: 80, Service: "HTTP", http: true},
|
||||
{Port: 8080, Service: "HTTP (alt)", http: true},
|
||||
{Port: 8000, Service: "ONVIF", http: true, camera: true},
|
||||
{Port: 8899, Service: "ONVIF (alt)", camera: true},
|
||||
{Port: 443, Service: "HTTPS"},
|
||||
{Port: 37777, Service: "Dahua", camera: true},
|
||||
{Port: 34567, Service: "XMeye/Sofia", camera: true},
|
||||
}
|
||||
|
||||
// ouiVendors maps the first three octets (OUI) of a MAC address, upper-cased and
|
||||
// without separators, to a known camera/NVR vendor. This lets us flag likely
|
||||
// cameras the same way tools such as Fing or WiFiman do, even when a device does
|
||||
// not answer to ONVIF WS-Discovery.
|
||||
var ouiVendors = map[string]string{
|
||||
"BCAD01": "Hikvision", "C056E3": "Hikvision", "4CBD8F": "Hikvision",
|
||||
"44A642": "Hikvision", "E0509B": "Hikvision", "ACB927": "Hikvision",
|
||||
"18800C": "Hikvision", "C40BCB": "Hikvision",
|
||||
"3CEF8C": "Dahua", "90020A": "Dahua", "E0509B00": "Dahua",
|
||||
"08ED02": "Dahua", "3CE376": "Dahua", "38AF29": "Dahua", "E45D51": "Dahua",
|
||||
"00408C": "Axis", "AABBCC": "Axis", "B8A44F": "Axis", "ACCC8E": "Axis",
|
||||
"E82725": "Bosch", "000CAB": "Bosch",
|
||||
"001B9E": "Hanwha", "0009D2": "Hanwha", "E44CC7": "Hanwha",
|
||||
"EC7196": "Reolink", "9CA3BA": "Reolink",
|
||||
"3C33F1": "Amcrest", "9C8ECD": "Amcrest",
|
||||
"000FFC": "Vivotek", "0002D1": "Vivotek",
|
||||
"001C27": "Mobotix", "0003C5": "Mobotix",
|
||||
"00126A": "Ubiquiti", "FCECDA": "Ubiquiti", "744401": "Ubiquiti",
|
||||
"F0234B": "Foscam", "00626E": "Foscam",
|
||||
"C09424": "TP-Link", "50C7BF": "TP-Link",
|
||||
}
|
||||
|
||||
// DiscoverDevices performs an advanced, Fing/WiFiman-style scan of the local
|
||||
// network. It combines:
|
||||
//
|
||||
// 1. ONVIF WS-Discovery (multicast probe), and
|
||||
// 2. an active TCP port scan of every host on the local IPv4 subnets for the
|
||||
// ports typically exposed by IP cameras, and
|
||||
// 3. MAC address + vendor (OUI) resolution from the local ARP table, and
|
||||
// 4. best-effort reverse-DNS hostname lookup.
|
||||
//
|
||||
// The results are merged per IP address so a single device is reported once
|
||||
// with all the information we could gather. Devices are flagged as cameras when
|
||||
// they answer to ONVIF, expose an RTSP port, or have a MAC that belongs to a
|
||||
// known camera vendor.
|
||||
//
|
||||
// Optional subnets (CIDR notation, e.g. "192.168.1.0/24") override the
|
||||
// automatically detected local subnets. This is useful when the agent runs in a
|
||||
// container/devcontainer whose interfaces are not on the same range as the
|
||||
// cameras, but the target range is still routable from the host network.
|
||||
func DiscoverDevices(timeout time.Duration, subnets ...string) []models.DiscoveredDevice {
|
||||
devicesByIP := make(map[string]*models.DiscoveredDevice)
|
||||
var mutex sync.Mutex
|
||||
|
||||
// upsert returns the (possibly newly created) device entry for an IP in a
|
||||
// concurrency-safe way.
|
||||
upsert := func(ip string) *models.DiscoveredDevice {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
device, ok := devicesByIP[ip]
|
||||
if !ok {
|
||||
device = &models.DiscoveredDevice{IP: ip}
|
||||
devicesByIP[ip] = device
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
// 1) ONVIF WS-Discovery. This is quick and reliable for ONVIF cameras.
|
||||
onvifDevices, err := onvifc.StartDiscovery(timeout)
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.DiscoverDevices(): WS-Discovery failed: " + err.Error())
|
||||
} else {
|
||||
for _, onvifDevice := range onvifDevices {
|
||||
ip := hostFromXAddr(onvifDevice.XAddr)
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
device := upsert(ip)
|
||||
device.ONVIF = true
|
||||
device.ONVIFXAddr = onvifDevice.XAddr
|
||||
device.IsCamera = true
|
||||
if hostname, hostErr := onvifDevice.GetHostname(); hostErr == nil && hostname.Name != "" {
|
||||
device.Hostname = hostname.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Active port scan across the requested (or auto-detected) IPv4 subnets.
|
||||
var targets []string
|
||||
if len(subnets) > 0 {
|
||||
targets = targetsFromSubnets(subnets)
|
||||
} else {
|
||||
targets = localScanTargets()
|
||||
}
|
||||
log.Log.Info("onvif.DiscoverDevices(): scanning " + strconv.Itoa(len(targets)) + " hosts on the local network(s)")
|
||||
|
||||
// Bound the amount of concurrent dials so we do not exhaust file
|
||||
// descriptors on constrained devices (e.g. Raspberry Pi).
|
||||
semaphore := make(chan struct{}, 128)
|
||||
dialTimeout := perHostTimeout(timeout)
|
||||
var waitGroup sync.WaitGroup
|
||||
|
||||
for _, ip := range targets {
|
||||
waitGroup.Add(1)
|
||||
semaphore <- struct{}{}
|
||||
go func(ip string) {
|
||||
defer waitGroup.Done()
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
openPorts, services, isCamera := scanHost(ip, dialTimeout)
|
||||
if len(openPorts) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Fingerprint the host (RTSP/HTTP banner grab) to determine its
|
||||
// manufacturer, model and type without any credentials.
|
||||
fingerprint := fingerprintHost(ip, openPorts, dialTimeout)
|
||||
|
||||
// Resolve a hostname now (ONVIF WS-Discovery may already have set
|
||||
// one; otherwise fall back to reverse DNS). Camera hostnames often
|
||||
// encode the model (e.g. Reolink "RLC-823S2"), which is a useful
|
||||
// brand hint when the RTSP/HTTP banners are anonymous.
|
||||
mutex.Lock()
|
||||
hostname := ""
|
||||
if existing, ok := devicesByIP[ip]; ok {
|
||||
hostname = existing.Hostname
|
||||
}
|
||||
mutex.Unlock()
|
||||
if hostname == "" {
|
||||
hostname = reverseDNS(ip, dialTimeout)
|
||||
}
|
||||
|
||||
// Guess (and actively confirm) the RTSP stream URLs from a built-in
|
||||
// brand -> RTSP path mapping when an RTSP port is open.
|
||||
var rtspPort int
|
||||
for _, port := range openPorts {
|
||||
if port == 554 || port == 8554 {
|
||||
rtspPort = port
|
||||
break
|
||||
}
|
||||
}
|
||||
// The banner manufacturer is most reliable; fall back to the
|
||||
// hostname (model code) so devices that only reveal themselves via
|
||||
// their name (e.g. Reolink RLC-*) still get the right stream paths.
|
||||
brandHint := fingerprint.Manufacturer
|
||||
if brandHint == "" {
|
||||
brandHint = hostname
|
||||
}
|
||||
var rtspStreams []models.RTSPStream
|
||||
detectedBrand := ""
|
||||
detectedModel := ""
|
||||
if rtspPort != 0 && !fingerprint.IsAudio {
|
||||
detectedBrand, detectedModel, rtspStreams = guessRTSPStreams(ip, rtspPort, brandHint, openPorts, dialTimeout)
|
||||
}
|
||||
|
||||
device := upsert(ip)
|
||||
mutex.Lock()
|
||||
device.OpenPorts = mergeSortedInts(device.OpenPorts, openPorts)
|
||||
device.Services = mergeUniqueStrings(device.Services, services)
|
||||
if hostname != "" && device.Hostname == "" {
|
||||
device.Hostname = hostname
|
||||
}
|
||||
if isCamera || fingerprint.IsCamera {
|
||||
device.IsCamera = true
|
||||
}
|
||||
if fingerprint.IsAudio {
|
||||
device.IsAudio = true
|
||||
device.IsCamera = false
|
||||
}
|
||||
if fingerprint.Manufacturer != "" {
|
||||
device.Manufacturer = fingerprint.Manufacturer
|
||||
}
|
||||
// A brand derived from the RTSP auth realm, a confirmed path probe or
|
||||
// a vendor-specific control port is more reliable than a banner
|
||||
// string, so let it win.
|
||||
if detectedBrand != "" && detectedBrand != "Generic" {
|
||||
device.Manufacturer = detectedBrand
|
||||
device.IsCamera = true
|
||||
}
|
||||
if fingerprint.Model != "" {
|
||||
device.Model = fingerprint.Model
|
||||
}
|
||||
if device.Model == "" && detectedModel != "" {
|
||||
device.Model = detectedModel
|
||||
}
|
||||
if fingerprint.Type != "" {
|
||||
device.Type = fingerprint.Type
|
||||
}
|
||||
if fingerprint.Server != "" {
|
||||
device.Server = fingerprint.Server
|
||||
}
|
||||
if len(rtspStreams) > 0 {
|
||||
device.RTSPStreams = rtspStreams
|
||||
// Prefer the first verified stream as the primary RTSP URL.
|
||||
device.RTSPURL = rtspStreams[0].URL
|
||||
for _, stream := range rtspStreams {
|
||||
if stream.Verified {
|
||||
device.RTSPURL = stream.URL
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if rtspPort != 0 && !fingerprint.IsAudio {
|
||||
device.RTSPURL = "rtsp://" + ip + ":" + strconv.Itoa(rtspPort) + "/"
|
||||
}
|
||||
mutex.Unlock()
|
||||
}(ip)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
|
||||
// 3) Enrich with MAC address / vendor from the ARP table and hostnames.
|
||||
arpTable := readARPTable()
|
||||
results := make([]models.DiscoveredDevice, 0, len(devicesByIP))
|
||||
for ip, device := range devicesByIP {
|
||||
if mac, ok := arpTable[ip]; ok {
|
||||
device.MAC = mac
|
||||
if vendor := vendorFromMAC(mac); vendor != "" {
|
||||
device.Vendor = vendor
|
||||
device.IsCamera = true
|
||||
}
|
||||
}
|
||||
// Fall back to the MAC vendor for the manufacturer, and make sure a
|
||||
// camera always carries a device type.
|
||||
if device.Manufacturer == "" && device.Vendor != "" {
|
||||
device.Manufacturer = device.Vendor
|
||||
}
|
||||
if device.IsCamera && device.Type == "" {
|
||||
device.Type = "IP Camera"
|
||||
}
|
||||
if device.Hostname == "" {
|
||||
device.Hostname = reverseDNS(ip, dialTimeout)
|
||||
}
|
||||
results = append(results, *device)
|
||||
}
|
||||
|
||||
// Cameras first, then by IP, for a stable and useful ordering.
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].IsCamera != results[j].IsCamera {
|
||||
return results[i].IsCamera
|
||||
}
|
||||
return ipLess(results[i].IP, results[j].IP)
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// scanHost probes the common camera ports on a single host and reports the open
|
||||
// ports, their service names, and whether the host looks like a camera.
|
||||
func scanHost(ip string, dialTimeout time.Duration) (openPorts []int, services []string, isCamera bool) {
|
||||
for _, candidate := range commonCameraPorts {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(candidate.Port))
|
||||
conn, err := net.DialTimeout("tcp", address, dialTimeout)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
conn.Close()
|
||||
openPorts = append(openPorts, candidate.Port)
|
||||
services = append(services, candidate.Service)
|
||||
if candidate.camera {
|
||||
isCamera = true
|
||||
}
|
||||
}
|
||||
return openPorts, services, isCamera
|
||||
}
|
||||
|
||||
// targetsFromSubnets expands one or more explicit CIDR ranges (e.g.
|
||||
// "192.168.1.0/24") into a de-duplicated list of host addresses. Invalid or
|
||||
// oversized ranges (mask < /22) are skipped so scans stay bounded.
|
||||
func targetsFromSubnets(subnets []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var targets []string
|
||||
|
||||
for _, subnet := range subnets {
|
||||
subnet = strings.TrimSpace(subnet)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
// Allow passing a bare host address (e.g. "192.168.1.50") too.
|
||||
if !strings.Contains(subnet, "/") {
|
||||
if net.ParseIP(subnet).To4() != nil {
|
||||
if _, exists := seen[subnet]; !exists {
|
||||
seen[subnet] = struct{}{}
|
||||
targets = append(targets, subnet)
|
||||
}
|
||||
} else {
|
||||
log.Log.Error("onvif.targetsFromSubnets(): invalid address '" + subnet + "'")
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, ipNet, err := net.ParseCIDR(subnet)
|
||||
if err != nil || ipNet.IP.To4() == nil {
|
||||
log.Log.Error("onvif.targetsFromSubnets(): invalid CIDR '" + subnet + "'")
|
||||
continue
|
||||
}
|
||||
if ones, bits := ipNet.Mask.Size(); bits != 32 || ones < 22 {
|
||||
log.Log.Error("onvif.targetsFromSubnets(): range '" + subnet + "' is too large to scan (use /22 or smaller)")
|
||||
continue
|
||||
}
|
||||
for _, host := range hostsInNetwork(ipNet) {
|
||||
if _, exists := seen[host]; exists {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
targets = append(targets, host)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// localScanTargets enumerates every usable IPv4 host address on the local
|
||||
// network interfaces. To keep scans bounded we only expand subnets with a mask
|
||||
// of /22 or smaller (at most ~1022 hosts per interface).
|
||||
func localScanTargets() []string {
|
||||
seen := make(map[string]struct{})
|
||||
var targets []string
|
||||
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.localScanTargets(): " + err.Error())
|
||||
return targets
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, addrErr := iface.Addrs()
|
||||
if addrErr != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok || ipNet.IP.To4() == nil {
|
||||
continue
|
||||
}
|
||||
ones, bits := ipNet.Mask.Size()
|
||||
if bits != 32 || ones < 22 {
|
||||
// Skip huge or non-IPv4 ranges to avoid endless scans.
|
||||
continue
|
||||
}
|
||||
for _, host := range hostsInNetwork(ipNet) {
|
||||
if _, exists := seen[host]; exists {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
targets = append(targets, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// hostsInNetwork returns all assignable host addresses in the given network,
|
||||
// excluding the network and broadcast addresses.
|
||||
func hostsInNetwork(ipNet *net.IPNet) []string {
|
||||
var hosts []string
|
||||
network := ipNet.IP.Mask(ipNet.Mask).To4()
|
||||
if network == nil {
|
||||
return hosts
|
||||
}
|
||||
|
||||
for ip := cloneIP(network); ipNet.Contains(ip); incrementIP(ip) {
|
||||
hosts = append(hosts, ip.String())
|
||||
}
|
||||
// Drop network + broadcast addresses when present.
|
||||
if len(hosts) > 2 {
|
||||
hosts = hosts[1 : len(hosts)-1]
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
func cloneIP(ip net.IP) net.IP {
|
||||
dup := make(net.IP, len(ip))
|
||||
copy(dup, ip)
|
||||
return dup
|
||||
}
|
||||
|
||||
func incrementIP(ip net.IP) {
|
||||
for i := len(ip) - 1; i >= 0; i-- {
|
||||
ip[i]++
|
||||
if ip[i] != 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hostFromXAddr extracts the host (IP) part from an ONVIF XAddr URL such as
|
||||
// "http://192.168.1.69:8000/onvif/device_service".
|
||||
func hostFromXAddr(xaddr string) string {
|
||||
parsed, err := url.Parse(xaddr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
// Fall back to a naive split for values without a scheme.
|
||||
host = strings.TrimPrefix(xaddr, "//")
|
||||
if idx := strings.IndexAny(host, ":/"); idx >= 0 {
|
||||
host = host[:idx]
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// readARPTable parses /proc/net/arp (Linux) and returns a map of IP -> MAC. On
|
||||
// non-Linux platforms or when the file is unavailable it returns an empty map.
|
||||
func readARPTable() map[string]string {
|
||||
table := make(map[string]string)
|
||||
file, err := os.Open("/proc/net/arp")
|
||||
if err != nil {
|
||||
return table
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
// Skip the header line.
|
||||
if scanner.Scan() {
|
||||
_ = scanner.Text()
|
||||
}
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
ip := fields[0]
|
||||
mac := fields[3]
|
||||
if mac == "00:00:00:00:00:00" || mac == "" {
|
||||
continue
|
||||
}
|
||||
table[ip] = strings.ToLower(mac)
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
// vendorFromMAC resolves a MAC address to a known camera vendor using its OUI.
|
||||
func vendorFromMAC(mac string) string {
|
||||
normalized := strings.ToUpper(strings.NewReplacer(":", "", "-", "", ".", "").Replace(mac))
|
||||
if len(normalized) < 6 {
|
||||
return ""
|
||||
}
|
||||
// Try a longer prefix first (some vendors share the first 3 octets).
|
||||
if len(normalized) >= 8 {
|
||||
if vendor, ok := ouiVendors[normalized[:8]]; ok {
|
||||
return vendor
|
||||
}
|
||||
}
|
||||
if vendor, ok := ouiVendors[normalized[:6]]; ok {
|
||||
return vendor
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// reverseDNS performs a best-effort, time-bounded reverse DNS lookup.
|
||||
func reverseDNS(ip string, timeout time.Duration) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
var resolver net.Resolver
|
||||
names, err := resolver.LookupAddr(ctx, ip)
|
||||
if err != nil || len(names) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(names[0], ".")
|
||||
}
|
||||
|
||||
// perHostTimeout derives a short per-connection dial timeout from the overall
|
||||
// discovery timeout, clamped to a sensible range.
|
||||
func perHostTimeout(timeout time.Duration) time.Duration {
|
||||
dialTimeout := timeout / 4
|
||||
if dialTimeout < 300*time.Millisecond {
|
||||
dialTimeout = 300 * time.Millisecond
|
||||
}
|
||||
if dialTimeout > 1500*time.Millisecond {
|
||||
dialTimeout = 1500 * time.Millisecond
|
||||
}
|
||||
return dialTimeout
|
||||
}
|
||||
|
||||
func mergeSortedInts(existing, added []int) []int {
|
||||
set := make(map[int]struct{}, len(existing)+len(added))
|
||||
for _, value := range existing {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
for _, value := range added {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
merged := make([]int, 0, len(set))
|
||||
for value := range set {
|
||||
merged = append(merged, value)
|
||||
}
|
||||
sort.Ints(merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeUniqueStrings(existing, added []string) []string {
|
||||
set := make(map[string]struct{}, len(existing)+len(added))
|
||||
merged := make([]string, 0, len(existing)+len(added))
|
||||
for _, value := range append(append([]string{}, existing...), added...) {
|
||||
if _, ok := set[value]; ok {
|
||||
continue
|
||||
}
|
||||
set[value] = struct{}{}
|
||||
merged = append(merged, value)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// ipLess compares two IPv4 address strings numerically.
|
||||
func ipLess(a, b string) bool {
|
||||
ipA := net.ParseIP(a).To4()
|
||||
ipB := net.ParseIP(b).To4()
|
||||
if ipA == nil || ipB == nil {
|
||||
return a < b
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
if ipA[i] != ipB[i] {
|
||||
return ipA[i] < ipB[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
225
machinery/src/onvif/events.go
Normal file
225
machinery/src/onvif/events.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/onvif/event/stream"
|
||||
)
|
||||
|
||||
// The library handles in-stream reconnect; these guards cover the
|
||||
// initial-connect path the library cannot see.
|
||||
const (
|
||||
initialBackoff = time.Second
|
||||
maxBackoff = 5 * time.Minute
|
||||
)
|
||||
|
||||
// HandleONVIFEventStream opens an event/stream against the configured
|
||||
// ONVIF camera and routes Motion events into communication.HandleMotion.
|
||||
//
|
||||
// Behind the Capture.ONVIFMotion flag; the goroutine returns
|
||||
// immediately when not enabled. The flag is read once at start, so
|
||||
// toggling at runtime requires an agent restart. On transient
|
||||
// construction failure (camera not yet ready at boot, brief network
|
||||
// blip, credential reload) the goroutine retries with exponential
|
||||
// backoff. Exits when ctx is cancelled.
|
||||
func HandleONVIFEventStream(ctx context.Context, configuration *models.Configuration, communication *models.Communication) {
|
||||
log.Log.Debug("onvif.HandleONVIFEventStream(): started")
|
||||
defer log.Log.Debug("onvif.HandleONVIFEventStream(): finished")
|
||||
|
||||
if !isONVIFMotionEnabled(configuration.Config.Capture.ONVIFMotion) {
|
||||
return
|
||||
}
|
||||
if configuration.Config.Capture.IPCamera.ONVIFXAddr == "" {
|
||||
log.Log.Warning("onvif.HandleONVIFEventStream(): ONVIFMotion enabled but ONVIFXAddr is empty; nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
backoff := initialBackoff
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
recoverable := runStreamOnce(ctx, configuration, communication)
|
||||
if !recoverable {
|
||||
return
|
||||
}
|
||||
if !sleepCtx(ctx, backoff) {
|
||||
return
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runStreamOnce returns true when the caller should retry construction
|
||||
// (transient failure), false on clean ctx-driven shutdown.
|
||||
func runStreamOnce(ctx context.Context, configuration *models.Configuration, communication *models.Communication) (retry bool) {
|
||||
camera := configuration.Config.Capture.IPCamera
|
||||
|
||||
device, _, err := ConnectToOnvifDevice(&camera)
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.HandleONVIFEventStream(): connect: " + err.Error())
|
||||
return true
|
||||
}
|
||||
|
||||
deviceID := resolveDeviceID(configuration.Name, camera.ONVIFXAddr)
|
||||
s, err := stream.NewStream(ctx, device, stream.Options{DeviceID: deviceID})
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.HandleONVIFEventStream(): open stream: " + err.Error())
|
||||
return true
|
||||
}
|
||||
defer func() {
|
||||
if err := s.Close(); err != nil {
|
||||
log.Log.Debug("onvif.HandleONVIFEventStream(): close: " + err.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
log.Log.Info("onvif.HandleONVIFEventStream(): consuming events for " + deviceID)
|
||||
|
||||
// recovering = the first successful event after an error streak
|
||||
// logs a recovery line so on-call operators see the clear-of-
|
||||
// condition for the ERROR they were paged on.
|
||||
var recovering bool
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case ev, ok := <-s.Events():
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if recovering {
|
||||
log.Log.Info("onvif.HandleONVIFEventStream(): event stream recovered for " + deviceID)
|
||||
recovering = false
|
||||
}
|
||||
dispatchEvent(ctx, ev, configuration, communication)
|
||||
case e, ok := <-s.Errors():
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
recovering = true
|
||||
logStreamError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchEvent routes motion-active events to HandleMotion.
|
||||
//
|
||||
// The ctx pre-check + ctx-in-select guards a shutdown race: the agent
|
||||
// closes HandleMotion shortly after cancelling ctx, and a stale event
|
||||
// reaching the send would otherwise panic on a closed channel.
|
||||
func dispatchEvent(ctx context.Context, ev stream.Event, configuration *models.Configuration, communication *models.Communication) {
|
||||
topic := sanitiseTopic(ev.Topic)
|
||||
if ev.Kind != stream.KindMotion {
|
||||
log.Log.Debug("onvif.dispatchEvent(): non-motion event " + ev.Kind.String() + " topic=" + topic)
|
||||
return
|
||||
}
|
||||
if ev.State != stream.StateActive {
|
||||
return
|
||||
}
|
||||
if !isTransition(ev.Operation) {
|
||||
log.Log.Debug("onvif.dispatchEvent(): " + ev.Operation.String() + " is not a transition, not a trigger: topic=" + topic)
|
||||
return
|
||||
}
|
||||
if configuration.Config.Capture.Recording == "false" {
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dataToPass := models.MotionDataPartial{
|
||||
Timestamp: time.Now().Unix(),
|
||||
NumberOfChanges: 0, // ONVIF does not quantify motion area.
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case communication.HandleMotion <- dataToPass:
|
||||
// Logged on the send, not before it: this line records that a
|
||||
// recording started, so a dropped event must not leave one.
|
||||
log.Log.Debug("onvif.dispatchEvent(): recording trigger " + ev.Kind.String() + " topic=" + topic)
|
||||
default:
|
||||
log.Log.Debug("onvif.dispatchEvent(): HandleMotion full, dropping ONVIF motion event")
|
||||
}
|
||||
}
|
||||
|
||||
// isTransition reports whether an operation represents a state change.
|
||||
// A camera replays every property's current state as Initialized on
|
||||
// each new subscription and announces removals as Deleted; neither is
|
||||
// motion starting. Absent (Unknown) counts — PropertyOperation is
|
||||
// optional per WS-Notification and many non-property events omit it.
|
||||
func isTransition(op stream.PropertyOperation) bool {
|
||||
return op == stream.PropertyChanged || op == stream.PropertyUnknown
|
||||
}
|
||||
|
||||
// maxLoggedTopic bounds a topic in the log; the wire imposes no limit,
|
||||
// and the reject path logs every event received.
|
||||
const maxLoggedTopic = 256
|
||||
|
||||
// sanitiseTopic makes a camera-controlled topic safe to concatenate
|
||||
// into a log line. logrus's coloured text formatter writes the message
|
||||
// unquoted, so a raw newline would let a camera forge entries in the
|
||||
// log being used to diagnose it.
|
||||
func sanitiseTopic(topic string) string {
|
||||
if len(topic) > maxLoggedTopic {
|
||||
topic = topic[:maxLoggedTopic] + "…(truncated)"
|
||||
}
|
||||
quoted := strconv.Quote(topic)
|
||||
return quoted[1 : len(quoted)-1]
|
||||
}
|
||||
|
||||
// logStreamError logs at a level matching severity: recreate is loud
|
||||
// because it usually means the camera is offline; pull and renew are
|
||||
// debug because the library recovers from them automatically.
|
||||
func logStreamError(e error) {
|
||||
var recreate stream.ErrRecreateFailed
|
||||
var pull stream.ErrPullFailed
|
||||
var renew stream.ErrRenewFailed
|
||||
switch {
|
||||
case errors.As(e, &recreate):
|
||||
log.Log.Error("onvif.HandleONVIFEventStream(): subscription recreate failed (camera may be offline): " + recreate.Err.Error())
|
||||
case errors.As(e, &renew):
|
||||
log.Log.Debug("onvif.HandleONVIFEventStream(): renew failed (will recover via pull/recreate): " + renew.Err.Error())
|
||||
case errors.As(e, &pull):
|
||||
log.Log.Debug("onvif.HandleONVIFEventStream(): pull failed (will retry): " + pull.Err.Error())
|
||||
default:
|
||||
log.Log.Info("onvif.HandleONVIFEventStream(): stream error: " + e.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func isONVIFMotionEnabled(v string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(v), "true")
|
||||
}
|
||||
|
||||
// resolveDeviceID falls back from operator-supplied name to ONVIF
|
||||
// endpoint to a constant placeholder so log lines always have
|
||||
// something to grep.
|
||||
func resolveDeviceID(configName, xaddr string) string {
|
||||
if n := strings.TrimSpace(configName); n != "" {
|
||||
return n
|
||||
}
|
||||
if x := strings.TrimSpace(xaddr); x != "" {
|
||||
return x
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// sleepCtx returns false if ctx was cancelled, true if d elapsed.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
375
machinery/src/onvif/events_test.go
Normal file
375
machinery/src/onvif/events_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/onvif/event/stream"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func makeConfig(recording, onvifMotion, name string) *models.Configuration {
|
||||
return &models.Configuration{
|
||||
Name: name,
|
||||
Config: models.Config{
|
||||
Capture: models.Capture{
|
||||
Recording: recording,
|
||||
ONVIFMotion: onvifMotion,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func makeCommunication(buffer int) *models.Communication {
|
||||
return &models.Communication{
|
||||
HandleMotion: make(chan models.MotionDataPartial, buffer),
|
||||
}
|
||||
}
|
||||
|
||||
// --- dispatchEvent ---------------------------------------------------
|
||||
|
||||
func TestDispatchEvent_MotionActive_SendsToHandleMotion(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case m := <-comm.HandleMotion:
|
||||
assert.NotZero(t, m.Timestamp)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected motion data on HandleMotion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_MotionInactive_DoesNotSend(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateInactive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case <-comm.HandleMotion:
|
||||
t.Fatal("inactive motion must not reach HandleMotion (motion-stop is a follow-up)")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_NonMotionKindIgnored(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindDigitalInput, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case <-comm.HandleMotion:
|
||||
t.Fatal("non-motion kinds must not reach HandleMotion")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// captureDebugLog redirects logrus to a buffer at debug level for the
|
||||
// duration of a test and returns what was written. It mutates package
|
||||
// globals, so callers must not run in parallel.
|
||||
func captureDebugLog(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
prevOut, prevLevel := logrus.StandardLogger().Out, logrus.GetLevel()
|
||||
logrus.SetOutput(&buf)
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
t.Cleanup(func() {
|
||||
logrus.SetOutput(prevOut)
|
||||
logrus.SetLevel(prevLevel)
|
||||
})
|
||||
return &buf
|
||||
}
|
||||
|
||||
// TestDispatchEvent_LogsTheTriggeringTopic — a dispatched event is what
|
||||
// actually starts a recording, so its topic is the one an operator needs
|
||||
// when a camera records for the wrong reason (or the right reason and
|
||||
// nobody can prove which). Rejected events were already logged; without
|
||||
// this the triggering topic is only knowable by elimination.
|
||||
func TestDispatchEvent_LogsTheTriggeringTopic(t *testing.T) {
|
||||
buf := captureDebugLog(t)
|
||||
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{
|
||||
Kind: stream.KindMotion,
|
||||
State: stream.StateActive,
|
||||
Topic: "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
assert.Contains(t, buf.String(), "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
|
||||
"the dispatched event's topic must appear in the log")
|
||||
assert.Contains(t, buf.String(), "Motion",
|
||||
"the dispatched event's Kind must appear in the log")
|
||||
}
|
||||
|
||||
// TestDispatchEvent_PropertyOperation — a camera replays the current
|
||||
// state of every property topic as Initialized whenever a pull-point
|
||||
// subscription is created. If that counts as a trigger, every
|
||||
// reconnect restarts a recording for any motion property that happens
|
||||
// to be active, and a flapping subscription manufactures motion out of
|
||||
// nothing. Only reject Initialized specifically: PropertyOperation is
|
||||
// optional per WS-Notification and absent on many non-property events,
|
||||
// which decode reports as PropertyUnknown.
|
||||
func TestDispatchEvent_PropertyOperation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op stream.PropertyOperation
|
||||
wantSend bool
|
||||
}{
|
||||
{"changed is a real transition", stream.PropertyChanged, true},
|
||||
{"absent attribute still counts", stream.PropertyUnknown, true},
|
||||
{"initialized is a subscription state replay", stream.PropertyInitialized, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{
|
||||
Kind: stream.KindMotion,
|
||||
State: stream.StateActive,
|
||||
Operation: tt.op,
|
||||
Topic: "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case <-comm.HandleMotion:
|
||||
if !tt.wantSend {
|
||||
t.Fatalf("%v must not trigger a recording", tt.op)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
if tt.wantSend {
|
||||
t.Fatalf("%v must trigger a recording", tt.op)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_RecordingDisabled_DoesNotSend(t *testing.T) {
|
||||
cfg := makeConfig("false", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
select {
|
||||
case <-comm.HandleMotion:
|
||||
t.Fatal("Recording=false must gate the send (matches computervision behaviour)")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_HandleMotionFull_DropsRatherThanBlocks(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
// Pre-fill the buffer so the next send would block.
|
||||
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
|
||||
comm.HandleMotion <- models.MotionDataPartial{}
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("dispatchEvent must drop when HandleMotion is full, not block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchEvent_CtxCancelledAndHandleMotionClosed_DoesNotPanic(t *testing.T) {
|
||||
// Regression for the shutdown race: between cancel() and
|
||||
// close(HandleMotion) the agent leaves a 3s window. If dispatchEvent
|
||||
// runs in that window AFTER the channel is closed, a non-protected
|
||||
// send would panic. The ctx pre-check must short-circuit before the
|
||||
// send is attempted.
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
|
||||
close(comm.HandleMotion)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // already cancelled, matching the shutdown sequence
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
})
|
||||
}
|
||||
|
||||
// --- isONVIFMotionEnabled --------------------------------------------
|
||||
|
||||
func TestIsONVIFMotionEnabled_CaseAndWhitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"true", true},
|
||||
{"True", true},
|
||||
{"TRUE", true},
|
||||
{" true", true},
|
||||
{"true ", true},
|
||||
{" true ", true},
|
||||
{"false", false},
|
||||
{"False", false},
|
||||
{"", false},
|
||||
{"yes", false},
|
||||
{"1", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, isONVIFMotionEnabled(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveDeviceID -------------------------------------------------
|
||||
|
||||
func TestResolveDeviceID_FallbackChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfgName string
|
||||
xaddr string
|
||||
want string
|
||||
}{
|
||||
{"name_set", "front-door", "192.168.1.10", "front-door"},
|
||||
{"name_empty_xaddr_set", "", "192.168.1.10", "192.168.1.10"},
|
||||
{"name_whitespace_only_xaddr_set", " ", "192.168.1.10", "192.168.1.10"},
|
||||
{"both_empty", "", "", "unknown"},
|
||||
{"name_with_trailing_whitespace", "cam-2 ", "192.168.1.10", "cam-2"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, resolveDeviceID(tc.cfgName, tc.xaddr))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchEvent_OnlyRealTransitionsTrigger — a camera replays every
|
||||
// property topic's state on each new subscription (Initialized) and
|
||||
// announces removals (Deleted). Neither is a motion transition, and a
|
||||
// flapping pull-point would otherwise manufacture recordings out of
|
||||
// replayed state. PropertyOperation is optional per WS-Notification, so
|
||||
// absent (Unknown) still counts — many non-property events omit it.
|
||||
func TestDispatchEvent_OnlyRealTransitionsTrigger(t *testing.T) {
|
||||
tests := []struct {
|
||||
op stream.PropertyOperation
|
||||
wantSend bool
|
||||
}{
|
||||
{stream.PropertyChanged, true},
|
||||
{stream.PropertyUnknown, true},
|
||||
{stream.PropertyInitialized, false},
|
||||
{stream.PropertyDeleted, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.op.String(), func(t *testing.T) {
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := makeCommunication(1)
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive, Operation: tt.op}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
if tt.wantSend {
|
||||
require.Len(t, comm.HandleMotion, 1, "%v must trigger a recording", tt.op)
|
||||
return
|
||||
}
|
||||
require.Empty(t, comm.HandleMotion, "%v must not trigger a recording", tt.op)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitiseTopic — ev.Topic is camera-controlled and reaches the log
|
||||
// unmodified. logrus's coloured text formatter (the default) writes the
|
||||
// message without quoting, so an embedded newline forges whole log
|
||||
// lines: a compromised camera can fabricate ERROR entries or spoof
|
||||
// another device's id, in the logs an operator is reading to diagnose
|
||||
// that very camera. Length is also unbounded on the wire, and the
|
||||
// reject path logs every event, so an oversized topic is a cheap way to
|
||||
// evict a container's whole retained history.
|
||||
func TestSanitiseTopic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ordinary topic passes through", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1"},
|
||||
{"newline cannot forge a line", "a\nERRO[fake] boom", `a\nERRO[fake] boom`},
|
||||
{"carriage return", "a\rb", `a\rb`},
|
||||
{"tab", "a\tb", `a\tb`},
|
||||
{"NUL", "a\x00b", `a\x00b`},
|
||||
{"empty", "", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sanitiseTopic(tt.in)
|
||||
assert.Equal(t, tt.want, got)
|
||||
assert.NotContains(t, got, "\n", "no raw newline may survive")
|
||||
assert.NotContains(t, got, "\r", "no raw carriage return may survive")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitiseTopic_Truncates(t *testing.T) {
|
||||
got := sanitiseTopic(strings.Repeat("x", maxLoggedTopic*2))
|
||||
assert.LessOrEqual(t, len(got), maxLoggedTopic+len("…(truncated)"))
|
||||
assert.Contains(t, got, "truncated")
|
||||
}
|
||||
|
||||
// TestDispatchEvent_LogsTriggerOnlyWhenSent — the trigger line is the
|
||||
// record that a recording started. Logging it before the send means a
|
||||
// dropped event (full channel, or shutdown) leaves a line claiming a
|
||||
// recording that never began.
|
||||
func TestDispatchEvent_LogsTriggerOnlyWhenSent(t *testing.T) {
|
||||
buf := captureDebugLog(t)
|
||||
|
||||
cfg := makeConfig("true", "true", "cam-1")
|
||||
comm := &models.Communication{HandleMotion: make(chan models.MotionDataPartial, 1)}
|
||||
comm.HandleMotion <- models.MotionDataPartial{} // full
|
||||
ev := stream.Event{Kind: stream.KindMotion, State: stream.StateActive, Topic: "tns1:VideoSource/MotionAlarm"}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dispatchEvent(ctx, ev, cfg, comm)
|
||||
|
||||
assert.NotContains(t, buf.String(), "recording trigger",
|
||||
"a dropped event must not be logged as a trigger")
|
||||
assert.Contains(t, buf.String(), "dropping", "the drop itself must still be logged")
|
||||
}
|
||||
380
machinery/src/onvif/fingerprint.go
Normal file
380
machinery/src/onvif/fingerprint.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// deviceFingerprint holds the identifying information we can gather from a host
|
||||
// without any credentials. It is populated by grabbing the RTSP and HTTP
|
||||
// service banners and is then distilled into a manufacturer, model and a
|
||||
// human-readable device type (e.g. "IP Camera", "DVR/NVR").
|
||||
type deviceFingerprint struct {
|
||||
Manufacturer string
|
||||
Model string
|
||||
Type string
|
||||
Server string
|
||||
// realm is the WWW-Authenticate realm advertised by the HTTP service. Many
|
||||
// cameras expose their model or vendor here (e.g. realm="Hikvision").
|
||||
realm string
|
||||
// body holds a lower-cased slice of the HTTP landing page, fetched only when
|
||||
// the banners are anonymous. Rebadged/OEM cameras often reveal their vendor
|
||||
// there (logo filenames, embedded scripts), e.g. ADI "Capture".
|
||||
body string
|
||||
// IsCamera is set when the collected evidence confidently identifies the
|
||||
// device as a camera, NVR or DVR.
|
||||
IsCamera bool
|
||||
// IsAudio is set for audio-only devices (IP speakers / intercoms, e.g. TOA)
|
||||
// that use RTSP for audio rather than video.
|
||||
IsAudio bool
|
||||
}
|
||||
|
||||
// bannerVendors maps a lower-cased substring commonly found in RTSP/HTTP
|
||||
// service banners or auth realms to a manufacturer. The list is ordered so the
|
||||
// most specific matches win. This mirrors how tools such as Fing or ONVIF
|
||||
// Device Manager fingerprint a device from its network banners.
|
||||
var bannerVendors = []struct {
|
||||
Match string
|
||||
Vendor string
|
||||
IsCamera bool
|
||||
}{
|
||||
{"hikvision", "Hikvision", true},
|
||||
{"dahua", "Dahua", true},
|
||||
{"axis", "Axis", true},
|
||||
{"reolink", "Reolink", true},
|
||||
{"amcrest", "Amcrest", true},
|
||||
{"vivotek", "Vivotek", true},
|
||||
{"mobotix", "Mobotix", true},
|
||||
{"hanwha", "Hanwha", true},
|
||||
{"wisenet", "Hanwha", true},
|
||||
{"bosch", "Bosch", true},
|
||||
{"foscam", "Foscam", true},
|
||||
{"ubiquiti", "Ubiquiti", true},
|
||||
{"unifi", "Ubiquiti", true},
|
||||
{"uniview", "Uniview", true},
|
||||
{"tp-link", "TP-Link", true},
|
||||
{"tapo", "TP-Link", true},
|
||||
{"linksys", "Linksys", true},
|
||||
{"d-link", "D-Link", true},
|
||||
{"dlink", "D-Link", true},
|
||||
{"trendnet", "Trendnet", true},
|
||||
{"lorex", "Lorex", true},
|
||||
{"honeywell", "Honeywell", true},
|
||||
{"pelco", "Pelco", true},
|
||||
{"toa rtsp", "TOA", false},
|
||||
{"hipcam", "Hipcam", true},
|
||||
{"h264dvr", "Generic DVR", true},
|
||||
{"dvrdvs", "Hikvision", true},
|
||||
{"webs", "", false}, // generic embedded web server, no vendor
|
||||
{"rtsp server", "", true},
|
||||
{"gstreamer", "", true},
|
||||
{"live555", "", true},
|
||||
}
|
||||
|
||||
// bodyVendors maps a distinctive lower-cased substring found in a camera's HTML
|
||||
// landing page (logo filename, embedded script, product string) to a
|
||||
// manufacturer. Used only when the RTSP/HTTP banners are anonymous, so it can
|
||||
// identify rebadged/OEM cameras (e.g. ADI "Capture") that hide their model
|
||||
// behind a generic "httpd" server and an "RTSP" realm.
|
||||
var bodyVendors = []struct {
|
||||
Match string
|
||||
Vendor string
|
||||
IsCamera bool
|
||||
}{
|
||||
{"logo_white(capture)", "Capture", true},
|
||||
{"logo_capture", "Capture", true},
|
||||
}
|
||||
|
||||
// genericRealms are auth realms that carry no useful model/vendor information.
|
||||
var genericRealms = map[string]struct{}{
|
||||
"": {},
|
||||
"ip camera": {},
|
||||
"ipcamera": {},
|
||||
"camera": {},
|
||||
"login": {},
|
||||
"index": {},
|
||||
"streaming": {},
|
||||
"realm": {},
|
||||
"network video": {},
|
||||
"web": {},
|
||||
"protected": {},
|
||||
"authorized users only": {},
|
||||
"please log in with a valid username.": {},
|
||||
"please log in with a valid username": {},
|
||||
}
|
||||
|
||||
// fingerprintHost grabs the RTSP and HTTP banners for the given host (based on
|
||||
// the ports found open during the scan) and classifies the device. It performs
|
||||
// at most two lightweight, unauthenticated requests and is safe to run
|
||||
// concurrently for every host.
|
||||
func fingerprintHost(ip string, openPorts []int, timeout time.Duration) deviceFingerprint {
|
||||
var fp deviceFingerprint
|
||||
|
||||
// 1) RTSP OPTIONS on the first open RTSP port. The Server response header of
|
||||
// most camera RTSP stacks reveals the device (e.g. "Dahua Rtsp Server",
|
||||
// "Hipcam RealServer/V1.0", "H264DVR 1.0").
|
||||
for _, port := range openPorts {
|
||||
if port == 554 || port == 8554 {
|
||||
if banner := rtspServerBanner(ip, port, timeout); banner != "" {
|
||||
fp.Server = banner
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2) HTTP banner + auth realm on the first open HTTP/ONVIF port. Cameras
|
||||
// frequently expose their vendor/model in the Server header or the
|
||||
// WWW-Authenticate realm.
|
||||
httpPort := 0
|
||||
for _, port := range openPorts {
|
||||
if port == 80 || port == 8080 || port == 8000 {
|
||||
server, realm := httpBanner(ip, port, timeout)
|
||||
if fp.Server == "" {
|
||||
fp.Server = server
|
||||
}
|
||||
fp.realm = realm
|
||||
httpPort = port
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 3) When the banners are anonymous (generic server, no vendor realm), fetch
|
||||
// a slice of the landing page. Rebadged/OEM cameras (e.g. ADI "Capture")
|
||||
// only reveal their vendor in the HTML.
|
||||
if httpPort != 0 && isGenericServer(fp.Server) {
|
||||
fp.body = httpBody(ip, httpPort, timeout)
|
||||
}
|
||||
|
||||
classifyFingerprint(&fp, openPorts)
|
||||
return fp
|
||||
}
|
||||
|
||||
// isGenericServer reports whether an HTTP Server header is a generic embedded
|
||||
// web server that carries no vendor information (so the HTML body is worth a
|
||||
// look).
|
||||
func isGenericServer(server string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(server))
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
for _, generic := range []string{"httpd", "webs", "boa", "lighttpd", "nginx", "gsoap", "mini_httpd", "thttpd", "apache"} {
|
||||
if strings.Contains(s, generic) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// httpBody issues an unauthenticated HTTP GET / and returns a lower-cased,
|
||||
// size-bounded slice of the response (headers + body). Best-effort; empty on
|
||||
// error.
|
||||
func httpBody(ip string, port int, timeout time.Duration) string {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
request := "GET / HTTP/1.0\r\nHost: " + ip + "\r\nUser-Agent: KerberosDiscovery\r\nAccept: */*\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for builder.Len() < 65536 {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
builder.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.ToLower(builder.String())
|
||||
}
|
||||
|
||||
// rtspServerBanner issues an unauthenticated RTSP OPTIONS request and returns
|
||||
// the value of the Server response header (empty when the host does not answer
|
||||
// or exposes no banner).
|
||||
func rtspServerBanner(ip string, port int, timeout time.Duration) string {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
request := "OPTIONS rtsp://" + address + " RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: KerberosDiscovery\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return ""
|
||||
}
|
||||
headers := readBannerHeaders(conn)
|
||||
return headers["server"]
|
||||
}
|
||||
|
||||
// httpBanner issues an unauthenticated HTTP HEAD request and returns the Server
|
||||
// header and the WWW-Authenticate realm (both best-effort, empty when absent).
|
||||
func httpBanner(ip string, port int, timeout time.Duration) (server string, realm string) {
|
||||
address := net.JoinHostPort(ip, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
request := "HEAD / HTTP/1.0\r\nHost: " + ip + "\r\nUser-Agent: KerberosDiscovery\r\nAccept: */*\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(request)); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
headers := readBannerHeaders(conn)
|
||||
return headers["server"], parseRealm(headers["www-authenticate"])
|
||||
}
|
||||
|
||||
// readBannerHeaders reads a status line followed by header lines from an
|
||||
// RTSP/HTTP response and returns the headers keyed by their lower-cased name.
|
||||
// Only the first occurrence of a header is kept.
|
||||
func readBannerHeaders(conn net.Conn) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
reader := bufio.NewReader(conn)
|
||||
|
||||
// Discard the status line (e.g. "RTSP/1.0 200 OK" or "HTTP/1.1 401 ...").
|
||||
if _, err := reader.ReadString('\n'); err != nil {
|
||||
return headers
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
idx := strings.Index(line, ":")
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(line[:idx]))
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if _, exists := headers[key]; !exists {
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// parseRealm extracts the realm token from a WWW-Authenticate header value such
|
||||
// as `Digest realm="Hikvision", nonce="..."`.
|
||||
func parseRealm(header string) string {
|
||||
lower := strings.ToLower(header)
|
||||
marker := "realm="
|
||||
idx := strings.Index(lower, marker)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
value := header[idx+len(marker):]
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
value = value[1:]
|
||||
if end := strings.Index(value, "\""); end >= 0 {
|
||||
value = value[:end]
|
||||
}
|
||||
} else if end := strings.IndexAny(value, ", "); end >= 0 {
|
||||
value = value[:end]
|
||||
}
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
// classifyFingerprint distils the collected banners and open ports into a
|
||||
// manufacturer, model and device type. It also decides whether the evidence is
|
||||
// strong enough to consider the host a camera/NVR.
|
||||
func classifyFingerprint(fp *deviceFingerprint, openPorts []int) {
|
||||
haystack := strings.ToLower(fp.Server + " " + fp.realm)
|
||||
|
||||
// Manufacturer from the banner/realm.
|
||||
for _, entry := range bannerVendors {
|
||||
if !strings.Contains(haystack, entry.Match) {
|
||||
continue
|
||||
}
|
||||
if entry.Vendor != "" && fp.Manufacturer == "" {
|
||||
fp.Manufacturer = entry.Vendor
|
||||
}
|
||||
if entry.IsCamera {
|
||||
fp.IsCamera = true
|
||||
}
|
||||
if fp.Manufacturer != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Model from the auth realm when it looks specific (not a generic word).
|
||||
if fp.Model == "" && fp.realm != "" {
|
||||
if _, generic := genericRealms[strings.ToLower(fp.realm)]; !generic {
|
||||
if !strings.EqualFold(fp.realm, fp.Manufacturer) {
|
||||
fp.Model = fp.realm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vendor from the HTML landing page when the banners revealed nothing.
|
||||
// Rebadged/OEM cameras (e.g. ADI "Capture") only identify themselves via
|
||||
// logo filenames or embedded scripts.
|
||||
if fp.Manufacturer == "" && fp.body != "" {
|
||||
for _, entry := range bodyVendors {
|
||||
if strings.Contains(fp.body, entry.Match) {
|
||||
fp.Manufacturer = entry.Vendor
|
||||
if entry.IsCamera {
|
||||
fp.IsCamera = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device type from ports and banners.
|
||||
hasRTSP := containsInt(openPorts, 554) || containsInt(openPorts, 8554)
|
||||
hasONVIF := containsInt(openPorts, 8000) || containsInt(openPorts, 8899)
|
||||
hasDVRPort := containsInt(openPorts, 37777) || containsInt(openPorts, 34567)
|
||||
|
||||
// Audio devices (IP speakers / intercoms) also speak RTSP, but for audio
|
||||
// rather than video, so classify them separately and never as a camera.
|
||||
if fp.Manufacturer == "TOA" ||
|
||||
strings.Contains(haystack, "speaker") ||
|
||||
strings.Contains(haystack, "sip audio") ||
|
||||
strings.Contains(haystack, "audio server") {
|
||||
fp.IsAudio = true
|
||||
fp.IsCamera = false
|
||||
fp.Type = "IP Speaker/Audio"
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(haystack, "nvr"):
|
||||
fp.Type = "NVR"
|
||||
fp.IsCamera = true
|
||||
case strings.Contains(haystack, "dvr") || hasDVRPort:
|
||||
fp.Type = "DVR/NVR"
|
||||
fp.IsCamera = true
|
||||
case hasRTSP || hasONVIF:
|
||||
fp.Type = "IP Camera"
|
||||
fp.IsCamera = true
|
||||
case fp.IsCamera:
|
||||
fp.Type = "IP Camera"
|
||||
}
|
||||
}
|
||||
|
||||
func containsInt(values []int, target int) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
onvifc "github.com/cedricve/go-onvif"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
@@ -24,19 +23,81 @@ import (
|
||||
xsdonvif "github.com/kerberos-io/onvif/xsd/onvif"
|
||||
)
|
||||
|
||||
func Discover(timeout time.Duration) {
|
||||
log.Log.Info("onvif.Discover(): Discovering devices")
|
||||
log.Log.Info("Waiting for " + timeout.String())
|
||||
devices, err := onvifc.StartDiscovery(timeout)
|
||||
if err != nil {
|
||||
log.Log.Error("onvif.Discover(): " + err.Error())
|
||||
} else {
|
||||
for _, device := range devices {
|
||||
hostname, _ := device.GetHostname()
|
||||
log.Log.Info("onvif.Discover(): " + hostname.Name + " (" + device.XAddr + ")")
|
||||
// Discover performs an advanced Fing/WiFiman-style scan of the local network
|
||||
// (ONVIF WS-Discovery + active port scan + MAC/vendor lookup) and prints a
|
||||
// human readable summary of everything it finds. It is used by the
|
||||
// `-action discover` CLI command. Optional subnets (CIDR, e.g.
|
||||
// "192.168.1.0/24") override the auto-detected local subnets.
|
||||
func Discover(timeout time.Duration, subnets ...string) {
|
||||
log.Log.Info("onvif.Discover(): starting advanced network discovery")
|
||||
log.Log.Info("onvif.Discover(): this may take up to " + timeout.String() + " for the ONVIF probe plus the port scan")
|
||||
|
||||
devices := DiscoverDevices(timeout, subnets...)
|
||||
if len(devices) == 0 {
|
||||
log.Log.Info("onvif.Discover(): no devices discovered on the local network")
|
||||
return
|
||||
}
|
||||
|
||||
cameraCount := 0
|
||||
for _, device := range devices {
|
||||
if device.IsCamera {
|
||||
cameraCount++
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
log.Log.Info("onvif.Discover(): No devices descovered\n")
|
||||
}
|
||||
log.Log.Info("onvif.Discover(): found " + strconv.Itoa(len(devices)) + " device(s), " + strconv.Itoa(cameraCount) + " likely camera(s)")
|
||||
|
||||
for _, device := range devices {
|
||||
label := "device"
|
||||
if device.IsCamera {
|
||||
label = "camera"
|
||||
} else if device.IsAudio {
|
||||
label = "speaker"
|
||||
}
|
||||
summary := "onvif.Discover(): [" + label + "] " + device.IP
|
||||
if device.Hostname != "" {
|
||||
summary += " (" + device.Hostname + ")"
|
||||
}
|
||||
if device.MAC != "" {
|
||||
summary += " mac=" + device.MAC
|
||||
}
|
||||
if device.Vendor != "" {
|
||||
summary += " vendor=" + device.Vendor
|
||||
}
|
||||
if device.Type != "" {
|
||||
summary += " type=" + device.Type
|
||||
}
|
||||
if device.Manufacturer != "" {
|
||||
summary += " manufacturer=" + device.Manufacturer
|
||||
}
|
||||
if device.Model != "" {
|
||||
summary += " model=" + device.Model
|
||||
}
|
||||
if device.Server != "" {
|
||||
summary += " server=\"" + device.Server + "\""
|
||||
}
|
||||
if device.ONVIF {
|
||||
summary += " onvif=" + device.ONVIFXAddr
|
||||
}
|
||||
if len(device.Services) > 0 {
|
||||
summary += " services=[" + strings.Join(device.Services, ", ") + "]"
|
||||
}
|
||||
if device.RTSPURL != "" {
|
||||
summary += " rtsp=" + device.RTSPURL
|
||||
}
|
||||
log.Log.Info(summary)
|
||||
|
||||
// Detail the guessed RTSP stream URLs from the brand -> RTSP mapping.
|
||||
for _, stream := range device.RTSPStreams {
|
||||
status := "guess"
|
||||
if stream.Verified {
|
||||
status = "confirmed"
|
||||
}
|
||||
line := "onvif.Discover(): -> " + stream.Stream + " stream [" + status + "]"
|
||||
if stream.RequiresAuth {
|
||||
line += " (auth required)"
|
||||
}
|
||||
line += ": " + stream.URL
|
||||
log.Log.Info(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ type Packet struct {
|
||||
IsKeyFrame bool // video packet is key frame
|
||||
Idx int8 // stream index in container format
|
||||
Codec string // codec name
|
||||
CompositionTime int64 // packet presentation time minus decode time for H264 B-Frame
|
||||
CompositionTime int64 // composition offset (PTS - DTS) in milliseconds, non-zero for H264/H265 B-frames
|
||||
Time int64 // packet decode time
|
||||
TimeLegacy time.Duration
|
||||
CurrentTime int64 // current time in milliseconds (UNIX timestamp)
|
||||
|
||||
@@ -14,7 +14,14 @@ import (
|
||||
func JWTMiddleWare() jwt.GinJWTMiddleware {
|
||||
|
||||
identityKey := "id"
|
||||
myKey := "TOBECHANGED"
|
||||
// Allow the JWT signing secret to be configured through an environment
|
||||
// variable so that tokens issued by another service (e.g. the Kerberos
|
||||
// Factory) can be validated by the agent. Falls back to the historic
|
||||
// default to preserve backwards compatibility.
|
||||
myKey := os.Getenv("AGENT_JWT_SECRET")
|
||||
if myKey == "" {
|
||||
myKey = "TOBECHANGED"
|
||||
}
|
||||
|
||||
m := jwt.GinJWTMiddleware{
|
||||
Realm: "kerberosio",
|
||||
@@ -106,7 +113,11 @@ func JWTMiddleWare() jwt.GinJWTMiddleware {
|
||||
// - "query:<name>"
|
||||
// - "cookie:<name>"
|
||||
// - "param:<name>"
|
||||
TokenLookup: "header: Authorization, query: token, cookie: jwt",
|
||||
// X-Authorization is included because requests proxied through the
|
||||
// Kubernetes apiserver service-proxy have their Authorization header
|
||||
// consumed by the apiserver; the original bearer token is forwarded in
|
||||
// the X-Authorization header instead.
|
||||
TokenLookup: "header: Authorization, header: X-Authorization, query: token, cookie: jwt",
|
||||
// TokenLookup: "query:token",
|
||||
// TokenLookup: "cookie:token",
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
@@ -17,6 +21,38 @@ import (
|
||||
// @Success 200 {object} models.Authorization
|
||||
func Login() {}
|
||||
|
||||
// DiscoverCameras godoc
|
||||
// @Router /api/camera/discover [get]
|
||||
// @ID camera-discover
|
||||
// @Tags onvif
|
||||
// @Param timeout query int false "Discovery timeout in milliseconds (default 2000)"
|
||||
// @Param subnet query string false "Optional subnet(s) to scan, e.g. '192.168.1.0/24' (comma-separated). Defaults to the local interfaces."
|
||||
// @Summary Discover cameras and other devices on the local network.
|
||||
// @Description Runs an advanced Fing/WiFiman-style scan (ONVIF WS-Discovery + TCP port scan + MAC/vendor lookup) and returns the devices found on the local network.
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func DiscoverCameras(c *gin.Context) {
|
||||
timeout := 2000 * time.Millisecond
|
||||
if raw := c.Query("timeout"); raw != "" {
|
||||
if milliseconds, err := strconv.Atoi(raw); err == nil && milliseconds > 0 {
|
||||
timeout = time.Duration(milliseconds) * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
var subnets []string
|
||||
if raw := c.Query("subnet"); raw != "" {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
subnets = append(subnets, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devices := onvif.DiscoverDevices(timeout, subnets...)
|
||||
c.JSON(200, models.APIResponse{
|
||||
Data: devices,
|
||||
})
|
||||
}
|
||||
|
||||
// LoginToOnvif godoc
|
||||
// @Router /api/camera/onvif/login [post]
|
||||
// @ID camera-onvif-login
|
||||
|
||||
@@ -96,6 +96,7 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
|
||||
})
|
||||
|
||||
// Onvif specific methods.
|
||||
api.GET("/camera/discover", DiscoverCameras)
|
||||
api.POST("/camera/onvif/verify", onvif.VerifyOnvifConnection)
|
||||
api.POST("/camera/onvif/login", LoginToOnvif)
|
||||
api.POST("/camera/onvif/capabilities", GetOnvifCapabilities)
|
||||
|
||||
@@ -11,9 +11,13 @@ import (
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
configService "github.com/kerberos-io/agent/machinery/src/config"
|
||||
"github.com/kerberos-io/agent/machinery/src/encryption"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
@@ -170,11 +174,45 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxSignalingAge is the maximum age of a WebRTC signaling message (request-hd-stream,
|
||||
// receive-hd-candidates) before it is considered stale and discarded. With CleanSession=false
|
||||
// the MQTT broker may replay queued messages from previous sessions; this prevents the agent
|
||||
// from setting up peer connections for viewers that are no longer waiting.
|
||||
const maxSignalingAge = 30 * time.Second
|
||||
// recentHDSessions tracks recently-seen WebRTC viewer session IDs so we can
|
||||
// dedupe duplicate request-hd-stream messages without relying on the broker's
|
||||
// (and the viewer's) wall clock. The viewer's offer-republish loop can fire
|
||||
// the same request several times for the same session_id while waiting for an
|
||||
// answer; the broker can also redeliver a message after a reconnect with
|
||||
// CleanSession=false. In both cases we want to handle the session exactly
|
||||
// once.
|
||||
//
|
||||
// Entries expire after recentHDSessionTTL. The map is small (one entry per
|
||||
// active viewer over the TTL window) so a periodic sweep is sufficient.
|
||||
const recentHDSessionTTL = 60 * time.Second
|
||||
|
||||
var (
|
||||
recentHDSessionsMu sync.Mutex
|
||||
recentHDSessions = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
// markHDSessionSeen returns true if this session_id was already processed
|
||||
// within the TTL window (i.e. this message should be treated as a duplicate).
|
||||
// It also opportunistically prunes expired entries.
|
||||
func markHDSessionSeen(sessionID string) bool {
|
||||
if sessionID == "" {
|
||||
return false
|
||||
}
|
||||
recentHDSessionsMu.Lock()
|
||||
defer recentHDSessionsMu.Unlock()
|
||||
now := time.Now()
|
||||
// Lazy GC — cheap given the expected map size.
|
||||
for k, t := range recentHDSessions {
|
||||
if now.Sub(t) > recentHDSessionTTL {
|
||||
delete(recentHDSessions, k)
|
||||
}
|
||||
}
|
||||
if _, exists := recentHDSessions[sessionID]; exists {
|
||||
return true
|
||||
}
|
||||
recentHDSessions[sessionID] = now
|
||||
return false
|
||||
}
|
||||
|
||||
func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
|
||||
if hubKey == "" {
|
||||
@@ -282,22 +320,19 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
|
||||
// We'll find out which message we received, and act accordingly.
|
||||
log.Log.Info("routers.mqtt.main.MQTTListenerHandler(): received message with action: " + payload.Action)
|
||||
|
||||
// For time-sensitive WebRTC signaling messages, discard stale ones that may
|
||||
// have been queued by the broker while CleanSession=false.
|
||||
if payload.Action == "request-hd-stream" || payload.Action == "receive-hd-candidates" {
|
||||
messageAge := time.Since(time.Unix(message.Timestamp, 0))
|
||||
if messageAge > maxSignalingAge {
|
||||
log.Log.Info("routers.mqtt.main.MQTTListenerHandler(): discarding stale " + payload.Action +
|
||||
" message (age: " + messageAge.Round(time.Second).String() + ")")
|
||||
return
|
||||
}
|
||||
}
|
||||
// NOTE: We intentionally do NOT discard request-hd-stream /
|
||||
// receive-hd-candidates messages based on a wall-clock age. The
|
||||
// viewer and agent clocks can drift (especially on embedded
|
||||
// devices), which previously caused valid requests to be
|
||||
// silently dropped and forced the user to refresh the page.
|
||||
// Duplicate handling for request-hd-stream is done by session_id
|
||||
// inside HandleRequestHDStream (see markHDSessionSeen).
|
||||
|
||||
switch payload.Action {
|
||||
case "record":
|
||||
go HandleRecording(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "get-audio-backchannel":
|
||||
go HandleAudio(mqttClient, hubKey, payload, configuration, communication)
|
||||
HandleAudio(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "get-ptz-position":
|
||||
go HandleGetPTZPosition(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "update-ptz-position":
|
||||
@@ -306,12 +341,16 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
|
||||
go HandleNavigatePTZ(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "request-config":
|
||||
go HandleRequestConfig(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "verify-stream":
|
||||
go HandleVerifyStream(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "update-config":
|
||||
go HandleUpdateConfig(mqttClient, hubKey, payload, configDirectory, configuration, communication)
|
||||
case "request-sd-stream":
|
||||
go HandleRequestSDStream(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "request-hd-stream":
|
||||
go HandleRequestHDStream(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "request-hls-stream":
|
||||
go HandleRequestHLSStream(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "receive-hd-candidates":
|
||||
go HandleReceiveHDCandidates(mqttClient, hubKey, payload, configuration, communication)
|
||||
case "trigger-relay":
|
||||
@@ -341,11 +380,52 @@ func HandleRecording(mqttClient mqtt.Client, hubKey string, payload models.Paylo
|
||||
var recordPayload models.RecordPayload
|
||||
json.Unmarshal(jsonData, &recordPayload)
|
||||
|
||||
if recordPayload.Timestamp != 0 {
|
||||
motionDataPartial := models.MotionDataPartial{
|
||||
Timestamp: recordPayload.Timestamp,
|
||||
timestamp := recordPayload.Timestamp
|
||||
if timestamp == 0 {
|
||||
timestamp = time.Now().Unix()
|
||||
}
|
||||
|
||||
if recordPayload.Recording {
|
||||
now := time.Now().UnixMilli()
|
||||
if recordPayload.Heartbeat {
|
||||
// Keep-alive from a viewer that supports heartbeats. Only refresh while
|
||||
// a manual recording is actually running; if it already auto-stopped
|
||||
// (heartbeat timeout / max duration) we IGNORE it so a stray heartbeat
|
||||
// can't restart a recording we just ended. Seeing a heartbeat also arms
|
||||
// the recorder's heartbeat-timeout auto-stop.
|
||||
if communication.IsRecordingManual.IsSet() {
|
||||
communication.RecordingManualHeartbeat.Store(now)
|
||||
communication.RecordingManualHeartbeatSeen.Set()
|
||||
log.Log.Debug("routers.mqtt.main.HandleRecording(): manual recording heartbeat received.")
|
||||
} else {
|
||||
log.Log.Debug("routers.mqtt.main.HandleRecording(): ignoring heartbeat, no active manual recording.")
|
||||
}
|
||||
} else {
|
||||
// Explicit start from the live view (record button). Start a manual
|
||||
// recording and keep it running — the motion recorder honours
|
||||
// communication.IsRecordingManual and won't auto-close on the
|
||||
// post-recording timeout while it's set. We also inject a motion event
|
||||
// so the recording starts immediately, even when nothing is moving.
|
||||
communication.RecordingManualHeartbeat.Store(now)
|
||||
if communication.IsRecordingManual.SetToIf(false, true) {
|
||||
communication.RecordingManualStart.Store(now)
|
||||
communication.RecordingManualHeartbeatSeen.UnSet()
|
||||
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording started.")
|
||||
select {
|
||||
case communication.HandleMotion <- models.MotionDataPartial{Timestamp: timestamp, NumberOfChanges: 100000000}:
|
||||
default:
|
||||
log.Log.Warning("routers.mqtt.main.HandleRecording(): motion channel full, manual recording start not queued.")
|
||||
}
|
||||
}
|
||||
}
|
||||
communication.HandleMotion <- motionDataPartial
|
||||
} else {
|
||||
// Stop the manual recording; the motion recorder closes the clip once the
|
||||
// post-recording window elapses. Clear the heartbeat/start markers too.
|
||||
log.Log.Info("routers.mqtt.main.HandleRecording(): manual recording stopped.")
|
||||
communication.IsRecordingManual.UnSet()
|
||||
communication.RecordingManualHeartbeat.Store(0)
|
||||
communication.RecordingManualStart.Store(0)
|
||||
communication.RecordingManualHeartbeatSeen.UnSet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,10 +442,31 @@ func HandleAudio(mqttClient mqtt.Client, hubKey string, payload models.Payload,
|
||||
Timestamp: audioPayload.Timestamp,
|
||||
Data: audioPayload.Data,
|
||||
}
|
||||
communication.HandleAudio <- audioDataPartial
|
||||
if enqueueLatestAudio(communication.HandleAudio, audioDataPartial) {
|
||||
log.Log.Debug("routers.mqtt.main.HandleAudio(): dropped stale audio because the backchannel queue was full")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueLatestAudio(audioChannel chan models.AudioDataPartial, audio models.AudioDataPartial) bool {
|
||||
select {
|
||||
case audioChannel <- audio:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case <-audioChannel:
|
||||
default:
|
||||
}
|
||||
|
||||
select {
|
||||
case audioChannel <- audio:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func HandleGetPTZPosition(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
|
||||
value := payload.Value
|
||||
|
||||
@@ -471,6 +572,98 @@ func HandleRequestConfig(mqttClient mqtt.Client, hubKey string, payload models.P
|
||||
}
|
||||
}
|
||||
|
||||
// HandleVerifyStream probes an RTSP stream (the one supplied in the request, or
|
||||
// the currently configured main/sub stream) and reports back whether it can be
|
||||
// connected to and decoded, along with the discovered codec/resolution/fps.
|
||||
func HandleVerifyStream(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
|
||||
value := payload.Value
|
||||
|
||||
// Convert map[string]interface{} to VerifyStreamPayload
|
||||
jsonData, _ := json.Marshal(value)
|
||||
var verifyPayload models.VerifyStreamPayload
|
||||
json.Unmarshal(jsonData, &verifyPayload)
|
||||
|
||||
if verifyPayload.Timestamp == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
stream := verifyPayload.Stream
|
||||
if stream != "sub" {
|
||||
stream = "main"
|
||||
}
|
||||
|
||||
// Resolve which RTSP url to verify: prefer the one supplied in the request
|
||||
// (so users can verify unsaved edits), otherwise fall back to the configured
|
||||
// stream url for the requested stream type.
|
||||
rtspUrl := verifyPayload.RTSP
|
||||
if rtspUrl == "" {
|
||||
if stream == "sub" {
|
||||
rtspUrl = configuration.Config.Capture.IPCamera.SubRTSP
|
||||
} else {
|
||||
rtspUrl = configuration.Config.Capture.IPCamera.RTSP
|
||||
}
|
||||
}
|
||||
|
||||
success := false
|
||||
errMsg := ""
|
||||
width := 0
|
||||
height := 0
|
||||
codec := ""
|
||||
fps := 0.0
|
||||
|
||||
if rtspUrl == "" {
|
||||
errMsg = "No RTSP url configured for this stream."
|
||||
} else {
|
||||
// Probe the stream with a bounded timeout so a dead/unreachable camera
|
||||
// can't hang the handler goroutine.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rtspClient := &capture.Golibrtsp{Url: rtspUrl}
|
||||
errConnect := rtspClient.Connect(ctx, ctx)
|
||||
if errConnect != nil {
|
||||
errMsg = errConnect.Error()
|
||||
} else {
|
||||
videoStreams, errStreams := rtspClient.GetVideoStreams()
|
||||
if errStreams != nil || len(videoStreams) == 0 {
|
||||
errMsg = "Connected, but no decodable video stream was found."
|
||||
} else {
|
||||
success = true
|
||||
vs := videoStreams[0]
|
||||
width = vs.Width
|
||||
height = vs.Height
|
||||
codec = vs.Name
|
||||
fps = vs.FPS
|
||||
}
|
||||
}
|
||||
// Always release the connection.
|
||||
rtspClient.Close(ctx)
|
||||
}
|
||||
|
||||
message := models.Message{
|
||||
Payload: models.Payload{
|
||||
Action: "verify-stream-result",
|
||||
DeviceId: configuration.Config.Key,
|
||||
Value: map[string]interface{}{
|
||||
"timestamp": verifyPayload.Timestamp,
|
||||
"stream": stream,
|
||||
"success": success,
|
||||
"error": errMsg,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"codec": codec,
|
||||
"fps": fps,
|
||||
},
|
||||
},
|
||||
}
|
||||
packagedPayload, err := models.PackageMQTTMessage(configuration, message)
|
||||
if err == nil {
|
||||
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, packagedPayload)
|
||||
} else {
|
||||
log.Log.Info("routers.mqtt.main.HandleVerifyStream(): something went wrong while sending result to hub: " + string(packagedPayload))
|
||||
}
|
||||
}
|
||||
|
||||
func HandleUpdateConfig(mqttClient mqtt.Client, hubKey string, payload models.Payload, configDirectory string, configuration *models.Configuration, communication *models.Communication) {
|
||||
value := payload.Value
|
||||
|
||||
@@ -516,9 +709,20 @@ func HandleRequestSDStream(mqttClient mqtt.Client, hubKey string, payload models
|
||||
|
||||
if requestSDStreamPayload.Timestamp != 0 {
|
||||
if communication.CameraConnected {
|
||||
select {
|
||||
case communication.HandleLiveSD <- time.Now().Unix():
|
||||
default:
|
||||
// A viewer that opted into the HTTP transport is signalled on a separate
|
||||
// channel so the producer ships its frames to hub-api over HTTP instead of
|
||||
// publishing them over MQTT. Any other (or absent) transport keeps the
|
||||
// legacy MQTT image push, so older frontends behave exactly as before.
|
||||
if requestSDStreamPayload.Transport == "http" {
|
||||
select {
|
||||
case communication.HandleLiveSDHTTP <- time.Now().Unix():
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case communication.HandleLiveSD <- time.Now().Unix():
|
||||
default:
|
||||
}
|
||||
}
|
||||
log.Log.Info("routers.mqtt.main.HandleRequestSDStream(): received request to livestream.")
|
||||
} else {
|
||||
@@ -527,6 +731,33 @@ func HandleRequestSDStream(mqttClient mqtt.Client, hubKey string, payload models
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRequestHLSStream is the viewer keepalive for live HLS. Like the SD
|
||||
// stream it simply signals that a viewer is watching; the agent owns the live
|
||||
// HLS session, so a single non-zero timestamp on the channel keeps the segment
|
||||
// pipeline alive (see cloud.HandleLiveStreamHLS). Viewers republish this
|
||||
// periodically; when the keepalives stop, the agent tears the session down.
|
||||
func HandleRequestHLSStream(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
|
||||
value := payload.Value
|
||||
jsonData, _ := json.Marshal(value)
|
||||
var requestHLSStreamPayload models.RequestHLSStreamPayload
|
||||
json.Unmarshal(jsonData, &requestHLSStreamPayload)
|
||||
|
||||
if requestHLSStreamPayload.Timestamp != 0 {
|
||||
if communication.CameraConnected {
|
||||
// Forward the requested quality ("auto"|"high"|"low"; empty => auto) so
|
||||
// the producer can switch the live session between the main and sub
|
||||
// stream on demand. The send doubles as the viewer keepalive.
|
||||
select {
|
||||
case communication.HandleLiveHLS <- requestHLSStreamPayload.Quality:
|
||||
default:
|
||||
}
|
||||
log.Log.Info("routers.mqtt.main.HandleRequestHLSStream(): received request to livestream over HLS.")
|
||||
} else {
|
||||
log.Log.Info("routers.mqtt.main.HandleRequestHLSStream(): received request to livestream over HLS, but camera is not connected.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleRequestHDStream(mqttClient mqtt.Client, hubKey string, payload models.Payload, configuration *models.Configuration, communication *models.Communication) {
|
||||
value := payload.Value
|
||||
// Convert map[string]interface{} to RequestHDStreamPayload
|
||||
@@ -536,6 +767,15 @@ func HandleRequestHDStream(mqttClient mqtt.Client, hubKey string, payload models
|
||||
|
||||
if requestHDStreamPayload.Timestamp != 0 {
|
||||
if communication.CameraConnected {
|
||||
// Dedupe by session_id: the viewer republishes its offer while
|
||||
// waiting for an answer (and the broker may redeliver), and we
|
||||
// don't want to spawn multiple peer connections for the same
|
||||
// browser session.
|
||||
if markHDSessionSeen(requestHDStreamPayload.SessionID) {
|
||||
log.Log.Info("routers.mqtt.main.HandleRequestHDStream(): duplicate request for session " +
|
||||
requestHDStreamPayload.SessionID + ", ignoring")
|
||||
return
|
||||
}
|
||||
// Set the Hub key, so we can send back the answer.
|
||||
requestHDStreamPayload.HubKey = hubKey
|
||||
if communication.HandleLiveHDHandshake == nil {
|
||||
|
||||
39
machinery/src/routers/mqtt/main_test.go
Normal file
39
machinery/src/routers/mqtt/main_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestEnqueueLatestAudioReplacesOldestFrameWhenFull(t *testing.T) {
|
||||
audioChannel := make(chan models.AudioDataPartial, 2)
|
||||
audioChannel <- models.AudioDataPartial{Timestamp: 1}
|
||||
audioChannel <- models.AudioDataPartial{Timestamp: 2}
|
||||
|
||||
dropped := enqueueLatestAudio(audioChannel, models.AudioDataPartial{Timestamp: 3})
|
||||
if !dropped {
|
||||
t.Fatal("enqueueLatestAudio() dropped = false, want true")
|
||||
}
|
||||
|
||||
first := <-audioChannel
|
||||
second := <-audioChannel
|
||||
if first.Timestamp != 2 || second.Timestamp != 3 {
|
||||
t.Fatalf("queued timestamps = (%d, %d), want (2, 3)", first.Timestamp, second.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnqueueLatestAudioDoesNotBlockNilChannel(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
enqueueLatestAudio(nil, models.AudioDataPartial{Timestamp: 1})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("enqueueLatestAudio() blocked on a nil channel")
|
||||
}
|
||||
}
|
||||
@@ -427,12 +427,49 @@ func ResizeImage(img image.Image, newWidth uint, newHeight uint) (*image.Image,
|
||||
return nil, errors.New("image is nil")
|
||||
}
|
||||
|
||||
// Callers cast int->uint, so a negative or poisoned int (e.g. MinInt from
|
||||
// `int(float * +Inf)` when the source width is 0) wraps to a near-MaxUint
|
||||
// value here and crashes nfnt/resize's allocator with "makeslice: len out
|
||||
// of range". Clamp anything past a sane camera ceiling to 0 ("auto" in
|
||||
// nfnt — preserves aspect from the source).
|
||||
const maxDim uint = 8192
|
||||
if newWidth > maxDim {
|
||||
newWidth = 0
|
||||
}
|
||||
if newHeight > maxDim {
|
||||
newHeight = 0
|
||||
}
|
||||
|
||||
// resize to width 640 using Lanczos resampling
|
||||
// and preserve aspect ratio
|
||||
m := resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ResolveBaseDimensions resolves the liveview/motion base dimensions for a
|
||||
// stream given the (optionally configured) base width/height and the camera's
|
||||
// probed source width/height. It returns the width and height that should be
|
||||
// stored on the configuration.
|
||||
//
|
||||
// The aspect-ratio branch is gated on width>0 && height>0: a not-yet-probed
|
||||
// stream has width=height=0, which previously made the ratio +Inf and
|
||||
// int(float * +Inf) yield MinInt. That poisoned value, later cast to uint at
|
||||
// the ResizeImage call sites, wrapped to ~MaxUint and crashed resize with
|
||||
// "makeslice: len out of range". When the source isn't probed yet we fall back
|
||||
// to the source dimensions (0,0 -> "auto") instead.
|
||||
func ResolveBaseDimensions(baseWidth, baseHeight, width, height int) (int, int) {
|
||||
if baseWidth > 0 && baseHeight == 0 && width > 0 && height > 0 {
|
||||
// Derive the height from the configured width and the source aspect ratio.
|
||||
widthAspectRatio := float64(baseWidth) / float64(width)
|
||||
return baseWidth, int(float64(height) * widthAspectRatio)
|
||||
} else if baseHeight > 0 && baseWidth > 0 {
|
||||
// Both base dimensions are configured; honor them as-is.
|
||||
return baseWidth, baseHeight
|
||||
}
|
||||
// Nothing usable configured (or source not probed yet): use source dimensions.
|
||||
return width, height
|
||||
}
|
||||
|
||||
func ResizeHeightWithAspectRatio(newWidth int, width int, height int) (int, int) {
|
||||
if newWidth <= 0 || width <= 0 || height <= 0 {
|
||||
return width, height
|
||||
|
||||
124
machinery/src/utils/resize_test.go
Normal file
124
machinery/src/utils/resize_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveBaseDimensions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseWidth, baseHeight int
|
||||
width, height int
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{
|
||||
name: "base width set, height derived from aspect ratio",
|
||||
baseWidth: 640, baseHeight: 0,
|
||||
width: 1920, height: 1080,
|
||||
wantWidth: 640, wantHeight: 360,
|
||||
},
|
||||
{
|
||||
name: "both base dimensions configured are honored",
|
||||
baseWidth: 640, baseHeight: 480,
|
||||
width: 1920, height: 1080,
|
||||
wantWidth: 640, wantHeight: 480,
|
||||
},
|
||||
{
|
||||
name: "no base configured falls back to source dimensions",
|
||||
baseWidth: 0, baseHeight: 0,
|
||||
width: 1920, height: 1080,
|
||||
wantWidth: 1920, wantHeight: 1080,
|
||||
},
|
||||
{
|
||||
// Regression: a not-yet-probed stream has width=height=0. The old
|
||||
// aspect-ratio branch divided by zero (float * +Inf -> MinInt) and
|
||||
// poisoned BaseHeight, later crashing resize with makeslice panic.
|
||||
name: "unprobed stream (width=0) does not poison dimensions",
|
||||
baseWidth: 640, baseHeight: 0,
|
||||
width: 0, height: 0,
|
||||
wantWidth: 0, wantHeight: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotWidth, gotHeight := ResolveBaseDimensions(tt.baseWidth, tt.baseHeight, tt.width, tt.height)
|
||||
if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight {
|
||||
t.Fatalf("ResolveBaseDimensions(%d,%d,%d,%d) = (%d,%d), want (%d,%d)",
|
||||
tt.baseWidth, tt.baseHeight, tt.width, tt.height,
|
||||
gotWidth, gotHeight, tt.wantWidth, tt.wantHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBaseDimensionsNeverNegative(t *testing.T) {
|
||||
// Whatever the inputs, the resolved dimensions must never be negative,
|
||||
// otherwise the uint cast at the resize call sites wraps to ~MaxUint.
|
||||
for _, c := range [][4]int{
|
||||
{640, 0, 0, 0},
|
||||
{640, 0, 0, 1080},
|
||||
{640, 0, 1920, 0},
|
||||
{0, 0, 0, 0},
|
||||
} {
|
||||
w, h := ResolveBaseDimensions(c[0], c[1], c[2], c[3])
|
||||
if w < 0 || h < 0 {
|
||||
t.Fatalf("ResolveBaseDimensions(%v) produced negative dims (%d,%d)", c, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageClampsPoisonedDimensions(t *testing.T) {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 320, 240))
|
||||
|
||||
// uint(math.MinInt) is the value produced when a poisoned int (from
|
||||
// int(float * +Inf)) is cast to uint at a call site. It must not panic
|
||||
// nfnt/resize's allocator; it should fall back to source-aspect resize.
|
||||
// Compute via a runtime int so the conversion doesn't overflow at compile time.
|
||||
minInt := math.MinInt
|
||||
poison := uint(minInt)
|
||||
|
||||
resized, err := ResizeImage(src, poison, poison)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resized == nil {
|
||||
t.Fatalf("expected an image, got nil")
|
||||
}
|
||||
b := (*resized).Bounds()
|
||||
if b.Dx() != 320 || b.Dy() != 240 {
|
||||
t.Fatalf("poisoned dims should fall back to source size, got %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageClampsAboveCameraCeiling(t *testing.T) {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 320, 240))
|
||||
|
||||
// A width beyond any sane camera resolution is treated as "auto" (0).
|
||||
resized, err := ResizeImage(src, 100000, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
b := (*resized).Bounds()
|
||||
if b.Dx() != 320 || b.Dy() != 240 {
|
||||
t.Fatalf("oversized width should fall back to source size, got %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageNormalResizeStillWorks(t *testing.T) {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 1920, 1080))
|
||||
|
||||
resized, err := ResizeImage(src, 640, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
b := (*resized).Bounds()
|
||||
if b.Dx() != 640 {
|
||||
t.Fatalf("expected width 640, got %d", b.Dx())
|
||||
}
|
||||
if b.Dy() != 360 {
|
||||
t.Fatalf("expected aspect-preserved height 360, got %d", b.Dy())
|
||||
}
|
||||
}
|
||||
586
machinery/src/video/livehls.go
Normal file
586
machinery/src/video/livehls.go
Normal file
@@ -0,0 +1,586 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
)
|
||||
|
||||
// LiveSegmenter turns a live stream of Annex B video samples into HLS-ready
|
||||
// fragmented-MP4 (CMAF) output: ONE init segment (ftyp+moov) followed by a
|
||||
// series of INDEPENDENT media segments (styp+moof+mdat), each beginning with a
|
||||
// keyframe and carrying its own tfdt. This is the building block for the live
|
||||
// HLS pipeline (agent -> hub-api -> vault -> hub-frontend) and is intentionally
|
||||
// kept separate from the recording muxer in mp4.go:
|
||||
//
|
||||
// - mp4.go writes ONE fragmented MP4 per recording (free-box placeholder up
|
||||
// front, back-filled on Close). That layout is great for archived files but
|
||||
// useless for live, where each segment must be shippable the instant it is
|
||||
// produced and must decode on its own after the init segment.
|
||||
// - LiveSegmenter emits discrete, self-contained segments via callbacks, so
|
||||
// the transport (single-POST to hub-api, drop-on-failure) never has to wait
|
||||
// for the recording to finish.
|
||||
//
|
||||
// Both producers use the SAME mp4ff fragment format, so live and archived video
|
||||
// share one toolchain on the player side (hls.js #EXT-X-MAP + byte-range parts).
|
||||
//
|
||||
// The spike scope is video-only H.264/H.265. Audio and multi-track interleaving
|
||||
// can be layered on later by adding tracks to the init segment and a second trun
|
||||
// to each fragment; nothing here precludes that.
|
||||
type LiveSegmenter struct {
|
||||
// codec is "H264"/"H265" (case handled in buildInit).
|
||||
codec string
|
||||
// timescale is the media timescale used in the init segment. The agent's
|
||||
// capture path feeds presentation timestamps in milliseconds, so a 1000-tick
|
||||
// timescale keeps sample durations exact with no rescaling.
|
||||
timescale uint32
|
||||
// targetSegmentMs is the minimum amount of media a segment accumulates before
|
||||
// the next keyframe is allowed to start a fresh segment. Keeping segments
|
||||
// keyframe-aligned is what makes each one independently decodable.
|
||||
targetSegmentMs uint64
|
||||
|
||||
spsNALUs [][]byte
|
||||
ppsNALUs [][]byte
|
||||
vpsNALUs [][]byte
|
||||
|
||||
// width/height are written into the visual sample entry. They are optional:
|
||||
// on a successful strict SPS parse mp4ff derives them, but the manual avcC
|
||||
// fallback (used for SPS that mp4ff cannot parse) needs them supplied.
|
||||
width uint16
|
||||
height uint16
|
||||
|
||||
videoTrackID uint32
|
||||
|
||||
initSegment *mp4ff.InitSegment
|
||||
initBytes []byte
|
||||
initEmitted bool
|
||||
|
||||
seg *mp4ff.MediaSegment
|
||||
frag *mp4ff.Fragment
|
||||
seqNr uint32
|
||||
|
||||
// started becomes true once the first segment has been opened.
|
||||
started bool
|
||||
// segStartPTS is the decode time (ms) of the first sample in the open
|
||||
// segment; elapsed media is measured against it to decide segment cuts.
|
||||
segStartPTS uint64
|
||||
// segDurationMs accumulates the committed sample durations of the open
|
||||
// segment so the playlist can advertise an accurate #EXTINF.
|
||||
segDurationMs uint64
|
||||
|
||||
// pending holds the most recently received sample. Its duration is only known
|
||||
// once the NEXT sample arrives (duration = nextPTS - thisPTS), mirroring the
|
||||
// pending-sample pattern used by the recording muxer.
|
||||
pending *mp4ff.FullSample
|
||||
// lastDurationMs is the previous committed duration, reused to close out the
|
||||
// final pending sample (and to bridge non-monotonic timestamps).
|
||||
lastDurationMs uint64
|
||||
|
||||
// OnInit is invoked exactly once with the encoded init segment bytes before
|
||||
// the first media segment is emitted. Optional.
|
||||
OnInit func(initBytes []byte) error
|
||||
// OnSegment is invoked once per completed media segment. Optional. It is left
|
||||
// unused in low-latency mode (see OnPart).
|
||||
OnSegment func(seg LiveSegment) error
|
||||
|
||||
// --- Low-latency (LL-HLS) partial-segment mode ---
|
||||
//
|
||||
// When partTargetMs > 0 the segmenter additionally slices each segment into
|
||||
// ~partTargetMs CMAF "parts" (chunks) and emits them via OnPart the instant
|
||||
// each one closes, instead of waiting for the whole segment. The classic
|
||||
// per-segment OnSegment path above is left untouched (and unused) in this mode.
|
||||
// Each part is one mp4ff fragment (moof+mdat); part 0 of a segment also carries
|
||||
// the CMAF styp, so concatenating a segment's parts yields one valid segment.
|
||||
partTargetMs uint64
|
||||
// partFrag is the open part's fragment; partIndex is its 0-based index within
|
||||
// the current segment; fragSeq is the globally monotonic moof sequence number
|
||||
// shared across all parts (MSE wants increasing moof sequence numbers).
|
||||
partFrag *mp4ff.Fragment
|
||||
partIndex uint32
|
||||
fragSeq uint32
|
||||
partSampleCount int
|
||||
partDurationMs uint64
|
||||
partIndependent bool
|
||||
// OnPart is invoked once per completed CMAF part when partTargetMs > 0.
|
||||
OnPart func(part LivePart) error
|
||||
}
|
||||
|
||||
// LiveSegment is one independently-decodable CMAF media segment.
|
||||
type LiveSegment struct {
|
||||
// SequenceNumber is the monotonically increasing fragment sequence number
|
||||
// (also used as the moof sequence number and the seg-N.m4s index).
|
||||
SequenceNumber uint32
|
||||
// DurationMs is the summed sample duration of the segment, for #EXTINF.
|
||||
DurationMs uint64
|
||||
// Data is the complete styp+moof+mdat segment, ready to append after the init
|
||||
// segment and hand to hls.js / a vault object.
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// LivePart is one CMAF partial segment (chunk) of a media segment, emitted in
|
||||
// low-latency mode the instant it closes - before the whole segment is done - so
|
||||
// the playlist can advertise it via #EXT-X-PART for near-live playback.
|
||||
type LivePart struct {
|
||||
// SegmentSeq is the parent media segment's sequence number (the N in
|
||||
// seg-N.K.m4s); PartIndex is K within that segment (0-based).
|
||||
SegmentSeq uint32
|
||||
PartIndex uint32
|
||||
// Independent is true when the part begins with a keyframe (its first sample is
|
||||
// an IDR), i.e. it is independently decodable (#EXT-X-PART INDEPENDENT=YES).
|
||||
Independent bool
|
||||
// DurationMs is the summed sample duration of the part (for #EXT-X-PART).
|
||||
DurationMs uint64
|
||||
// Data of part 0 is styp+moof+mdat; later parts are bare moof+mdat, so
|
||||
// concatenating a segment's parts in order yields one valid CMAF segment.
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Sample-entry flags matching the recording muxer so live and archived fragments
|
||||
// describe random access points identically.
|
||||
//
|
||||
// keyframe 0x02000000 = sampleDependsOn=2 (depends on nothing), sync sample
|
||||
// non-keyframe 0x01010000 = sampleDependsOn=1, sampleIsNonSyncSample=1
|
||||
const (
|
||||
liveSyncSampleFlags uint32 = 0x02000000
|
||||
liveNonSyncSampleFlags uint32 = 0x01010000
|
||||
// liveFallbackDurationMs is used when a duration cannot be derived (first
|
||||
// frame at Close, or non-monotonic timestamps) and no prior duration exists.
|
||||
// ~33 ms approximates 30 fps and is only ever a single-frame nicety.
|
||||
liveFallbackDurationMs uint64 = 33
|
||||
)
|
||||
|
||||
// NewLiveSegmenter creates a video-only live segmenter for the given codec.
|
||||
// spsNALUs/ppsNALUs (and vpsNALUs for H.265) may be raw NAL units or Annex B
|
||||
// blobs with start codes; both are normalized. targetSegmentMs is clamped to a
|
||||
// sane floor so a misconfiguration cannot produce one-frame segments.
|
||||
func NewLiveSegmenter(codec string, spsNALUs, ppsNALUs, vpsNALUs [][]byte, targetSegmentMs uint64) *LiveSegmenter {
|
||||
if targetSegmentMs < 500 {
|
||||
targetSegmentMs = 500
|
||||
}
|
||||
return &LiveSegmenter{
|
||||
codec: codec,
|
||||
timescale: 1000,
|
||||
targetSegmentMs: targetSegmentMs,
|
||||
spsNALUs: spsNALUs,
|
||||
ppsNALUs: ppsNALUs,
|
||||
vpsNALUs: vpsNALUs,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDimensions records the encoded video width/height in pixels. They are
|
||||
// written into the avc1/hvc1 visual sample entry and are required for the manual
|
||||
// descriptor fallback path (SPS that mp4ff's strict parser rejects).
|
||||
func (ls *LiveSegmenter) SetDimensions(width, height uint16) {
|
||||
ls.width = width
|
||||
ls.height = height
|
||||
}
|
||||
|
||||
// EnableLowLatency switches the segmenter into LL-HLS mode, additionally slicing
|
||||
// each segment into ~partTargetMs CMAF parts emitted via OnPart as they close.
|
||||
// partTargetMs is clamped to a sane floor. Call before the first WriteSample.
|
||||
func (ls *LiveSegmenter) EnableLowLatency(partTargetMs uint64) {
|
||||
if partTargetMs < 100 {
|
||||
partTargetMs = 100
|
||||
}
|
||||
ls.partTargetMs = partTargetMs
|
||||
}
|
||||
|
||||
// InitSegment returns the encoded init segment bytes, building them on demand.
|
||||
// Useful for tests and for serving the #EXT-X-MAP target without waiting for the
|
||||
// first media segment.
|
||||
func (ls *LiveSegmenter) InitSegment() ([]byte, error) {
|
||||
if ls.initBytes == nil {
|
||||
if err := ls.buildInit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return ls.initBytes, nil
|
||||
}
|
||||
|
||||
// buildInit constructs the ftyp+moov init segment from the parameter sets.
|
||||
func (ls *LiveSegmenter) buildInit() error {
|
||||
init := mp4ff.CreateEmptyInit()
|
||||
init.AddEmptyTrack(ls.timescale, "video", "und")
|
||||
trak := init.Moov.Traks[0]
|
||||
|
||||
switch ls.codec {
|
||||
case "H264", "h264", "AVC", "avc", "AVC1", "avc1":
|
||||
sps, pps := normalizeH264ParameterSets(ls.spsNALUs, ls.ppsNALUs)
|
||||
if len(sps) == 0 || len(pps) == 0 {
|
||||
return fmt.Errorf("livehls: missing H264 SPS/PPS (sps=%d pps=%d)", len(sps), len(pps))
|
||||
}
|
||||
// includePS=true stores SPS/PPS in the avcC so segments need not carry
|
||||
// in-band parameter sets - browsers read them from the init segment. Some
|
||||
// camera SPS variants trip mp4ff's strict parser (e.g. unusual VUI/SAR);
|
||||
// fall back to a manually built avcC just like the recording muxer does so
|
||||
// those cameras still produce a valid init segment.
|
||||
if err := trak.SetAVCDescriptor("avc1", sps, pps, true); err != nil {
|
||||
log.Log.Warning("livehls: SetAVCDescriptor failed, using manual avcC fallback: " + err.Error())
|
||||
if fbErr := addAVCDescriptorFallback(trak, sps, pps, ls.width, ls.height); fbErr != nil {
|
||||
return fmt.Errorf("livehls: AVC descriptor fallback: %w", fbErr)
|
||||
}
|
||||
}
|
||||
case "H265", "h265", "HEVC", "hevc", "HVC1", "hvc1":
|
||||
vps, sps, pps := normalizeH265ParameterSets(ls.vpsNALUs, ls.spsNALUs, ls.ppsNALUs)
|
||||
if len(vps) == 0 || len(sps) == 0 || len(pps) == 0 {
|
||||
return fmt.Errorf("livehls: missing H265 VPS/SPS/PPS (vps=%d sps=%d pps=%d)", len(vps), len(sps), len(pps))
|
||||
}
|
||||
if err := trak.SetHEVCDescriptor("hvc1", vps, sps, pps, [][]byte{}, true); err != nil {
|
||||
return fmt.Errorf("livehls: SetHEVCDescriptor: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("livehls: unsupported codec %q", ls.codec)
|
||||
}
|
||||
|
||||
// Record the encoded dimensions in the track header when known.
|
||||
if ls.width > 0 && ls.height > 0 {
|
||||
trak.Tkhd.Width = mp4ff.Fixed32(uint32(ls.width) << 16)
|
||||
trak.Tkhd.Height = mp4ff.Fixed32(uint32(ls.height) << 16)
|
||||
}
|
||||
// mdhd.Duration MUST be 0 for fragmented MP4 so players derive duration from
|
||||
// the fragments rather than a (here unknown) total.
|
||||
trak.Mdia.Mdhd.Duration = 0
|
||||
|
||||
ls.videoTrackID = trak.Tkhd.TrackID
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := init.Encode(&buf); err != nil {
|
||||
return fmt.Errorf("livehls: encode init: %w", err)
|
||||
}
|
||||
ls.initSegment = init
|
||||
ls.initBytes = buf.Bytes()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteSample feeds one Annex B access unit with its decode timestamp (DTS) in
|
||||
// milliseconds. The first sample of a session MUST be a keyframe; a non-keyframe
|
||||
// first sample is dropped (it could not be decoded without a preceding IDR).
|
||||
//
|
||||
// compositionOffsetMs is the CTS offset (PTS-DTS, for B-frame reordering) in
|
||||
// timescale ticks; pass 0 for streams without B-frames.
|
||||
func (ls *LiveSegmenter) WriteSample(isKeyframe bool, annexB []byte, ptsMs uint64, compositionOffsetMs int32) error {
|
||||
// Lazily build + emit the init segment on the first accepted sample.
|
||||
if ls.initBytes == nil {
|
||||
if err := ls.buildInit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !ls.initEmitted {
|
||||
ls.initEmitted = true
|
||||
if ls.OnInit != nil {
|
||||
if err := ls.OnInit(ls.initBytes); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A session must open on a random-access point; otherwise the first segment
|
||||
// would reference frames that never arrived.
|
||||
if !ls.started && !isKeyframe {
|
||||
log.Log.Debug("LiveSegmenter.WriteSample(): dropping leading non-keyframe before first IDR")
|
||||
return nil
|
||||
}
|
||||
|
||||
lengthPrefixed, err := annexBToLengthPrefixed(annexB)
|
||||
if err != nil {
|
||||
return fmt.Errorf("livehls: convert AnnexB: %w", err)
|
||||
}
|
||||
|
||||
// Low-latency mode slices each segment into parts; the classic per-segment path
|
||||
// below is left exactly as-is for the default (non-LL) configuration.
|
||||
if ls.partTargetMs > 0 {
|
||||
return ls.writeSampleLL(isKeyframe, lengthPrefixed, ptsMs, compositionOffsetMs)
|
||||
}
|
||||
|
||||
// The previous sample's duration is the gap to this sample's PTS. Commit it
|
||||
// to the (still open) current fragment before we consider rolling segments,
|
||||
// because the pending sample always precedes this one in decode order.
|
||||
if ls.pending != nil {
|
||||
dur := ls.lastDurationMs
|
||||
if ptsMs > ls.pending.DecodeTime {
|
||||
dur = ptsMs - ls.pending.DecodeTime
|
||||
}
|
||||
if dur == 0 {
|
||||
dur = liveFallbackDurationMs
|
||||
}
|
||||
ls.lastDurationMs = dur
|
||||
ls.pending.Sample.Dur = uint32(dur)
|
||||
if err := ls.commitPending(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// At every keyframe, decide whether enough media has accumulated to close the
|
||||
// open segment and start a new one. Cutting only on keyframes guarantees each
|
||||
// segment is independently decodable.
|
||||
if isKeyframe {
|
||||
shouldCut := !ls.started || (ptsMs-ls.segStartPTS) >= ls.targetSegmentMs
|
||||
if shouldCut {
|
||||
if ls.started {
|
||||
if err := ls.emitSegment(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ls.openSegment(ptsMs)
|
||||
}
|
||||
}
|
||||
|
||||
// Stage this sample; its duration is filled in when the next sample arrives
|
||||
// (or at Close()).
|
||||
flags := liveNonSyncSampleFlags
|
||||
if isKeyframe {
|
||||
flags = liveSyncSampleFlags
|
||||
}
|
||||
ls.pending = &mp4ff.FullSample{
|
||||
Sample: mp4ff.Sample{
|
||||
Flags: flags,
|
||||
Size: uint32(len(lengthPrefixed)),
|
||||
CompositionTimeOffset: compositionOffsetMs,
|
||||
},
|
||||
DecodeTime: ptsMs,
|
||||
Data: lengthPrefixed,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openSegment starts a fresh media segment (with CMAF styp) and an empty
|
||||
// single-track fragment whose moof sequence number is the segment index.
|
||||
func (ls *LiveSegmenter) openSegment(startPTS uint64) {
|
||||
ls.seqNr++
|
||||
ls.seg = mp4ff.NewMediaSegment() // includes a CMAF styp box by default
|
||||
frag, err := mp4ff.CreateFragment(ls.seqNr, ls.videoTrackID)
|
||||
if err != nil {
|
||||
log.Log.Error("LiveSegmenter.openSegment(): CreateFragment failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
ls.seg.AddFragment(frag)
|
||||
ls.frag = frag
|
||||
ls.segStartPTS = startPTS
|
||||
ls.segDurationMs = 0
|
||||
ls.started = true
|
||||
}
|
||||
|
||||
// commitPending appends the staged sample to the open fragment. The first sample
|
||||
// of a fragment seeds the tfdt baseMediaDecodeTime from its absolute DecodeTime,
|
||||
// which is what makes the segment independently seekable/decodable.
|
||||
func (ls *LiveSegmenter) commitPending() error {
|
||||
if ls.pending == nil {
|
||||
return nil
|
||||
}
|
||||
if ls.frag == nil {
|
||||
// No open segment yet (e.g. pending set before the first keyframe cut). The
|
||||
// keyframe path always opens a segment before staging, so this only guards
|
||||
// against logic drift; drop rather than panic.
|
||||
ls.pending = nil
|
||||
return nil
|
||||
}
|
||||
if err := ls.frag.AddFullSampleToTrack(*ls.pending, ls.videoTrackID); err != nil {
|
||||
return fmt.Errorf("livehls: AddFullSampleToTrack: %w", err)
|
||||
}
|
||||
ls.segDurationMs += uint64(ls.pending.Sample.Dur)
|
||||
ls.pending = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// emitSegment encodes the open segment and hands it to OnSegment.
|
||||
func (ls *LiveSegmenter) emitSegment() error {
|
||||
if ls.seg == nil {
|
||||
return nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := ls.seg.Encode(&buf); err != nil {
|
||||
return fmt.Errorf("livehls: encode segment %d: %w", ls.seqNr, err)
|
||||
}
|
||||
out := LiveSegment{
|
||||
SequenceNumber: ls.seqNr,
|
||||
DurationMs: ls.segDurationMs,
|
||||
Data: buf.Bytes(),
|
||||
}
|
||||
ls.seg = nil
|
||||
ls.frag = nil
|
||||
if ls.OnSegment != nil {
|
||||
return ls.OnSegment(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close flushes the final pending sample and emits the last open segment (or, in
|
||||
// low-latency mode, the last open part). Call once when the live session ends so
|
||||
// no trailing media is lost.
|
||||
func (ls *LiveSegmenter) Close() error {
|
||||
if ls.partTargetMs > 0 {
|
||||
if ls.pending != nil {
|
||||
dur := ls.lastDurationMs
|
||||
if dur == 0 {
|
||||
dur = liveFallbackDurationMs
|
||||
}
|
||||
ls.pending.Sample.Dur = uint32(dur)
|
||||
if err := ls.commitPendingPart(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ls.closePart()
|
||||
}
|
||||
if ls.pending != nil {
|
||||
dur := ls.lastDurationMs
|
||||
if dur == 0 {
|
||||
dur = liveFallbackDurationMs
|
||||
}
|
||||
ls.pending.Sample.Dur = uint32(dur)
|
||||
if err := ls.commitPending(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ls.emitSegment()
|
||||
}
|
||||
|
||||
// writeSampleLL is the low-latency counterpart of the per-segment staging in
|
||||
// WriteSample: it commits the previous sample into the open part, rolls the part
|
||||
// (every ~partTargetMs) and the segment (at keyframes, every ~targetSegmentMs),
|
||||
// then stages the current sample. Parts are emitted via OnPart as they close.
|
||||
func (ls *LiveSegmenter) writeSampleLL(isKeyframe bool, lengthPrefixed []byte, ptsMs uint64, compositionOffsetMs int32) error {
|
||||
if ls.pending != nil {
|
||||
dur := ls.lastDurationMs
|
||||
if ptsMs > ls.pending.DecodeTime {
|
||||
dur = ptsMs - ls.pending.DecodeTime
|
||||
}
|
||||
if dur == 0 {
|
||||
dur = liveFallbackDurationMs
|
||||
}
|
||||
ls.lastDurationMs = dur
|
||||
ls.pending.Sample.Dur = uint32(dur)
|
||||
if err := ls.commitPendingPart(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Roll the segment at keyframes once enough media accumulated; otherwise roll a
|
||||
// part once it reaches the part target. The two are mutually exclusive: a
|
||||
// keyframe cut also closes the current part.
|
||||
cut := false
|
||||
if isKeyframe {
|
||||
cut = !ls.started || (ptsMs-ls.segStartPTS) >= ls.targetSegmentMs
|
||||
}
|
||||
switch {
|
||||
case cut:
|
||||
if ls.started {
|
||||
if err := ls.closePart(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ls.openSegmentLL(ptsMs)
|
||||
case ls.started && ls.partDurationMs >= ls.partTargetMs:
|
||||
if err := ls.closePart(); err != nil {
|
||||
return err
|
||||
}
|
||||
ls.openPartLL()
|
||||
}
|
||||
|
||||
flags := liveNonSyncSampleFlags
|
||||
if isKeyframe {
|
||||
flags = liveSyncSampleFlags
|
||||
}
|
||||
ls.pending = &mp4ff.FullSample{
|
||||
Sample: mp4ff.Sample{
|
||||
Flags: flags,
|
||||
Size: uint32(len(lengthPrefixed)),
|
||||
CompositionTimeOffset: compositionOffsetMs,
|
||||
},
|
||||
DecodeTime: ptsMs,
|
||||
Data: lengthPrefixed,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// commitPendingPart appends the staged sample to the open part fragment, marking
|
||||
// the part independent when its first sample is a keyframe.
|
||||
func (ls *LiveSegmenter) commitPendingPart() error {
|
||||
if ls.pending == nil {
|
||||
return nil
|
||||
}
|
||||
if ls.partFrag == nil {
|
||||
// No open part yet (pending staged before the first keyframe cut). The cut
|
||||
// path always opens a part before staging, so this only guards against logic
|
||||
// drift; drop rather than panic.
|
||||
ls.pending = nil
|
||||
return nil
|
||||
}
|
||||
first := ls.partSampleCount == 0
|
||||
if err := ls.partFrag.AddFullSampleToTrack(*ls.pending, ls.videoTrackID); err != nil {
|
||||
return fmt.Errorf("livehls: AddFullSampleToTrack: %w", err)
|
||||
}
|
||||
if first && ls.pending.Sample.Flags == liveSyncSampleFlags {
|
||||
ls.partIndependent = true
|
||||
}
|
||||
ls.partSampleCount++
|
||||
ls.partDurationMs += uint64(ls.pending.Sample.Dur)
|
||||
ls.segDurationMs += uint64(ls.pending.Sample.Dur)
|
||||
ls.pending = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// openSegmentLL starts a fresh media segment at a keyframe by opening its part 0.
|
||||
func (ls *LiveSegmenter) openSegmentLL(startPTS uint64) {
|
||||
ls.seqNr++
|
||||
ls.partIndex = 0
|
||||
ls.segStartPTS = startPTS
|
||||
ls.segDurationMs = 0
|
||||
ls.started = true
|
||||
ls.openPartFragment()
|
||||
}
|
||||
|
||||
// openPartLL starts the next part within the current segment.
|
||||
func (ls *LiveSegmenter) openPartLL() {
|
||||
ls.partIndex++
|
||||
ls.openPartFragment()
|
||||
}
|
||||
|
||||
// openPartFragment allocates a fresh single-track fragment (one moof+mdat) for
|
||||
// the next part, with a globally monotonic moof sequence number.
|
||||
func (ls *LiveSegmenter) openPartFragment() {
|
||||
ls.fragSeq++
|
||||
frag, err := mp4ff.CreateFragment(ls.fragSeq, ls.videoTrackID)
|
||||
if err != nil {
|
||||
log.Log.Error("LiveSegmenter.openPartFragment(): CreateFragment failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
ls.partFrag = frag
|
||||
ls.partSampleCount = 0
|
||||
ls.partDurationMs = 0
|
||||
ls.partIndependent = false
|
||||
}
|
||||
|
||||
// closePart encodes the open part and hands it to OnPart. Part 0 of a segment
|
||||
// carries the CMAF styp; later parts are bare moof+mdat, so a segment's parts
|
||||
// concatenate into one valid segment. Empty parts are skipped.
|
||||
func (ls *LiveSegmenter) closePart() error {
|
||||
if ls.partFrag == nil || ls.partSampleCount == 0 {
|
||||
return nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if ls.partIndex == 0 {
|
||||
seg := mp4ff.NewMediaSegment() // includes a CMAF styp box by default
|
||||
seg.AddFragment(ls.partFrag)
|
||||
if err := seg.Encode(&buf); err != nil {
|
||||
return fmt.Errorf("livehls: encode part %d.%d: %w", ls.seqNr, ls.partIndex, err)
|
||||
}
|
||||
} else {
|
||||
if err := ls.partFrag.Encode(&buf); err != nil {
|
||||
return fmt.Errorf("livehls: encode part %d.%d: %w", ls.seqNr, ls.partIndex, err)
|
||||
}
|
||||
}
|
||||
out := LivePart{
|
||||
SegmentSeq: ls.seqNr,
|
||||
PartIndex: ls.partIndex,
|
||||
Independent: ls.partIndependent,
|
||||
DurationMs: ls.partDurationMs,
|
||||
Data: buf.Bytes(),
|
||||
}
|
||||
ls.partFrag = nil
|
||||
if ls.OnPart != nil {
|
||||
return ls.OnPart(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
554
machinery/src/video/livehls_test.go
Normal file
554
machinery/src/video/livehls_test.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
)
|
||||
|
||||
// Known-good minimal H.264 baseline parameter sets (640x480), reused from the
|
||||
// recording-muxer tests so the live segmenter is exercised against the exact
|
||||
// SPS/PPS mp4ff is already known to parse into an avcC descriptor.
|
||||
var (
|
||||
liveTestSPS = []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
|
||||
liveTestPPS = []byte{0x68, 0xce, 0x38, 0x80}
|
||||
)
|
||||
|
||||
// makeAnnexBFrame builds a single-NALU Annex B access unit: a 4-byte start code,
|
||||
// the NAL header (IDR=0x65 for keyframes, non-IDR=0x01 otherwise) and padding.
|
||||
func makeAnnexBFrame(isKey bool) []byte {
|
||||
nalType := byte(0x01)
|
||||
if isKey {
|
||||
nalType = 0x65
|
||||
}
|
||||
frame := []byte{0x00, 0x00, 0x00, 0x01, nalType}
|
||||
for i := 0; i < 100; i++ {
|
||||
frame = append(frame, byte(i))
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
// isSyncSample reports whether a parsed sample is a random-access point
|
||||
// (sample_depends_on == 2 => "depends on nothing" => IDR/sync).
|
||||
func isSyncSample(s mp4ff.Sample) bool {
|
||||
return (s.Flags>>24)&0x03 == 0x02
|
||||
}
|
||||
|
||||
// TestLiveSegmenterProducesIndependentCMAFSegments feeds a synthetic H.264
|
||||
// stream (25 fps, 1s GOPs) through the live segmenter and asserts that:
|
||||
// - exactly one init segment (ftyp+moov, single avc1 video track) is produced;
|
||||
// - segments are cut on keyframe boundaries honoring the target duration;
|
||||
// - every media segment carries a CMAF styp + exactly one moof+mdat fragment;
|
||||
// - each segment begins with a sync sample and its tfdt equals the absolute
|
||||
// decode time of that first sample (the property that makes it independently
|
||||
// decodable after the init segment);
|
||||
// - sample counts and durations are preserved end to end.
|
||||
func TestLiveSegmenterProducesIndependentCMAFSegments(t *testing.T) {
|
||||
const (
|
||||
frameDurMs = uint64(40) // 25 fps
|
||||
gopFrames = 25 // keyframe every 1000 ms
|
||||
numGOPs = 6
|
||||
numFrames = gopFrames * numGOPs // 150 frames, 6000 ms
|
||||
targetMs = uint64(2000) // 2s segments => 2 GOPs each
|
||||
)
|
||||
|
||||
seg := NewLiveSegmenter("H264", [][]byte{liveTestSPS}, [][]byte{liveTestPPS}, nil, targetMs)
|
||||
seg.SetDimensions(640, 480)
|
||||
|
||||
var initBytes []byte
|
||||
var initCalls int
|
||||
var segments []LiveSegment
|
||||
seg.OnInit = func(b []byte) error {
|
||||
initCalls++
|
||||
initBytes = append([]byte(nil), b...)
|
||||
return nil
|
||||
}
|
||||
seg.OnSegment = func(s LiveSegment) error {
|
||||
segments = append(segments, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 0; i < numFrames; i++ {
|
||||
isKey := i%gopFrames == 0
|
||||
pts := uint64(i) * frameDurMs
|
||||
if err := seg.WriteSample(isKey, makeAnnexBFrame(isKey), pts, 0); err != nil {
|
||||
t.Fatalf("WriteSample(frame=%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := seg.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
// --- Init segment: emitted exactly once, well-formed, single video track. ---
|
||||
if initCalls != 1 {
|
||||
t.Fatalf("OnInit called %d times, want 1", initCalls)
|
||||
}
|
||||
if len(initBytes) == 0 {
|
||||
t.Fatal("init segment is empty")
|
||||
}
|
||||
parsedInit, err := mp4ff.DecodeFile(bytes.NewReader(initBytes))
|
||||
if err != nil {
|
||||
t.Fatalf("decode init: %v", err)
|
||||
}
|
||||
if parsedInit.Init == nil || parsedInit.Init.Ftyp == nil || parsedInit.Init.Moov == nil {
|
||||
t.Fatal("init segment missing ftyp/moov")
|
||||
}
|
||||
if got := len(parsedInit.Init.Moov.Traks); got != 1 {
|
||||
t.Fatalf("init moov has %d traks, want 1", got)
|
||||
}
|
||||
|
||||
// --- Segment cut cadence: 6 GOPs at 2s target => 3 segments of 2 GOPs each. ---
|
||||
const wantSegments = 3
|
||||
if len(segments) != wantSegments {
|
||||
t.Fatalf("got %d media segments, want %d", len(segments), wantSegments)
|
||||
}
|
||||
for i, s := range segments {
|
||||
if want := uint32(i + 1); s.SequenceNumber != want {
|
||||
t.Errorf("segment %d: SequenceNumber=%d, want %d", i, s.SequenceNumber, want)
|
||||
}
|
||||
if s.DurationMs != targetMs {
|
||||
t.Errorf("segment %d: DurationMs=%d, want %d", i, s.DurationMs, targetMs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Each segment must decode INDEPENDENTLY after the init segment. ---
|
||||
// Parsing init+oneSegment in isolation mirrors exactly what hls.js does with
|
||||
// an #EXT-X-MAP init and a single media part.
|
||||
var totalSamples, totalSync int
|
||||
wantTFDT := []uint64{0, 2000, 4000}
|
||||
for i, s := range segments {
|
||||
standalone := append(append([]byte(nil), initBytes...), s.Data...)
|
||||
parsed, err := mp4ff.DecodeFile(bytes.NewReader(standalone))
|
||||
if err != nil {
|
||||
t.Fatalf("segment %d: decode init+segment: %v", i, err)
|
||||
}
|
||||
if len(parsed.Segments) != 1 {
|
||||
t.Fatalf("segment %d: parsed %d media segments, want 1", i, len(parsed.Segments))
|
||||
}
|
||||
mseg := parsed.Segments[0]
|
||||
if mseg.Styp == nil {
|
||||
t.Errorf("segment %d: missing CMAF styp box", i)
|
||||
}
|
||||
if len(mseg.Fragments) != 1 {
|
||||
t.Fatalf("segment %d: %d fragments, want 1", i, len(mseg.Fragments))
|
||||
}
|
||||
fr := mseg.Fragments[0]
|
||||
if got := fr.Moof.Mfhd.SequenceNumber; got != s.SequenceNumber {
|
||||
t.Errorf("segment %d: moof sequence=%d, want %d", i, got, s.SequenceNumber)
|
||||
}
|
||||
traf := fr.Moof.Traf
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
t.Errorf("segment %d: track id=%d, want 1", i, traf.Tfhd.TrackID)
|
||||
}
|
||||
if got := traf.Tfdt.BaseMediaDecodeTime(); got != wantTFDT[i] {
|
||||
t.Errorf("segment %d: tfdt baseMediaDecodeTime=%d, want %d", i, got, wantTFDT[i])
|
||||
}
|
||||
|
||||
var samples []mp4ff.Sample
|
||||
for _, trun := range traf.Truns {
|
||||
samples = append(samples, trun.Samples...)
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
t.Fatalf("segment %d: no samples", i)
|
||||
}
|
||||
if !isSyncSample(samples[0]) {
|
||||
t.Errorf("segment %d: first sample is not a keyframe/sync sample", i)
|
||||
}
|
||||
var segDur uint64
|
||||
for j, smp := range samples {
|
||||
totalSamples++
|
||||
if isSyncSample(smp) {
|
||||
totalSync++
|
||||
}
|
||||
segDur += uint64(smp.Dur)
|
||||
if smp.Size == 0 {
|
||||
t.Errorf("segment %d sample %d: zero size", i, j)
|
||||
}
|
||||
}
|
||||
if segDur != s.DurationMs {
|
||||
t.Errorf("segment %d: summed sample dur=%d, reported DurationMs=%d", i, segDur, s.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
if totalSamples != numFrames {
|
||||
t.Errorf("total samples across segments=%d, want %d", totalSamples, numFrames)
|
||||
}
|
||||
if totalSync != numGOPs {
|
||||
t.Errorf("total sync samples=%d, want %d (one per GOP)", totalSync, numGOPs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLiveSegmenterDropsLeadingNonKeyframe verifies a session cannot open on a
|
||||
// non-IDR frame (which would reference frames that never arrived); such leading
|
||||
// samples are dropped until the first keyframe.
|
||||
func TestLiveSegmenterDropsLeadingNonKeyframe(t *testing.T) {
|
||||
seg := NewLiveSegmenter("H264", [][]byte{liveTestSPS}, [][]byte{liveTestPPS}, nil, 1000)
|
||||
seg.SetDimensions(640, 480)
|
||||
var segments []LiveSegment
|
||||
seg.OnSegment = func(s LiveSegment) error { segments = append(segments, s); return nil }
|
||||
|
||||
// Two P-frames before any IDR must be ignored.
|
||||
if err := seg.WriteSample(false, makeAnnexBFrame(false), 0, 0); err != nil {
|
||||
t.Fatalf("WriteSample(p0): %v", err)
|
||||
}
|
||||
if err := seg.WriteSample(false, makeAnnexBFrame(false), 40, 0); err != nil {
|
||||
t.Fatalf("WriteSample(p1): %v", err)
|
||||
}
|
||||
// First IDR opens the session at decode time 0.
|
||||
for i := 0; i < 25; i++ {
|
||||
isKey := i == 0
|
||||
if err := seg.WriteSample(isKey, makeAnnexBFrame(isKey), uint64(i)*40, 0); err != nil {
|
||||
t.Fatalf("WriteSample(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := seg.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if len(segments) == 0 {
|
||||
t.Fatal("expected at least one segment after the first IDR")
|
||||
}
|
||||
initBytes, err := seg.InitSegment()
|
||||
if err != nil {
|
||||
t.Fatalf("InitSegment: %v", err)
|
||||
}
|
||||
standalone := append(append([]byte(nil), initBytes...), segments[0].Data...)
|
||||
parsed, err := mp4ff.DecodeFile(bytes.NewReader(standalone))
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
traf := parsed.Segments[0].Fragments[0].Moof.Traf
|
||||
if got := traf.Tfdt.BaseMediaDecodeTime(); got != 0 {
|
||||
t.Errorf("first segment tfdt=%d, want 0 (session opens on the IDR)", got)
|
||||
}
|
||||
var first mp4ff.Sample
|
||||
for _, trun := range traf.Truns {
|
||||
if len(trun.Samples) > 0 {
|
||||
first = trun.Samples[0]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isSyncSample(first) {
|
||||
t.Error("first committed sample must be the IDR, not a dropped P-frame")
|
||||
}
|
||||
}
|
||||
|
||||
// renderLiveMediaPlaylist renders a live (no #EXT-X-ENDLIST) fMP4 HLS media
|
||||
// playlist for the given segments. This mirrors the shape hub-api will serve for
|
||||
// live streams: an #EXT-X-MAP init segment followed by one #EXTINF per CMAF part.
|
||||
// In production hub-api emits a sliding WINDOW of the most recent segments and
|
||||
// advances #EXT-X-MEDIA-SEQUENCE; here we list the whole synthetic capture for a
|
||||
// self-contained, inspectable bundle.
|
||||
func renderLiveMediaPlaylist(initURI string, segs []LiveSegment, mediaSequence uint32) string {
|
||||
var maxDurMs uint64
|
||||
for _, s := range segs {
|
||||
if s.DurationMs > maxDurMs {
|
||||
maxDurMs = s.DurationMs
|
||||
}
|
||||
}
|
||||
target := uint64(math.Ceil(float64(maxDurMs) / 1000.0))
|
||||
if target == 0 {
|
||||
target = 1
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("#EXTM3U\n")
|
||||
b.WriteString("#EXT-X-VERSION:7\n")
|
||||
fmt.Fprintf(&b, "#EXT-X-TARGETDURATION:%d\n", target)
|
||||
fmt.Fprintf(&b, "#EXT-X-MEDIA-SEQUENCE:%d\n", mediaSequence)
|
||||
b.WriteString("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
fmt.Fprintf(&b, "#EXT-X-MAP:URI=%q\n", initURI)
|
||||
for _, s := range segs {
|
||||
fmt.Fprintf(&b, "#EXTINF:%.3f,\n", float64(s.DurationMs)/1000.0)
|
||||
fmt.Fprintf(&b, "seg-%d.m4s\n", s.SequenceNumber)
|
||||
}
|
||||
// NOTE: deliberately no #EXT-X-ENDLIST - its absence is what marks the
|
||||
// playlist as live so hls.js keeps polling for new segments.
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestLiveSegmenterWritesHLSBundle runs the segmenter over a synthetic stream and
|
||||
// writes a complete on-disk fMP4 HLS bundle (init.mp4 + seg-N.m4s + a live
|
||||
// stream.m3u8). It validates the playlist shape and that every referenced file
|
||||
// exists, then logs the output directory so the structure can be eyeballed.
|
||||
//
|
||||
// Set LIVEHLS_OUT=/some/dir to keep the bundle for manual inspection (e.g. serve
|
||||
// it and point hls.js at stream.m3u8); otherwise a temp dir is used and removed.
|
||||
//
|
||||
// The frames here are synthetic (valid fMP4 boxing, non-decodable payloads), so
|
||||
// this validates CONTAINER/playlist structure, not pixel decode - the round-trip
|
||||
// assertions in TestLiveSegmenterProducesIndependentCMAFSegments cover decodable
|
||||
// box layout.
|
||||
func TestLiveSegmenterWritesHLSBundle(t *testing.T) {
|
||||
const (
|
||||
frameDurMs = uint64(40)
|
||||
gopFrames = 25
|
||||
numGOPs = 6
|
||||
numFrames = gopFrames * numGOPs
|
||||
targetMs = uint64(2000)
|
||||
)
|
||||
|
||||
outDir := os.Getenv("LIVEHLS_OUT")
|
||||
if outDir == "" {
|
||||
outDir = t.TempDir()
|
||||
} else {
|
||||
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", outDir, err)
|
||||
}
|
||||
}
|
||||
|
||||
seg := NewLiveSegmenter("H264", [][]byte{liveTestSPS}, [][]byte{liveTestPPS}, nil, targetMs)
|
||||
seg.SetDimensions(640, 480)
|
||||
|
||||
var segments []LiveSegment
|
||||
seg.OnInit = func(b []byte) error {
|
||||
return os.WriteFile(filepath.Join(outDir, "init.mp4"), b, 0o644)
|
||||
}
|
||||
seg.OnSegment = func(s LiveSegment) error {
|
||||
segments = append(segments, s)
|
||||
name := fmt.Sprintf("seg-%d.m4s", s.SequenceNumber)
|
||||
return os.WriteFile(filepath.Join(outDir, name), s.Data, 0o644)
|
||||
}
|
||||
|
||||
for i := 0; i < numFrames; i++ {
|
||||
isKey := i%gopFrames == 0
|
||||
if err := seg.WriteSample(isKey, makeAnnexBFrame(isKey), uint64(i)*frameDurMs, 0); err != nil {
|
||||
t.Fatalf("WriteSample(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := seg.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
t.Fatal("no segments produced")
|
||||
}
|
||||
|
||||
playlist := renderLiveMediaPlaylist("init.mp4", segments, segments[0].SequenceNumber)
|
||||
if err := os.WriteFile(filepath.Join(outDir, "stream.m3u8"), []byte(playlist), 0o644); err != nil {
|
||||
t.Fatalf("write playlist: %v", err)
|
||||
}
|
||||
|
||||
// --- Validate the live playlist shape. ---
|
||||
mustContain := []string{
|
||||
"#EXTM3U",
|
||||
"#EXT-X-VERSION:7",
|
||||
"#EXT-X-TARGETDURATION:2",
|
||||
"#EXT-X-MEDIA-SEQUENCE:1",
|
||||
`#EXT-X-MAP:URI="init.mp4"`,
|
||||
"#EXT-X-INDEPENDENT-SEGMENTS",
|
||||
}
|
||||
for _, tag := range mustContain {
|
||||
if !strings.Contains(playlist, tag) {
|
||||
t.Errorf("playlist missing %q\n---\n%s", tag, playlist)
|
||||
}
|
||||
}
|
||||
if strings.Contains(playlist, "#EXT-X-ENDLIST") {
|
||||
t.Error("live playlist must NOT contain #EXT-X-ENDLIST")
|
||||
}
|
||||
if got, want := strings.Count(playlist, "#EXTINF:"), len(segments); got != want {
|
||||
t.Errorf("playlist has %d #EXTINF entries, want %d", got, want)
|
||||
}
|
||||
|
||||
// --- Every referenced file must exist on disk. ---
|
||||
if _, err := os.Stat(filepath.Join(outDir, "init.mp4")); err != nil {
|
||||
t.Errorf("init.mp4 missing: %v", err)
|
||||
}
|
||||
for _, s := range segments {
|
||||
name := fmt.Sprintf("seg-%d.m4s", s.SequenceNumber)
|
||||
if _, err := os.Stat(filepath.Join(outDir, name)); err != nil {
|
||||
t.Errorf("%s missing: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("wrote HLS bundle to %s (%d segments)\n%s", outDir, len(segments), playlist)
|
||||
}
|
||||
|
||||
// boxTypeAt returns the 4CC box type at the front of a top-level box blob (the
|
||||
// 4 bytes following the 32-bit size), or "" if the blob is too short.
|
||||
func boxTypeAt(b []byte) string {
|
||||
if len(b) < 8 {
|
||||
return ""
|
||||
}
|
||||
return string(b[4:8])
|
||||
}
|
||||
|
||||
// TestLiveSegmenterLowLatencyParts runs the segmenter in LL-HLS mode over the
|
||||
// same synthetic stream and asserts that:
|
||||
// - each ~2s segment is sliced into multiple CMAF parts (more parts than
|
||||
// segments overall);
|
||||
// - part 0 of every segment carries the CMAF styp and is INDEPENDENT (begins
|
||||
// with the segment keyframe); later parts are bare moof+mdat (no styp);
|
||||
// - moof sequence numbers are globally monotonic across all parts (MSE needs
|
||||
// increasing moof sequence numbers);
|
||||
// - concatenating a segment's parts in order yields exactly the same bytes the
|
||||
// classic per-segment path would emit, decoding into one independent CMAF
|
||||
// segment whose first sample is a sync sample with the expected tfdt;
|
||||
// - every sample and keyframe of the input is preserved end to end.
|
||||
func TestLiveSegmenterLowLatencyParts(t *testing.T) {
|
||||
const (
|
||||
frameDurMs = uint64(40) // 25 fps
|
||||
gopFrames = 25 // keyframe every 1000 ms
|
||||
numGOPs = 6
|
||||
numFrames = gopFrames * numGOPs // 150 frames, 6000 ms
|
||||
targetMs = uint64(2000) // 2s segments => 2 GOPs each
|
||||
partMs = uint64(300) // ~300 ms parts => ~6-7 parts/segment
|
||||
)
|
||||
|
||||
seg := NewLiveSegmenter("H264", [][]byte{liveTestSPS}, [][]byte{liveTestPPS}, nil, targetMs)
|
||||
seg.SetDimensions(640, 480)
|
||||
seg.EnableLowLatency(partMs)
|
||||
|
||||
var initBytes []byte
|
||||
var initCalls int
|
||||
var parts []LivePart
|
||||
seg.OnInit = func(b []byte) error {
|
||||
initCalls++
|
||||
initBytes = append([]byte(nil), b...)
|
||||
return nil
|
||||
}
|
||||
seg.OnPart = func(p LivePart) error {
|
||||
parts = append(parts, p)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 0; i < numFrames; i++ {
|
||||
isKey := i%gopFrames == 0
|
||||
if err := seg.WriteSample(isKey, makeAnnexBFrame(isKey), uint64(i)*frameDurMs, 0); err != nil {
|
||||
t.Fatalf("WriteSample(frame=%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := seg.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
if initCalls != 1 {
|
||||
t.Fatalf("OnInit called %d times, want 1", initCalls)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
t.Fatal("no parts produced in low-latency mode")
|
||||
}
|
||||
|
||||
// --- Parts are globally moof-monotonic, and group into 3 segments whose part
|
||||
// indices are contiguous from 0. ---
|
||||
bySeg := map[uint32][]LivePart{}
|
||||
var order []uint32
|
||||
var lastMoof uint32
|
||||
for i, p := range parts {
|
||||
if _, seen := bySeg[p.SegmentSeq]; !seen {
|
||||
order = append(order, p.SegmentSeq)
|
||||
}
|
||||
bySeg[p.SegmentSeq] = append(bySeg[p.SegmentSeq], p)
|
||||
|
||||
// Decode the part to read its moof sequence number and confirm the styp
|
||||
// convention (part 0 => styp present, later parts => bare moof+mdat).
|
||||
front := boxTypeAt(p.Data)
|
||||
if p.PartIndex == 0 {
|
||||
if front != "styp" {
|
||||
t.Errorf("seg %d part 0: leading box=%q, want styp", p.SegmentSeq, front)
|
||||
}
|
||||
if !p.Independent {
|
||||
t.Errorf("seg %d part 0: Independent=false, want true (starts on keyframe)", p.SegmentSeq)
|
||||
}
|
||||
} else if front != "moof" {
|
||||
t.Errorf("seg %d part %d: leading box=%q, want moof (no styp on later parts)", p.SegmentSeq, p.PartIndex, front)
|
||||
}
|
||||
|
||||
parsed, err := mp4ff.DecodeFile(bytes.NewReader(p.Data))
|
||||
if err != nil {
|
||||
t.Fatalf("seg %d part %d: decode: %v", p.SegmentSeq, p.PartIndex, err)
|
||||
}
|
||||
if len(parsed.Segments) != 1 || len(parsed.Segments[0].Fragments) != 1 {
|
||||
t.Fatalf("seg %d part %d: want exactly one fragment", p.SegmentSeq, p.PartIndex)
|
||||
}
|
||||
moof := parsed.Segments[0].Fragments[0].Moof.Mfhd.SequenceNumber
|
||||
if i > 0 && moof <= lastMoof {
|
||||
t.Errorf("part %d: moof sequence=%d not greater than previous %d", i, moof, lastMoof)
|
||||
}
|
||||
lastMoof = moof
|
||||
}
|
||||
|
||||
if len(order) != 3 {
|
||||
t.Fatalf("got %d segments, want 3", len(order))
|
||||
}
|
||||
if len(parts) <= len(order) {
|
||||
t.Fatalf("got %d parts for %d segments, expected each segment to be sliced into multiple parts", len(parts), len(order))
|
||||
}
|
||||
for _, segSeq := range order {
|
||||
for idx, p := range bySeg[segSeq] {
|
||||
if p.PartIndex != uint32(idx) {
|
||||
t.Errorf("seg %d: part index %d out of order (want %d)", segSeq, p.PartIndex, idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Concatenating a segment's parts must reconstruct one independent CMAF
|
||||
// segment that decodes against the init segment. ---
|
||||
wantTFDT := map[uint32]uint64{1: 0, 2: 2000, 3: 4000}
|
||||
var totalSamples, totalSync int
|
||||
for _, segSeq := range order {
|
||||
segParts := bySeg[segSeq]
|
||||
var full []byte
|
||||
var wantPartDur uint64
|
||||
for _, p := range segParts {
|
||||
full = append(full, p.Data...)
|
||||
wantPartDur += p.DurationMs
|
||||
}
|
||||
standalone := append(append([]byte(nil), initBytes...), full...)
|
||||
parsed, err := mp4ff.DecodeFile(bytes.NewReader(standalone))
|
||||
if err != nil {
|
||||
t.Fatalf("seg %d: decode concatenated parts: %v", segSeq, err)
|
||||
}
|
||||
if len(parsed.Segments) != 1 {
|
||||
t.Fatalf("seg %d: parsed %d media segments, want 1", segSeq, len(parsed.Segments))
|
||||
}
|
||||
mseg := parsed.Segments[0]
|
||||
if mseg.Styp == nil {
|
||||
t.Errorf("seg %d: reconstructed segment missing CMAF styp", segSeq)
|
||||
}
|
||||
if len(mseg.Fragments) != len(segParts) {
|
||||
t.Errorf("seg %d: %d fragments, want %d (one per part)", segSeq, len(mseg.Fragments), len(segParts))
|
||||
}
|
||||
firstTraf := mseg.Fragments[0].Moof.Traf
|
||||
if got := firstTraf.Tfdt.BaseMediaDecodeTime(); got != wantTFDT[segSeq] {
|
||||
t.Errorf("seg %d: first fragment tfdt=%d, want %d", segSeq, got, wantTFDT[segSeq])
|
||||
}
|
||||
var segDur uint64
|
||||
var firstSample mp4ff.Sample
|
||||
var haveFirst bool
|
||||
for _, fr := range mseg.Fragments {
|
||||
for _, trun := range fr.Moof.Traf.Truns {
|
||||
for _, smp := range trun.Samples {
|
||||
if !haveFirst {
|
||||
firstSample = smp
|
||||
haveFirst = true
|
||||
}
|
||||
totalSamples++
|
||||
if isSyncSample(smp) {
|
||||
totalSync++
|
||||
}
|
||||
segDur += uint64(smp.Dur)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isSyncSample(firstSample) {
|
||||
t.Errorf("seg %d: first sample is not a sync sample", segSeq)
|
||||
}
|
||||
if segDur != wantPartDur {
|
||||
t.Errorf("seg %d: summed sample dur=%d, summed part dur=%d", segSeq, segDur, wantPartDur)
|
||||
}
|
||||
}
|
||||
|
||||
if totalSamples != numFrames {
|
||||
t.Errorf("total samples across parts=%d, want %d", totalSamples, numFrames)
|
||||
}
|
||||
if totalSync != numGOPs {
|
||||
t.Errorf("total sync samples=%d, want %d (one per GOP)", totalSync, numGOPs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,21 @@ const MacEpochOffset uint64 = 2082844800
|
||||
// resulting in ~3 second fragments (assuming a typical GOP interval).
|
||||
const FragmentDurationMs = 3000
|
||||
|
||||
// SeamGapDivisor controls loop-seam detection. A keyframe is treated as an
|
||||
// upstream loop/restart seam when it arrives in less than (smallest normal
|
||||
// keyframe interval / SeamGapDivisor) — i.e. far sooner than the camera's
|
||||
// tightest established keyframe cadence.
|
||||
//
|
||||
// The reference is the running *minimum* keyframe interval, NOT the immediately
|
||||
// preceding one. Variable-GOP ("smart codec") cameras lengthen the GOP during
|
||||
// static scenes and shorten it again on motion, so consecutive intervals differ
|
||||
// wildly (e.g. 2000 ms then 500 ms). Comparing against the previous interval
|
||||
// then flags every normal short GOP that happens to follow a long static GOP as
|
||||
// a seam and drops healthy video. Comparing against the minimum cadence instead
|
||||
// scales with any configured GOP size (0.5s, 1s, 2s, ...) yet never mistakes the
|
||||
// camera's own normal cadence for a premature seam IDR.
|
||||
const SeamGapDivisor = 2
|
||||
|
||||
type MP4 struct {
|
||||
// FileName is the name of the file
|
||||
FileName string
|
||||
@@ -74,6 +89,21 @@ type MP4 struct {
|
||||
TotalKeyframesWritten int // Total keyframes written to trun boxes
|
||||
FragmentKeyframeCount int // Keyframes in the current fragment
|
||||
PendingSampleIsKeyframe bool // Whether the pending video sample is a keyframe
|
||||
LastKeyframeRawPTS uint64 // Raw PTS of the most recently seen keyframe (across fragments)
|
||||
LastKeyframeGapMs uint64 // Interval (ms) between the two most recent keyframes (diagnostic only)
|
||||
MinKeyframeGapMs uint64 // Smallest keyframe interval (ms) seen so far; the camera's tightest cadence and the reference for seam detection
|
||||
gopBuffer []bufferedSample // Current, not-yet-committed GOP (video frames + interleaved audio), held so a loop-seam GOP can be dropped before it reaches the file
|
||||
}
|
||||
|
||||
// bufferedSample is a single sample (video or audio) held in the current-GOP
|
||||
// buffer until we know whether the GOP should be committed to the file or
|
||||
// dropped as an upstream loop-seam artifact (see AddSampleToTrack).
|
||||
type bufferedSample struct {
|
||||
trackID uint32
|
||||
isKeyframe bool
|
||||
data []byte
|
||||
pts uint64
|
||||
compositionOffset int64
|
||||
}
|
||||
|
||||
// NewMP4 creates a new MP4 object.
|
||||
@@ -253,6 +283,8 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
|
||||
err := mp4.MultiTrackFragment.AddFullSampleToTrack(*mp4.VideoFullSample, uint32(mp4.VideoTrack))
|
||||
if err != nil {
|
||||
log.Log.Error("mp4.flushPendingVideoSample(): error adding sample: " + err.Error())
|
||||
} else {
|
||||
mp4.SampleCount++
|
||||
}
|
||||
if isKF {
|
||||
mp4.TotalKeyframesWritten++
|
||||
@@ -266,7 +298,200 @@ func (mp4 *MP4) flushPendingVideoSample(nextPTS uint64) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, pts uint64) error {
|
||||
// AverageFPS returns the average frame rate of the video samples actually
|
||||
// committed to this recording.
|
||||
func (mp4 *MP4) AverageFPS() float64 {
|
||||
if mp4.SampleCount == 0 || mp4.VideoTotalDuration == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(mp4.SampleCount) * 1000 / float64(mp4.VideoTotalDuration)
|
||||
}
|
||||
|
||||
// AddSampleToTrack appends a sample to the given track.
|
||||
//
|
||||
// For video, pts is the decode timestamp (DTS, in milliseconds) and
|
||||
// compositionOffset is the composition time offset (PTS - DTS, in milliseconds).
|
||||
// The offset is non-zero only for streams that contain B-frames; it is written
|
||||
// as the sample's signed composition time offset so the decoder presents frames
|
||||
// in PTS order while the fragment timeline stays monotonic in DTS.
|
||||
//
|
||||
// For audio, pts is the sample timestamp and compositionOffset should be 0.
|
||||
//
|
||||
// Samples are not written straight through. Each video GOP is held in a small
|
||||
// buffer (gopBuffer) until the next keyframe arrives, so a GOP belonging to an
|
||||
// upstream source-loop / restart seam can be dropped before it ever reaches the
|
||||
// file. When a source MP4 is looped through virtual-rtsp
|
||||
// (ffmpeg `-stream_loop -1 -re`), the loop boundary leaves a truncated tail GOP
|
||||
// whose first inter-frame is incomplete: software decoders conceal the missing
|
||||
// macroblocks, but hardware decoders (macOS VideoToolbox) reject it with
|
||||
// kVTVideoDecoderBadDataErr (-12909) and MSE players (Video.js / Chromium /
|
||||
// Firefox) report media corruption, freezing playback at the seam (e.g. the
|
||||
// ~10s mark in the original recordings). The seam IDR that follows is a clean
|
||||
// random-access point, so dropping the truncated GOP lets playback continue
|
||||
// seamlessly. Holding back at most one GOP only delays on-disk fragments; for
|
||||
// any recording without a seam the finalized file is identical to the straight
|
||||
// pass-through output (Close flushes the final buffered GOP).
|
||||
func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, pts uint64, compositionOffset int64) error {
|
||||
isVideoKeyframe := isKeyframe && trackID == uint32(mp4.VideoTrack)
|
||||
if !isVideoKeyframe {
|
||||
// Part of the current GOP window (P/B frame or interleaved audio): hold it
|
||||
// until the GOP is committed or dropped at the next video keyframe.
|
||||
mp4.gopBuffer = append(mp4.gopBuffer, bufferedSample{
|
||||
trackID: trackID,
|
||||
isKeyframe: isKeyframe,
|
||||
data: data,
|
||||
pts: pts,
|
||||
compositionOffset: compositionOffset,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// A video keyframe ends the GOP we have been buffering. Decide whether that
|
||||
// buffered GOP is genuine (commit it) or the truncated tail GOP at an upstream
|
||||
// loop/restart seam (drop it).
|
||||
//
|
||||
// A genuine loop/restart seam has TWO signatures that must BOTH hold; we never
|
||||
// drop a GOP on the interval alone, because variable-GOP ("smart codec")
|
||||
// cameras legitimately shorten the GOP on motion:
|
||||
//
|
||||
// 1. The new keyframe arrives much sooner than the camera's tightest normal
|
||||
// cadence: gap*SeamGapDivisor < MinKeyframeGapMs (the running MINIMUM
|
||||
// interval). Using the minimum — not the previous interval — means a
|
||||
// normal short GOP that merely follows a long static GOP (2000 ms -> 500 ms)
|
||||
// is NOT flagged, while a true premature restart still is.
|
||||
// 2. The GOP we just buffered is actually TRUNCATED — far shorter than a full
|
||||
// GOP. A real seam cuts a GOP off mid-stream, leaving only a handful of
|
||||
// frames; a healthy GOP (even a legitimately short one) is left intact and
|
||||
// must be committed in full. We require the buffered tail to be under half
|
||||
// the minimum normal GOP length to qualify as truncated.
|
||||
//
|
||||
// Deriving both thresholds from the observed cadence keeps detection correct
|
||||
// for any configured GOP size (0.5s, 1s, 2s, ...) and stops the heuristic from
|
||||
// discarding healthy video.
|
||||
seam := false
|
||||
if mp4.LastKeyframeRawPTS > 0 && pts > mp4.LastKeyframeRawPTS {
|
||||
gap := pts - mp4.LastKeyframeRawPTS
|
||||
bufferedVideo := mp4.bufferedVideoCount()
|
||||
// Frames a full GOP at the tightest normal cadence would contain.
|
||||
fullGopFrames := mp4.expectedGopFrames(gap)
|
||||
closeKeyframe := mp4.MinKeyframeGapMs > 0 && gap*SeamGapDivisor < mp4.MinKeyframeGapMs
|
||||
truncatedTail := fullGopFrames > 0 && bufferedVideo*2 < fullGopFrames
|
||||
if closeKeyframe && truncatedTail {
|
||||
seam = true
|
||||
log.Log.Warning(fmt.Sprintf("mp4.AddSampleToTrack(): dropping truncated GOP at premature keyframe (interval=%d ms, min interval=%d ms, buffered video frames=%d of ~%d) - likely upstream loop/restart discontinuity", gap, mp4.MinKeyframeGapMs, bufferedVideo, fullGopFrames))
|
||||
}
|
||||
mp4.LastKeyframeGapMs = gap
|
||||
if !seam && (mp4.MinKeyframeGapMs == 0 || gap < mp4.MinKeyframeGapMs) {
|
||||
mp4.MinKeyframeGapMs = gap
|
||||
}
|
||||
}
|
||||
mp4.LastKeyframeRawPTS = pts
|
||||
|
||||
if seam {
|
||||
// Discard the truncated tail GOP; this keyframe is a clean restart point.
|
||||
mp4.gopBuffer = mp4.gopBuffer[:0]
|
||||
} else {
|
||||
// Genuine GOP boundary: commit the GOP we just finished buffering.
|
||||
mp4.commitBufferedGOP()
|
||||
}
|
||||
|
||||
// Begin buffering the new GOP, starting with this keyframe.
|
||||
mp4.gopBuffer = append(mp4.gopBuffer, bufferedSample{
|
||||
trackID: trackID,
|
||||
isKeyframe: isKeyframe,
|
||||
data: data,
|
||||
pts: pts,
|
||||
compositionOffset: compositionOffset,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// bufferedVideoCount returns how many video-track samples are currently held in
|
||||
// the GOP buffer (interleaved audio samples are ignored). It measures how
|
||||
// complete the buffered GOP is, used to tell a truncated seam tail from a
|
||||
// healthy — possibly legitimately short — GOP.
|
||||
func (mp4 *MP4) bufferedVideoCount() uint64 {
|
||||
var n uint64
|
||||
for _, s := range mp4.gopBuffer {
|
||||
if s.trackID == uint32(mp4.VideoTrack) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// expectedGopFrames estimates how many video frames a full GOP at the camera's
|
||||
// tightest normal cadence (MinKeyframeGapMs) would contain, using the video
|
||||
// frame interval inferred from the buffered GOP. gap is the current keyframe
|
||||
// interval, used as a fallback frame-duration source. Returns 0 when there is
|
||||
// not yet enough information to judge (so callers must not treat a GOP as
|
||||
// truncated without a reliable estimate).
|
||||
func (mp4 *MP4) expectedGopFrames(gap uint64) uint64 {
|
||||
cadence := mp4.MinKeyframeGapMs
|
||||
if cadence == 0 {
|
||||
return 0
|
||||
}
|
||||
frameDur := mp4.bufferedVideoFrameDuration()
|
||||
if frameDur == 0 {
|
||||
// Fall back to deriving a per-frame duration from the buffered tail across
|
||||
// the current interval; if that is unavailable too, we cannot estimate.
|
||||
if n := mp4.bufferedVideoCount(); n > 0 && gap > 0 {
|
||||
frameDur = gap / n
|
||||
}
|
||||
}
|
||||
if frameDur == 0 {
|
||||
return 0
|
||||
}
|
||||
return cadence / frameDur
|
||||
}
|
||||
|
||||
// bufferedVideoFrameDuration returns the average per-frame duration (in PTS
|
||||
// units) of the video samples currently buffered, derived from the PTS deltas
|
||||
// between consecutive video frames. Returns 0 when fewer than two video frames
|
||||
// are buffered.
|
||||
func (mp4 *MP4) bufferedVideoFrameDuration() uint64 {
|
||||
var prev uint64
|
||||
havePrev := false
|
||||
var sum, count uint64
|
||||
for _, s := range mp4.gopBuffer {
|
||||
if s.trackID != uint32(mp4.VideoTrack) {
|
||||
continue
|
||||
}
|
||||
if havePrev && s.pts > prev {
|
||||
sum += s.pts - prev
|
||||
count++
|
||||
}
|
||||
prev = s.pts
|
||||
havePrev = true
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / count
|
||||
}
|
||||
|
||||
// commitBufferedGOP writes every sample currently held in gopBuffer to the file
|
||||
// in arrival order, then clears the buffer. Committing in arrival order
|
||||
// preserves the original audio/video interleave and lets commitSampleToTrack's
|
||||
// pending-sample mechanism derive each sample's duration from the next one, so
|
||||
// the on-disk result matches a straight pass-through.
|
||||
func (mp4 *MP4) commitBufferedGOP() {
|
||||
if len(mp4.gopBuffer) == 0 {
|
||||
return
|
||||
}
|
||||
buffered := mp4.gopBuffer
|
||||
mp4.gopBuffer = nil // detach so commitSampleToTrack never observes a half-cleared buffer
|
||||
for _, s := range buffered {
|
||||
if err := mp4.commitSampleToTrack(s.trackID, s.isKeyframe, s.data, s.pts, s.compositionOffset); err != nil {
|
||||
log.Log.Error("mp4.commitBufferedGOP(): " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// commitSampleToTrack appends a single buffered sample to the current fragment.
|
||||
// It is the low-level writer behind AddSampleToTrack and is only ever invoked
|
||||
// from commitBufferedGOP, after a GOP has been confirmed as non-seam.
|
||||
func (mp4 *MP4) commitSampleToTrack(trackID uint32, isKeyframe bool, data []byte, pts uint64, compositionOffset int64) error {
|
||||
|
||||
if isKeyframe && trackID == uint32(mp4.VideoTrack) {
|
||||
mp4.TotalKeyframesReceived++
|
||||
@@ -375,7 +600,7 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
|
||||
fullSample.Sample = mp4ff.Sample{
|
||||
Size: uint32(len(fullSample.Data)),
|
||||
Flags: flags,
|
||||
CompositionTimeOffset: 0, // No composition time offset for video
|
||||
CompositionTimeOffset: int32(compositionOffset), // PTS-DTS, non-zero for B-frames
|
||||
}
|
||||
mp4.VideoFullSample = &fullSample
|
||||
mp4.PendingSampleIsKeyframe = isKeyframe
|
||||
@@ -428,6 +653,10 @@ func (mp4 *MP4) AddSampleToTrack(trackID uint32, isKeyframe bool, data []byte, p
|
||||
|
||||
func (mp4 *MP4) Close(config *models.Config) {
|
||||
|
||||
// Commit the final buffered GOP held back for seam detection. The last GOP of
|
||||
// a recording is never a loop seam, so it must always be written out.
|
||||
mp4.commitBufferedGOP()
|
||||
|
||||
log.Log.Info(fmt.Sprintf("mp4.Close(): KEYFRAME SUMMARY - totalReceived=%d, totalWritten=%d, segments=%d, lastFragmentKF=%d",
|
||||
mp4.TotalKeyframesReceived, mp4.TotalKeyframesWritten, mp4.SegmentCount, mp4.FragmentKeyframeCount))
|
||||
|
||||
@@ -562,6 +791,13 @@ func (mp4 *MP4) Close(config *models.Config) {
|
||||
includePS := true
|
||||
spsNALUs, ppsNALUs := normalizeH264ParameterSets(mp4.SPSNALUs, mp4.PPSNALUs)
|
||||
log.Log.Debug("mp4.Close(): AVC parameter sets: SPS=" + formatNaluDebug(spsNALUs) + ", PPS=" + formatNaluDebug(ppsNALUs))
|
||||
if len(spsNALUs) == 0 || len(ppsNALUs) == 0 {
|
||||
// An avcC without both SPS and PPS is invalid: downstream FFmpeg-based
|
||||
// pipelines decoding this file will report "non-existing PPS 0 referenced"
|
||||
// and fail to extract any frame. Surface it loudly so the capture-side
|
||||
// parameter-set handling can be diagnosed.
|
||||
log.Log.Error(fmt.Sprintf("mp4.Close(): incomplete H264 parameter sets (SPS=%d, PPS=%d) - the avcC will be invalid and downstream decoders will report 'non-existing PPS 0 referenced'", len(spsNALUs), len(ppsNALUs)))
|
||||
}
|
||||
err := init.Moov.Traks[0].SetAVCDescriptor("avc1", spsNALUs, ppsNALUs, includePS)
|
||||
if err != nil {
|
||||
log.Log.Error("mp4.Close(): error setting AVC descriptor: " + err.Error())
|
||||
@@ -588,6 +824,11 @@ func (mp4 *MP4) Close(config *models.Config) {
|
||||
includePS := true
|
||||
vpsNALUs, spsNALUs, ppsNALUs := normalizeH265ParameterSets(mp4.VPSNALUs, mp4.SPSNALUs, mp4.PPSNALUs)
|
||||
log.Log.Debug("mp4.Close(): HEVC parameter sets: VPS=" + formatNaluDebug(vpsNALUs) + ", SPS=" + formatNaluDebug(spsNALUs) + ", PPS=" + formatNaluDebug(ppsNALUs))
|
||||
if len(vpsNALUs) == 0 || len(spsNALUs) == 0 || len(ppsNALUs) == 0 {
|
||||
// An hvcC missing VPS/SPS/PPS is invalid and downstream FFmpeg-based
|
||||
// pipelines will fail to decode the recording. Surface it loudly.
|
||||
log.Log.Error(fmt.Sprintf("mp4.Close(): incomplete H265 parameter sets (VPS=%d, SPS=%d, PPS=%d) - the hvcC will be invalid and downstream decoders will fail to process the recording", len(vpsNALUs), len(spsNALUs), len(ppsNALUs)))
|
||||
}
|
||||
err := init.Moov.Traks[0].SetHEVCDescriptor("hvc1", vpsNALUs, spsNALUs, ppsNALUs, [][]byte{}, includePS)
|
||||
if err != nil {
|
||||
log.Log.Error("mp4.Close(): error setting HEVC descriptor: " + err.Error())
|
||||
|
||||
@@ -2,6 +2,7 @@ package video
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
@@ -49,7 +50,7 @@ func TestMP4Duration(t *testing.T) {
|
||||
for i := 0; i < numFrames; i++ {
|
||||
pts := uint64(i) * frameDuration
|
||||
isKeyframe := i%gopSize == 0
|
||||
err := mp4Video.AddSampleToTrack(videoTrack, isKeyframe, makeFrame(isKeyframe), pts)
|
||||
err := mp4Video.AddSampleToTrack(videoTrack, isKeyframe, makeFrame(isKeyframe), pts, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("AddSampleToTrack failed at frame %d: %v", i, err)
|
||||
}
|
||||
@@ -173,4 +174,10 @@ func TestMP4Duration(t *testing.T) {
|
||||
t.Errorf("MISMATCH: mdhd.Duration should be 0 for fragmented MP4, got %d",
|
||||
parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration)
|
||||
}
|
||||
if mp4Video.SampleCount != sampleCount {
|
||||
t.Errorf("SampleCount = %d, finalized MP4 contains %d video samples", mp4Video.SampleCount, sampleCount)
|
||||
}
|
||||
if fps := mp4Video.AverageFPS(); math.Abs(fps-25) > 0.001 {
|
||||
t.Errorf("AverageFPS() = %.3f, want 25", fps)
|
||||
}
|
||||
}
|
||||
|
||||
194
machinery/src/video/mp4_loopseam_test.go
Normal file
194
machinery/src/video/mp4_loopseam_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// runLoopSeamScenario builds a fragmented MP4 that reproduces the loop-seam
|
||||
// pattern observed in the failing virtual-rtsp recordings (e.g.
|
||||
// thales_1781196512_3-138_2top_0-0-0-0_-1_30219.mp4): a steady GOP cadence, but
|
||||
// at the source-MP4 loop boundary the source restarts and emits a fresh IDR far
|
||||
// sooner than a normal GOP. In the real recordings the short tail GOP left just
|
||||
// before that premature IDR contains a truncated inter-frame - software decoders
|
||||
// conceal the missing macroblocks, but hardware decoders (macOS VideoToolbox,
|
||||
// kVTVideoDecoderBadDataErr / -12909) and MSE players reject it and freeze
|
||||
// playback at the seam (~10s in the original file).
|
||||
//
|
||||
// The fix detects the premature seam IDR and drops the truncated tail GOP that
|
||||
// precedes it. The seam IDR is itself a clean random-access point, so playback
|
||||
// resumes seamlessly. This scenario asserts that the tail GOP is removed -
|
||||
// exactly one GOP fewer than emitted - while every healthy GOP is preserved in
|
||||
// full and no two IDRs are left bunched in a fragment.
|
||||
//
|
||||
// gopFrames is the number of frames per GOP, so the same scenario can be
|
||||
// exercised at different (configurable) camera GOP sizes. The fix derives its
|
||||
// threshold from the observed keyframe cadence, so the truncated tail GOP is
|
||||
// dropped regardless of GOP size.
|
||||
func runLoopSeamScenario(t *testing.T, gopFrames int) {
|
||||
t.Helper()
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "test_loop_seam_*.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
|
||||
pps := []byte{0x68, 0xce, 0x38, 0x80}
|
||||
mp4Video := NewMP4(tmpFile.Name(), [][]byte{sps}, [][]byte{pps}, nil, 60)
|
||||
mp4Video.SetWidth(1920)
|
||||
mp4Video.SetHeight(1080)
|
||||
v := mp4Video.AddVideoTrack("H264")
|
||||
|
||||
mk := func(k bool) []byte {
|
||||
nt := byte(0x01)
|
||||
if k {
|
||||
nt = 0x65
|
||||
}
|
||||
f := []byte{0, 0, 0, 1, nt}
|
||||
for i := 0; i < 200; i++ {
|
||||
f = append(f, byte(i))
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
frameDur := uint64(33)
|
||||
normalGOPms := uint64(gopFrames) * frameDur
|
||||
pts := uint64(0)
|
||||
emitFrame := func(isKey bool) {
|
||||
// compositionOffset is 0: synthetic stream has no B-frames.
|
||||
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts, 0)
|
||||
pts += frameDur
|
||||
}
|
||||
emitP := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
emitFrame(false)
|
||||
}
|
||||
}
|
||||
// emitGOP emits one GOP: a leading keyframe followed by gopFrames-1 P-frames.
|
||||
emitGOP := func() {
|
||||
emitFrame(true)
|
||||
emitP(gopFrames - 1)
|
||||
}
|
||||
|
||||
// Several healthy GOPs to establish the cadence and fill a couple of
|
||||
// fragments, then the truncated tail GOP: a keyframe followed by only a few
|
||||
// P-frames before the source loops. This is the GOP that must be dropped.
|
||||
for g := 0; g < 9; g++ {
|
||||
emitGOP()
|
||||
}
|
||||
emitFrame(true)
|
||||
seamLead := gopFrames / 5 // tail GOP is only ~20% of a normal GOP before the loop
|
||||
if seamLead < 1 {
|
||||
seamLead = 1
|
||||
}
|
||||
emitP(seamLead)
|
||||
// Loop seam: the source recording restarts, emitting a fresh IDR far sooner
|
||||
// than the normal GOP. The short tail GOP emitted just above is the truncated
|
||||
// one that must be dropped; this seam IDR opens a fresh, healthy GOP.
|
||||
emitFrame(true)
|
||||
emitP(gopFrames - 1)
|
||||
// The recording continues with normal GOPs to the end.
|
||||
for g := 0; g < 10; g++ {
|
||||
emitGOP()
|
||||
}
|
||||
|
||||
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
|
||||
|
||||
f, err := os.Open(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
parsed, err := mp4ff.DecodeFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
// After the fix, the truncated tail GOP that precedes the premature seam IDR
|
||||
// is dropped entirely (its first inter-frame is the incomplete one that
|
||||
// freezes hardware decoders), while every other GOP is preserved in full.
|
||||
//
|
||||
// 9 lead GOPs + the seam's own (healthy) GOP + 10 trailing GOPs = 20 committed
|
||||
// GOPs. The standalone "tail" keyframe and its seamLead P-frames are the
|
||||
// dropped truncated GOP, so the output must contain exactly one GOP fewer than
|
||||
// emitted and a whole number of complete GOPs.
|
||||
const committedGOPs = 9 + 1 + 10
|
||||
wantSync := committedGOPs
|
||||
wantSamples := committedGOPs * gopFrames
|
||||
|
||||
// A healthy fragment only ever contains keyframes spaced ~normalGOPms apart.
|
||||
// If any fragment contains two keyframes closer than half a normal GOP, the
|
||||
// premature seam IDR was not dropped and the file will freeze on playback.
|
||||
maxBunchMs := normalGOPms / 2
|
||||
|
||||
totalSamples := 0
|
||||
totalSync := 0
|
||||
fragIdx := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
tfdt := traf.Tfdt.BaseMediaDecodeTime()
|
||||
offset := uint64(0)
|
||||
var keys []uint64
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
totalSamples++
|
||||
// sample_depends_on == 2 => "does not depend on others" => IDR/sync.
|
||||
if (s.Flags>>24)&0x03 == 0x02 {
|
||||
keys = append(keys, offset)
|
||||
totalSync++
|
||||
}
|
||||
offset += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
t.Logf("gop=%dframes frag %d tfdt=%d samples_dur=%d keys@%v", gopFrames, fragIdx, tfdt, offset, keys)
|
||||
for i := 1; i < len(keys); i++ {
|
||||
gap := keys[i] - keys[i-1]
|
||||
if gap < maxBunchMs {
|
||||
t.Errorf("gop=%dframes frag %d (tfdt=%d): two IDRs only %d ms apart in same fragment (< %d) - seam was not dropped",
|
||||
gopFrames, fragIdx, tfdt, gap, maxBunchMs)
|
||||
}
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalSync != wantSync {
|
||||
t.Errorf("gop=%dframes: got %d keyframes in output, want %d - the truncated seam GOP was not dropped exactly once",
|
||||
gopFrames, totalSync, wantSync)
|
||||
}
|
||||
if totalSamples != wantSamples {
|
||||
t.Errorf("gop=%dframes: got %d video samples in output, want %d (= %d committed GOPs x %d frames) - the seam GOP drop removed the wrong frames",
|
||||
gopFrames, totalSamples, wantSamples, committedGOPs, gopFrames)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMP4LoopSeamDrop exercises the ~1s GOP case (30 frames @ ~33ms),
|
||||
// matching the original failing recording.
|
||||
func TestMP4LoopSeamDrop(t *testing.T) {
|
||||
runLoopSeamScenario(t, 30)
|
||||
}
|
||||
|
||||
// TestMP4LoopSeamDropLargeGOP exercises a larger ~2s GOP (60 frames). The
|
||||
// GOP size is configurable per camera; this guards against regressing to a
|
||||
// fixed-millisecond threshold that would only work for ~1s GOPs.
|
||||
func TestMP4LoopSeamDropLargeGOP(t *testing.T) {
|
||||
runLoopSeamScenario(t, 60)
|
||||
}
|
||||
|
||||
// TestMP4LoopSeamDropShortGOP exercises a short ~0.5s GOP (15 frames),
|
||||
// where a fixed ~1s threshold would misfire on every keyframe. The relative
|
||||
// detection must only drop the genuine premature seam's truncated tail GOP.
|
||||
func TestMP4LoopSeamDropShortGOP(t *testing.T) {
|
||||
runLoopSeamScenario(t, 15)
|
||||
}
|
||||
129
machinery/src/video/mp4_variablegop_test.go
Normal file
129
machinery/src/video/mp4_variablegop_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// TestMP4VariableGOPKeepsHealthyShortGOP reproduces the adam-drive regression:
|
||||
// a variable-GOP ("smart codec") camera lengthens its keyframe interval during a
|
||||
// static scene (e.g. 500ms -> 1500/2000ms) and then drops back to its normal
|
||||
// 500ms cadence on motion. That normal, FULL 500ms GOP arrives much sooner than
|
||||
// the immediately preceding (long, static) GOP.
|
||||
//
|
||||
// The previous heuristic compared the new keyframe interval against the *previous*
|
||||
// interval and dropped the GOP whenever gap < previousInterval/2 — so every normal
|
||||
// 500ms keyframe following a long static GOP was misclassified as a premature
|
||||
// loop/restart seam and a whole healthy GOP (~15 frames) was discarded. In the
|
||||
// field this silently deleted ~0.5s of video on virtually every recording from
|
||||
// such cameras, producing a freeze/jump artifact.
|
||||
//
|
||||
// After the fix the seam check compares against the running MINIMUM cadence and
|
||||
// additionally requires the buffered GOP to be genuinely truncated, so a full
|
||||
// healthy GOP is always kept regardless of how long the preceding GOP was. This
|
||||
// test asserts that NO frames are dropped for a pure variable-GOP stream.
|
||||
func TestMP4VariableGOPKeepsHealthyShortGOP(t *testing.T) {
|
||||
tmpFile, err := os.CreateTemp("", "test_variable_gop_*.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
|
||||
pps := []byte{0x68, 0xce, 0x38, 0x80}
|
||||
mp4Video := NewMP4(tmpFile.Name(), [][]byte{sps}, [][]byte{pps}, nil, 60)
|
||||
mp4Video.SetWidth(1920)
|
||||
mp4Video.SetHeight(1080)
|
||||
v := mp4Video.AddVideoTrack("H264")
|
||||
|
||||
mk := func(k bool) []byte {
|
||||
nt := byte(0x01)
|
||||
if k {
|
||||
nt = 0x65
|
||||
}
|
||||
f := []byte{0, 0, 0, 1, nt}
|
||||
for i := 0; i < 200; i++ {
|
||||
f = append(f, byte(i))
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
const frameDur = uint64(33)
|
||||
pts := uint64(0)
|
||||
emitFrame := func(isKey bool) {
|
||||
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts, 0)
|
||||
pts += frameDur
|
||||
}
|
||||
// emitGOP emits a complete GOP of exactly frames frames: a leading keyframe
|
||||
// followed by frames-1 P-frames. Every GOP here is healthy and complete; only
|
||||
// its length varies, exactly as a smart-codec camera varies the GOP.
|
||||
emitGOP := func(frames int) {
|
||||
emitFrame(true)
|
||||
for i := 0; i < frames-1; i++ {
|
||||
emitFrame(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Normal cadence is 15 frames (~500ms). The camera then lengthens the GOP for
|
||||
// several static scenes (45 and 60 frames, ~1500ms and ~2000ms) before
|
||||
// dropping back to the normal 15-frame GOP on motion — the transition the old
|
||||
// heuristic wrongly treated as a seam. The whole sequence is then repeated to
|
||||
// cover multiple long->short transitions.
|
||||
gopLengths := []int{15, 15, 45, 15, 60, 15, 15, 45, 15, 15, 60, 15}
|
||||
totalEmittedFrames := 0
|
||||
emittedKeyframes := 0
|
||||
for _, n := range gopLengths {
|
||||
emitGOP(n)
|
||||
totalEmittedFrames += n
|
||||
emittedKeyframes++
|
||||
}
|
||||
|
||||
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
|
||||
|
||||
f, err := os.Open(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
parsed, err := mp4ff.DecodeFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
totalSamples := 0
|
||||
totalSync := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
totalSamples++
|
||||
// sample_depends_on == 2 => "does not depend on others" => IDR/sync.
|
||||
if (s.Flags>>24)&0x03 == 0x02 {
|
||||
totalSync++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every GOP is healthy, so nothing must be dropped: all keyframes and all
|
||||
// frames must survive. A shortfall means a normal variable-GOP keyframe was
|
||||
// misclassified as a seam.
|
||||
if totalSync != emittedKeyframes {
|
||||
t.Errorf("got %d keyframes in output, want %d - a healthy variable-GOP keyframe was wrongly dropped as a seam",
|
||||
totalSync, emittedKeyframes)
|
||||
}
|
||||
if totalSamples != totalEmittedFrames {
|
||||
t.Errorf("got %d video samples in output, want %d - a healthy variable-GOP GOP was wrongly dropped as a seam",
|
||||
totalSamples, totalEmittedFrames)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func NewAACTranscoder() (*AACTranscoder, error) {
|
||||
buffered := t.outBuf.Len()
|
||||
t.outMu.Unlock()
|
||||
if buffered <= 8192 || buffered%16000 == 0 {
|
||||
log.Log.Info("webrtc.aac_transcoder: ffmpeg produced PCMU bytes, buffered=" + strconv.Itoa(buffered))
|
||||
log.Log.Debug("webrtc.aac_transcoder: ffmpeg produced PCMU bytes, buffered=" + strconv.Itoa(buffered))
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
@@ -129,14 +129,14 @@ func (t *AACTranscoder) Transcode(adtsData []byte) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
if len(adtsData) <= 512 || len(adtsData)%1024 == 0 {
|
||||
log.Log.Info("webrtc.aac_transcoder: wrote AAC bytes to ffmpeg, input=" + strconv.Itoa(len(adtsData)))
|
||||
log.Log.Debug("webrtc.aac_transcoder: wrote AAC bytes to ffmpeg, input=" + strconv.Itoa(len(adtsData)))
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(75 * time.Millisecond)
|
||||
for {
|
||||
data := t.readAvailable()
|
||||
if len(data) > 0 {
|
||||
log.Log.Info("webrtc.aac_transcoder: returning PCMU bytes=" + strconv.Itoa(len(data)))
|
||||
log.Log.Debug("webrtc.aac_transcoder: returning PCMU bytes=" + strconv.Itoa(len(data)))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func (t *AACTranscoder) Transcode(adtsData []byte) ([]byte, error) {
|
||||
if stderr := t.stderrString(); stderr != "" {
|
||||
log.Log.Warning("webrtc.aac_transcoder: no output before deadline, ffmpeg stderr: " + stderr)
|
||||
} else {
|
||||
log.Log.Info("webrtc.aac_transcoder: no PCMU output before deadline")
|
||||
log.Log.Debug("webrtc.aac_transcoder: no PCMU output before deadline")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ import (
|
||||
|
||||
const (
|
||||
// Channel buffer sizes
|
||||
candidateChannelBuffer = 100
|
||||
// candidateChannelBuffer: large enough to absorb the burst of trickled ICE
|
||||
// candidates that can arrive over MQTT before the SetRemoteDescription
|
||||
// goroutine starts draining them. A small buffer caused candidates to be
|
||||
// dropped silently on restrictive networks, leaving ICE stuck in
|
||||
// "checking" until the viewer refreshed.
|
||||
candidateChannelBuffer = 512
|
||||
rtcpBufferSize = 1500
|
||||
|
||||
// Timeouts and intervals
|
||||
@@ -116,6 +121,22 @@ func (cm *ConnectionManager) RemovePeerConnection(sessionKey string) {
|
||||
}
|
||||
}
|
||||
|
||||
// CloseExistingPeerConnection closes and removes any peer connection currently
|
||||
// registered under sessionKey. Returns true if one was found. This is used to
|
||||
// reset state cleanly when a new request-hd-stream arrives for a session id
|
||||
// that the agent thinks is still active (for example after a viewer reload
|
||||
// where the previous PC hasn't yet been timed out by ICE).
|
||||
func (cm *ConnectionManager) CloseExistingPeerConnection(sessionKey string) bool {
|
||||
cm.mu.RLock()
|
||||
wrapper, exists := cm.peerConnections[sessionKey]
|
||||
cm.mu.RUnlock()
|
||||
if !exists || wrapper == nil {
|
||||
return false
|
||||
}
|
||||
cleanupPeerConnection(sessionKey, wrapper)
|
||||
return true
|
||||
}
|
||||
|
||||
// QueueCandidate safely queues a candidate for a session without racing with channel closure.
|
||||
func (cm *ConnectionManager) QueueCandidate(sessionKey string, candidate string) bool {
|
||||
cm.mu.Lock()
|
||||
@@ -341,6 +362,17 @@ func InitializeWebRTCConnection(configuration *models.Configuration, communicati
|
||||
|
||||
// We create a channel which will hold the candidates for this session.
|
||||
sessionKey := config.Key + "/" + handshakePayload.SessionID
|
||||
|
||||
// If a previous peer connection for this exact session is still hanging
|
||||
// around (e.g. a viewer reloaded before pion's ICE timeout fired) close it
|
||||
// first so we start from a clean slate. Without this, the new request would
|
||||
// race against a stale PC that still owns the per-peer broadcaster tracks.
|
||||
if globalConnectionManager.CloseExistingPeerConnection(sessionKey) {
|
||||
log.Log.Info("webrtc.main.InitializeWebRTCConnection(): closed stale peer connection for session " + handshakePayload.SessionID)
|
||||
}
|
||||
// Drain/reset the candidate channel too \u2014 leftover candidates from the
|
||||
// prior session are not valid for the new ICE agent.
|
||||
globalConnectionManager.CloseCandidateChannel(sessionKey)
|
||||
candidateChannel := globalConnectionManager.GetOrCreateCandidateChannel(sessionKey)
|
||||
|
||||
// Set variables
|
||||
@@ -956,7 +988,7 @@ func processAudioPacket(pkt packets.Packet, state *streamState, audioBroadcaster
|
||||
if len(pcmu) == 0 {
|
||||
state.aacNoOutput++
|
||||
if state.aacNoOutput <= 5 || state.aacNoOutput%100 == 0 {
|
||||
log.Log.Info(fmt.Sprintf("webrtc.main.processAudioPacket(): AAC packet produced no PCMU output yet (aac_packets=%d, no_output=%d, input_bytes=%d)", state.aacPacketsSeen, state.aacNoOutput, len(pkt.Data)))
|
||||
log.Log.Debug(fmt.Sprintf("webrtc.main.processAudioPacket(): AAC packet produced no PCMU output yet (aac_packets=%d, no_output=%d, input_bytes=%d)", state.aacPacketsSeen, state.aacNoOutput, len(pkt.Data)))
|
||||
}
|
||||
return // decoder still buffering
|
||||
}
|
||||
@@ -972,7 +1004,7 @@ func processAudioPacket(pkt packets.Packet, state *streamState, audioBroadcaster
|
||||
state.lastAudioSample.Duration = sampleDuration(pkt, state.lastAudioSample.PacketTimestamp, 20*time.Millisecond)
|
||||
state.audioSamplesSent++
|
||||
if state.audioSamplesSent <= 5 || state.audioSamplesSent%100 == 0 {
|
||||
log.Log.Info(fmt.Sprintf("webrtc.main.processAudioPacket(): queueing audio sample (samples=%d, codec=%s, bytes=%d, duration_ms=%d, peers=%d)", state.audioSamplesSent, pkt.Codec, len(state.lastAudioSample.Data), state.lastAudioSample.Duration.Milliseconds(), audioBroadcaster.PeerCount()))
|
||||
log.Log.Debug(fmt.Sprintf("webrtc.main.processAudioPacket(): queueing audio sample (samples=%d, codec=%s, bytes=%d, duration_ms=%d, peers=%d)", state.audioSamplesSent, pkt.Codec, len(state.lastAudioSample.Data), state.lastAudioSample.Duration.Milliseconds(), audioBroadcaster.PeerCount()))
|
||||
}
|
||||
audioBroadcaster.WriteSample(*state.lastAudioSample)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
#!/bin/bash
|
||||
swag init -g ./src/routers/http/Server.go
|
||||
swag init -g ./src/routers/http/server.go
|
||||
|
||||
23
machinery/verify-moq-devcontainer.sh
Normal file
23
machinery/verify-moq-devcontainer.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
required_glibc="2.38"
|
||||
current_glibc="$(getconf GNU_LIBC_VERSION | awk '{print $2}')"
|
||||
|
||||
if ! dpkg --compare-versions "$current_glibc" ge "$required_glibc"; then
|
||||
echo "MoQ requires glibc ${required_glibc}+; this container has ${current_glibc}." >&2
|
||||
echo "Rebuild the devcontainer with the Trixie base, then run this check again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Testing MoQ packages (glibc ${current_glibc})"
|
||||
GOWORK=off go test -tags moq ./src/cloud/livemoq ./src/cloud
|
||||
|
||||
binary="${TMPDIR:-/tmp}/agent-moq"
|
||||
trap 'rm -f "$binary"' EXIT
|
||||
|
||||
echo "==> Linking the MoQ Agent"
|
||||
GOWORK=off go build -tags moq -o "$binary" ./main.go
|
||||
|
||||
echo "==> Running the linked binary"
|
||||
"$binary" -action version
|
||||
Reference in New Issue
Block a user