Compare commits

..

1 Commits

Author SHA1 Message Date
Cedric Verstraeten
5457842f6e do not read if not needed 2025-01-20 15:03:38 +01:00
72 changed files with 1707 additions and 9607 deletions

View File

@@ -1,26 +1,2 @@
FROM mcr.microsoft.com/devcontainers/go:1.24-bookworm
# Install node environment
RUN apt-get update && \
apt-get install -y --no-install-recommends \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Install ffmpeg
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
libavcodec-extra \
libavutil-dev \
libavformat-dev \
libavfilter-dev \
libavdevice-dev \
libswscale-dev \
libswresample-dev \
&& rm -rf /var/lib/apt/lists/*
USER vscode
# Install go swagger
RUN go install github.com/swaggo/swag/cmd/swag@latest
FROM kerberos/devcontainer:5da0fe7
LABEL AUTHOR=Kerberos.io

View File

@@ -1,24 +1,21 @@
// 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",
"runArgs": [
"--name=agent",
"--network=host"
"name": "A Dockerfile containing FFmpeg, OpenCV, Go and Yarn",
"context": "..",
"dockerFile": "./Dockerfile",
"forwardPorts": [
3000,
80
],
"dockerFile": "Dockerfile",
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download",
"customizations": {
"vscode": {
"extensions": [
"GitHub.copilot",
"ms-kubernetes-tools.vscode-kubernetes-tools",
"ms-azuretools.vscode-docker",
"mongodb.mongodb-vscode"
"GitHub.copilot",
"golang.go",
"ms-vscode.vscode-typescript-next"
]
}
},
"forwardPorts": [
3000,
8080
],
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download"
}
}

58
.github/workflows/docker-dev.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Docker development build
on:
push:
branches: [develop]
jobs:
build-amd64:
runs-on: ubuntu-latest
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
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create -t kerberos/agent-dev:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
- name: Create new and append to latest manifest
run: docker buildx imagetools create -t kerberos/agent-dev:latest kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
build-other:
runs-on: ubuntu-latest
strategy:
matrix:
#architecture: [arm64, arm/v7, arm/v6]
architecture: [arm64, arm/v7]
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
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create --append -t kerberos/agent-dev:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
- name: Create new and append to manifest latest
run: docker buildx imagetools create --append -t kerberos/agent-dev:latest kerberos/agent-dev:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)

View File

@@ -1,14 +1,12 @@
name: Nightly build
name: Docker nightly build
on:
# Triggers the workflow every day at 9PM (CET).
schedule:
- cron: "0 22 * * *"
# Allows manual triggering from the Actions tab.
workflow_dispatch:
jobs:
nightly-build-amd64:
build-amd64:
runs-on: ubuntu-latest
strategy:
matrix:
@@ -20,9 +18,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
run: git clone https://github.com/kerberos-io/agent && cd agent
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
@@ -30,10 +26,10 @@ jobs:
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
run: cd agent && docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
nightly-build-other:
run: cd agent && docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
build-other:
runs-on: ubuntu-latest
strategy:
matrix:
@@ -45,9 +41,7 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Checkout
uses: actions/checkout@v4
with:
ref: master
run: git clone https://github.com/kerberos-io/agent && cd agent
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
@@ -55,6 +49,6 @@ jobs:
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
run: cd agent && docker buildx build --platform linux/${{matrix.architecture}} -t kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
- name: Create new and append to manifest
run: docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
run: cd agent && docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)

113
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
name: Docker release build
on:
release:
types: [created]
env:
REPO: kerberos/agent
jobs:
build-amd64:
runs-on: ubuntu-latest
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: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}} --push .
- name: Create new and append to manifest
run: docker buildx imagetools create -t $REPO:${{ github.ref_name }} $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}}
- name: Create new and append to manifest latest
run: docker buildx imagetools create -t $REPO:latest $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}}
- name: Run Buildx with output
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-$(echo ${{matrix.architecture}} | tr / -)-${{github.ref_name}} --output type=tar,dest=output-${{matrix.architecture}}.tar .
- name: Strip binary
run: mkdir -p output/ && tar -xf output-${{matrix.architecture}}.tar -C output && rm output-${{matrix.architecture}}.tar && cd output/ && tar -cf ../agent-${{matrix.architecture}}.tar -C home/agent . && rm -rf output
- name: Create a release
uses: ncipollo/release-action@v1
with:
latest: true
allowUpdates: true
name: ${{ github.ref_name }}
tag: ${{ github.ref_name }}
generateReleaseNotes: false
omitBodyDuringUpdate: true
artifacts: "agent-${{matrix.architecture}}.tar"
# Taken from GoReleaser's own release workflow.
# The available Snapcraft Action has some bugs described in the issue below.
# The mkdirs are a hack for https://github.com/goreleaser/goreleaser/issues/1715.
#- name: Setup Snapcraft
# run: |
# sudo apt-get update
# sudo apt-get -yq --no-install-suggests --no-install-recommends install snapcraft
# mkdir -p $HOME/.cache/snapcraft/download
# mkdir -p $HOME/.cache/snapcraft/stage-packages
#- name: Use Snapcraft
# run: tar -xf agent-${{matrix.architecture}}.tar && snapcraft
build-other:
runs-on: ubuntu-latest
permissions:
contents: write
needs: build-amd64
strategy:
matrix:
architecture: [arm64, arm-v7, arm-v6]
#architecture: [arm64, arm-v7]
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: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Run Buildx
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}} --push .
- name: Create new and append to manifest
run: docker buildx imagetools create --append -t $REPO:${{ github.ref_name }} $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}}
- name: Create new and append to manifest latest
run: docker buildx imagetools create --append -t $REPO:latest $REPO-arch:arch-${{matrix.architecture}}-${{github.ref_name}}
- name: Run Buildx with output
run: docker buildx build --platform linux/$(echo ${{matrix.architecture}} | tr - /) -t $REPO-arch:arch-$(echo ${{matrix.architecture}} | tr / -)-${{github.ref_name}} --output type=tar,dest=output-${{matrix.architecture}}.tar .
- name: Strip binary
run: mkdir -p output/ && tar -xf output-${{matrix.architecture}}.tar -C output && rm output-${{matrix.architecture}}.tar && cd output/ && tar -cf ../agent-${{matrix.architecture}}.tar -C home/agent . && rm -rf output
- name: Create a release
uses: ncipollo/release-action@v1
with:
latest: true
allowUpdates: true
name: ${{ github.ref_name }}
tag: ${{ github.ref_name }}
generateReleaseNotes: false
omitBodyDuringUpdate: true
artifacts: "agent-${{matrix.architecture}}.tar"

View File

@@ -17,7 +17,7 @@ jobs:
matrix:
#No longer supported Go versions.
#go-version: ['1.17', '1.18', '1.19', '1.20', '1.21']
go-version: ["1.24"]
go-version: ["1.22"]
steps:
- name: Set up Go ${{ matrix.go-version }}

View File

@@ -1,51 +0,0 @@
name: Create User Story Issue
on:
workflow_dispatch:
inputs:
issue_title:
description: 'Title for the issue'
required: true
issue_description:
description: 'Brief description of the feature'
required: true
complexity:
description: 'Complexity of the feature'
required: true
type: choice
options:
- 'Low'
- 'Medium'
- 'High'
default: 'Medium'
duration:
description: 'Estimated duration'
required: true
type: choice
options:
- '1 day'
- '3 days'
- '1 week'
- '2 weeks'
- '1 month'
default: '1 week'
jobs:
create-issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Create Issue with User Story
uses: cedricve/llm-create-issue-user-story@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
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 }}
issue_title: ${{ github.event.inputs.issue_title }}
issue_description: ${{ github.event.inputs.issue_description }}
complexity: ${{ github.event.inputs.complexity }}
duration: ${{ github.event.inputs.duration }}
labels: 'user-story,feature'
assignees: ${{ github.actor }}

View File

@@ -1,48 +0,0 @@
name: Build pull request
on:
pull_request:
types: [opened, synchronize]
env:
REPO: kerberos/agent
jobs:
build:
runs-on: ${{ matrix.runner }}
permissions:
contents: write
strategy:
matrix:
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
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- uses: benjlevesque/short-sha@v2.1
id: short-sha
with:
length: 7
- name: Run Build
run: |
docker build -t ${{ matrix.architecture }} -f ${{ matrix.dockerfile }} .
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: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar

View File

@@ -4,14 +4,16 @@ on: pull_request
jobs:
openai-pr-description:
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 }}
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 }}
overwrite_description: true

View File

@@ -1,168 +0,0 @@
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}} -f Dockerfile.arm64 .
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"

View File

@@ -1,130 +0,0 @@
name: Create a new release
on:
release:
types: [created]
workflow_dispatch:
inputs:
tag:
description: "Tag for the Docker image"
required: true
default: "test"
env:
REPO: kerberos/agent
jobs:
build-amd64:
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=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID}
- name: Strip binary
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}}-${{github.event.inputs.tag || github.ref_name}}
docker push $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: agent-${{matrix.architecture}}.tar
path: agent-${{matrix.architecture}}.tar
build-arm64:
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=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} -f Dockerfile.arm64 .
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}}-${{github.event.inputs.tag || github.ref_name}}
docker push $REPO-arch:arch-${{matrix.architecture}}-${{github.event.inputs.tag || github.ref_name}}
- 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: [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:${{ github.event.inputs.tag || github.ref_name }} \
$REPO-arch:arch-amd64-${{github.event.inputs.tag || github.ref_name}} \
$REPO-arch:arch-arm64-${{github.event.inputs.tag || github.ref_name}}
docker manifest push $REPO:${{ github.event.inputs.tag || github.ref_name }}
- name: Create and push latest manifest
run: |
docker manifest create $REPO:latest \
$REPO-arch:arch-amd64-${{github.event.inputs.tag || github.ref_name}} \
$REPO-arch:arch-arm64-${{github.event.inputs.tag || github.ref_name}}
docker manifest push $REPO:latest
if: github.event.inputs.tag == 'test'
create-release:
runs-on: ubuntu-24.04
needs: [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: ${{ github.event.inputs.tag || github.ref_name }}
tag: ${{ github.event.inputs.tag || github.ref_name }}
generateReleaseNotes: false
omitBodyDuringUpdate: true
artifacts: "agent-*.tar/agent-*.tar"

7
.gitignore vendored
View File

@@ -1,8 +1,6 @@
ui/node_modules
ui/build
ui/public/assets/env.js
.DS_Store
__debug*
.idea
machinery/www
yarn.lock
@@ -12,7 +10,6 @@ machinery/data/recordings
machinery/data/snapshots
machinery/test*
machinery/init-dev.sh
machinery/.env.local
machinery/.env
machinery/vendor
deployments/docker/private-docker-compose.yaml
video.mp4
deployments/docker/private-docker-compose.yaml

19
.travis.yml Normal file
View File

@@ -0,0 +1,19 @@
language: go
go:
- 1.12.x
- 1.13.x
- 1.14.x
- 1.15.x
- tip
before_install:
- cd machinery
- go mod download
script:
- go vet
- go test -race -coverprofile=coverage.txt -covermode=atomic
after_success:
- bash <(curl -s https://codecov.io/bash)

6
.vscode/launch.json vendored
View File

@@ -12,11 +12,9 @@
"program": "${workspaceFolder}/machinery/main.go",
"args": [
"-action",
"run",
"-port",
"8080"
"run"
],
"envFile": "${workspaceFolder}/machinery/.env.local",
"envFile": "${workspaceFolder}/machinery/.env",
"buildFlags": "--tags dynamic",
},
{

View File

@@ -1,13 +1,6 @@
ARG BASE_IMAGE_VERSION=amd64-ddbe40e
ARG VERSION=0.0.0
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
LABEL AUTHOR=uug.ai
# Re-declare VERSION inside this stage so the value passed via
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
# ARGs declared before the first FROM are not visible inside build stages.
ARG VERSION
FROM kerberos/base:5d5e86b AS build-machinery
LABEL AUTHOR=Kerberos.io
ENV GOROOT=/usr/local/go
ENV GOPATH=/go
@@ -40,10 +33,7 @@ RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
go mod download && \
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
fi && \
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
go build -tags timetzdata,netgo,osusergo --ldflags '-s -w -extldflags "-static -latomic"' main.go && \
mkdir -p /agent && \
mv main /agent && \
mv version /agent && \
@@ -53,7 +43,8 @@ RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
mkdir -p /agent/data/log && \
mkdir -p /agent/data/recordings && \
mkdir -p /agent/data/capture-test && \
mkdir -p /agent/data/config
mkdir -p /agent/data/config && \
rm -rf /go/src/gitlab.com/
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
@@ -67,7 +58,19 @@ RUN cp -r /agent ./
RUN /dist/agent/main version
FROM node:22-alpine AS build-ui
###############################################
# Build Bento4 -> we want fragmented mp4 files
ENV BENTO4_VERSION 1.6.0-639
RUN cd /tmp && git clone https://github.com/axiomatic-systems/Bento4 && cd Bento4 && \
git checkout tags/v${BENTO4_VERSION} && \
cd Build && \
cmake -DCMAKE_BUILD_TYPE=Release .. && \
make && \
mv /tmp/Bento4/Build/mp4fragment /dist/agent/ && \
rm -rf /tmp/Bento4
FROM node:18.14.0-alpine3.16 AS build-ui
RUN apk update && apk upgrade --available && sync
@@ -102,12 +105,13 @@ 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/*
RUN apk update && apk add ca-certificates curl libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
##################
# Try running agent
RUN mv /agent/* /home/agent/
RUN cp /home/agent/mp4fragment /usr/local/bin/
RUN /home/agent/main version
#######################
@@ -144,4 +148,4 @@ 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"]
CMD ["./main", "-action", "run", "-port", "80"]

View File

@@ -1,147 +0,0 @@
ARG BASE_IMAGE_VERSION=arm64-ddbe40e
ARG VERSION=0.0.0
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery
LABEL AUTHOR=uug.ai
# Re-declare VERSION inside this stage so the value passed via
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
# ARGs declared before the first FROM are not visible inside build stages.
ARG VERSION
ENV GOROOT=/usr/local/go
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:$GOROOT/bin:/usr/local/lib:$PATH
ENV GOSUMDB=off
##########################################
# Installing some additional dependencies.
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \
git build-essential cmake pkg-config unzip libgtk2.0-dev \
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \
rm -rf /var/lib/apt/lists/*
##############################################################################
# Copy all the relevant source code in the Docker image, so we can build this.
RUN mkdir -p /go/src/github.com/kerberos-io/agent
COPY machinery /go/src/github.com/kerberos-io/agent/machinery
RUN rm -rf /go/src/github.com/kerberos-io/agent/machinery/.env
##################################################################
# Get the latest commit hash, so we know which version we're running
COPY .git /go/src/github.com/kerberos-io/agent/.git
RUN cd /go/src/github.com/kerberos-io/agent/.git && git log --format="%H" -n 1 | head -c7 > /go/src/github.com/kerberos-io/agent/machinery/version
RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
##################
# Build Machinery
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
go mod download && \
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
fi && \
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \
mkdir -p /agent && \
mv main /agent && \
mv version /agent && \
mv data /agent && \
mkdir -p /agent/data/cloud && \
mkdir -p /agent/data/snapshots && \
mkdir -p /agent/data/log && \
mkdir -p /agent/data/recordings && \
mkdir -p /agent/data/capture-test && \
mkdir -p /agent/data/config
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
# Later, it will be copied as the / (root) of the output image.
WORKDIR /dist
RUN cp -r /agent ./
####################################################################################
# This will collect dependent libraries so they're later copied to the final image.
RUN /dist/agent/main version
FROM node:22-alpine AS build-ui
RUN apk update && apk upgrade --available && sync
########################
# Build Web (React app)
RUN mkdir -p /go/src/github.com/kerberos-io/agent/machinery/www
COPY ui /go/src/github.com/kerberos-io/agent/ui
RUN cd /go/src/github.com/kerberos-io/agent/ui && rm -rf yarn.lock && yarn config set network-timeout 300000 && \
yarn && yarn build
####################################
# Let's create a /dist folder containing just the files necessary for runtime.
# Later, it will be copied as the / (root) of the output image.
WORKDIR /dist
RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www ./agent/
############################################
# Publish main binary to GitHub release
FROM alpine:latest
############################
# Protect by non-root user.
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
#################################
# Copy files from previous images
COPY --chown=0:0 --from=build-machinery /dist /
COPY --chown=0:0 --from=build-ui /dist /
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
##################
# Try running agent
RUN mv /agent/* /home/agent/
RUN /home/agent/main version
#######################
# Make template config
RUN cp /home/agent/data/config/config.json /home/agent/data/config.template.json
###########################
# Set permissions correctly
RUN chown -R agent:kerberosio /home/agent/data
RUN chown -R agent:kerberosio /home/agent/www
###########################
# Grant the necessary root capabilities to the process trying to bind to the privileged port
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main
###################
# Run non-root user
USER agent
######################################
# By default the app runs on port 80
EXPOSE 80
######################################
# Check if agent is still running
HEALTHCHECK CMD curl --fail http://localhost:80 || exit 1
###################################################
# Leeeeettttt'ssss goooooo!!!
# Run the shizzle from the right working directory.
WORKDIR /home/agent
CMD ["./main", "-action", "run", "-port", "80"]

141
README.md
View File

@@ -65,7 +65,6 @@ There are a myriad of cameras out there (USB, IP and other cameras), and it migh
### Contributing
1. [Security vulnerability reporting](#security-vulnerability-reporting)
1. [Contribute with Codespaces](#contribute-with-codespaces)
2. [Develop and build](#develop-and-build)
3. [Building from source](#building-from-source)
@@ -191,78 +190,68 @@ Next to attaching the configuration file, it is also possible to override the co
| Name | Description | Default Value |
| --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
| `LOG_OUTPUT` | Logging output format "json" or "text". | "text" |
| `AGENT_MODE` | You can choose to run this in 'release' for production, and or 'demo' for showcasing. | "release" |
| `AGENT_TLS_INSECURE` | Specify if you want to use `InsecureSkipVerify` for the internal HTTP client. | "false" |
| `AGENT_USERNAME` | The username used to authenticate against the Kerberos Agent login page. | "root" |
| `AGENT_PASSWORD` | The password used to authenticate against the Kerberos Agent login page. | "root" |
| `AGENT_KEY` | A unique identifier for your Kerberos Agent, this is auto-generated but can be overriden. | "" |
| `AGENT_NAME` | The agent friendly-name. | "agent" |
| `AGENT_TIMEZONE` | Timezone which is used for converting time. | "Africa/Ceuta" |
| `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_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_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. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR` | ONVIF endpoint/address running on the camera. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME` | ONVIF username to authenticate against. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD` | ONVIF password to authenticate against. | "" |
| `AGENT_CAPTURE_MOTION` | Toggle for enabling or disabling motion. | "true" |
| `AGENT_CAPTURE_LIVEVIEW` | Toggle for enabling or disabling liveview. | "true" |
| `AGENT_CAPTURE_SNAPSHOTS` | Toggle for enabling or disabling snapshot generation. | "true" |
| `AGENT_CAPTURE_RECORDING` | Toggle for enabling making recordings. | "true" |
| `AGENT_CAPTURE_CONTINUOUS` | Toggle for enabling continuous "true" or motion "false". | "false" |
| `AGENT_CAPTURE_PRERECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) before/after motion event. | "10" |
| `AGENT_CAPTURE_POSTRECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) after motion event. | "20" |
| `AGENT_CAPTURE_MAXLENGTH` | The maximum length of a single recording (seconds). | "30" |
| `AGENT_CAPTURE_PIXEL_CHANGE` | If `CONTINUOUS` set to `false`, the number of pixel require to change before motion triggers. | "150" |
| `AGENT_CAPTURE_FRAGMENTED` | Set the format of the recorded MP4 to fragmented (suitable for HLS). | "false" |
| `AGENT_CAPTURE_FRAGMENTED_DURATION` | If `AGENT_CAPTURE_FRAGMENTED` set to `true`, define the duration (seconds) of a fragment. | "8" |
| `AGENT_MQTT_URI` | An MQTT broker endpoint that is used for bi-directional communication (live view, onvif, etc) | "tcp://mqtt.kerberos.io:1883" |
| `AGENT_MQTT_USERNAME` | Username of the MQTT broker. | "" |
| `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_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_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" |
| `AGENT_HUB_ENCRYPTION` | Turning on/off encryption of traffic from your Kerberos Agent to Kerberos Hub. | "true" |
| `AGENT_HUB_URI` | The Kerberos Hub API, defaults to our Kerberos Hub SAAS. | "https://api.hub.domain.com" |
| `AGENT_HUB_KEY` | The access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_PRIVATE_KEY` | The secret access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_REGION` | The Kerberos Hub region, to which you want to upload. | "" |
| `AGENT_HUB_SITE` | The site ID of a site you've created in your Kerberos Hub account. | "" |
| `AGENT_KERBEROSVAULT_URI` | The Kerberos Vault API url. | "https://vault.domain.com/api" |
| `AGENT_KERBEROSVAULT_ACCESS_KEY` | The access key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECRET_KEY` | The secret key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_PROVIDER` | A Kerberos Vault provider you have created (optional). | "" |
| `AGENT_KERBEROSVAULT_DIRECTORY` | The directory, in the Kerberos vault, where the recordings will be stored. | "" |
| `AGENT_KERBEROSVAULT_SECONDARY_URI` | The Kerberos Vault API url. | "https://vault.domain.com/api" |
| `AGENT_KERBEROSVAULT_SECONDARY_ACCESS_KEY` | The access key of a secondary Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECONDARY_SECRET_KEY` | The secret key of a secondary Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECONDARY_PROVIDER` | A secondary Kerberos Vault provider you have created (optional). | "" |
| `AGENT_KERBEROSVAULT_SECONDARY_DIRECTORY` | The directory, in the secondary Kerberos vault, where the recordings will be stored. | "" |
| `AGENT_DROPBOX_ACCESS_TOKEN` | The Access Token from your Dropbox app, that is used to leverage the Dropbox SDK. | "" |
| `AGENT_DROPBOX_DIRECTORY` | The directory, in Dropbox, where the recordings will be stored. | "" |
| `AGENT_ENCRYPTION` | Enable 'true' or disable 'false' end-to-end encryption for MQTT messages. | "false" |
| `AGENT_ENCRYPTION_RECORDINGS` | Enable 'true' or disable 'false' end-to-end encryption for recordings. | "false" |
| `AGENT_ENCRYPTION_FINGERPRINT` | The fingerprint of the keypair (public/private keys), so you know which one to use. | "" |
| `AGENT_ENCRYPTION_PRIVATE_KEY` | The private key (assymetric/RSA) to decrypt and sign requests send over MQTT. | "" |
| `AGENT_ENCRYPTION_SYMMETRIC_KEY` | The symmetric key (AES) to encrypt and decrypt requests sent over MQTT. | "" |
| `AGENT_SIGNING` | Enable 'true' or disable 'false' for signing recordings. | "true" |
| `AGENT_SIGNING_PRIVATE_KEY` | The private key (RSA) to sign the recordings fingerprint to validate origin. | "" - uses default one if empty |
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
| `LOG_OUTPUT` | Logging output format "json" or "text". | "text" |
| `AGENT_MODE` | You can choose to run this in 'release' for production, and or 'demo' for showcasing. | "release" |
| `AGENT_TLS_INSECURE` | Specify if you want to use `InsecureSkipVerify` for the internal HTTP client. | "false" |
| `AGENT_USERNAME` | The username used to authenticate against the Kerberos Agent login page. | "root" |
| `AGENT_PASSWORD` | The password used to authenticate against the Kerberos Agent login page. | "root" |
| `AGENT_KEY` | A unique identifier for your Kerberos Agent, this is auto-generated but can be overriden. | "" |
| `AGENT_NAME` | The agent friendly-name. | "agent" |
| `AGENT_TIMEZONE` | Timezone which is used for converting time. | "Africa/Ceuta" |
| `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_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_ONVIF` | Mark as a compliant ONVIF device. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR` | ONVIF endpoint/address running on the camera. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME` | ONVIF username to authenticate against. | "" |
| `AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD` | ONVIF password to authenticate against. | "" |
| `AGENT_CAPTURE_MOTION` | Toggle for enabling or disabling motion. | "true" |
| `AGENT_CAPTURE_LIVEVIEW` | Toggle for enabling or disabling liveview. | "true" |
| `AGENT_CAPTURE_SNAPSHOTS` | Toggle for enabling or disabling snapshot generation. | "true" |
| `AGENT_CAPTURE_RECORDING` | Toggle for enabling making recordings. | "true" |
| `AGENT_CAPTURE_CONTINUOUS` | Toggle for enabling continuous "true" or motion "false". | "false" |
| `AGENT_CAPTURE_PRERECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) before/after motion event. | "10" |
| `AGENT_CAPTURE_POSTRECORDING` | If `CONTINUOUS` set to `false`, specify the recording time (seconds) after motion event. | "20" |
| `AGENT_CAPTURE_MAXLENGTH` | The maximum length of a single recording (seconds). | "30" |
| `AGENT_CAPTURE_PIXEL_CHANGE` | If `CONTINUOUS` set to `false`, the number of pixel require to change before motion triggers. | "150" |
| `AGENT_CAPTURE_FRAGMENTED` | Set the format of the recorded MP4 to fragmented (suitable for HLS). | "false" |
| `AGENT_CAPTURE_FRAGMENTED_DURATION` | If `AGENT_CAPTURE_FRAGMENTED` set to `true`, define the duration (seconds) of a fragment. | "8" |
| `AGENT_MQTT_URI` | An MQTT broker endpoint that is used for bi-directional communication (live view, onvif, etc) | "tcp://mqtt.kerberos.io:1883" |
| `AGENT_MQTT_USERNAME` | Username of the MQTT broker. | "" |
| `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_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_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" |
| `AGENT_HUB_ENCRYPTION` | Turning on/off encryption of traffic from your Kerberos Agent to Kerberos Hub. | "true" |
| `AGENT_HUB_URI` | The Kerberos Hub API, defaults to our Kerberos Hub SAAS. | "https://api.hub.domain.com" |
| `AGENT_HUB_KEY` | The access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_PRIVATE_KEY` | The secret access key linked to your account in Kerberos Hub. | "" |
| `AGENT_HUB_REGION` | The Kerberos Hub region, to which you want to upload. | "" |
| `AGENT_HUB_SITE` | The site ID of a site you've created in your Kerberos Hub account. | "" |
| `AGENT_KERBEROSVAULT_URI` | The Kerberos Vault API url. | "https://vault.domain.com/api" |
| `AGENT_KERBEROSVAULT_ACCESS_KEY` | The access key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_SECRET_KEY` | The secret key of a Kerberos Vault account. | "" |
| `AGENT_KERBEROSVAULT_PROVIDER` | A Kerberos Vault provider you have created (optional). | "" |
| `AGENT_KERBEROSVAULT_DIRECTORY` | The directory, in the Kerberos vault, where the recordings will be stored. | "" |
| `AGENT_DROPBOX_ACCESS_TOKEN` | The Access Token from your Dropbox app, that is used to leverage the Dropbox SDK. | "" |
| `AGENT_DROPBOX_DIRECTORY` | The directory, in Dropbox, where the recordings will be stored. | "" |
| `AGENT_ENCRYPTION` | Enable 'true' or disable 'false' end-to-end encryption for MQTT messages. | "false" |
| `AGENT_ENCRYPTION_RECORDINGS` | Enable 'true' or disable 'false' end-to-end encryption for recordings. | "false" |
| `AGENT_ENCRYPTION_FINGERPRINT` | The fingerprint of the keypair (public/private keys), so you know which one to use. | "" |
| `AGENT_ENCRYPTION_PRIVATE_KEY` | The private key (assymetric/RSA) to decrypt and sign requests send over MQTT. | "" |
| `AGENT_ENCRYPTION_SYMMETRIC_KEY` | The symmetric key (AES) to encrypt and decrypt requests sent over MQTT. | "" |
## Encryption
@@ -302,10 +291,6 @@ If we talk about video encoders and decoders (codecs) there are 2 major video co
Conclusion: depending on the use case you might choose one over the other, and you can use both at the same time. For example you can use H264 (main stream) for livestreaming, and H265 (sub stream) for recording. If you wish to play recordings in a cross-platform and cross-browser environment, you might opt for H264 for better support.
## Security vulnerability reporting
If you found a potential security vulnerability, please use the private channels described in [SECURITY.md](SECURITY.md). Avoid opening public GitHub issues for sensitive findings.
## Contribute with Codespaces
One of the major blockers for letting you contribute to an Open Source project is to set up your local development machine. Why? Because you might already have some tools and libraries installed that are used for other projects, and the libraries you would need for Kerberos Agent, for example FFmpeg, might require a different version. Welcome to dependency hell...
@@ -428,7 +413,7 @@ Read more about this [at the FAQ](#faq) below.
## Contributors
This project exists thanks to all the people who contribute. Bravo!
This project exists thanks to all the people who contribute.
<a href="https://github.com/kerberos-io/agent/graphs/contributors">
<img src="https://contrib.rocks/image?repo=kerberos-io/agent" />

View File

@@ -1,40 +0,0 @@
# Security Policy
## Supported Versions
We only provide security fixes for the latest release series on the `master` branch.
## Reporting a Vulnerability
Please do **not** open a public GitHub issue for potential security vulnerabilities.
Use one of the private channels below:
1. Preferred: GitHub private vulnerability reporting
- https://github.com/kerberos-io/agent/security/advisories/new
2. Fallback: Email
- support@kerberos.io
- Optional CC: support@uug.ai
Please include:
- A short summary and impact.
- Reproduction steps or proof of concept.
- Affected version(s), commit hash, or deployment details.
- Any proposed mitigation/workaround.
- Your preferred attribution name.
For faster triage, use this subject format in email:
`[Security][Kerberos Agent] <short title>`
## Response Expectations
- Acknowledgement target: within 3 business days.
- Triage/update target: within 7 business days after acknowledgement.
If you do not receive a response in time, please resend your report and include your original timestamp.
## Disclosure and Credits
We follow coordinated disclosure. After a fix is available, we will credit reporters unless they prefer to stay anonymous.

10
build.sh Normal file
View File

@@ -0,0 +1,10 @@
export version=0.0.1
export name=agent
docker build -t $name .
docker tag $name kerberos/$name:$version
docker push kerberos/$name:$version
docker tag $name kerberos/$name:latest
docker push kerberos/$name:latest

View File

@@ -41,7 +41,7 @@ You attach a volume to your container by leveraging the `-v` option. To mount yo
### Override with environment variables
Next to attaching the configuration file, it is also possible to override the configuration with environment variables. This makes deployments when leveraging `docker compose` or `kubernetes` much easier and more scalable. Using this approach we simplify automation through `ansible` and `terraform`. You'll find [the full list of environment variables on the main README.md file](https://github.com/kerberos-io/agent#override-with-environment-variables).
Next to attaching the configuration file, it is also possible to override the configuration with environment variables. This makes deployments easier when leveraging `docker compose` or `kubernetes` deployments much easier and scalable. Using this approach we simplify automation through `ansible` and `terraform`. You'll find [the full list of environment variables on the main README.md file](https://github.com/kerberos-io/agent#override-with-environment-variables).
### 2. Running multiple containers with Docker compose

BIN
machinery/.DS_Store vendored

Binary file not shown.

View File

@@ -1,31 +0,0 @@
AGENT_NAME=camera-name
AGENT_KEY=uniq-camera-id
AGENT_TIMEZONE=Europe/Brussels
#AGENT_CAPTURE_CONTINUOUS=true
#AGENT_CAPTURE_IPCAMERA_RTSP=rtsp://fake.kerberos.io/stream
#AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsp://fake.kerberos.io/stream
AGENT_CAPTURE_IPCAMERA_ONVIF_XADDR=x.x.x.x
AGENT_CAPTURE_IPCAMERA_ONVIF_USERNAME=xxx
AGENT_CAPTURE_IPCAMERA_ONVIF_PASSWORD=xxx
AGENT_HUB_URI=https://api.cloud.kerberos.io
AGENT_HUB_KEY=AKIXxxx4JBEI
AGENT_HUB_PRIVATE_KEY=DIOXxxxAlYpaxxxxXioL0txxx
AGENT_HUB_SITE=681xxxxxxx9bcda5
# By default will send to Hub (=S3), if you wish to send to Kerberos Vault, set to "kstorage"
AGENT_CLOUD=s3
AGENT_KERBEROSVAULT_URI=
AGENT_KERBEROSVAULT_PROVIDER=
AGENT_KERBEROSVAULT_DIRECTORY=
AGENT_KERBEROSVAULT_ACCESS_KEY=
AGENT_KERBEROSVAULT_SECRET_KEY=
AGENT_KERBEROSVAULT_MAX_RETRIES=10
AGENT_KERBEROSVAULT_TIMEOUT=120
AGENT_KERBEROSVAULT_SECONDARY_URI=
AGENT_KERBEROSVAULT_SECONDARY_PROVIDER=
AGENT_KERBEROSVAULT_SECONDARY_DIRECTORY=
AGENT_KERBEROSVAULT_SECONDARY_ACCESS_KEY=
AGENT_KERBEROSVAULT_SECONDARY_SECRET_KEY=
# Open telemetry tracing endpoint
OTEL_EXPORTER_OTLP_ENDPOINT=

View File

@@ -22,8 +22,4 @@ https://brianmacdonald.github.io/Ethonate/address#0xf4a759C9436E2280Ea9cdd23d314
[**Docker Hub**](https://hub.docker.com/r/kerberos/agent) | [**Documentation**](https://doc.kerberos.io) | [**Website**](https://kerberos.io)
Kerberos Open source (v3) is a cutting edge video surveillance management system made available as Open Source under the MIT License. This means that all the source code is available for you or your company, and you can use, transform and distribute the source code; as long you keep a reference of the original license. Kerberos Open Source (v3) can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
## Security reporting
For sensitive vulnerabilities, use private disclosure channels documented in [../SECURITY.md](../SECURITY.md).
Kerberos Open source (v3) is a cutting edge video surveillance management system made available as Open Source under the MIT License. This means that all the source code is available for you or your company, and you can use, transform and distribute the source code; as long you keep a reference of the original license. Kerberos Open Source (v3) can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).

View File

@@ -14,9 +14,7 @@
"ipcamera": {
"rtsp": "",
"sub_rtsp": "",
"fps": "",
"base_width": 640,
"base_height": 0
"fps": ""
},
"usbcamera": {
"device": ""
@@ -28,7 +26,6 @@
"recording": "true",
"snapshots": "true",
"liveview": "true",
"liveview_chunking": "false",
"motion": "true",
"postrecording": 20,
"prerecording": 10,
@@ -101,7 +98,6 @@
"region": "eu-west-1"
},
"kstorage": {},
"kstorage_secondary": {},
"dropbox": {},
"mqtturi": "tcp://mqtt.kerberos.io:1883",
"mqtt_username": "",
@@ -119,7 +115,6 @@
"hub_site": "",
"condition_uri": "",
"encryption": {},
"signing": {},
"realtimeprocessing": "false",
"realtimeprocessing_topic": ""
}

View File

@@ -1,115 +1,147 @@
module github.com/kerberos-io/agent/machinery
go 1.24.2
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237
go 1.23.1
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/mediacommon v1.14.0
github.com/appleboy/gin-jwt/v2 v2.10.1
github.com/bluenviron/gortsplib/v4 v4.12.1
github.com/bluenviron/mediacommon v1.13.3
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6
github.com/dromara/carbon/v2 v2.6.8
github.com/dromara/carbon/v2 v2.5.2
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/elastic/go-sysinfo v1.15.3
github.com/gin-contrib/cors v1.7.5
github.com/gin-contrib/pprof v1.5.3
github.com/gin-gonic/contrib v0.0.0-20250521004450-2b1292699c15
github.com/gin-gonic/gin v1.10.1
github.com/elastic/go-sysinfo v1.15.0
github.com/gin-contrib/cors v1.7.3
github.com/gin-contrib/pprof v1.5.2
github.com/gin-gonic/contrib v0.0.0-20241229022435-d12709533de6
github.com/gin-gonic/gin v1.10.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/golang-jwt/jwt/v4 v4.5.1
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/minio/minio-go/v6 v6.0.57
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/rtp v1.8.10
github.com/pion/webrtc/v4 v4.0.7
github.com/sirupsen/logrus v1.9.3
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.4
github.com/tevino/abool v1.2.0
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
github.com/zaf/g711 v1.4.0
go.mongodb.org/mongo-driver v1.17.3
go.opentelemetry.io/otel v1.36.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/trace v1.36.0
go.mongodb.org/mongo-driver v1.17.1
gopkg.in/DataDog/dd-trace-go.v1 v1.70.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/DataDog/appsec-internal-go v1.9.0 // indirect
github.com/DataDog/datadog-agent/pkg/obfuscate v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/proto v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/trace v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/util/log v0.58.0 // indirect
github.com/DataDog/datadog-agent/pkg/util/scrubber v0.58.0 // indirect
github.com/DataDog/datadog-go/v5 v5.5.0 // indirect
github.com/DataDog/go-libddwaf/v3 v3.5.1 // indirect
github.com/DataDog/go-runtime-metrics-internal v0.0.0-20241106155157-194426bbbd59 // indirect
github.com/DataDog/go-sqllexer v0.0.14 // indirect
github.com/DataDog/go-tuf v1.1.0-0.5.2 // indirect
github.com/DataDog/gostackparse v0.7.0 // indirect
github.com/DataDog/opentelemetry-mapping-go/pkg/otlp/attributes v0.20.0 // indirect
github.com/DataDog/sketches-go v1.4.5 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
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/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/bytedance/sonic v1.12.6 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // 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/elastic/go-windows v1.0.2 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect
github.com/ebitengine/purego v0.6.0-alpha.5 // indirect
github.com/elastic/go-windows v1.0.0 // indirect
github.com/elgs/gostrgen v0.0.0-20161222160715-9d61ae07eeae // indirect
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.2 // 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
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/compress v1.17.1 // indirect
github.com/klauspost/cpuid v1.2.3 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kylelemons/go-gypsy v1.0.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/lib/pq v1.10.2 // indirect
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.0 // indirect
github.com/minio/sha256-simd v0.1.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
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/nxadm/tail v1.4.11 // indirect
github.com/outcaste-io/ristretto v0.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/philhofer/fwd v1.1.3-0.20240612014219-fbbf4953d986 // 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/dtls/v3 v3.0.4 // indirect
github.com/pion/ice/v4 v4.0.3 // indirect
github.com/pion/interceptor v0.1.37 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns/v2 v2.0.7 // 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/sctp v1.8.35 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // 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/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/richardartoul/molecule v1.0.1-0.20240531184615-7ca0df43c0b3 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.7.0 // indirect
github.com/shirou/gopsutil/v3 v3.24.4 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tinylib/msgp v1.2.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
@@ -117,23 +149,34 @@ require (
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/ziutek/mymysql v1.5.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/proto/otlp v1.6.0 // indirect
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/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
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
google.golang.org/grpc v1.72.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.42.0 // indirect
go.opentelemetry.io/collector/component v0.104.0 // indirect
go.opentelemetry.io/collector/config/configtelemetry v0.104.0 // indirect
go.opentelemetry.io/collector/pdata v1.11.0 // indirect
go.opentelemetry.io/collector/semconv v0.104.0 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/mod v0.20.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.18.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.24.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 // indirect
google.golang.org/grpc v1.64.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@ package main
import (
"context"
"flag"
"fmt"
"os"
"time"
@@ -12,61 +11,47 @@ import (
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/onvif"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
configService "github.com/kerberos-io/agent/machinery/src/config"
"github.com/kerberos-io/agent/machinery/src/routers"
"github.com/kerberos-io/agent/machinery/src/utils"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
"gopkg.in/DataDog/dd-trace-go.v1/profiler"
)
var VERSION = utils.VERSION
func startTracing(agentKey string, otelEndpoint string) (*trace.TracerProvider, error) {
serviceName := "agent-" + agentKey
headers := map[string]string{
"content-type": "application/json",
}
exporter, err := otlptrace.New(
context.Background(),
otlptracehttp.NewClient(
otlptracehttp.WithEndpoint(otelEndpoint),
otlptracehttp.WithHeaders(headers),
otlptracehttp.WithInsecure(),
),
)
if err != nil {
return nil, fmt.Errorf("creating new exporter: %w", err)
}
tracerprovider := trace.NewTracerProvider(
trace.WithBatcher(
exporter,
trace.WithMaxExportBatchSize(trace.DefaultMaxExportBatchSize),
trace.WithBatchTimeout(trace.DefaultScheduleDelay*time.Millisecond),
trace.WithMaxExportBatchSize(trace.DefaultMaxExportBatchSize),
),
trace.WithResource(
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
attribute.String("environment", "develop"),
),
),
)
otel.SetTracerProvider(tracerprovider)
return tracerprovider, nil
}
func main() {
// You might be interested in debugging the agent.
if os.Getenv("DATADOG_AGENT_ENABLED") == "true" {
if os.Getenv("DATADOG_AGENT_K8S_ENABLED") == "true" {
tracer.Start()
defer tracer.Stop()
} else {
service := os.Getenv("DATADOG_AGENT_SERVICE")
environment := os.Getenv("DATADOG_AGENT_ENVIRONMENT")
log.Log.Info("Starting Datadog Agent with service: " + service + " and environment: " + environment)
rules := []tracer.SamplingRule{tracer.RateRule(1)}
tracer.Start(
tracer.WithSamplingRules(rules),
tracer.WithService(service),
tracer.WithEnv(environment),
)
defer tracer.Stop()
err := profiler.Start(
profiler.WithService(service),
profiler.WithEnv(environment),
profiler.WithProfileTypes(
profiler.CPUProfile,
profiler.HeapProfile,
),
)
if err != nil {
log.Log.Fatal(err.Error())
}
defer profiler.Stop()
}
}
// Start the show ;)
// We'll parse the flags (named variables), and start the agent.
@@ -101,39 +86,35 @@ func main() {
switch action {
case "version":
{
log.Log.Info("main.Main(): You are currrently running Kerberos Agent " + VERSION)
}
log.Log.Info("main.Main(): You are currrently running Kerberos Agent " + VERSION)
case "discover":
{
// Convert duration to int
timeout, err := time.ParseDuration(timeout + "ms")
if err != nil {
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
return
}
onvif.Discover(timeout)
// Convert duration to int
timeout, err := time.ParseDuration(timeout + "ms")
if err != nil {
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
return
}
onvif.Discover(timeout)
case "decrypt":
{
log.Log.Info("main.Main(): Decrypting: " + flag.Arg(0) + " with key: " + flag.Arg(1))
symmetricKey := []byte(flag.Arg(1))
log.Log.Info("main.Main(): Decrypting: " + flag.Arg(0) + " with key: " + flag.Arg(1))
symmetricKey := []byte(flag.Arg(1))
if len(symmetricKey) == 0 {
log.Log.Fatal("main.Main(): symmetric key should not be empty")
return
}
if len(symmetricKey) != 32 {
log.Log.Fatal("main.Main(): symmetric key should be 32 bytes")
return
}
utils.Decrypt(flag.Arg(0), symmetricKey)
if symmetricKey == nil || len(symmetricKey) == 0 {
log.Log.Fatal("main.Main(): symmetric key should not be empty")
return
}
if len(symmetricKey) != 32 {
log.Log.Fatal("main.Main(): symmetric key should be 32 bytes")
return
}
utils.Decrypt(flag.Arg(0), symmetricKey)
case "run":
{
// Print Agent ASCII art
// Print Kerberos.io ASCII art
utils.PrintASCIIArt()
// Print the environment variables which include "AGENT_" as prefix.
@@ -146,29 +127,12 @@ func main() {
configuration.Name = name
configuration.Port = port
// Open this configuration either from Agent or Factory.
// Open this configuration either from Kerberos Agent or Kerberos Factory.
configService.OpenConfig(configDirectory, &configuration)
// We will override the configuration with the environment variables
configService.OverrideWithEnvironmentVariables(&configuration)
// Start OpenTelemetry tracing
if otelEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); otelEndpoint == "" {
log.Log.Info("main.Main(): No OpenTelemetry endpoint provided, skipping tracing")
} else {
log.Log.Info("main.Main(): Starting OpenTelemetry tracing with endpoint: " + otelEndpoint)
agentKey := configuration.Config.Key
traceProvider, err := startTracing(agentKey, otelEndpoint)
if err != nil {
log.Log.Error("traceprovider: " + err.Error())
}
defer func() {
if err := traceProvider.Shutdown(context.Background()); err != nil {
log.Log.Error("traceprovider: " + err.Error())
}
}()
}
// Printing final configuration
utils.PrintConfiguration(&configuration)
@@ -211,14 +175,12 @@ func main() {
HandleBootstrap: make(chan string, 1),
}
go components.Bootstrap(ctx, configDirectory, &configuration, &communication, &capture)
go components.Bootstrap(configDirectory, &configuration, &communication, &capture)
// Start the REST API.
routers.StartWebserver(configDirectory, &configuration, &communication, &capture)
}
default:
{
log.Log.Error("main.Main(): Sorry I don't understand :(")
}
log.Log.Error("main.Main(): Sorry I don't understand :(")
}
}

View File

@@ -38,16 +38,16 @@ func (c *Capture) SetBackChannelClient(rtspUrl string) *Golibrtsp {
// RTSPClient is a interface that abstracts the RTSP client implementation.
type RTSPClient interface {
// Connect to the RTSP server.
Connect(ctx context.Context, otelContext context.Context) error
Connect(ctx context.Context) error
// Connect to a backchannel RTSP server.
ConnectBackChannel(ctx context.Context, otelContext context.Context) error
ConnectBackChannel(ctx context.Context) error
// Start the RTSP client, and start reading packets.
Start(ctx context.Context, streamType string, queue *packets.Queue, configuration *models.Configuration, communication *models.Communication) error
// Start the RTSP client, and start reading packets.
StartBackChannel(ctx context.Context, otelContext context.Context) error
StartBackChannel(ctx context.Context) (err error)
// Decode a packet into a image.
DecodePacket(pkt packets.Packet) (image.YCbCr, error)
@@ -59,7 +59,7 @@ type RTSPClient interface {
WritePacket(pkt packets.Packet) error
// Close the connection to the RTSP server.
Close(ctx context.Context) error
Close() error
// Get a list of streams from the RTSP server.
GetStreams() ([]packets.Stream, error)

View File

@@ -33,11 +33,8 @@ import (
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/pion/rtp"
"go.opentelemetry.io/otel"
)
var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/capture")
// Implements the RTSPClient interface.
type Golibrtsp struct {
RTSPClient
@@ -84,212 +81,6 @@ type Golibrtsp struct {
AudioMPEG4Decoder *rtpmpeg4audio.Decoder
Streams []packets.Stream
// Per-stream FPS calculation (keyed by stream index)
fpsTrackers map[int8]*fpsTracker
// I-frame interval tracking fields
packetsSinceLastKeyframe int
lastKeyframePacketCount int
keyframeIntervals []int
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.
// Each video stream (H264 / H265) gets its own tracker so PTS
// samples from different codecs never interleave.
type fpsTracker struct {
mu sync.Mutex
lastPTS time.Duration
hasPTS bool
frameTimeBuffer []time.Duration
bufferSize int
bufferIndex int
cachedFPS float64 // latest computed FPS
}
func newFPSTracker(bufferSize int) *fpsTracker {
return &fpsTracker{
frameTimeBuffer: make([]time.Duration, bufferSize),
bufferSize: bufferSize,
}
}
// update records a new PTS sample and returns the latest FPS estimate.
// It must be called once per complete decoded frame (after Decode()
// succeeds), not on every RTP packet fragment.
func (ft *fpsTracker) update(pts time.Duration) float64 {
ft.mu.Lock()
defer ft.mu.Unlock()
if !ft.hasPTS {
ft.lastPTS = pts
ft.hasPTS = true
return 0
}
interval := pts - ft.lastPTS
ft.lastPTS = pts
// Skip invalid intervals (zero, negative, or very large which
// indicate a PTS discontinuity or wrap).
if interval <= 0 || interval > 5*time.Second {
return ft.cachedFPS
}
ft.frameTimeBuffer[ft.bufferIndex] = interval
ft.bufferIndex = (ft.bufferIndex + 1) % ft.bufferSize
var totalInterval time.Duration
validSamples := 0
for _, iv := range ft.frameTimeBuffer {
if iv > 0 {
totalInterval += iv
validSamples++
}
}
if validSamples == 0 {
return ft.cachedFPS
}
avgInterval := totalInterval / time.Duration(validSamples)
if avgInterval == 0 {
return ft.cachedFPS
}
ft.cachedFPS = float64(time.Second) / float64(avgInterval)
return ft.cachedFPS
}
// fps returns the most recent FPS estimate without recording a new sample.
func (ft *fpsTracker) fps() float64 {
ft.mu.Lock()
defer ft.mu.Unlock()
return ft.cachedFPS
}
// Init function
@@ -312,26 +103,12 @@ func init() {
}
// Connect to the RTSP server.
func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err error) {
_, span := tracer.Start(ctxOtel, "Connect")
defer span.End()
func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
transport := gortsplib.TransportTCP
g.health = newStreamHealth()
g.Client = gortsplib.Client{
RequestBackChannels: false,
Transport: &transport,
// 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
@@ -354,9 +131,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
return
}
// Initialize the mutex and FPS calculation.
// Iniatlise the mutex.
g.VideoDecoderMutex = &sync.Mutex{}
g.initFPSCalculation()
// find the H264 media and format
var formaH264 *format.H264
@@ -380,9 +156,7 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
// but try to fetch it later on.
if errSPS != nil {
log.Log.Debug("capture.golibrtsp.Connect(H264): " + errSPS.Error())
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: formaH264.Codec(),
IsVideo: true,
IsAudio: false,
@@ -394,9 +168,7 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
IsBackChannel: false,
})
} else {
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: formaH264.Codec(),
IsVideo: true,
IsAudio: false,
@@ -444,9 +216,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
log.Log.Info("capture.golibrtsp.Connect(H265): " + err.Error())
return
}
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: formaH265.Codec(),
IsVideo: true,
IsAudio: false,
@@ -494,9 +265,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
log.Log.Error("capture.golibrtsp.Connect(G711): " + err.Error())
} else {
g.AudioG711Decoder = audiortpDec
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "PCM_MULAW",
IsVideo: false,
IsAudio: true,
@@ -530,9 +300,8 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
log.Log.Error("capture.golibrtsp.Connect(Opus): " + err.Error())
} else {
g.AudioOpusDecoder = audiortpDec
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "OPUS",
IsVideo: false,
IsAudio: true,
@@ -559,15 +328,11 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
// Something went wrong .. Do something
log.Log.Error("capture.golibrtsp.Connect(MPEG4): " + err.Error())
} else {
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "AAC",
IsVideo: false,
IsAudio: true,
IsBackChannel: false,
SampleRate: audioFormaMPEG4.Config.SampleRate,
Channels: audioFormaMPEG4.Config.ChannelCount,
})
// Set the index for the audio
@@ -587,11 +352,7 @@ func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err e
return
}
func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.Context) (err error) {
_, span := tracer.Start(ctxRunAgent, "ConnectBackChannel")
defer span.End()
func (g *Golibrtsp) ConnectBackChannel(ctx context.Context) (err error) {
// Transport TCP
transport := gortsplib.TransportTCP
g.Client = gortsplib.Client{
@@ -636,9 +397,7 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.
g.HasBackChannel = false
} else {
g.HasBackChannel = true
streamIndex := len(g.Streams)
g.Streams = append(g.Streams, packets.Stream{
Index: streamIndex,
Name: "PCM_MULAW",
IsVideo: false,
IsAudio: true,
@@ -651,45 +410,10 @@ 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)
}
// 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) {
@@ -715,7 +439,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
Time: pts2,
TimeLegacy: pts,
CompositionTime: pts2,
CurrentTime: time.Now().UnixMilli(),
Idx: g.AudioG711Index,
IsVideo: false,
IsAudio: true,
@@ -757,7 +480,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
Time: pts2,
TimeLegacy: pts,
CompositionTime: pts2,
CurrentTime: time.Now().UnixMilli(),
Idx: g.AudioG711Index,
IsVideo: false,
IsAudio: true,
@@ -771,9 +493,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
var filteredAU [][]byte
if g.VideoH264Media != nil && g.VideoH264Forma != nil {
// Extracts DTS from the bitstream to support B-frame H264 streams.
// Created once per stream (tracks reorder state across access units).
h264DTSExtractor := h264.NewDTSExtractor2()
//dtsExtractor := h264.NewDTSExtractor2()
g.Client.OnPacketRTP(g.VideoH264Media, g.VideoH264Forma, func(rtppkt *rtp.Packet) {
@@ -787,17 +507,18 @@ 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)
if !okPTS2 {
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS2 from PacketPTS2")
// decode timestamp
pts, ok := g.Client.PacketPTS(g.VideoH264Media, rtppkt)
pts2, ok := g.Client.PacketPTS2(g.VideoH264Media, rtppkt)
if !ok {
log.Log.Debug("capture.golibrtsp.Start(): " + "unable to get PTS")
return
}
// Extract access units from RTP packets.
// We need a complete access unit to determine whether
// this is a keyframe.
// Extract access units from RTP packets
// We need to do this, because the decoder expects a full
// access unit. Once we have a full access unit, we can
// decode it, and know if it's a keyframe or not.
au, errDecode := g.VideoH264Decoder.Decode(rtppkt)
if errDecode != nil {
if errDecode != rtph264.ErrNonStartingPacketAndNoPrevious && errDecode != rtph264.ErrMorePacketsNeeded {
@@ -806,18 +527,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
return
}
// 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
}
}
// We'll need to read out a few things.
// prepend an AUD. This is required by some players
filteredAU = [][]byte{
@@ -828,10 +537,8 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
nonIDRPresent := false
idrPresent := false
var naluTypes []string
for _, nalu := range au {
typ := h264.NALUType(nalu[0] & 0x1F)
naluTypes = append(naluTypes, fmt.Sprintf("%s(%d,sz=%d)", typ.String(), int(typ), len(nalu)))
switch typ {
case h264.NALUTypeAccessUnitDelimiter:
continue
@@ -844,9 +551,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
var sps h264.SPS
errSPS := sps.Unmarshal(nalu)
if errSPS == nil {
// Debug SPS information
g.debugSPSInfo(&sps, streamType)
// Get width
g.Streams[g.VideoH264Index].Width = sps.Width()
if streamType == "main" {
@@ -861,51 +565,21 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
} else if streamType == "sub" {
configuration.Config.Capture.IPCamera.SubHeight = sps.Height()
}
// Get FPS using enhanced method
fps := g.getEnhancedFPS(&sps, g.VideoH264Index)
g.Streams[g.VideoH264Index].FPS = fps
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Final FPS=%.2f", streamType, fps))
// Get FPS
g.Streams[g.VideoH264Index].FPS = sps.FPS()
g.VideoH264Forma.SPS = nalu
if streamType == "main" && len(nalu) > 0 {
// Fallback: store SPS from in-band NALUs when SDP was missing it.
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{nalu}
}
}
case h264.NALUTypePPS:
// Read out pps
g.VideoH264Forma.PPS = nalu
if streamType == "main" && len(nalu) > 0 {
// Fallback: store PPS from in-band NALUs when SDP was missing it.
configuration.Config.Capture.IPCamera.PPSNALUs = [][]byte{nalu}
}
}
filteredAU = append(filteredAU, nalu)
}
if idrPresent && streamType == "main" {
// Ensure config has parameter sets before recordings start.
if len(configuration.Config.Capture.IPCamera.SPSNALUs) == 0 && len(g.VideoH264Forma.SPS) > 0 {
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{g.VideoH264Forma.SPS}
log.Log.Warning("capture.golibrtsp.Start(main): fallback SPS set from keyframe")
}
if len(configuration.Config.Capture.IPCamera.PPSNALUs) == 0 && len(g.VideoH264Forma.PPS) > 0 {
configuration.Config.Capture.IPCamera.PPSNALUs = [][]byte{g.VideoH264Forma.PPS}
log.Log.Warning("capture.golibrtsp.Start(main): fallback PPS set from keyframe")
}
if len(configuration.Config.Capture.IPCamera.SPSNALUs) == 0 || len(configuration.Config.Capture.IPCamera.PPSNALUs) == 0 {
log.Log.Warning("capture.golibrtsp.Start(main): SPS/PPS still missing after IDR keyframe")
}
}
if len(filteredAU) <= 1 || (!nonIDRPresent && !idrPresent) {
return
}
if idrPresent {
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): IDR frame NALUs: [%s]",
streamType, fmt.Sprintf("%v", naluTypes)))
}
// Convert to packet.
enc, err := h264.AnnexBMarshal(filteredAU)
if err != nil {
@@ -913,10 +587,12 @@ 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())
// Extract DTS from RTP packets
//dts2, err := dtsExtractor.Extract(filteredAU, pts2)
//if err != nil {
// log.Log.Error("capture.golibrtsp.Start(): " + err.Error())
// return
//}
pkt := packets.Packet{
IsKeyFrame: idrPresent,
@@ -924,67 +600,24 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
Data: enc,
Time: pts2,
TimeLegacy: pts,
CurrentTime: time.Now().UnixMilli(),
CompositionTime: compositionOffset,
CompositionTime: pts2,
Idx: g.VideoH264Index,
IsVideo: true,
IsAudio: false,
Codec: "H264",
}
// Track keyframe intervals
keyframeInterval := g.trackKeyframeInterval(idrPresent)
if idrPresent && keyframeInterval > 0 {
avgInterval := g.getAverageKeyframeInterval()
fps := g.Streams[g.VideoH264Index].FPS
if fps <= 0 {
fps = 25.0 // Default fallback FPS
}
gopDuration := float64(keyframeInterval) / fps
gopSize := int(avgInterval) // Store GOP size in a separate variable
g.Streams[g.VideoH264Index].GopSize = gopSize
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Keyframe interval=%d packets, Avg=%.1f, GOP=%.1fs, GOPSize=%d",
streamType, keyframeInterval, avgInterval, gopDuration, gopSize))
preRecording := configuration.Config.Capture.PreRecording
if preRecording > 0 && int(gopDuration) > 0 {
queue.SetMaxGopCount(int(preRecording)/int(gopDuration) + 1)
}
}
pkt.Data = pkt.Data[4:]
if pkt.IsKeyFrame {
// 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
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...)
}
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.
@@ -1016,11 +649,6 @@ 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,
@@ -1033,17 +661,18 @@ 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)
if !okPTS2 {
log.Log.Debug("capture.golibrtsp.Start(): unable to get PTS")
// decode timestamp
pts, ok := g.Client.PacketPTS(g.VideoH265Media, rtppkt)
pts2, ok := g.Client.PacketPTS2(g.VideoH265Media, rtppkt)
if !ok {
log.Log.Debug("capture.golibrtsp.Start(): " + "unable to get PTS")
return
}
// Extract access units from RTP packets.
// We need a complete access unit to determine whether
// this is a keyframe.
// Extract access units from RTP packets
// We need to do this, because the decoder expects a full
// access unit. Once we have a full access unit, we can
// decode it, and know if it's a keyframe or not.
au, errDecode := g.VideoH265Decoder.Decode(rtppkt)
if errDecode != nil {
if errDecode != rtph265.ErrNonStartingPacketAndNoPrevious && errDecode != rtph265.ErrMorePacketsNeeded {
@@ -1052,22 +681,6 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
return
}
// 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
}
}
// 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},
}
@@ -1110,47 +723,20 @@ 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,
Data: enc,
Time: pts2,
TimeLegacy: pts,
CurrentTime: time.Now().UnixMilli(),
CompositionTime: compositionOffset,
CompositionTime: pts2,
Idx: g.VideoH265Index,
IsVideo: true,
IsAudio: false,
Codec: "H265",
}
// Track keyframe intervals for H265
keyframeInterval := g.trackKeyframeInterval(isRandomAccess)
if isRandomAccess && keyframeInterval > 0 {
avgInterval := g.getAverageKeyframeInterval()
fps := g.Streams[g.VideoH265Index].FPS
if fps <= 0 {
fps = 25.0 // Default fallback FPS
}
gopDuration := float64(keyframeInterval) / fps
gopSize := int(avgInterval) // Store GOP size in a separate variable
g.Streams[g.VideoH265Index].GopSize = gopSize
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.Start(%s): Keyframe interval=%d packets, Avg=%.1f, GOP=%.1fs, GOPSize=%d",
streamType, keyframeInterval, avgInterval, gopDuration, gopSize))
preRecording := configuration.Config.Capture.PreRecording
if preRecording > 0 && int(gopDuration) > 0 {
queue.SetMaxGopCount(int(preRecording)/int(gopDuration) + 1)
}
}
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.
@@ -1192,7 +778,7 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
}
// Start the RTSP client, and start reading packets.
func (g *Golibrtsp) StartBackChannel(ctx context.Context, ctxRunAgent context.Context) (err error) {
func (g *Golibrtsp) StartBackChannel(ctx context.Context) (err error) {
log.Log.Info("capture.golibrtsp.StartBackChannel(): started")
// Wait for a second, so we can be sure the stream is playing.
time.Sleep(1 * time.Second)
@@ -1274,8 +860,8 @@ func (g *Golibrtsp) DecodePacketRaw(pkt packets.Packet) (image.Gray, error) {
}
// Get a list of streams from the RTSP server.
func (g *Golibrtsp) GetStreams() ([]packets.Stream, error) {
return g.Streams, nil
func (j *Golibrtsp) GetStreams() ([]packets.Stream, error) {
return j.Streams, nil
}
// Get a list of video streams from the RTSP server.
@@ -1301,11 +887,7 @@ func (g *Golibrtsp) GetAudioStreams() ([]packets.Stream, error) {
}
// Close the connection to the RTSP server.
func (g *Golibrtsp) Close(ctxOtel context.Context) error {
_, span := tracer.Start(ctxOtel, "Close")
defer span.End()
func (g *Golibrtsp) Close() error {
// Close the demuxer.
g.Client.Close()
@@ -1507,7 +1089,7 @@ func WriteMPEG4Audio(forma *format.MPEG4Audio, aus [][]byte) ([]byte, error) {
pkts := make(mpeg4audio.ADTSPackets, len(aus))
for i, au := range aus {
pkts[i] = &mpeg4audio.ADTSPacket{
Type: mpeg4audio.ObjectType(forma.Config.Type),
Type: forma.Config.Type,
SampleRate: forma.Config.SampleRate,
ChannelCount: forma.Config.ChannelCount,
AU: au,
@@ -1519,149 +1101,3 @@ func WriteMPEG4Audio(forma *format.MPEG4Audio, aus [][]byte) ([]byte, error) {
}
return enc, nil
}
// Initialize FPS calculation buffers
func (g *Golibrtsp) initFPSCalculation() {
// Ensure the per-stream FPS trackers map exists. Individual trackers
// can be created lazily when a given stream index is first used.
if g.fpsTrackers == nil {
g.fpsTrackers = make(map[int8]*fpsTracker)
}
// Initialize I-frame interval tracking
g.keyframeBufferSize = 10 // Store last 10 keyframe intervals
g.keyframeIntervals = make([]int, g.keyframeBufferSize)
g.keyframeBufferIndex = 0
g.packetsSinceLastKeyframe = 0
g.lastKeyframePacketCount = 0
}
// Get enhanced FPS information from SPS with fallback to PTS-based calculation.
// 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 {
// First try to get FPS from SPS VUI parameters
spsFPS := sps.FPS()
// Check if SPS FPS is reasonable (between 1 and 120 fps)
if spsFPS > 0 && spsFPS <= 120 {
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.getEnhancedFPS(): SPS FPS: %.2f", spsFPS))
return spsFPS
}
// Fallback to PTS-based FPS (already calculated per-frame)
if ft := g.fpsTrackers[streamIndex]; ft != nil {
ptsFPS := ft.fps()
if ptsFPS > 0 && ptsFPS <= 120 {
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.getEnhancedFPS(): PTS FPS: %.2f", ptsFPS))
return ptsFPS
}
}
// Return SPS FPS even if it seems unreasonable, or default
if spsFPS > 0 {
return spsFPS
}
return 25.0 // Default fallback FPS
}
// Track I-frame intervals by counting packets between keyframes
func (g *Golibrtsp) trackKeyframeInterval(isKeyframe bool) int {
g.keyframeMutex.Lock()
defer g.keyframeMutex.Unlock()
g.packetsSinceLastKeyframe++
if isKeyframe {
// Store the interval since the last keyframe
if g.lastKeyframePacketCount > 0 {
interval := g.packetsSinceLastKeyframe
g.keyframeIntervals[g.keyframeBufferIndex] = interval
g.keyframeBufferIndex = (g.keyframeBufferIndex + 1) % g.keyframeBufferSize
}
// Reset counter for next interval
g.lastKeyframePacketCount = g.packetsSinceLastKeyframe
g.packetsSinceLastKeyframe = 0
return g.lastKeyframePacketCount
}
return 0
}
// Get average keyframe interval (GOP size)
func (g *Golibrtsp) getAverageKeyframeInterval() float64 {
g.keyframeMutex.Lock()
defer g.keyframeMutex.Unlock()
var totalInterval int
validSamples := 0
for _, interval := range g.keyframeIntervals {
if interval > 0 {
totalInterval += interval
validSamples++
}
}
if validSamples == 0 {
return 0
}
return float64(totalInterval) / float64(validSamples)
}
// Calculate GOP size in seconds based on FPS and keyframe interval
func (g *Golibrtsp) getGOPDuration(fps float64) float64 {
avgInterval := g.getAverageKeyframeInterval()
if avgInterval > 0 && fps > 0 {
return avgInterval / fps
}
return 0
}
// Get detailed SPS timing information
func (g *Golibrtsp) getSPSTimingInfo(sps *h264.SPS) (hasVUI bool, timeScale uint32, numUnitsInTick uint32, fps float64) {
// Try to get FPS from SPS
fps = sps.FPS()
// Note: The gortsplib SPS struct may not expose VUI parameters directly
// but we can still work with the calculated FPS
if fps > 0 {
hasVUI = true
// These are estimated values based on common patterns
if fps == 25.0 {
timeScale = 50
numUnitsInTick = 1
} else if fps == 30.0 {
timeScale = 60
numUnitsInTick = 1
} else if fps == 24.0 {
timeScale = 48
numUnitsInTick = 1
} else {
// Generic calculation
timeScale = uint32(fps * 2)
numUnitsInTick = 1
}
}
return hasVUI, timeScale, numUnitsInTick, fps
}
// Debug SPS information
func (g *Golibrtsp) debugSPSInfo(sps *h264.SPS, streamType string) {
hasVUI, timeScale, numUnitsInTick, fps := g.getSPSTimingInfo(sps)
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.debugSPSInfo(%s): Width=%d, Height=%d",
streamType, sps.Width(), sps.Height()))
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.debugSPSInfo(%s): HasVUI=%t, FPS=%.2f",
streamType, hasVUI, fps))
if hasVUI {
log.Log.Debug(fmt.Sprintf("capture.golibrtsp.debugSPSInfo(%s): TimeScale=%d, NumUnitsInTick=%d",
streamType, timeScale, numUnitsInTick))
}
}

View File

@@ -16,8 +16,7 @@ import (
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/utils"
"github.com/kerberos-io/agent/machinery/src/video"
"go.opentelemetry.io/otel/trace"
"github.com/yapingcat/gomedia/go-mp4"
)
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
@@ -64,46 +63,26 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
} else {
log.Log.Debug("capture.main.HandleRecordStream(): started")
preRecording := config.Capture.PreRecording * 1000
postRecording := config.Capture.PostRecording * 1000 // number of seconds to record.
maxRecordingPeriod := config.Capture.MaxLengthRecording * 1000 // maximum number of seconds to record.
recordingPeriod := config.Capture.PostRecording // number of seconds to record.
maxRecordingPeriod := config.Capture.MaxLengthRecording // maximum number of seconds to record.
// We will calculate the maxRecordingPeriod based on the preRecording and postRecording values.
if maxRecordingPeriod == 0 {
// If maxRecordingPeriod is not set, we will use the preRecording and postRecording values
maxRecordingPeriod = preRecording + postRecording
}
if maxRecordingPeriod < preRecording+postRecording {
log.Log.Error("capture.main.HandleRecordStream(): maxRecordingPeriod is less than preRecording + postRecording, this is not allowed. Setting maxRecordingPeriod to preRecording + postRecording.")
maxRecordingPeriod = preRecording + postRecording
}
// Synchronise the last synced time
now := time.Now().Unix()
startRecording := now
timestamp := now
if config.FriendlyName != "" {
config.Name = config.FriendlyName
}
// Get the audio and video codec from the camera.
// We only expect one audio and one video codec.
// If there are multiple audio or video streams, we will use the first one.
audioCodec := ""
videoCodec := ""
audioStreams, _ := rtspClient.GetAudioStreams()
videoStreams, _ := rtspClient.GetVideoStreams()
if len(audioStreams) > 0 {
audioCodec = audioStreams[0].Name
config.Capture.IPCamera.SampleRate = audioStreams[0].SampleRate
config.Capture.IPCamera.Channels = audioStreams[0].Channels
}
if len(videoStreams) > 0 {
videoCodec = videoStreams[0].Name
}
// For continuous and motion based recording we will use a single file.
var file *os.File
// Check if continuous recording.
if config.Capture.Continuous == "true" {
//var cws *cacheWriterSeeker
var mp4Video *video.MP4
var myMuxer *mp4.Movmuxer
var videoTrack uint32
var audioTrack uint32
var name string
@@ -111,15 +90,15 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Do not do anything!
log.Log.Info("capture.main.HandleRecordStream(continuous): start recording")
now = time.Now().Unix()
timestamp = now
start := false
// If continuous record the full length
postRecording = maxRecordingPeriod
recordingPeriod = maxRecordingPeriod
// Recording file name
fullName := ""
var startRecording int64 = 0 // start recording timestamp in milliseconds
// Get as much packets we need.
var cursorError error
var pkt packets.Packet
@@ -135,65 +114,43 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
nextPkt, cursorError = recordingCursor.ReadPacket()
now := time.Now().UnixMilli()
now := time.Now().Unix()
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) {
nextPkt.IsKeyFrame && (timestamp+recordingPeriod-now <= 0 || now-startRecording >= maxRecordingPeriod) {
// Write the last packet before closing the recording.
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
// Write the last packet
ttime := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := myMuxer.Write(videoTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := myMuxer.Write(audioTrack, pkt.Data, ttime, ttime); 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.")
}
}
// Close mp4
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
mp4Video.SPSNALUs = configuration.Config.Capture.IPCamera.SPSNALUs
// This will write the trailer a well.
if err := myMuxer.WriteTrailer(); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
if len(mp4Video.PPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.PPSNALUs) > 0 {
mp4Video.PPSNALUs = configuration.Config.Capture.IPCamera.PPSNALUs
}
if len(mp4Video.VPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.VPSNALUs) > 0 {
mp4Video.VPSNALUs = configuration.Config.Capture.IPCamera.VPSNALUs
}
if (videoCodec == "H264" && (len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) ||
(videoCodec == "H265" && (len(mp4Video.VPSNALUs) == 0 || len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) {
log.Log.Warning("capture.main.HandleRecordStream(continuous): closing MP4 without full parameter sets, moov may be incomplete")
}
mp4Video.Close(&config)
log.Log.Info("capture.main.HandleRecordStream(continuous): recording finished: file save: " + name)
// Cleanup muxer
start = false
file.Close()
file = nil
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
if mp4Video.VideoTotalDuration > 0 {
duration := mp4Video.VideoTotalDuration
// Update the name with the duration in milliseconds.
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
config.Name + "_" +
"0-0-0-0" + "_" + // region coordinates, we
"-1" + "_" + // token
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
// Check if need to convert to fragmented using bento
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
}
// Check if we need to encrypt the recording.
@@ -240,6 +197,7 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
}
start = true
timestamp = now
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
@@ -250,17 +208,13 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// - Number of changes
// - Token
startRecording = pkt.CurrentTime
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" + // start timestamp in seconds
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" + // length of milliseconds
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" + // milliseconds
config.Name + "_" + // device name
"0-0-0-0" + "_" + // region coordinates, we will not use this for continuous recording
"0" + "_" + // token
"0" + "_" //+ // duration of recording in milliseconds
//utils.VERSION // version of the agent
startRecording = time.Now().Unix() // we mark the current time when the record started.ss
s := strconv.FormatInt(startRecording, 10) + "_" +
"6" + "-" +
"967003" + "_" +
config.Name + "_" +
"200-200-400-400" + "_0_" +
"769"
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
@@ -268,41 +222,62 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// Running...
log.Log.Info("capture.main.HandleRecordStream(continuous): recording started")
// Get width and height from the camera.
width := configuration.Config.Capture.IPCamera.Width
height := configuration.Config.Capture.IPCamera.Height
// Get SPS and PPS NALUs from the camera.
spsNALUS := configuration.Config.Capture.IPCamera.SPSNALUs
ppsNALUS := configuration.Config.Capture.IPCamera.PPSNALUs
vpsNALUS := configuration.Config.Capture.IPCamera.VPSNALUs
if len(spsNALUS) == 0 || len(ppsNALUS) == 0 {
log.Log.Warning("capture.main.HandleRecordStream(continuous): missing SPS/PPS at recording start")
}
// Create a video file, and set the dimensions.
mp4Video = video.NewMP4(fullName, spsNALUS, ppsNALUS, vpsNALUS, configuration.Config.Capture.MaxLengthRecording)
mp4Video.SetWidth(width)
mp4Video.SetHeight(height)
if videoCodec == "H264" {
videoTrack = mp4Video.AddVideoTrack("H264")
} else if videoCodec == "H265" {
videoTrack = mp4Video.AddVideoTrack("H265")
}
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
file, err = os.Create(fullName)
if err == nil {
//cws = newCacheWriterSeeker(4096)
myMuxer, _ = mp4.CreateMp4Muxer(file)
// We choose between H264 and H265
width := configuration.Config.Capture.IPCamera.Width
height := configuration.Config.Capture.IPCamera.Height
widthOption := mp4.WithVideoWidth(uint32(width))
heightOption := mp4.WithVideoHeight(uint32(height))
if pkt.Codec == "H264" {
videoTrack = myMuxer.AddVideoTrack(mp4.MP4_CODEC_H264, widthOption, heightOption)
} else if pkt.Codec == "H265" {
videoTrack = myMuxer.AddVideoTrack(mp4.MP4_CODEC_H265, widthOption, heightOption)
}
// For an MP4 container, AAC is the only audio codec supported.
audioTrack = myMuxer.AddAudioTrack(mp4.MP4_CODEC_AAC)
} else {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
ttime := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := myMuxer.Write(videoTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := myMuxer.Write(audioTrack, pkt.Data, ttime, ttime); 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)
recordingStatus = "started"
} else if start {
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
ttime := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := myMuxer.Write(videoTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(continuous): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := myMuxer.Write(audioTrack, pkt.Data, ttime, ttime); 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.")
}
}
}
pkt = nextPkt
}
@@ -310,43 +285,21 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// If this happens we need to check to properly close the recording.
if cursorError != nil {
if recordingStatus == "started" {
// This will write the trailer a well.
if err := myMuxer.WriteTrailer(); err != nil {
log.Log.Error(err.Error())
}
log.Log.Info("capture.main.HandleRecordStream(continuous): Recording finished: file save: " + name)
// Cleanup muxer
start = false
file.Close()
file = nil
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
if mp4Video.VideoTotalDuration > 0 {
duration := mp4Video.VideoTotalDuration
// Update the name with the duration in milliseconds.
startRecordingSeconds := startRecording / 1000 // convert to seconds
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" +
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" +
config.Name + "_" +
"0-0-0-0" + "_" + // region coordinates, we
"-1" + "_" + // token
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(continuous): no video data recorded, not renaming file.")
// Check if need to convert to fragmented using bento
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
}
// Check if we need to encrypt the recording.
@@ -384,44 +337,33 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
log.Log.Info("capture.main.HandleRecordStream(motiondetection): Start motion based recording ")
var lastRecordingTime int64 = 0 // last recording timestamp in milliseconds
var displayTime int64 = 0 // display time in milliseconds
var lastDuration int64
var lastRecordingTime int64
//var cws *cacheWriterSeeker
var myMuxer *mp4.Movmuxer
var videoTrack uint32
var audioTrack uint32
for motion := range communication.HandleMotion {
// Get as much packets we need.
var cursorError error
var pkt packets.Packet
var nextPkt packets.Packet
recordingCursor := queue.Oldest() // Start from the latest packet in the queue)
timestamp = time.Now().Unix()
startRecording = time.Now().Unix() // we mark the current time when the record started.
numberOfChanges := motion.NumberOfChanges
now := time.Now().UnixMilli()
motionTimestamp := now
// If we have prerecording we will substract the number of seconds.
// Taking into account FPS = GOP size (Keyfram interval)
if config.Capture.PreRecording > 0 {
start := false
// Might be that recordings are coming short after each other.
// Therefore we do some math with the current time and the last recording time.
if cursorError == nil {
pkt, cursorError = recordingCursor.ReadPacket()
}
displayTime = pkt.CurrentTime
startRecording := pkt.CurrentTime
// We have more packets in the queue (which might still be older than where we close the previous recording).
// In that case we will use the last recording time to determine the start time of the recording, otherwise
// we will have duplicate frames in the recording.
if startRecording < lastRecordingTime {
displayTime = lastRecordingTime
startRecording = lastRecordingTime
}
// If startRecording is 0, we will continue as it might be we are in a state of restarting the agent.
if startRecording == 0 {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): startRecording is 0, we will continue as it might be we are in a state of restarting the agent.")
continue
timeBetweenNowAndLastRecording := startRecording - lastRecordingTime
if timeBetweenNowAndLastRecording > int64(config.Capture.PreRecording) {
startRecording = startRecording - int64(config.Capture.PreRecording) + 1
} else {
startRecording = startRecording - timeBetweenNowAndLastRecording
}
}
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
@@ -433,47 +375,48 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// - Number of changes
// - Token
displayTimeSeconds := displayTime / 1000 // convert to seconds
displayTimeMilliseconds := displayTime % 1000 // convert to milliseconds
motionRectangleString := "0-0-0-0"
if motion.Rectangle.X != 0 || motion.Rectangle.Y != 0 ||
motion.Rectangle.Width != 0 || motion.Rectangle.Height != 0 {
motionRectangleString = strconv.Itoa(motion.Rectangle.X) + "-" + strconv.Itoa(motion.Rectangle.Y) + "-" +
strconv.Itoa(motion.Rectangle.Width) + "-" + strconv.Itoa(motion.Rectangle.Height)
}
// Get the number of changes from the motion detection.
numberOfChanges := motion.NumberOfChanges
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" + // start timestamp in seconds
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" + // length of milliseconds
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" + // milliseconds
config.Name + "_" + // device name
motionRectangleString + "_" + // region coordinates, we will not use this for continuous recording
strconv.Itoa(numberOfChanges) + "_" + // number of changes
"0" // + "_" + // duration of recording in milliseconds
//utils.VERSION // version of the agent
s := strconv.FormatInt(startRecording, 10) + "_" +
"6" + "-" +
"967003" + "_" +
config.Name + "_" +
"200-200-400-400" + "_" +
strconv.Itoa(numberOfChanges) + "_" +
"769"
name := s + ".mp4"
fullName := configDirectory + "/data/recordings/" + name
// Running...
log.Log.Info("capture.main.HandleRecordStream(motiondetection): recording started (" + name + ")" + " at " + strconv.FormatInt(displayTimeSeconds, 10) + " unix")
log.Log.Info("capture.main.HandleRecordStream(motiondetection): recording started")
file, _ = os.Create(fullName)
myMuxer, _ = mp4.CreateMp4Muxer(file)
// Get width and height from the camera.
width := configuration.Config.Capture.IPCamera.Width
height := configuration.Config.Capture.IPCamera.Height
// Get SPS and PPS NALUs from the camera.
spsNALUS := configuration.Config.Capture.IPCamera.SPSNALUs
ppsNALUS := configuration.Config.Capture.IPCamera.PPSNALUs
vpsNALUS := configuration.Config.Capture.IPCamera.VPSNALUs
if len(spsNALUS) == 0 || len(ppsNALUS) == 0 {
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): missing SPS/PPS at recording start")
// Check which video codec we need to use.
videoSteams, _ := rtspClient.GetVideoStreams()
for _, stream := range videoSteams {
width := configuration.Config.Capture.IPCamera.Width
height := configuration.Config.Capture.IPCamera.Height
widthOption := mp4.WithVideoWidth(uint32(width))
heightOption := mp4.WithVideoHeight(uint32(height))
if stream.Name == "H264" {
videoTrack = myMuxer.AddVideoTrack(mp4.MP4_CODEC_H264, widthOption, heightOption)
} else if stream.Name == "H265" {
videoTrack = myMuxer.AddVideoTrack(mp4.MP4_CODEC_H265, widthOption, heightOption)
}
}
// For an MP4 container, AAC is the only audio codec supported.
audioTrack = myMuxer.AddAudioTrack(mp4.MP4_CODEC_AAC)
start := false
// Get as much packets we need.
var cursorError error
var pkt packets.Packet
var nextPkt packets.Packet
recordingCursor := queue.DelayedGopCount(int(config.Capture.PreRecording + 1))
if cursorError == nil {
pkt, cursorError = recordingCursor.ReadPacket()
}
// Create the MP4 only once the first keyframe arrives.
var mp4Video *video.MP4
for cursorError == nil {
@@ -482,111 +425,68 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + cursorError.Error())
}
now = time.Now().UnixMilli()
now := time.Now().Unix()
select {
case motion := <-communication.HandleMotion:
motionTimestamp = now
timestamp = now
log.Log.Info("capture.main.HandleRecordStream(motiondetection): motion detected while recording. Expanding recording.")
numberOfChanges := motion.NumberOfChanges
numberOfChanges = motion.NumberOfChanges
log.Log.Info("capture.main.HandleRecordStream(motiondetection): Received message with recording data, detected changes to save: " + strconv.Itoa(numberOfChanges))
default:
}
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))
log.Log.Info("capture.main.HandleRecordStream(motiondetection): closing recording (timestamp: " + strconv.FormatInt(motionTimestamp, 10) + ", postRecording: " + strconv.FormatInt(postRecording, 10) + ", now: " + strconv.FormatInt(now, 10) + ", startRecording: " + strconv.FormatInt(startRecording, 10) + ", maxRecordingPeriod: " + strconv.FormatInt(maxRecordingPeriod, 10))
if (timestamp+recordingPeriod-now < 0 || now-startRecording > maxRecordingPeriod) && nextPkt.IsKeyFrame {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): closing recording (timestamp: " + strconv.FormatInt(timestamp, 10) + ", recordingPeriod: " + strconv.FormatInt(recordingPeriod, 10) + ", now: " + strconv.FormatInt(now, 10) + ", startRecording: " + strconv.FormatInt(startRecording, 10) + ", maxRecordingPeriod: " + strconv.FormatInt(maxRecordingPeriod, 10))
break
}
if pkt.IsKeyFrame && !start && pkt.CurrentTime >= startRecording {
// We start the recording if we have a keyframe and the last duration is 0 or less than the current packet time.
// It could be start we start from the beginning of the recording.
if pkt.IsKeyFrame && !start && pkt.Time >= lastDuration {
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): write frames")
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): recording started on keyframe")
// Align duration timers with the first keyframe.
startRecording = pkt.CurrentTime
// Create a video file, and set the dimensions.
mp4Video = video.NewMP4(fullName, spsNALUS, ppsNALUS, vpsNALUS, configuration.Config.Capture.MaxLengthRecording)
mp4Video.SetWidth(width)
mp4Video.SetHeight(height)
if videoCodec == "H264" {
videoTrack = mp4Video.AddVideoTrack("H264")
} else if videoCodec == "H265" {
videoTrack = mp4Video.AddVideoTrack("H265")
}
if audioCodec == "AAC" {
audioTrack = mp4Video.AddAudioTrack("AAC")
} else if audioCodec == "PCM_MULAW" {
log.Log.Debug("capture.main.HandleRecordStream(continuous): no AAC audio codec detected, skipping audio track.")
}
start = true
}
if start {
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
ttime := convertPTS(pkt.TimeLegacy)
if pkt.IsVideo {
if err := myMuxer.Write(videoTrack, pkt.Data, ttime, ttime); err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
}
} else if pkt.IsAudio {
if pkt.Codec == "AAC" {
if err := myMuxer.Write(audioTrack, pkt.Data, ttime, ttime); 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..
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): no AAC audio codec detected, skipping audio track.")
}
}
// We will sync to file every keyframe.
if pkt.IsKeyFrame {
err := file.Sync()
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + err.Error())
} else {
log.Log.Debug("capture.main.HandleRecordStream(motiondetection): synced file " + name)
}
}
}
pkt = nextPkt
}
// Update the last duration and last recording time.
// This is used to determine if we need to start a new recording.
lastRecordingTime = pkt.CurrentTime
// This will write the trailer a well.
myMuxer.WriteTrailer()
if mp4Video == nil {
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): recording closed without keyframe; no MP4 created")
continue
}
// This will close the recording and write the last packet.
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
mp4Video.SPSNALUs = configuration.Config.Capture.IPCamera.SPSNALUs
}
if len(mp4Video.PPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.PPSNALUs) > 0 {
mp4Video.PPSNALUs = configuration.Config.Capture.IPCamera.PPSNALUs
}
if len(mp4Video.VPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.VPSNALUs) > 0 {
mp4Video.VPSNALUs = configuration.Config.Capture.IPCamera.VPSNALUs
}
if (videoCodec == "H264" && (len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) ||
(videoCodec == "H265" && (len(mp4Video.VPSNALUs) == 0 || len(mp4Video.SPSNALUs) == 0 || len(mp4Video.PPSNALUs) == 0)) {
log.Log.Warning("capture.main.HandleRecordStream(motiondetection): closing MP4 without full parameter sets, moov may be incomplete")
}
mp4Video.Close(&config)
log.Log.Info("capture.main.HandleRecordStream(motiondetection): file save: " + name)
// Update the name of the recording with the duration.
// We will update the name of the recording with the duration in milliseconds.
if mp4Video.VideoTotalDuration > 0 {
duration := mp4Video.VideoTotalDuration
lastDuration = pkt.Time
lastRecordingTime = time.Now().Unix()
file.Close()
file = nil
// Update the name with the duration in milliseconds.
s := strconv.FormatInt(displayTimeSeconds, 10) + "_" +
strconv.Itoa(len(strconv.FormatInt(displayTimeMilliseconds, 10))) + "-" +
strconv.FormatInt(displayTimeMilliseconds, 10) + "_" +
config.Name + "_" +
motionRectangleString + "_" +
strconv.Itoa(numberOfChanges) + "_" + // number of changes
strconv.FormatInt(int64(duration), 10) // + "_" + // duration of recording in milliseconds
//utils.VERSION // version of the agent
oldName := name
name = s + ".mp4"
fullName = configDirectory + "/data/recordings/" + name
log.Log.Info("capture.main.HandleRecordStream(motiondetection): renamed file from: " + oldName + " to: " + name)
// Rename the file to the new name.
err := os.Rename(
configDirectory+"/data/recordings/"+oldName,
configDirectory+"/data/recordings/"+s+".mp4")
if err != nil {
log.Log.Error("capture.main.HandleRecordStream(motiondetection): error renaming file: " + err.Error())
}
} else {
log.Log.Info("capture.main.HandleRecordStream(motiondetection): no video data recorded, not renaming file.")
// Check if need to convert to fragmented using bento
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
}
// Check if we need to encrypt the recording.
@@ -634,10 +534,6 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
// @Success 200 {object} models.APIResponse
func VerifyCamera(c *gin.Context) {
// Start OpenTelemetry tracing
ctxVerifyCamera, span := tracer.Start(context.Background(), "VerifyCamera", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
var cameraStreams models.CameraStreams
err := c.BindJSON(&cameraStreams)
@@ -663,11 +559,12 @@ func VerifyCamera(c *gin.Context) {
Url: rtspUrl,
}
err := rtspClient.Connect(ctx, ctxVerifyCamera)
err := rtspClient.Connect(ctx)
if err == nil {
// Get the streams from the rtsp client.
streams, _ := rtspClient.GetStreams()
videoIdx := -1
audioIdx := -1
for i, stream := range streams {
@@ -678,7 +575,7 @@ func VerifyCamera(c *gin.Context) {
}
}
err := rtspClient.Close(ctxVerifyCamera)
err := rtspClient.Close()
if err == nil {
if videoIdx > -1 {
c.JSON(200, models.APIResponse{
@@ -707,7 +604,7 @@ func VerifyCamera(c *gin.Context) {
}
}
func Base64Image(captureDevice *Capture, communication *models.Communication, configuration *models.Configuration) string {
func Base64Image(captureDevice *Capture, communication *models.Communication) string {
// We'll try to get a snapshot from the camera.
var queue *packets.Queue
var cursor *packets.QueueCursor
@@ -737,8 +634,7 @@ func Base64Image(captureDevice *Capture, communication *models.Communication, co
var img image.YCbCr
img, err = (*rtspClient).DecodePacket(pkt)
if err == nil {
imageResized, _ := utils.ResizeImage(&img, uint(configuration.Config.Capture.IPCamera.BaseWidth), uint(configuration.Config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
bytes, _ := utils.ImageToBytes(&img)
encodedImage = base64.StdEncoding.EncodeToString(bytes)
break
} else {
@@ -799,41 +695,6 @@ 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 {
func convertPTS2(v int64) uint64 {
return uint64(v) / 100
}*/
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"image"
"io"
"os"
"strings"
@@ -131,7 +132,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
log.Log.Error("HandleUpload: " + err.Error())
}
} else {
delay = 5 * time.Second // slow down
delay = 20 * time.Second // slow down
if err != nil {
log.Log.Error("HandleUpload: " + err.Error())
}
@@ -672,7 +673,6 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
// Check if we need to enable the live stream
if config.Capture.Liveview != "false" {
deviceId := config.Key
hubKey := ""
if config.Cloud == "s3" && config.S3 != nil && config.S3.Publickey != "" {
hubKey = config.S3.Publickey
@@ -690,95 +690,46 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
var pkt packets.Packet
for cursorError == nil {
pkt, cursorError = livestreamCursor.ReadPacket()
if len(pkt.Data) == 0 || !pkt.IsKeyFrame {
continue
}
now := time.Now().Unix()
select {
case <-communication.HandleLiveSD:
lastLivestreamRequest = now
default:
}
if now-lastLivestreamRequest > 3 {
continue
}
pkt, cursorError = livestreamCursor.ReadPacket()
if len(pkt.Data) == 0 || !pkt.IsKeyFrame {
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)
chunking := config.Capture.LiveviewChunking
if chunking == "true" {
// Split encoded image into chunks of 2kb
// This is to prevent the MQTT message to be too large.
// By default, bytes are not encoded to base64 here; you are splitting the raw JPEG/PNG bytes.
// However, in MQTT and web contexts, binary data may not be handled well, so base64 is often used.
// To avoid base64 encoding, just send the raw []byte chunks as you do here.
// If you want to avoid base64, make sure the receiver can handle binary payloads.
chunkSize := 25 * 1024 // 25KB chunks
var chunks [][]byte
for i := 0; i < len(bytes); i += chunkSize {
end := i + chunkSize
if end > len(bytes) {
end = len(bytes)
}
chunk := bytes[i:end]
chunks = append(chunks, chunk)
}
log.Log.Infof("cloud.HandleLiveStreamSD(): Sending %d chunks of size %d bytes.", len(chunks), chunkSize)
timestamp := time.Now().Unix()
for i, chunk := range chunks {
valueMap := make(map[string]interface{})
valueMap["id"] = timestamp
valueMap["chunk"] = chunk
valueMap["chunkIndex"] = i
valueMap["chunkSize"] = chunkSize
valueMap["chunkCount"] = len(chunks)
message := models.Message{
Payload: models.Payload{
Version: "v1.0.0",
Action: "receive-sd-stream",
DeviceId: deviceId,
Value: valueMap,
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey+"/"+deviceId, 1, false, payload)
log.Log.Infof("cloud.HandleLiveStreamSD(): sent chunk %d/%d to MQTT topic kerberos/hub/%s/%s", i+1, len(chunks), hubKey, deviceId)
time.Sleep(33 * time.Millisecond) // Sleep to avoid flooding the MQTT broker with messages
} else {
log.Log.Info("cloud.HandleLiveStreamSD(): something went wrong while sending acknowledge config to hub: " + string(payload))
}
}
bytes, _ := utils.ImageToBytes(&img)
encoded := base64.StdEncoding.EncodeToString(bytes)
valueMap := make(map[string]interface{})
valueMap["image"] = encoded
message := models.Message{
Payload: models.Payload{
Action: "receive-sd-stream",
DeviceId: configuration.Config.Key,
Value: valueMap,
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
valueMap := make(map[string]interface{})
valueMap["image"] = bytes
message := models.Message{
Payload: models.Payload{
Action: "receive-sd-stream",
DeviceId: configuration.Config.Key,
Value: valueMap,
},
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("cloud.HandleLiveStreamSD(): something went wrong while sending acknowledge config to hub: " + string(payload))
}
log.Log.Info("cloud.HandleLiveStreamSD(): something went wrong while sending acknowledge config to hub: " + string(payload))
}
}
time.Sleep(1000 * time.Millisecond) // Sleep to avoid flooding the MQTT broker with messages
// Cleaning image
img = image.YCbCr{}
}
} else {
@@ -800,19 +751,11 @@ func HandleLiveStreamHD(livestreamCursor *packets.QueueCursor, configuration *mo
// Check if we need to enable the live stream
if config.Capture.Liveview != "false" {
// 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.
// Should create a track here.
streams, _ := rtspClient.GetStreams()
videoBroadcaster := webrtc.NewVideoBroadcaster(streams)
audioBroadcaster := webrtc.NewAudioBroadcaster(streams)
if videoBroadcaster == nil && audioBroadcaster == nil {
log.Log.Error("cloud.HandleLiveStreamHD(): failed to create both video and audio broadcasters")
return
}
go webrtc.WriteToTrack(livestreamCursor, configuration, communication, mqttClient, videoBroadcaster, audioBroadcaster, rtspClient)
videoTrack := webrtc.NewVideoTrack(streams)
audioTrack := webrtc.NewAudioTrack(streams)
go webrtc.WriteToTrack(livestreamCursor, configuration, communication, mqttClient, videoTrack, audioTrack, rtspClient)
if config.Capture.ForwardWebRTC == "true" {
@@ -820,7 +763,7 @@ func HandleLiveStreamHD(livestreamCursor *packets.QueueCursor, configuration *mo
log.Log.Info("cloud.HandleLiveStreamHD(): Waiting for peer connections.")
for handshake := range communication.HandleLiveHDHandshake {
log.Log.Info("cloud.HandleLiveStreamHD(): setting up a peer connection.")
go webrtc.InitializeWebRTCConnection(configuration, communication, mqttClient, videoBroadcaster, audioBroadcaster, handshake)
go webrtc.InitializeWebRTCConnection(configuration, communication, mqttClient, videoTrack, audioTrack, handshake)
}
}
@@ -873,8 +816,7 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
log.Log.Info("cloud.RealtimeProcessing(): 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)
bytes, _ := utils.ImageToBytes(&img)
encoded := base64.StdEncoding.EncodeToString(bytes)
valueMap := make(map[string]interface{})
@@ -888,6 +830,7 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish(realtimeProcessingTopic, 0, false, payload)
} else {
log.Log.Info("cloud.RealtimeProcessing(): something went wrong while sending acknowledge config to hub: " + string(payload))
@@ -1226,184 +1169,3 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
})
}
}
// VerifySecondaryPersistence godoc
// @Router /api/persistence/secondary/verify [post]
// @ID verify-persistence
// @Security Bearer
// @securityDefinitions.apikey Bearer
// @in header
// @name Authorization
// @Tags persistence
// @Param config body models.Config true "Config"
// @Summary Will verify the secondary persistence.
// @Description Will verify the secondary persistence.
// @Success 200 {object} models.APIResponse
func VerifySecondaryPersistence(c *gin.Context, configDirectory string) {
var config models.Config
err := c.BindJSON(&config)
if err != nil || config.Cloud != "" {
if config.Cloud == "kstorage" || config.Cloud == "kerberosvault" {
if config.KStorageSecondary == nil {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): please fill-in the required Kerberos Vault credentials."
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
} else {
uri := config.KStorageSecondary.URI
accessKey := config.KStorageSecondary.AccessKey
secretAccessKey := config.KStorageSecondary.SecretAccessKey
directory := config.KStorageSecondary.Directory
provider := config.KStorageSecondary.Provider
if err == nil && uri != "" && accessKey != "" && secretAccessKey != "" {
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{}
}
req, err := http.NewRequest("POST", uri+"/ping", nil)
if err == nil {
req.Header.Add("X-Kerberos-Storage-AccessKey", accessKey)
req.Header.Add("X-Kerberos-Storage-SecretAccessKey", secretAccessKey)
resp, err := client.Do(req)
if err == nil {
body, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err == nil && resp.StatusCode == http.StatusOK {
if provider != "" || directory != "" {
// Generate a random name.
timestamp := time.Now().Unix()
fileName := strconv.FormatInt(timestamp, 10) +
"_6-967003_" + config.Name + "_200-200-400-400_24_769.mp4"
// Open test-480p.mp4
file, err := os.Open(configDirectory + "/data/test-480p.mp4")
if err != nil {
msg := "cloud.VerifyPersistence(kerberosvault): error reading test-480p.mp4: " + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
defer file.Close()
req, err := http.NewRequest("POST", uri+"/storage", file)
if err == nil {
req.Header.Set("Content-Type", "video/mp4")
req.Header.Set("X-Kerberos-Storage-CloudKey", config.HubKey)
req.Header.Set("X-Kerberos-Storage-AccessKey", accessKey)
req.Header.Set("X-Kerberos-Storage-SecretAccessKey", secretAccessKey)
req.Header.Set("X-Kerberos-Storage-Provider", 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", 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 err == nil {
if resp != nil {
body, err := io.ReadAll(resp.Body)
defer resp.Body.Close()
if err == nil {
if resp.StatusCode == 200 {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Upload allowed using the credentials provided (" + accessKey + ", " + secretAccessKey + ")"
log.Log.Info(msg)
c.JSON(200, models.APIResponse{
Data: body,
})
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while verifying your persistence settings. Make sure your provider is the same as the storage provider in your Kerberos Vault, and the relevant storage provider is configured properly."
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
}
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Upload of fake recording failed: " + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while creating /storage POST request." + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Provider and/or directory is missing from the request."
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while verifying storage credentials: " + string(body)
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while verifying storage credentials:" + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): Something went wrong while verifying storage credentials:" + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifySecondaryPersistence(kerberosvault): please fill-in the required Kerberos Vault credentials."
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
}
}
} else {
msg := "cloud.VerifySecondaryPersistence(): No persistence was specified, so do not know what to verify:" + err.Error()
log.Log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
}

View File

@@ -68,9 +68,9 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr, CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
client = &http.Client{Transport: tr}
} else {
client = &http.Client{CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
client = &http.Client{}
}
resp, err := client.Do(req)
@@ -129,20 +129,3 @@ 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
}

View File

@@ -3,20 +3,14 @@ package cloud
import (
"crypto/tls"
"errors"
"io"
"io/ioutil"
"net/http"
"os"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
)
// We will count the number of retries we have done.
// If we have done more than "kstorageRetryPolicy" retries, we will stop, and start sending to the secondary storage.
var kstorageRetryCount = 0
var kstorageRetryTimeout = time.Now().Unix()
func UploadKerberosVault(configuration *models.Configuration, fileName string) (bool, bool, error) {
config := configuration.Config
@@ -25,7 +19,7 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
config.KStorage.SecretAccessKey == "" ||
config.KStorage.Directory == "" ||
config.KStorage.URI == "" {
err := "UploadKerberosVault: Kerberos Vault not properly configured"
err := "UploadKerberosVault: Kerberos Vault not properly configured."
log.Log.Info(err)
return false, false, errors.New(err)
}
@@ -48,147 +42,64 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
defer file.Close()
}
if err != nil {
err := "UploadKerberosVault: Upload Failed, file doesn't exists anymore"
err := "UploadKerberosVault: Upload Failed, file doesn't exists anymore."
log.Log.Info(err)
return false, false, errors.New(err)
}
publicKey := config.KStorage.CloudKey
// This is the new way ;)
if config.HubKey != "" {
publicKey = config.HubKey
}
// We need to check if we are in a retry timeout.
if kstorageRetryTimeout <= time.Now().Unix() {
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())
}
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)
// We might need to check if we can upload to our secondary storage.
if config.KStorageSecondary.AccessKey == "" ||
config.KStorageSecondary.SecretAccessKey == "" ||
config.KStorageSecondary.Directory == "" ||
config.KStorageSecondary.URI == "" {
log.Log.Info("UploadKerberosVault (Secondary): Secondary Kerberos Vault not properly configured.")
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{}
}
if kstorageRetryCount < config.KStorage.MaxRetries {
log.Log.Info("UploadKerberosVault (Secondary): Do not upload to secondary storage, we are still in retry policy.")
return false, true, nil
}
resp, err := client.Do(req)
if resp != nil {
defer resp.Body.Close()
}
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)
}
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}
} else {
client = &http.Client{}
}
resp, err := client.Do(req)
if err == nil {
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))
}
body, err := ioutil.ReadAll(resp.Body)
if err == nil {
if resp.StatusCode == 200 {
log.Log.Info("UploadKerberosVault: Upload Finished, " + resp.Status + ", " + string(body))
return true, true, nil
} else {
log.Log.Info("UploadKerberosVault: Upload Failed, " + resp.Status + ", " + string(body))
return false, true, nil
}
}
}
}
return false, true, nil
errorMessage := "UploadKerberosVault: Upload Failed, " + err.Error()
log.Log.Info(errorMessage)
return false, true, errors.New(errorMessage)
}

View File

@@ -2,7 +2,6 @@ package components
import (
"context"
"fmt"
"os"
"strconv"
"sync/atomic"
@@ -10,7 +9,6 @@ import (
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
"github.com/kerberos-io/agent/machinery/src/capture"
"github.com/kerberos-io/agent/machinery/src/cloud"
@@ -22,19 +20,12 @@ import (
"github.com/kerberos-io/agent/machinery/src/packets"
routers "github.com/kerberos-io/agent/machinery/src/routers/mqtt"
"github.com/kerberos-io/agent/machinery/src/utils"
"github.com/kerberos-io/agent/machinery/src/webrtc"
"github.com/tevino/abool"
)
var tracer = otel.Tracer("github.com/kerberos-io/agent/machinery/src/components")
func Bootstrap(ctx context.Context, configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
func Bootstrap(configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
log.Log.Debug("components.Kerberos.Bootstrap(): bootstrapping the kerberos agent.")
bootstrapContext := context.Background()
_, span := tracer.Start(bootstrapContext, "Bootstrap")
// We will keep track of the Kerberos Agent up time
// This is send to Kerberos Hub in a heartbeat.
uptimeStart := time.Now()
@@ -87,8 +78,6 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
// Configure a MQTT client which helps for a bi-directional communication
mqttClient := routers.ConfigureMQTT(configDirectory, configuration, communication)
span.End()
// Run the agent and fire up all the other
// goroutines which do image capture, motion detection, onvif, etc.
for {
@@ -125,9 +114,6 @@ func Bootstrap(ctx context.Context, configDirectory string, configuration *model
func RunAgent(configDirectory string, configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, uptimeStart time.Time, cameraSettings *models.Camera, captureDevice *capture.Capture) string {
ctx := context.Background()
ctxRunAgent, span := tracer.Start(ctx, "RunAgent")
log.Log.Info("components.Kerberos.RunAgent(): Creating camera and processing threads.")
config := configuration.Config
@@ -138,10 +124,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
rtspUrl := config.Capture.IPCamera.RTSP
rtspClient := captureDevice.SetMainClient(rtspUrl)
if rtspUrl != "" {
err := rtspClient.Connect(ctx, ctxRunAgent)
err := rtspClient.Connect(context.Background())
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error connecting to RTSP stream: " + err.Error())
rtspClient.Close(ctxRunAgent)
rtspClient.Close()
rtspClient = nil
time.Sleep(time.Second * 3)
return status
@@ -159,7 +145,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
videoStreams, err := rtspClient.GetVideoStreams()
if err != nil || len(videoStreams) == 0 {
log.Log.Error("components.Kerberos.RunAgent(): no video stream found, might be the wrong codec (we only support H264 for the moment)")
rtspClient.Close(ctxRunAgent)
rtspClient.Close()
time.Sleep(time.Second * 3)
return status
}
@@ -175,27 +161,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
configuration.Config.Capture.IPCamera.Width = width
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
}
// Set the SPS and PPS values in the configuration.
configuration.Config.Capture.IPCamera.SPSNALUs = [][]byte{videoStream.SPS}
configuration.Config.Capture.IPCamera.PPSNALUs = [][]byte{videoStream.PPS}
configuration.Config.Capture.IPCamera.VPSNALUs = [][]byte{videoStream.VPS}
// Define queues for the main and sub stream.
var queue *packets.Queue
var subQueue *packets.Queue
@@ -217,7 +182,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
rtspSubClient := captureDevice.SetSubClient(subRtspUrl)
captureDevice.RTSPSubClient = rtspSubClient
err := rtspSubClient.Connect(ctx, ctxRunAgent)
err := rtspSubClient.Connect(context.Background())
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error connecting to RTSP sub stream: " + err.Error())
time.Sleep(time.Second * 3)
@@ -229,7 +194,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
videoSubStreams, err = rtspSubClient.GetVideoStreams()
if err != nil || len(videoSubStreams) == 0 {
log.Log.Error("components.Kerberos.RunAgent(): no video sub stream found, might be the wrong codec (we only support H264 for the moment)")
rtspSubClient.Close(ctxRunAgent)
rtspSubClient.Close()
time.Sleep(time.Second * 3)
return status
}
@@ -243,22 +208,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
// Set config values as well
configuration.Config.Capture.IPCamera.SubWidth = width
configuration.Config.Capture.IPCamera.SubHeight = height
// 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
}
}
// We are creating a queue to store the RTSP frames in, these frames will be
@@ -268,28 +217,28 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
// Set the maximum GOP count, this is used to determine the pre-recording time.
log.Log.Info("components.Kerberos.RunAgent(): SetMaxGopCount was set with: " + strconv.Itoa(int(config.Capture.PreRecording)+1))
queue.SetMaxGopCount(1) // We will adjust this later on, when we have the GOP size.
queue.SetMaxGopCount(int(config.Capture.PreRecording) + 1) // GOP time frame is set to prerecording (we'll add 2 gops to leave some room).
queue.WriteHeader(videoStreams)
go rtspClient.Start(ctx, "main", queue, configuration, communication)
go rtspClient.Start(context.Background(), "main", queue, configuration, communication)
// Main stream is connected and ready to go.
communication.MainStreamConnected = true
// Try to create backchannel
rtspBackChannelClient := captureDevice.SetBackChannelClient(rtspUrl)
err = rtspBackChannelClient.ConnectBackChannel(ctx, ctxRunAgent)
err = rtspBackChannelClient.ConnectBackChannel(context.Background())
if err == nil {
log.Log.Info("components.Kerberos.RunAgent(): opened RTSP backchannel stream: " + rtspUrl)
go rtspBackChannelClient.StartBackChannel(ctx, ctxRunAgent)
go rtspBackChannelClient.StartBackChannel(context.Background())
}
rtspSubClient := captureDevice.RTSPSubClient
if subStreamEnabled && rtspSubClient != nil {
subQueue = packets.NewQueue()
communication.SubQueue = subQueue
subQueue.SetMaxGopCount(1) // GOP time frame is set to 1 for motion detection and livestreaming.
subQueue.SetMaxGopCount(3) // GOP time frame is set to prerecording (we'll add 2 gops to leave some room).
subQueue.WriteHeader(videoSubStreams)
go rtspSubClient.Start(ctx, "sub", subQueue, configuration, communication)
go rtspSubClient.Start(context.Background(), "sub", subQueue, configuration, communication)
// Sub stream is connected and ready to go.
communication.SubStreamConnected = true
@@ -305,7 +254,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
}
// Handle livestream HD (high resolution over WEBRTC)
communication.HandleLiveHDHandshake = make(chan models.LiveHDHandshake, 100)
communication.HandleLiveHDHandshake = make(chan models.RequestHDStreamPayload, 1)
if subStreamEnabled {
livestreamHDCursor := subQueue.Latest()
go cloud.HandleLiveStreamHD(livestreamHDCursor, configuration, communication, mqttClient, rtspSubClient)
@@ -318,7 +267,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
go capture.HandleRecordStream(queue, configDirectory, configuration, communication, rtspClient)
// Handle processing of motion
communication.HandleMotion = make(chan models.MotionDataPartial, 10)
communication.HandleMotion = make(chan models.MotionDataPartial, 1)
if subStreamEnabled {
motionCursor := subQueue.Latest()
go computervision.ProcessMotion(motionCursor, configuration, communication, mqttClient, rtspSubClient)
@@ -340,10 +289,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
go cloud.HandleUpload(configDirectory, configuration, communication)
// Handle ONVIF actions
communication.HandleONVIF = make(chan models.OnvifAction, 10)
communication.HandleONVIF = make(chan models.OnvifAction, 1)
go onvif.HandleONVIFActions(configuration, communication)
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
communication.HandleAudio = make(chan models.AudioDataPartial, 1)
if rtspBackChannelClient.HasBackChannel {
communication.HasBackChannel = true
go WriteAudioToBackchannel(communication, rtspBackChannelClient)
@@ -352,9 +301,6 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
// If we reach this point, we have a working RTSP connection.
communication.CameraConnected = true
// Otel end span
span.End()
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
// This will go into a blocking state, once this channel is triggered
// the agent will cleanup and restart.
@@ -398,7 +344,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
time.Sleep(time.Second * 3)
err = rtspClient.Close(ctxRunAgent)
err = rtspClient.Close()
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP stream: " + err.Error())
time.Sleep(time.Second * 3)
@@ -410,7 +356,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
communication.Queue = nil
if subStreamEnabled {
err = rtspSubClient.Close(ctxRunAgent)
err = rtspSubClient.Close()
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP sub stream: " + err.Error())
time.Sleep(time.Second * 3)
@@ -421,7 +367,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
communication.SubQueue = nil
}
err = rtspBackChannelClient.Close(ctxRunAgent)
err = rtspBackChannelClient.Close()
if err != nil {
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP backchannel stream: " + err.Error())
}
@@ -447,37 +393,6 @@ 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.
@@ -512,8 +427,7 @@ func ControlAgent(communication *models.Communication) {
// After 15 seconds without activity this is thrown..
if occurence == 3 {
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()))
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream.")
select {
case communication.HandleBootstrap <- "restart":
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
@@ -540,8 +454,6 @@ 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.")
@@ -588,11 +500,6 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
// The total number of recordings stored in the directory.
recordingDirectory := configDirectory + "/data/recordings"
numberOfRecordings := utils.NumberOfMP4sInDirectory(recordingDirectory)
activeWebRTCReaders := webrtc.GetActivePeerConnectionCount()
pendingWebRTCHandshakes := 0
if communication.HandleLiveHDHandshake != nil {
pendingWebRTCHandshakes = len(communication.HandleLiveHDHandshake)
}
// All days stored in this agent.
days := []string{}
@@ -615,8 +522,6 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
"cameraOnline": cameraIsOnline,
"cloudOnline": cloudIsOnline,
"numberOfRecordings": numberOfRecordings,
"webrtcReaders": activeWebRTCReaders,
"webrtcPending": pendingWebRTCHandshakes,
"days": days,
"latestEvents": latestEvents,
})
@@ -750,7 +655,7 @@ func MakeRecording(c *gin.Context, communication *models.Communication) {
// @Success 200
func GetSnapshotBase64(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)
base64Image := capture.Base64Image(captureDevice, communication)
if base64Image != "" {
communication.Image = base64Image
}
@@ -772,8 +677,7 @@ func GetSnapshotRaw(c *gin.Context, captureDevice *capture.Capture, configuratio
image := capture.JpegImage(captureDevice, communication)
// encode image to jpeg
imageResized, _ := utils.ResizeImage(&image, uint(configuration.Config.Capture.IPCamera.BaseWidth), uint(configuration.Config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
bytes, _ := utils.ImageToBytes(&image)
// Return image/jpeg
c.Data(200, "image/jpeg", bytes)
@@ -787,24 +691,10 @@ 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 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.")
// We'll try to get a snapshot from the camera.
base64Image := capture.Base64Image(captureDevice, communication)
if base64Image != "" {
communication.Image = base64Image
}
c.JSON(200, gin.H{

View File

@@ -21,7 +21,6 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
var isPixelChangeThresholdReached = false
var changesToReturn = 0
var motionRectangle models.MotionRectangle
pixelThreshold := config.Capture.PixelChangeThreshold
// Might not be set in the config file, so set it to 150
@@ -63,34 +62,16 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
}
// A user might have set the base width and height for the IPCamera.
// This means also the polygon coordinates are set to a specific width and height (which might be different than the actual packets
// received from the IPCamera). So we will resize the polygon coordinates to the base width and height.
baseWidthRatio := 1.0
baseHeightRatio := 1.0
baseWidth := config.Capture.IPCamera.BaseWidth
baseHeight := config.Capture.IPCamera.BaseHeight
if baseWidth > 0 && baseHeight > 0 {
// We'll get the first image to calculate the ratio
img := imageArray[0]
if img != nil {
bounds := img.Bounds()
rows := bounds.Dy()
cols := bounds.Dx()
baseWidthRatio = float64(cols) / float64(baseWidth)
baseHeightRatio = float64(rows) / float64(baseHeight)
}
}
// Calculate mask
var polyObjects []geo.Polygon
if config.Region != nil {
for _, polygon := range config.Region.Polygon {
coords := polygon.Coordinates
poly := geo.Polygon{}
for _, c := range coords {
x := c.X * baseWidthRatio
y := c.Y * baseHeightRatio
x := c.X
y := c.Y
p := geo.NewPoint(x, y)
if !poly.Contains(p) {
poly.Add(p)
@@ -151,7 +132,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
if detectMotion {
// Remember additional information about the result of findmotion
isPixelChangeThresholdReached, changesToReturn, motionRectangle = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
isPixelChangeThresholdReached, changesToReturn = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
if isPixelChangeThresholdReached {
// If offline mode is disabled, send a message to the hub
@@ -169,7 +150,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("computervision.main.ProcessMotion(): failed to package MQTT message: " + err.Error())
}
@@ -183,7 +164,6 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
dataToPass := models.MotionDataPartial{
Timestamp: time.Now().Unix(),
NumberOfChanges: changesToReturn,
Rectangle: motionRectangle,
}
communication.HandleMotion <- dataToPass //Save data to the channel
}
@@ -205,58 +185,24 @@ 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, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int) {
image1 := imageArray[0]
image2 := imageArray[1]
image3 := imageArray[2]
threshold := 60
changes, motionRectangle := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
return changes > pixelChangeThreshold, changes, motionRectangle
changes := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
return changes > pixelChangeThreshold, changes
}
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle) {
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) int {
changes := 0
var pixelList [][]int
for i := 0; i < len(coordinatesToCheck); i++ {
pixel := coordinatesToCheck[i]
diff := int(img3.Pix[pixel]) - int(img1.Pix[pixel])
diff2 := int(img3.Pix[pixel]) - int(img2.Pix[pixel])
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()})
}
}
// Calculate rectangle of pixelList (startX, startY, endX, endY)
var motionRectangle models.MotionRectangle
if len(pixelList) > 0 {
startX := pixelList[0][0]
startY := pixelList[0][1]
endX := startX
endY := startY
for _, pixel := range pixelList {
if pixel[0] < startX {
startX = pixel[0]
}
if pixel[1] < startY {
startY = pixel[1]
}
if pixel[0] > endX {
endX = pixel[0]
}
if pixel[1] > endY {
endY = pixel[1]
}
}
log.Log.Debugf("Rectangle of changes detected: startX: %d, startY: %d, endX: %d, endY: %d", startX, startY, endX, endY)
motionRectangle = models.MotionRectangle{
X: startX,
Y: startY,
Width: endX - startX,
Height: endY - startY,
}
log.Log.Debugf("Motion rectangle: %+v", motionRectangle)
}
return changes, motionRectangle
return changes
}

View File

@@ -135,12 +135,6 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
conjungo.Merge(&kerberosvault, configuration.CustomConfig.KStorage, opts)
configuration.Config.KStorage = &kerberosvault
// Merge Secondary Kerberos Vault settings
var kerberosvaultSecondary models.KStorage
conjungo.Merge(&kerberosvaultSecondary, configuration.GlobalConfig.KStorageSecondary, opts)
conjungo.Merge(&kerberosvaultSecondary, configuration.CustomConfig.KStorageSecondary, opts)
configuration.Config.KStorageSecondary = &kerberosvaultSecondary
// Merge Kerberos S3 settings
var s3 models.S3
conjungo.Merge(&s3, configuration.GlobalConfig.S3, opts)
@@ -189,120 +183,19 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
}
jsonFile.Close()
}
}
return
}
// 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.
// This function will override the configuration with environment variables.
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.
if configuration.Config.KStorageSecondary == nil {
configuration.Config.KStorageSecondary = &models.KStorage{}
}
for _, env := range environmentVariables {
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)
if strings.Contains(env, "AGENT_") {
key := strings.Split(env, "=")[0]
value := os.Getenv(key)
switch key {
/* General configuration */
@@ -336,15 +229,7 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.Capture.IPCamera.SubRTSP = value
break
/* Base width and height for the liveview and motion regions */
case "AGENT_CAPTURE_IPCAMERA_BASE_WIDTH":
configuration.Config.Capture.IPCamera.BaseWidth, _ = strconv.Atoi(value)
break
case "AGENT_CAPTURE_IPCAMERA_BASE_HEIGHT":
configuration.Config.Capture.IPCamera.BaseHeight, _ = strconv.Atoi(value)
break
/* ONVIF connnection settings */
/* ONVIF connnection settings */
case "AGENT_CAPTURE_IPCAMERA_ONVIF":
configuration.Config.Capture.IPCamera.ONVIF = value
break
@@ -497,11 +382,6 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.MQTTPassword = value
break
/* MQTT chunking of low-resolution images into multiple messages */
case "AGENT_CAPTURE_LIVEVIEW_CHUNKING":
configuration.Config.Capture.LiveviewChunking = value
break
/* Real-time streaming of keyframes to a MQTT topic */
case "AGENT_REALTIME_PROCESSING":
configuration.Config.RealtimeProcessing = value
@@ -556,7 +436,7 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.S3.Region = value
break
/* When storing in a Vault */
/* When storing in a Kerberos Vault */
case "AGENT_KERBEROSVAULT_URI":
configuration.Config.KStorage.URI = value
break
@@ -573,37 +453,6 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
configuration.Config.KStorage.Directory = value
break
/* Retry policy and timeout */
case "AGENT_KERBEROSVAULT_MAX_RETRIES":
maxRetries, err := strconv.Atoi(value)
if err == nil {
configuration.Config.KStorage.MaxRetries = maxRetries
}
break
case "AGENT_KERBEROSVAULT_TIMEOUT":
timeout, err := strconv.Atoi(value)
if err == nil {
configuration.Config.KStorage.Timeout = timeout
}
break
/* When storing in a secondary Vault */
case "AGENT_KERBEROSVAULT_SECONDARY_URI":
configuration.Config.KStorageSecondary.URI = value
break
case "AGENT_KERBEROSVAULT_SECONDARY_ACCESS_KEY":
configuration.Config.KStorageSecondary.AccessKey = value
break
case "AGENT_KERBEROSVAULT_SECONDARY_SECRET_KEY":
configuration.Config.KStorageSecondary.SecretAccessKey = value
break
case "AGENT_KERBEROSVAULT_SECONDARY_PROVIDER":
configuration.Config.KStorageSecondary.Provider = value
break
case "AGENT_KERBEROSVAULT_SECONDARY_DIRECTORY":
configuration.Config.KStorageSecondary.Directory = value
break
/* When storing in dropbox */
case "AGENT_DROPBOX_ACCESS_TOKEN":
configuration.Config.Dropbox.AccessToken = value
@@ -629,33 +478,9 @@ func applyAgentEnvVars(configuration *models.Configuration, prefix string, apply
case "AGENT_ENCRYPTION_SYMMETRIC_KEY":
configuration.Config.Encryption.SymmetricKey = value
break
/* When signing is enabled */
case "AGENT_SIGNING":
configuration.Config.Signing.Enabled = value
break
case "AGENT_SIGNING_PRIVATE_KEY":
signingPrivateKey := strings.ReplaceAll(value, "\\n", "\n")
configuration.Config.Signing.PrivateKey = signingPrivateKey
break
}
}
}
// 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 {
@@ -695,10 +520,6 @@ func StoreConfig(configDirectory string, config models.Config) error {
config.Encryption.PrivateKey = encryptionPrivateKey
}
// Reset the basewidth and baseheight
config.Capture.IPCamera.BaseWidth = 0
config.Capture.IPCamera.BaseHeight = 0
// Save into database
if os.Getenv("DEPLOYMENT") == "factory" || os.Getenv("MACHINERY_ENVIRONMENT") == "kubernetes" {
// Write to mongodb

View File

@@ -118,16 +118,6 @@ func (self *Logging) Info(sentence string) {
}
}
func (self *Logging) Infof(format string, args ...interface{}) {
switch self.Logger {
case "go-logging":
gologging.Infof(format, args...)
case "logrus":
logrus.Infof(format, args...)
default:
}
}
func (self *Logging) Warning(sentence string) {
switch self.Logger {
case "go-logging":
@@ -148,16 +138,6 @@ func (self *Logging) Debug(sentence string) {
}
}
func (self *Logging) Debugf(format string, args ...interface{}) {
switch self.Logger {
case "go-logging":
gologging.Debugf(format, args...)
case "logrus":
logrus.Debugf(format, args...)
default:
}
}
func (self *Logging) Error(sentence string) {
switch self.Logger {
case "go-logging":

View File

@@ -8,17 +8,6 @@ import (
"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 {
@@ -38,7 +27,7 @@ type Communication struct {
HandleHeartBeat chan string
HandleLiveSD chan int64
HandleLiveHDKeepalive chan string
HandleLiveHDHandshake chan LiveHDHandshake
HandleLiveHDHandshake chan RequestHDStreamPayload
HandleLiveHDPeers chan string
HandleONVIF chan OnvifAction
IsConfiguring *abool.AtomicBool

View File

@@ -28,7 +28,6 @@ type Config struct {
Cloud string `json:"cloud" bson:"cloud"`
S3 *S3 `json:"s3,omitempty" bson:"s3,omitempty"`
KStorage *KStorage `json:"kstorage,omitempty" bson:"kstorage,omitempty"`
KStorageSecondary *KStorage `json:"kstorage_secondary,omitempty" bson:"kstorage_secondary,omitempty"`
Dropbox *Dropbox `json:"dropbox,omitempty" bson:"dropbox,omitempty"`
MQTTURI string `json:"mqtturi" bson:"mqtturi,omitempty"`
MQTTUsername string `json:"mqtt_username" bson:"mqtt_username"`
@@ -46,7 +45,6 @@ type Config struct {
HubSite string `json:"hub_site" bson:"hub_site"`
ConditionURI string `json:"condition_uri" bson:"condition_uri"`
Encryption *Encryption `json:"encryption,omitempty" bson:"encryption,omitempty"`
Signing *Signing `json:"signing,omitempty" bson:"signing,omitempty"`
RealtimeProcessing string `json:"realtimeprocessing,omitempty" bson:"realtimeprocessing,omitempty"`
RealtimeProcessingTopic string `json:"realtimeprocessing_topic" bson:"realtimeprocessing_topic"`
}
@@ -62,11 +60,9 @@ type Capture struct {
Snapshots string `json:"snapshots,omitempty"`
Motion string `json:"motion,omitempty"`
Liveview string `json:"liveview,omitempty"`
LiveviewChunking string `json:"liveview_chunking,omitempty" bson:"liveview_chunking,omitempty"`
Continuous string `json:"continuous,omitempty"`
PostRecording int64 `json:"postrecording"`
PreRecording int64 `json:"prerecording"`
GopSize int `json:"gopsize,omitempty" bson:"gopsize,omitempty"` // GOP size in seconds, used for pre-recording
MaxLengthRecording int64 `json:"maxlengthrecording"`
TranscodingWebRTC string `json:"transcodingwebrtc"`
TranscodingResolution int64 `json:"transcodingresolution"`
@@ -79,28 +75,18 @@ type Capture struct {
// IPCamera configuration, such as the RTSP url of the IPCamera and the FPS.
// Also includes ONVIF integration
type IPCamera struct {
RTSP string `json:"rtsp"`
Width int `json:"width"`
Height int `json:"height"`
FPS string `json:"fps"`
SubRTSP string `json:"sub_rtsp"`
SubWidth int `json:"sub_width"`
SubHeight int `json:"sub_height"`
BaseWidth int `json:"base_width"`
BaseHeight int `json:"base_height"`
SubFPS string `json:"sub_fps"`
ONVIF string `json:"onvif,omitempty" bson:"onvif"`
ONVIFXAddr string `json:"onvif_xaddr" bson:"onvif_xaddr"`
ONVIFUsername string `json:"onvif_username" bson:"onvif_username"`
ONVIFPassword string `json:"onvif_password" bson:"onvif_password"`
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"`
RTSP string `json:"rtsp"`
Width int `json:"width"`
Height int `json:"height"`
FPS string `json:"fps"`
SubRTSP string `json:"sub_rtsp"`
SubWidth int `json:"sub_width"`
SubHeight int `json:"sub_height"`
SubFPS string `json:"sub_fps"`
ONVIF string `json:"onvif,omitempty" bson:"onvif"`
ONVIFXAddr string `json:"onvif_xaddr" bson:"onvif_xaddr"`
ONVIFUsername string `json:"onvif_username" bson:"onvif_username"`
ONVIFPassword string `json:"onvif_password" bson:"onvif_password"`
}
// USBCamera configuration, such as the device path (/dev/video*)
@@ -172,8 +158,6 @@ type KStorage struct {
SecretAccessKey string `json:"secret_access_key,omitempty" bson:"secret_access_key,omitempty"`
Provider string `json:"provider,omitempty" bson:"provider,omitempty"`
Directory string `json:"directory,omitempty" bson:"directory,omitempty"`
MaxRetries int `json:"max_retries,omitempty" bson:"max_retries,omitempty"`
Timeout int `json:"timeout,omitempty" bson:"timeout,omitempty"`
}
// Dropbox integration
@@ -190,9 +174,3 @@ type Encryption struct {
PrivateKey string `json:"private_key" bson:"private_key"`
SymmetricKey string `json:"symmetric_key" bson:"symmetric_key"`
}
// Signing
type Signing struct {
Enabled string `json:"enabled" bson:"enabled"`
PrivateKey string `json:"private_key" bson:"private_key"`
}

View File

@@ -132,7 +132,6 @@ type Message struct {
// The payload structure which is used to send over
// and receive messages from the MQTT broker
type Payload struct {
Version string `json:"version"` // Version of the message, e.g. "1.0"
Action string `json:"action"`
DeviceId string `json:"device_id"`
Signature string `json:"signature"`

View File

@@ -1,9 +1,8 @@
package models
type MotionDataPartial struct {
Timestamp int64 `json:"timestamp" bson:"timestamp"`
NumberOfChanges int `json:"numberOfChanges" bson:"numberOfChanges"`
Rectangle MotionRectangle `json:"rectangle" bson:"rectangle"`
Timestamp int64 `json:"timestamp" bson:"timestamp"`
NumberOfChanges int `json:"numberOfChanges" bson:"numberOfChanges"`
}
type MotionDataFull struct {
@@ -15,10 +14,3 @@ type MotionDataFull struct {
NumberOfChanges int `json:"numberOfChanges" bson:"numberOfChanges"`
Token int `json:"token" bson:"token"`
}
type MotionRectangle struct {
X int `json:"x" bson:"x"`
Y int `json:"y" bson:"y"`
Width int `json:"width" bson:"width"`
Height int `json:"height" bson:"height"`
}

View File

@@ -14,10 +14,8 @@ type Packet struct {
IsKeyFrame bool // video packet is key frame
Idx int8 // stream index in container format
Codec string // codec name
CompositionTime int64 // composition offset (PTS - DTS) in milliseconds, non-zero for H264/H265 B-frames
CompositionTime int64 // packet presentation time minus decode time for H264 B-Frame
Time int64 // packet decode time
TimeLegacy time.Duration
CurrentTime int64 // current time in milliseconds (UNIX timestamp)
Data []byte // packet data
Gopsize int // size of the GOP
}

View File

@@ -45,11 +45,6 @@ func (self *Queue) SetMaxGopCount(n int) {
return
}
func (self *Queue) GetMaxGopCount() int {
n := self.maxgopcount
return n
}
func (self *Queue) WriteHeader(streams []Stream) error {
self.lock.Lock()

View File

@@ -1,9 +1,6 @@
package packets
type Stream struct {
// The ID of the stream.
Index int `json:"index" bson:"index"`
// The name of the stream.
Name string
@@ -42,13 +39,4 @@ type Stream struct {
// IsBackChannel is true if this stream is a back channel.
IsBackChannel bool
// SampleRate is the sample rate of the audio stream.
SampleRate int
// Channels is the number of audio channels.
Channels int
// GopSize is the size of the GOP (Group of Pictures).
GopSize int
}

View File

@@ -14,14 +14,7 @@ import (
func JWTMiddleWare() jwt.GinJWTMiddleware {
identityKey := "id"
// 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"
}
myKey := "TOBECHANGED"
m := jwt.GinJWTMiddleware{
Realm: "kerberosio",
@@ -113,11 +106,7 @@ func JWTMiddleWare() jwt.GinJWTMiddleware {
// - "query:<name>"
// - "cookie:<name>"
// - "param:<name>"
// 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: "header: Authorization, query: token, cookie: jwt",
// TokenLookup: "query:token",
// TokenLookup: "cookie:token",

View File

@@ -15,98 +15,96 @@ import (
func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) *gin.RouterGroup {
r.GET("/ws", func(c *gin.Context) {
websocket.WebsocketHandler(c, configuration, communication, captureDevice)
websocket.WebsocketHandler(c, communication, captureDevice)
})
// This is legacy should be removed in future! Now everything
// lives under the /api prefix.
r.GET("/config", authMiddleware.MiddlewareFunc(), func(c *gin.Context) {
r.GET("/config", func(c *gin.Context) {
components.GetConfig(c, captureDevice, configuration, communication)
})
// This is legacy should be removed in future! Now everything
// lives under the /api prefix.
r.POST("/config", authMiddleware.MiddlewareFunc(), func(c *gin.Context) {
r.POST("/config", func(c *gin.Context) {
components.UpdateConfig(c, configDirectory, configuration, communication)
})
api := r.Group("/api")
{
// Public endpoints (no authentication required)
api.POST("/login", authMiddleware.LoginHandler)
// Apply JWT authentication middleware.
// All routes registered below this line require a valid JWT token.
api.GET("/dashboard", func(c *gin.Context) {
components.GetDashboard(c, configDirectory, configuration, communication)
})
api.POST("/latest-events", func(c *gin.Context) {
components.GetLatestEvents(c, configDirectory, configuration, communication)
})
api.GET("/days", func(c *gin.Context) {
components.GetDays(c, configDirectory, configuration, communication)
})
api.GET("/config", func(c *gin.Context) {
components.GetConfig(c, captureDevice, configuration, communication)
})
api.POST("/config", func(c *gin.Context) {
components.UpdateConfig(c, configDirectory, configuration, communication)
})
// Will verify the current hub settings.
api.POST("/hub/verify", func(c *gin.Context) {
cloud.VerifyHub(c)
})
// Will verify the current persistence settings.
api.POST("/persistence/verify", func(c *gin.Context) {
cloud.VerifyPersistence(c, configDirectory)
})
// Camera specific methods. Doesn't require any authorization.
// These are available for anyone, but require the agent, to reach
// the camera.
api.POST("/camera/restart", func(c *gin.Context) {
components.RestartAgent(c, communication)
})
api.POST("/camera/stop", func(c *gin.Context) {
components.StopAgent(c, communication)
})
api.POST("/camera/record", func(c *gin.Context) {
components.MakeRecording(c, communication)
})
api.GET("/camera/snapshot/jpeg", func(c *gin.Context) {
components.GetSnapshotRaw(c, captureDevice, configuration, communication)
})
api.GET("/camera/snapshot/base64", func(c *gin.Context) {
components.GetSnapshotBase64(c, captureDevice, configuration, communication)
})
// Onvif specific methods. Doesn't require any authorization.
// Will verify the current onvif settings.
api.POST("/camera/onvif/verify", onvif.VerifyOnvifConnection)
api.POST("/camera/onvif/login", LoginToOnvif)
api.POST("/camera/onvif/capabilities", GetOnvifCapabilities)
api.POST("/camera/onvif/presets", GetOnvifPresets)
api.POST("/camera/onvif/gotopreset", GoToOnvifPreset)
api.POST("/camera/onvif/pantilt", DoOnvifPanTilt)
api.POST("/camera/onvif/zoom", DoOnvifZoom)
api.POST("/camera/onvif/inputs", DoGetDigitalInputs)
api.POST("/camera/onvif/outputs", DoGetRelayOutputs)
api.POST("/camera/onvif/outputs/:output", DoTriggerRelayOutput)
api.POST("/camera/verify/:streamType", capture.VerifyCamera)
// Secured endpoints..
api.Use(authMiddleware.MiddlewareFunc())
{
api.GET("/dashboard", func(c *gin.Context) {
components.GetDashboard(c, configDirectory, configuration, communication)
})
api.POST("/latest-events", func(c *gin.Context) {
components.GetLatestEvents(c, configDirectory, configuration, communication)
})
api.GET("/days", func(c *gin.Context) {
components.GetDays(c, configDirectory, configuration, communication)
})
api.GET("/config", func(c *gin.Context) {
components.GetConfig(c, captureDevice, configuration, communication)
})
api.POST("/config", func(c *gin.Context) {
components.UpdateConfig(c, configDirectory, configuration, communication)
})
// Will verify the hub settings.
api.POST("/hub/verify", func(c *gin.Context) {
cloud.VerifyHub(c)
})
// Will verify the persistence settings.
api.POST("/persistence/verify", func(c *gin.Context) {
cloud.VerifyPersistence(c, configDirectory)
})
// Will verify the secondary persistence settings.
api.POST("/persistence/secondary/verify", func(c *gin.Context) {
cloud.VerifySecondaryPersistence(c, configDirectory)
})
// Camera specific methods.
api.POST("/camera/restart", func(c *gin.Context) {
components.RestartAgent(c, communication)
})
api.POST("/camera/stop", func(c *gin.Context) {
components.StopAgent(c, communication)
})
api.POST("/camera/record", func(c *gin.Context) {
components.MakeRecording(c, communication)
})
api.GET("/camera/snapshot/jpeg", func(c *gin.Context) {
components.GetSnapshotRaw(c, captureDevice, configuration, communication)
})
api.GET("/camera/snapshot/base64", func(c *gin.Context) {
components.GetSnapshotBase64(c, captureDevice, configuration, communication)
})
// Onvif specific methods.
api.POST("/camera/onvif/verify", onvif.VerifyOnvifConnection)
api.POST("/camera/onvif/login", LoginToOnvif)
api.POST("/camera/onvif/capabilities", GetOnvifCapabilities)
api.POST("/camera/onvif/presets", GetOnvifPresets)
api.POST("/camera/onvif/gotopreset", GoToOnvifPreset)
api.POST("/camera/onvif/pantilt", DoOnvifPanTilt)
api.POST("/camera/onvif/zoom", DoOnvifZoom)
api.POST("/camera/onvif/inputs", DoGetDigitalInputs)
api.POST("/camera/onvif/outputs", DoGetRelayOutputs)
api.POST("/camera/onvif/outputs/:output", DoTriggerRelayOutput)
api.POST("/camera/verify/:streamType", capture.VerifyCamera)
}
}
return api

View File

@@ -11,7 +11,6 @@ import (
"math/rand"
"strconv"
"strings"
"sync"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
@@ -91,32 +90,10 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
// Some extra options to make sure the connection behaves
// properly. More information here: github.com/eclipse/paho.mqtt.golang.
//opts.SetCleanSession(true)
opts.SetCleanSession(false)
opts.SetResumeSubs(true)
opts.SetStore(mqtt.NewMemoryStore())
opts.SetCleanSession(true)
opts.SetConnectRetry(true)
opts.SetAutoReconnect(true)
opts.SetConnectRetryInterval(5 * time.Second)
opts.SetMaxReconnectInterval(1 * time.Minute)
opts.SetKeepAlive(30 * time.Second)
opts.SetPingTimeout(10 * time.Second)
opts.SetWriteTimeout(10 * time.Second)
opts.SetOrderMatters(false)
//opts.SetAutoReconnect(true)
opts.SetConnectTimeout(30 * time.Second)
opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
if err != nil {
log.Log.Error("routers.mqtt.main.ConfigureMQTT(): MQTT connection lost: " + err.Error())
} else {
log.Log.Error("routers.mqtt.main.ConfigureMQTT(): MQTT connection lost")
}
})
opts.SetReconnectingHandler(func(client mqtt.Client, options *mqtt.ClientOptions) {
log.Log.Warning("routers.mqtt.main.ConfigureMQTT(): reconnecting to MQTT broker")
})
opts.SetOnConnectHandler(func(c mqtt.Client) {
log.Log.Info("routers.mqtt.main.ConfigureMQTT(): MQTT session is online")
})
hubKey := ""
// This is the old way ;)
@@ -146,6 +123,7 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
opts.SetClientID(mqttClientID)
log.Log.Info("routers.mqtt.main.ConfigureMQTT(): Set ClientID " + mqttClientID)
rand.Seed(time.Now().UnixNano())
webrtc.CandidateArrays = make(map[string](chan string))
opts.OnConnect = func(c mqtt.Client) {
// We managed to connect to the MQTT broker, hurray!
@@ -156,14 +134,10 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
}
}
mqc := mqtt.NewClient(opts)
if token := mqc.Connect(); token.WaitTimeout(30 * time.Second) {
if token := mqc.Connect(); token.WaitTimeout(3 * time.Second) {
if token.Error() != nil {
log.Log.Error("routers.mqtt.main.ConfigureMQTT(): unable to establish mqtt broker connection, error was: " + token.Error().Error())
} else {
log.Log.Info("routers.mqtt.main.ConfigureMQTT(): initial MQTT connection established")
}
} else {
log.Log.Error("routers.mqtt.main.ConfigureMQTT(): timed out while establishing mqtt broker connection")
}
return mqc
}
@@ -171,52 +145,12 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
return nil
}
// 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 == "" {
log.Log.Info("routers.mqtt.main.MQTTListenerHandler(): no hub key provided, not subscribing to kerberos/hub/{hubkey}")
} else {
agentListener := fmt.Sprintf("kerberos/agent/%s", hubKey)
token := mqttClient.Subscribe(agentListener, 1, func(c mqtt.Client, msg mqtt.Message) {
mqttClient.Subscribe(agentListener, 1, func(c mqtt.Client, msg mqtt.Message) {
// Decode the message, we are expecting following format.
// {
@@ -316,15 +250,6 @@ 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)
// 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)
@@ -352,16 +277,6 @@ func MQTTListenerHandler(mqttClient mqtt.Client, hubKey string, configDirectory
}
})
if token.WaitTimeout(10 * time.Second) {
if token.Error() != nil {
log.Log.Error("routers.mqtt.main.MQTTListenerHandler(): failed to subscribe to " + agentListener + ": " + token.Error().Error())
} else {
log.Log.Info("routers.mqtt.main.MQTTListenerHandler(): subscribed to " + agentListener)
}
} else {
log.Log.Error("routers.mqtt.main.MQTTListenerHandler(): timed out while subscribing to " + agentListener)
}
}
}
@@ -426,7 +341,7 @@ func HandleGetPTZPosition(mqttClient mqtt.Client, hubKey string, payload models.
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("routers.mqtt.main.HandlePTZPosition(): something went wrong while sending position to hub: " + string(payload))
}
@@ -474,6 +389,13 @@ func HandleRequestConfig(mqttClient mqtt.Client, hubKey string, payload models.P
// Copy the config, as we don't want to share the encryption part.
deepCopy := configuration.Config
// We need a fix for the width and height if a substream.
// The ROI requires the width and height of the sub stream.
if configuration.Config.Capture.IPCamera.SubRTSP != "" {
deepCopy.Capture.IPCamera.Width = configuration.Config.Capture.IPCamera.SubWidth
deepCopy.Capture.IPCamera.Height = configuration.Config.Capture.IPCamera.SubHeight
}
var configMap map[string]interface{}
inrec, _ := json.Marshal(deepCopy)
json.Unmarshal(inrec, &configMap)
@@ -490,7 +412,7 @@ func HandleRequestConfig(mqttClient mqtt.Client, hubKey string, payload models.P
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("routers.mqtt.main.HandleRequestConfig(): something went wrong while sending config to hub: " + string(payload))
}
@@ -529,7 +451,7 @@ func HandleUpdateConfig(mqttClient mqtt.Client, hubKey string, payload models.Pa
}
payload, err := models.PackageMQTTMessage(configuration, message)
if err == nil {
mqttClient.Publish("kerberos/hub/"+hubKey, 2, false, payload)
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
} else {
log.Log.Info("routers.mqtt.main.HandleUpdateConfig(): something went wrong while sending acknowledge config to hub: " + string(payload))
}
@@ -568,24 +490,11 @@ 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 {
log.Log.Error("routers.mqtt.main.HandleRequestHDStream(): handshake channel is nil, dropping request")
return
}
communication.HandleLiveHDHandshake <- models.LiveHDHandshake{
Payload: requestHDStreamPayload,
select {
case communication.HandleLiveHDHandshake <- requestHDStreamPayload:
default:
}
log.Log.Info("routers.mqtt.main.HandleRequestHDStream(): received request to setup webrtc.")
} else {

View File

@@ -6,7 +6,6 @@ import (
"image"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@@ -15,7 +14,6 @@ import (
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/kerberos-io/agent/machinery/src/packets"
"github.com/kerberos-io/agent/machinery/src/utils"
"github.com/kerberos-io/agent/machinery/src/webrtc"
)
type Message struct {
@@ -30,23 +28,6 @@ type Connection struct {
Cancels map[string]context.CancelFunc
}
func writeWebRTCError(connection *Connection, clientID string, sessionID string, errorMessage string) {
if connection == nil {
return
}
if err := connection.WriteJson(Message{
ClientID: clientID,
MessageType: "webrtc-error",
Message: map[string]string{
"session_id": sessionID,
"message": errorMessage,
},
}); err != nil {
log.Log.Error("routers.websocket.main.writeWebRTCError(): " + err.Error())
}
}
// Concurrency handling - sending messages
func (c *Connection) WriteJson(message Message) error {
c.mu.Lock()
@@ -68,7 +49,7 @@ var upgrader = websocket.Upgrader{
},
}
func WebsocketHandler(c *gin.Context, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
func WebsocketHandler(c *gin.Context, communication *models.Communication, captureDevice *capture.Capture) {
w := c.Writer
r := c.Request
conn, err := upgrader.Upgrade(w, r, nil)
@@ -131,85 +112,9 @@ func WebsocketHandler(c *gin.Context, configuration *models.Configuration, commu
ctx, cancel := context.WithCancel(context.Background())
sockets[clientID].Cancels["stream-sd"] = cancel
go ForwardSDStream(ctx, clientID, sockets[clientID], configuration, communication, captureDevice)
go ForwardSDStream(ctx, clientID, sockets[clientID], communication, captureDevice)
}
}
case "stream-hd":
sessionID := message.Message["session_id"]
sessionDescription := message.Message["sdp"]
if sessionID == "" || sessionDescription == "" {
writeWebRTCError(sockets[clientID], clientID, sessionID, "missing session_id or sdp")
break
}
if !communication.CameraConnected {
writeWebRTCError(sockets[clientID], clientID, sessionID, "camera is not connected")
break
}
if communication.HandleLiveHDHandshake == nil {
writeWebRTCError(sockets[clientID], clientID, sessionID, "webrtc liveview is not available")
break
}
handshake := models.LiveHDHandshake{
Payload: models.RequestHDStreamPayload{
Timestamp: time.Now().Unix(),
SessionID: sessionID,
SessionDescription: sessionDescription,
},
Signaling: &models.LiveHDSignalingCallbacks{
SendAnswer: func(callbackSessionID string, sdp string) error {
return sockets[clientID].WriteJson(Message{
ClientID: clientID,
MessageType: "webrtc-answer",
Message: map[string]string{
"session_id": callbackSessionID,
"sdp": sdp,
},
})
},
SendCandidate: func(callbackSessionID string, candidate string) error {
return sockets[clientID].WriteJson(Message{
ClientID: clientID,
MessageType: "webrtc-candidate",
Message: map[string]string{
"session_id": callbackSessionID,
"candidate": candidate,
},
})
},
SendError: func(callbackSessionID string, errorMessage string) error {
writeWebRTCError(sockets[clientID], clientID, callbackSessionID, errorMessage)
return nil
},
},
}
communication.HandleLiveHDHandshake <- handshake
case "webrtc-candidate":
sessionID := message.Message["session_id"]
candidate := message.Message["candidate"]
if sessionID == "" || candidate == "" {
writeWebRTCError(sockets[clientID], clientID, sessionID, "missing session_id or candidate")
break
}
if !communication.CameraConnected {
writeWebRTCError(sockets[clientID], clientID, sessionID, "camera is not connected")
break
}
key := configuration.Config.Key + "/" + sessionID
go webrtc.RegisterCandidates(key, models.ReceiveHDCandidatesPayload{
Timestamp: time.Now().Unix(),
SessionID: sessionID,
Candidate: candidate,
})
}
err = conn.ReadJSON(&message)
@@ -226,7 +131,7 @@ func WebsocketHandler(c *gin.Context, configuration *models.Configuration, commu
}
}
func ForwardSDStream(ctx context.Context, clientID string, connection *Connection, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
func ForwardSDStream(ctx context.Context, clientID string, connection *Connection, communication *models.Communication, captureDevice *capture.Capture) {
var queue *packets.Queue
var cursor *packets.QueueCursor
@@ -254,10 +159,7 @@ logreader:
var img image.YCbCr
img, err = (*rtspClient).DecodePacket(pkt)
if err == nil {
config := configuration.Config
// Resize the image to the base width and height
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
bytes, _ := utils.ImageToBytes(imageResized)
bytes, _ := utils.ImageToBytes(&img)
encodedImage = base64.StdEncoding.EncodeToString(bytes)
} else {
continue

View File

@@ -21,15 +21,9 @@ import (
"github.com/kerberos-io/agent/machinery/src/encryption"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/kerberos-io/agent/machinery/src/models"
"github.com/nfnt/resize"
)
// VERSION is the agent version. It defaults to "0.0.0" for local dev builds
// and is overridden at build time via:
//
// go build -ldflags "-X github.com/kerberos-io/agent/machinery/src/utils.VERSION=v1.2.3"
var VERSION = "0.0.0"
const VERSION = "3.2.6"
const letterBytes = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
@@ -199,13 +193,6 @@ func GetMediaFormatted(files []os.FileInfo, recordingDirectory string, configura
timestampInt, err := strconv.ParseInt(timestamp, 10, 64)
if err == nil {
if eventFilter.TimestampOffsetStart > 0 {
// TimestampOffsetStart represents the newest lower bound to include.
if timestampInt < eventFilter.TimestampOffsetStart {
continue
}
}
// If we have an offset we will check if we should skip or not
if eventFilter.TimestampOffsetEnd > 0 {
// Medias are sorted from new to older. TimestampOffsetEnd holds the oldest
@@ -414,31 +401,9 @@ func Decrypt(directoryOrFile string, symmetricKey []byte) {
}
}
func ImageToBytes(img *image.Image) ([]byte, error) {
func ImageToBytes(img image.Image) ([]byte, error) {
buffer := new(bytes.Buffer)
w := bufio.NewWriter(buffer)
err := jpeg.Encode(w, *img, &jpeg.Options{Quality: 35})
log.Log.Debug("ImageToBytes() - buffer size: " + strconv.Itoa(buffer.Len()))
err := jpeg.Encode(w, img, &jpeg.Options{Quality: 15})
return buffer.Bytes(), err
}
func ResizeImage(img image.Image, newWidth uint, newHeight uint) (*image.Image, error) {
if img == nil {
return nil, errors.New("image is nil")
}
// resize to width 640 using Lanczos resampling
// and preserve aspect ratio
m := resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
return &m, nil
}
func ResizeHeightWithAspectRatio(newWidth int, width int, height int) (int, int) {
if newWidth <= 0 || width <= 0 || height <= 0 {
return width, height
}
// Calculate the new height based on the aspect ratio
newHeight := (newWidth * height) / width
// Return the new dimensions
return newWidth, newHeight
}

View File

@@ -1,54 +0,0 @@
package utils
import (
"os"
"testing"
"time"
"github.com/kerberos-io/agent/machinery/src/models"
)
type stubFileInfo struct {
name string
}
func (s stubFileInfo) Name() string { return s.name }
func (s stubFileInfo) Size() int64 { return 0 }
func (s stubFileInfo) Mode() os.FileMode { return 0 }
func (s stubFileInfo) ModTime() time.Time { return time.Unix(0, 0) }
func (s stubFileInfo) IsDir() bool { return false }
func (s stubFileInfo) Sys() interface{} { return nil }
func TestGetMediaFormattedHonorsTimestampRange(t *testing.T) {
configuration := &models.Configuration{}
configuration.Config.Timezone = "UTC"
configuration.Config.Name = "Front Door"
configuration.Config.Key = "camera-1"
files := []os.FileInfo{
stubFileInfo{name: "1700000200_6_7_8_9_10.mp4"},
stubFileInfo{name: "1700000100_6_7_8_9_10.mp4"},
stubFileInfo{name: "1700000000_6_7_8_9_10.mp4"},
}
media := GetMediaFormatted(files, "/tmp/recordings", configuration, models.EventFilter{
TimestampOffsetStart: 1700000050,
TimestampOffsetEnd: 1700000200,
NumberOfElements: 10,
})
if len(media) != 1 {
t.Fatalf("expected 1 media item in time range, got %d", len(media))
}
if media[0].Timestamp != "1700000100" {
t.Fatalf("expected timestamp 1700000100, got %s", media[0].Timestamp)
}
if media[0].CameraName != "Front Door" {
t.Fatalf("expected camera name to be preserved, got %s", media[0].CameraName)
}
if media[0].CameraKey != "camera-1" {
t.Fatalf("expected camera key to be preserved, got %s", media[0].CameraKey)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,176 +0,0 @@
package video
import (
"fmt"
"os"
"testing"
mp4ff "github.com/Eyevinn/mp4ff/mp4"
"github.com/kerberos-io/agent/machinery/src/models"
)
// TestMP4Duration creates an MP4 file simulating a 5-second video recording
// and verifies that the durations in all boxes match the sum of sample durations.
func TestMP4Duration(t *testing.T) {
tmpFile := "/tmp/test_duration.mp4"
defer os.Remove(tmpFile)
// Minimal SPS for H.264 (baseline, 640x480) - proper Annex B format with start code
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
pps := []byte{0x68, 0xce, 0x38, 0x80}
mp4Video := NewMP4(tmpFile, [][]byte{sps}, [][]byte{pps}, nil, 10)
mp4Video.SetWidth(640)
mp4Video.SetHeight(480)
videoTrack := mp4Video.AddVideoTrack("H264")
// Simulate 5 seconds at 25fps (200 frames, keyframe every 50 frames = 2s)
// PTS in milliseconds (timescale=1000)
frameDuration := uint64(40) // 40ms per frame = 25fps
numFrames := 150
gopSize := 50
// Create a fake Annex B NAL unit (keyframe IDR = type 5, non-keyframe = type 1)
makeFrame := func(isKey bool) []byte {
nalType := byte(0x01) // non-IDR slice
if isKey {
nalType = 0x65 // IDR slice
}
// Start code (4 bytes) + NAL header + some data
frame := []byte{0x00, 0x00, 0x00, 0x01, nalType}
// Add some padding data
for i := 0; i < 100; i++ {
frame = append(frame, byte(i))
}
return frame
}
var expectedDuration uint64
for i := 0; i < numFrames; i++ {
pts := uint64(i) * frameDuration
isKeyframe := i%gopSize == 0
err := mp4Video.AddSampleToTrack(videoTrack, isKeyframe, makeFrame(isKeyframe), pts, 0)
if err != nil {
t.Fatalf("AddSampleToTrack failed at frame %d: %v", i, err)
}
}
expectedDuration = uint64(numFrames) * frameDuration // Should be 6000ms (150 * 40)
// Close with config that has signing key to avoid nil panics
config := &models.Config{
Signing: &models.Signing{
PrivateKey: "",
},
}
mp4Video.Close(config)
// Log what the code computed
t.Logf("VideoTotalDuration: %d ms", mp4Video.VideoTotalDuration)
t.Logf("Expected duration: %d ms", expectedDuration)
t.Logf("Segments: %d", len(mp4Video.SegmentDurations))
var sumSegDur uint64
for i, d := range mp4Video.SegmentDurations {
t.Logf(" Segment %d: duration=%d ms", i, d)
sumSegDur += d
}
t.Logf("Sum of segment durations: %d ms", sumSegDur)
// Now read back the file and inspect the boxes
f, err := os.Open(tmpFile)
if err != nil {
t.Fatalf("Failed to open output file: %v", err)
}
defer f.Close()
fi, err := f.Stat()
if err != nil {
t.Fatalf("Failed to stat output file: %v", err)
}
parsedFile, err := mp4ff.DecodeFile(f)
if err != nil {
t.Fatalf("Failed to decode MP4: %v", err)
}
t.Logf("File size: %d bytes", fi.Size())
// Check moov box
if parsedFile.Moov == nil {
t.Fatal("No moov box found")
}
// Check mvhd duration
mvhd := parsedFile.Moov.Mvhd
t.Logf("mvhd.Duration: %d (timescale=%d) = %.2f seconds", mvhd.Duration, mvhd.Timescale, float64(mvhd.Duration)/float64(mvhd.Timescale))
t.Logf("mvhd.Rate: 0x%08x", mvhd.Rate)
t.Logf("mvhd.Volume: 0x%04x", mvhd.Volume)
// Check each trak
for i, trak := range parsedFile.Moov.Traks {
t.Logf("Track %d:", i)
t.Logf(" tkhd.Duration: %d", trak.Tkhd.Duration)
t.Logf(" mdhd.Duration: %d (timescale=%d) = %.2f seconds", trak.Mdia.Mdhd.Duration, trak.Mdia.Mdhd.Timescale, float64(trak.Mdia.Mdhd.Duration)/float64(trak.Mdia.Mdhd.Timescale))
}
// Check mvex/mehd
if parsedFile.Moov.Mvex != nil && parsedFile.Moov.Mvex.Mehd != nil {
t.Logf("mehd.FragmentDuration: %d", parsedFile.Moov.Mvex.Mehd.FragmentDuration)
}
// Sum up actual sample durations from trun boxes in all segments
var actualTrunDuration uint64
var sampleCount int
for _, seg := range parsedFile.Segments {
for _, frag := range seg.Fragments {
for _, traf := range frag.Moof.Trafs {
// Only count video track (track 1)
if traf.Tfhd.TrackID == 1 {
for _, trun := range traf.Truns {
for _, s := range trun.Samples {
actualTrunDuration += uint64(s.Dur)
sampleCount++
}
}
}
}
}
}
t.Logf("Actual trun sample count: %d", sampleCount)
t.Logf("Actual trun total duration: %d ms", actualTrunDuration)
// Check sidx
if parsedFile.Sidx != nil {
var sidxDuration uint64
for _, ref := range parsedFile.Sidx.SidxRefs {
sidxDuration += uint64(ref.SubSegmentDuration)
}
t.Logf("sidx total duration: %d ms", sidxDuration)
}
// VERIFY: All duration values should be consistent
// The expected duration for 150 frames at 40ms each:
// - The sample-buffering pattern means the LAST sample uses LastVideoSampleDTS as duration
// - So all 150 samples should produce 150 * 40ms = 6000ms total
// But due to the pending sample pattern, the actual trun durations might differ
fmt.Println()
fmt.Println("=== DURATION CONSISTENCY CHECK ===")
fmt.Printf("Expected (150 * 40ms): %d ms\n", expectedDuration)
fmt.Printf("mvhd.Duration: %d ms\n", mvhd.Duration)
fmt.Printf("tkhd.Duration: %d ms\n", parsedFile.Moov.Traks[0].Tkhd.Duration)
fmt.Printf("mdhd.Duration: %d ms\n", parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration)
fmt.Printf("Actual trun durations sum: %d ms\n", actualTrunDuration)
fmt.Printf("VideoTotalDuration: %d ms\n", mp4Video.VideoTotalDuration)
fmt.Printf("Sum of SegmentDurations: %d ms\n", sumSegDur)
fmt.Println()
// The key assertion: header duration must equal trun sum
if mvhd.Duration != actualTrunDuration {
t.Errorf("MISMATCH: mvhd.Duration (%d) != actual trun sum (%d), diff = %d ms",
mvhd.Duration, actualTrunDuration, int64(mvhd.Duration)-int64(actualTrunDuration))
}
if parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration != 0 {
t.Errorf("MISMATCH: mdhd.Duration should be 0 for fragmented MP4, got %d",
parsedFile.Moov.Traks[0].Mdia.Mdhd.Duration)
}
}

View File

@@ -1,194 +0,0 @@
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)
}

View File

@@ -1,270 +0,0 @@
// AAC to G.711 µ-law transcoder using FFmpeg (libavcodec + libswresample).
// Build with: go build -tags ffmpeg ...
//
// Requires: libavcodec-dev, libavutil-dev, libswresample-dev (FFmpeg ≥ 5.x)
// and an AAC decoder compiled into the FFmpeg build (usually the default).
//
//go:build ffmpeg
package webrtc
/*
#cgo pkg-config: libavcodec libavutil libswresample
#cgo CFLAGS: -Wno-deprecated-declarations
#include <libavcodec/avcodec.h>
#include <libavutil/channel_layout.h>
#include <libavutil/frame.h>
#include <libavutil/mem.h>
#include <libavutil/opt.h>
#include <libswresample/swresample.h>
#include <stdlib.h>
#include <string.h>
// ── Transcoder handle ───────────────────────────────────────────────────
typedef struct {
AVCodecContext *codec_ctx;
AVCodecParserContext *parser;
SwrContext *swr_ctx;
AVFrame *frame;
AVPacket *pkt;
int swr_initialized;
int in_sample_rate;
int in_channels;
} aac_transcoder_t;
// ── Create / Destroy ────────────────────────────────────────────────────
static aac_transcoder_t* aac_transcoder_create(void) {
const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_AAC);
if (!codec) return NULL;
aac_transcoder_t *t = (aac_transcoder_t*)calloc(1, sizeof(aac_transcoder_t));
if (!t) return NULL;
t->codec_ctx = avcodec_alloc_context3(codec);
if (!t->codec_ctx) { free(t); return NULL; }
if (avcodec_open2(t->codec_ctx, codec, NULL) < 0) {
avcodec_free_context(&t->codec_ctx);
free(t);
return NULL;
}
t->parser = av_parser_init(AV_CODEC_ID_AAC);
if (!t->parser) {
avcodec_free_context(&t->codec_ctx);
free(t);
return NULL;
}
t->frame = av_frame_alloc();
t->pkt = av_packet_alloc();
if (!t->frame || !t->pkt) {
if (t->frame) av_frame_free(&t->frame);
if (t->pkt) av_packet_free(&t->pkt);
av_parser_close(t->parser);
avcodec_free_context(&t->codec_ctx);
free(t);
return NULL;
}
return t;
}
static void aac_transcoder_destroy(aac_transcoder_t *t) {
if (!t) return;
if (t->swr_ctx) swr_free(&t->swr_ctx);
if (t->frame) av_frame_free(&t->frame);
if (t->pkt) av_packet_free(&t->pkt);
if (t->parser) av_parser_close(t->parser);
if (t->codec_ctx) avcodec_free_context(&t->codec_ctx);
free(t);
}
// ── Lazy resampler init (called after the first decoded frame) ──────────
static int aac_init_swr(aac_transcoder_t *t) {
int64_t in_ch_layout = (int64_t)t->codec_ctx->channel_layout;
if (in_ch_layout == 0)
in_ch_layout = av_get_default_channel_layout(t->codec_ctx->channels);
t->swr_ctx = swr_alloc_set_opts(
NULL,
AV_CH_LAYOUT_MONO, // out: mono
AV_SAMPLE_FMT_S16, // out: signed 16-bit
8000, // out: 8 kHz
in_ch_layout, // in: from decoder
t->codec_ctx->sample_fmt, // in: from decoder
t->codec_ctx->sample_rate, // in: from decoder
0, NULL);
if (!t->swr_ctx) return -1;
if (swr_init(t->swr_ctx) < 0) {
swr_free(&t->swr_ctx);
return -1;
}
t->in_sample_rate = t->codec_ctx->sample_rate;
t->in_channels = t->codec_ctx->channels;
t->swr_initialized = 1;
return 0;
}
// ── Transcode ADTS → 8 kHz mono S16 PCM ────────────────────────────────
// Caller must free *out_pcm with av_free() when non-NULL.
static int aac_transcode_to_pcm(aac_transcoder_t *t,
const uint8_t *data, int data_size,
uint8_t **out_pcm, int *out_size) {
*out_pcm = NULL;
*out_size = 0;
if (!data || data_size <= 0) return 0;
int buf_cap = 8192;
uint8_t *buf = (uint8_t*)av_malloc(buf_cap);
if (!buf) return -1;
int buf_len = 0;
while (data_size > 0) {
uint8_t *pout = NULL;
int pout_size = 0;
int used = av_parser_parse2(t->parser, t->codec_ctx,
&pout, &pout_size,
data, data_size,
AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
if (used < 0) break;
data += used;
data_size -= used;
if (pout_size == 0) continue;
// Feed parsed frame to decoder
t->pkt->data = pout;
t->pkt->size = pout_size;
if (avcodec_send_packet(t->codec_ctx, t->pkt) < 0) continue;
// Pull all decoded frames
while (avcodec_receive_frame(t->codec_ctx, t->frame) == 0) {
if (!t->swr_initialized) {
if (aac_init_swr(t) < 0) {
av_frame_unref(t->frame);
av_free(buf);
return -1;
}
}
int out_samples = swr_get_out_samples(t->swr_ctx,
t->frame->nb_samples);
if (out_samples <= 0) out_samples = t->frame->nb_samples;
int needed = buf_len + out_samples * 2; // S16 = 2 bytes/sample
if (needed > buf_cap) {
buf_cap = needed * 2;
uint8_t *tmp = (uint8_t*)av_realloc(buf, buf_cap);
if (!tmp) { av_frame_unref(t->frame); av_free(buf); return -1; }
buf = tmp;
}
uint8_t *dst = buf + buf_len;
int converted = swr_convert(t->swr_ctx,
&dst, out_samples,
(const uint8_t**)t->frame->extended_data,
t->frame->nb_samples);
if (converted > 0)
buf_len += converted * 2;
av_frame_unref(t->frame);
}
}
if (buf_len == 0) {
av_free(buf);
return 0;
}
*out_pcm = buf;
*out_size = buf_len;
return 0;
}
*/
import "C"
import (
"errors"
"fmt"
"unsafe"
"github.com/kerberos-io/agent/machinery/src/log"
"github.com/zaf/g711"
)
// AACTranscodingAvailable reports whether AAC→PCMU transcoding
// is compiled in (requires the "ffmpeg" build tag).
func AACTranscodingAvailable() bool { return true }
// AACTranscoder decodes ADTS-wrapped AAC audio to 8 kHz mono PCM
// and encodes it as G.711 µ-law for WebRTC transport.
type AACTranscoder struct {
handle *C.aac_transcoder_t
}
// NewAACTranscoder creates a transcoder backed by FFmpeg's AAC decoder.
func NewAACTranscoder() (*AACTranscoder, error) {
h := C.aac_transcoder_create()
if h == nil {
return nil, errors.New("failed to create AAC transcoder (FFmpeg AAC decoder not available?)")
}
log.Log.Info("webrtc.aac_transcoder: AAC → G.711 µ-law transcoder initialised (FFmpeg)")
return &AACTranscoder{handle: h}, nil
}
// Transcode converts an ADTS buffer (one or more AAC frames) into
// G.711 µ-law encoded audio suitable for a PCMU WebRTC track.
func (t *AACTranscoder) Transcode(adtsData []byte) ([]byte, error) {
if t == nil || t.handle == nil || len(adtsData) == 0 {
return nil, nil
}
var outPCM *C.uint8_t
var outSize C.int
ret := C.aac_transcode_to_pcm(
t.handle,
(*C.uint8_t)(unsafe.Pointer(&adtsData[0])),
C.int(len(adtsData)),
&outPCM, &outSize,
)
if ret < 0 {
return nil, errors.New("AAC decode/resample failed")
}
if outSize == 0 || outPCM == nil {
return nil, nil // decoder buffering, no output yet
}
defer C.av_free(unsafe.Pointer(outPCM))
// Copy S16LE PCM to Go slice, then encode to µ-law.
pcm := C.GoBytes(unsafe.Pointer(outPCM), outSize)
ulaw := g711.EncodeUlaw(pcm)
// Log resampler details once.
if t.handle.swr_initialized == 1 && t.handle.in_sample_rate != 0 {
log.Log.Info(fmt.Sprintf(
"webrtc.aac_transcoder: first output resampling %d Hz / %d ch → 8000 Hz mono → µ-law",
int(t.handle.in_sample_rate), int(t.handle.in_channels)))
// Prevent repeated logging by zeroing the field we check.
t.handle.in_sample_rate = 0
}
return ulaw, nil
}
// Close releases all FFmpeg resources held by the transcoder.
func (t *AACTranscoder) Close() {
if t != nil && t.handle != nil {
C.aac_transcoder_destroy(t.handle)
t.handle = nil
log.Log.Info("webrtc.aac_transcoder: transcoder closed")
}
}

View File

@@ -1,205 +0,0 @@
// AAC transcoding fallback that uses the ffmpeg binary at runtime.
// Build with -tags ffmpeg to use the in-process CGO implementation instead.
//
//go:build !ffmpeg
package webrtc
import (
"bytes"
"errors"
"io"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"github.com/kerberos-io/agent/machinery/src/log"
)
// AACTranscodingAvailable reports whether AAC→PCMU transcoding
// is available in the current runtime.
func AACTranscodingAvailable() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}
// AACTranscoder uses an ffmpeg subprocess to convert ADTS AAC to raw PCMU.
type AACTranscoder struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderrBuf bytes.Buffer
mu sync.Mutex
outMu sync.Mutex
outBuf bytes.Buffer
closed bool
closeOnce sync.Once
}
// NewAACTranscoder creates a runtime ffmpeg-based transcoder.
func NewAACTranscoder() (*AACTranscoder, error) {
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
return nil, errors.New("AAC transcoding not available: ffmpeg binary not found in PATH")
}
log.Log.Info("webrtc.aac_transcoder: using ffmpeg binary at " + ffmpegPath)
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-fflags", "+nobuffer",
"-flags", "low_delay",
"-f", "aac",
"-i", "pipe:0",
"-vn",
"-ac", "1",
"-ar", "8000",
"-acodec", "pcm_mulaw",
"-f", "mulaw",
"pipe:1",
)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
cmd.Stderr = &bytes.Buffer{}
if err := cmd.Start(); err != nil {
return nil, err
}
t := &AACTranscoder{
cmd: cmd,
stdin: stdin,
stdout: stdout,
}
if stderrBuf, ok := cmd.Stderr.(*bytes.Buffer); ok {
t.stderrBuf = *stderrBuf
}
go func() {
buf := make([]byte, 4096)
for {
n, readErr := stdout.Read(buf)
if n > 0 {
t.outMu.Lock()
_, _ = t.outBuf.Write(buf[:n])
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))
}
}
if readErr != nil {
if readErr != io.EOF {
log.Log.Warning("webrtc.aac_transcoder: stdout reader stopped: " + readErr.Error())
}
return
}
}
}()
log.Log.Info("webrtc.aac_transcoder: AAC → PCMU transcoder initialised (ffmpeg process)")
return t, nil
}
// Transcode writes ADTS AAC to ffmpeg and returns any PCMU bytes produced.
func (t *AACTranscoder) Transcode(adtsData []byte) ([]byte, error) {
if t == nil || len(adtsData) == 0 {
return nil, nil
}
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return nil, errors.New("AAC transcoder is closed")
}
if _, err := t.stdin.Write(adtsData); err != nil {
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)))
}
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)))
return data, nil
}
if time.Now().After(deadline) {
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")
}
return nil, nil
}
time.Sleep(5 * time.Millisecond)
}
}
func (t *AACTranscoder) readAvailable() []byte {
t.outMu.Lock()
defer t.outMu.Unlock()
if t.outBuf.Len() == 0 {
return nil
}
out := make([]byte, t.outBuf.Len())
copy(out, t.outBuf.Bytes())
t.outBuf.Reset()
return out
}
func (t *AACTranscoder) stderrString() string {
if t == nil {
return ""
}
if stderrBuf, ok := t.cmd.Stderr.(*bytes.Buffer); ok {
return strings.TrimSpace(stderrBuf.String())
}
return strings.TrimSpace(t.stderrBuf.String())
}
// Close stops the ffmpeg subprocess.
func (t *AACTranscoder) Close() {
if t == nil {
return
}
t.closeOnce.Do(func() {
t.mu.Lock()
t.closed = true
if t.stdin != nil {
_ = t.stdin.Close()
}
t.mu.Unlock()
if t.stdout != nil {
_ = t.stdout.Close()
}
if t.cmd != nil {
_ = t.cmd.Process.Kill()
_, _ = t.cmd.Process.Wait()
if stderr := t.stderrString(); stderr != "" {
log.Log.Info("webrtc.aac_transcoder: ffmpeg stderr on close: " + stderr)
}
}
})
}

View File

@@ -1,137 +0,0 @@
package webrtc
import (
"io"
"sync"
"github.com/kerberos-io/agent/machinery/src/log"
pionWebRTC "github.com/pion/webrtc/v4"
pionMedia "github.com/pion/webrtc/v4/pkg/media"
)
const (
// peerSampleBuffer controls how many samples can be buffered per peer before
// dropping. Keeps slow peers from blocking the broadcaster.
peerSampleBuffer = 60
)
// peerTrack is a per-peer track with its own non-blocking sample channel.
type peerTrack struct {
track *pionWebRTC.TrackLocalStaticSample
samples chan pionMedia.Sample
done chan struct{}
}
// TrackBroadcaster fans out media samples to multiple peer-specific tracks
// without blocking. Each peer gets its own TrackLocalStaticSample and a
// goroutine that drains samples independently, so a slow/congested peer
// cannot stall the others.
type TrackBroadcaster struct {
mu sync.RWMutex
peers map[string]*peerTrack
mimeType string
id string
streamID string
}
// NewTrackBroadcaster creates a new broadcaster for either video or audio.
func NewTrackBroadcaster(mimeType string, id string, streamID string) *TrackBroadcaster {
return &TrackBroadcaster{
peers: make(map[string]*peerTrack),
mimeType: mimeType,
id: id,
streamID: streamID,
}
}
// AddPeer creates a new per-peer track and starts a writer goroutine.
// Returns the track to be added to the PeerConnection via AddTrack().
func (b *TrackBroadcaster) AddPeer(sessionKey string) (*pionWebRTC.TrackLocalStaticSample, error) {
track, err := pionWebRTC.NewTrackLocalStaticSample(
pionWebRTC.RTPCodecCapability{MimeType: b.mimeType},
b.id,
b.streamID,
)
if err != nil {
return nil, err
}
pt := &peerTrack{
track: track,
samples: make(chan pionMedia.Sample, peerSampleBuffer),
done: make(chan struct{}),
}
b.mu.Lock()
b.peers[sessionKey] = pt
b.mu.Unlock()
// Per-peer writer goroutine — drains samples independently.
go func() {
defer close(pt.done)
for sample := range pt.samples {
if err := pt.track.WriteSample(sample); err != nil {
if err == io.ErrClosedPipe {
return
}
log.Log.Error("webrtc.broadcaster.peerWriter(): error writing sample for " + sessionKey + ": " + err.Error())
}
}
}()
log.Log.Info("webrtc.broadcaster.AddPeer(): added peer track for " + sessionKey)
return track, nil
}
// RemovePeer stops the writer goroutine and removes the peer.
func (b *TrackBroadcaster) RemovePeer(sessionKey string) {
b.mu.Lock()
pt, exists := b.peers[sessionKey]
if exists {
delete(b.peers, sessionKey)
}
b.mu.Unlock()
if exists {
close(pt.samples)
<-pt.done // wait for writer goroutine to finish
log.Log.Info("webrtc.broadcaster.RemovePeer(): removed peer track for " + sessionKey)
}
}
// WriteSample fans out a sample to all connected peers without blocking.
// If a peer's buffer is full (slow consumer), the sample is dropped for
// that peer only — other peers are unaffected.
func (b *TrackBroadcaster) WriteSample(sample pionMedia.Sample) {
b.mu.RLock()
defer b.mu.RUnlock()
for sessionKey, pt := range b.peers {
select {
case pt.samples <- sample:
default:
log.Log.Warning("webrtc.broadcaster.WriteSample(): dropping sample for slow peer " + sessionKey)
}
}
}
// PeerCount returns the current number of connected peers.
func (b *TrackBroadcaster) PeerCount() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.peers)
}
// Close removes all peers and stops all writer goroutines.
func (b *TrackBroadcaster) Close() {
b.mu.Lock()
keys := make([]string, 0, len(b.peers))
for k := range b.peers {
keys = append(keys, k)
}
b.mu.Unlock()
for _, key := range keys {
b.RemovePeer(key)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -45,9 +45,9 @@
"crypto": false
},
"scripts": {
"start": "DISABLE_ESLINT_PLUGIN=true react-scripts start",
"build": "DISABLE_ESLINT_PLUGIN=true GENERATE_SOURCEMAP=false REACT_APP_ENVIRONMENT=production react-scripts build && rm -rf ../machinery/www && mv build ../machinery/www",
"test": "DISABLE_ESLINT_PLUGIN=true react-scripts test",
"start": "react-scripts start",
"build": "GENERATE_SOURCEMAP=false REACT_APP_ENVIRONMENT=production react-scripts build && rm -rf ../machinery/www && mv build ../machinery/www",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint --debug 'src/**/*.{js,jsx,ts,tsx}'",
"format": "prettier --write \"**/*.{js,jsx,json,md}\""

View File

@@ -9,7 +9,7 @@
},
"navigation": {
"profile": "Profile",
"admin": "Admin",
"admin": "admin",
"management": "Management",
"dashboard": "Dashboard",
"recordings": "Recordings",
@@ -32,11 +32,11 @@
"latest_events": "Latest events",
"configure_connection": "Configure connection",
"no_events": "No events",
"no_events_description": "No recordings were found, make sure your Agent is properly configured.",
"no_events_description": "No recordings where found, make sure your Kerberos Agent is properly configured.",
"motion_detected": "Motion was detected",
"live_view": "Live view",
"loading_live_view": "Loading live view",
"loading_live_view_description": "Hold on, we are loading your live view here. If you didn't configure your camera connection, update it on the settings pages.",
"loading_live_view_description": "Hold on we are loading your live view here. If you didn't configure your camera connection, update it on the settings pages.",
"time": "Time",
"description": "Description",
"name": "Name"
@@ -59,32 +59,32 @@
"persistence": "Persistence"
},
"info": {
"kerberos_hub_demo": "Have a look at our Hub demo environment, to see Hub in action!",
"configuration_updated_success": "Your configuration has been updated successfully.",
"kerberos_hub_demo": "Have a look at our Kerberos Hub demo environment, to see Kerberos Hub in action!",
"configuration_updated_success": "Your configuration have been updated successfully.",
"configuration_updated_error": "Something went wrong while saving.",
"verify_hub": "Verifying your Hub settings.",
"verify_hub_success": "Hub settings are successfully verified.",
"verify_hub_error": "Something went wrong while verifying Hub.",
"verify_hub": "Verifying your Kerberos Hub settings.",
"verify_hub_success": "Kerberos Hub settings are successfully verified.",
"verify_hub_error": "Something went wrong while verifying Kerberos Hub",
"verify_persistence": "Verifying your persistence settings.",
"verify_persistence_success": "Persistence settings are successfully verified.",
"verify_persistence_error": "Something went wrong while verifying the persistence.",
"verify_persistence_error": "Something went wrong while verifying the persistence",
"verify_camera": "Verifying your camera settings.",
"verify_camera_success": "Camera settings are successfully verified.",
"verify_camera_error": "Something went wrong while verifying the camera settings.",
"verify_camera_error": "Something went wrong while verifying the camera settings",
"verify_onvif": "Verifying your ONVIF settings.",
"verify_onvif_success": "ONVIF settings are successfully verified.",
"verify_onvif_error": "Something went wrong while verifying the ONVIF settings."
"verify_onvif_error": "Something went wrong while verifying the ONVIF settings"
},
"overview": {
"general": "General",
"description_general": "General settings for your Agent",
"description_general": "General settings for your Kerberos Agent",
"key": "Key",
"camera_name": "Camera name",
"camera_friendly_name": "Friendly name",
"timezone": "Timezone",
"select_timezone": "Select a timezone",
"advanced_configuration": "Advanced configuration",
"description_advanced_configuration": "Detailed configuration options to enable or disable specific parts of the Agent",
"description_advanced_configuration": "Detailed configuration options to enable or disable specific parts of the Kerberos Agent",
"offline_mode": "Offline mode",
"description_offline_mode": "Disable all outgoing traffic",
"encryption": "Encryption",
@@ -101,9 +101,9 @@
"camera": "Camera",
"description_camera": "Camera settings are required to make a connection to your camera of choice.",
"only_h264": "Currently only H264/H265 RTSP streams are supported.",
"rtsp_url": "RTSP URL",
"rtsp_url": "RTSP url",
"rtsp_h264": "A H264/H265 RTSP connection to your camera.",
"sub_rtsp_url": "Sub RTSP URL (used for livestreaming)",
"sub_rtsp_url": "Sub RTSP url (used for livestreaming)",
"sub_rtsp_h264": "A secondary RTSP connection to the low resolution of your camera.",
"onvif": "ONVIF",
"description_onvif": "Credentials to communicate with ONVIF capabilities. These are used for PTZ or other capabilities provided by the camera.",
@@ -115,28 +115,28 @@
},
"recording": {
"recording": "Recording",
"description_recording": "Specify how you would like to make recordings. Having a continuous 24/7 setup or a motion-based recording.",
"description_recording": "Specify how you would like to make recordings. Having a continuous 24/7 setup or a motion based recording.",
"continuous_recording": "Continuous recording",
"description_continuous_recording": "Make 24/7 or motion-based recordings.",
"max_duration": "Max video duration (seconds)",
"description_continuous_recording": "Make 24/7 or motion based recordings.",
"max_duration": "max video duration (seconds)",
"description_max_duration": "The maximum duration of a recording.",
"pre_recording": "Pre recording (key frames buffered)",
"pre_recording": "pre recording (key frames buffered)",
"description_pre_recording": "Seconds before an event occurred.",
"post_recording": "Post recording (seconds)",
"post_recording": "post recording (seconds)",
"description_post_recording": "Seconds after an event occurred.",
"threshold": "Recording threshold (pixels)",
"description_threshold": "The number of pixels changed to record.",
"description_threshold": "The number of pixels changed to record",
"autoclean": "Auto clean",
"description_autoclean": "Specify if the Agent can clean up recordings when a specific storage capacity (MB) is reached. This will remove the oldest recordings when the capacity is reached.",
"description_autoclean": "Specify if the Kerberos Agent can cleanup recordings when a specific storage capacity (MB) is reached. This will remove the oldest recordings when the capacity is reached.",
"autoclean_enable": "Enable auto clean",
"autoclean_description_enable": "Remove oldest recording when capacity reached.",
"autoclean_max_directory_size": "Maximum directory size (MB)",
"autoclean_description_max_directory_size": "The maximum MBs of recordings stored.",
"autoclean_description_max_directory_size": "The maximum MB's of recordings stored.",
"fragmentedrecordings": "Fragmented recordings",
"description_fragmentedrecordings": "When recordings are fragmented they are suitable for an HLS stream. When turned on, the MP4 container will look a bit different.",
"description_fragmentedrecordings": "When recordings are fragmented they are suitable for an HLS stream. When turned on the MP4 container will look a bit different.",
"fragmentedrecordings_enable": "Enable fragmentation",
"fragmentedrecordings_description_enable": "Fragmented recordings are required for HLS.",
"fragmentedrecordings_duration": "Fragment duration",
"fragmentedrecordings_duration": "fragment duration",
"fragmentedrecordings_description_duration": "Duration of a single fragment."
},
"streaming": {
@@ -149,16 +149,16 @@
"force_turn": "Force TURN",
"force_turn_description": "Force TURN usage, even when STUN is available.",
"stun_turn_forward": "Forwarding and transcoding",
"stun_turn_description_forward": "Optimizations and enhancements for TURN/STUN communication.",
"stun_turn_description_forward": "Optimisations and enhancements for TURN/STUN communication.",
"stun_turn_webrtc": "Forwarding to WebRTC broker",
"stun_turn_description_webrtc": "Forward H264 stream through MQTT",
"stun_turn_description_webrtc": "Forward h264 stream through MQTT",
"stun_turn_transcode": "Transcode stream",
"stun_turn_description_transcode": "Convert stream to a lower resolution",
"stun_turn_downscale": "Downscale resolution (in % of original resolution)",
"mqtt": "MQTT",
"description_mqtt": "An MQTT broker is used to communicate from",
"description2_mqtt": "to the Agent, to achieve for example livestreaming or ONVIF (PTZ) capabilities.",
"mqtt_brokeruri": "Broker URI",
"description_mqtt": "A MQTT broker is used to communicate from",
"description2_mqtt": "to the Kerberos Agent, to achieve for example livestreaming or ONVIF (PTZ) capabilities.",
"mqtt_brokeruri": "Broker Uri",
"mqtt_username": "Username",
"mqtt_password": "Password",
"realtimeprocessing": "Realtime Processing",
@@ -180,61 +180,55 @@
"friday": "Friday",
"saturday": "Saturday",
"externalcondition": "External Condition",
"description_externalcondition": "Depending on an external web service, recording can be enabled or disabled.",
"description_externalcondition": "Depending on an external webservice recording can be enabled or disabled.",
"regionofinterest": "Region Of Interest",
"description_regionofinterest": "By defining one or more regions, motion will be tracked only in the regions you have defined."
},
"persistence": {
"kerberoshub": "Hub",
"description_kerberoshub": "Agents can send heartbeats to a central",
"description2_kerberoshub": "installation. Heartbeats and other relevant information are synced to Hub to show realtime information about your video landscape.",
"kerberoshub": "Kerberos Hub",
"description_kerberoshub": "Kerberos Agents can send heartbeats to a central",
"description2_kerberoshub": "installation. Heartbeats and other relevant information are synced to Kerberos Hub to show realtime information about your video landscape.",
"persistence": "Persistence",
"secondary_persistence": "Secondary Persistence",
"description_secondary_persistence": "Recordings will be sent to secondary persistence if the primary persistence is unavailable or fails. This can be useful for failover purposes.",
"saasoffering": "Hub (SaaS offering)",
"saasoffering": "Kerberos Hub (SAAS offering)",
"description_persistence": "Having the ability to store your recordings is the beginning of everything. You can choose between our",
"description2_persistence": ", or a 3rd party provider",
"select_persistence": "Select a persistence",
"kerberoshub_encryption": "Encryption",
"kerberoshub_encryption_description": "All traffic from/to Hub will be encrypted using AES-256.",
"kerberoshub_proxyurl": "Hub Proxy URL",
"kerberoshub_encryption_description": "All traffic from/to Kerberos Hub will encrypted using AES-256.",
"kerberoshub_proxyurl": "Kerberos Hub Proxy URL",
"kerberoshub_description_proxyurl": "The Proxy endpoint for uploading your recordings.",
"kerberoshub_apiurl": "Hub API URL",
"kerberoshub_apiurl": "Kerberos Hub API URL",
"kerberoshub_description_apiurl": "The API endpoint for uploading your recordings.",
"kerberoshub_publickey": "Public key",
"kerberoshub_description_publickey": "The public key granted to your Hub account.",
"kerberoshub_description_publickey": "The public key granted to your Kerberos Hub account.",
"kerberoshub_privatekey": "Private key",
"kerberoshub_description_privatekey": "The private key granted to your Hub account.",
"kerberoshub_description_privatekey": "The private key granted to your Kerberos Hub account.",
"kerberoshub_site": "Site",
"kerberoshub_description_site": "The site ID the Agents belong to in Hub.",
"kerberoshub_description_site": "The site ID the Kerberos Agents are belonging to in Kerberos Hub.",
"kerberoshub_region": "Region",
"kerberoshub_description_region": "The region we are storing our recordings in.",
"kerberoshub_bucket": "Bucket",
"kerberoshub_description_bucket": "The bucket we are storing our recordings in.",
"kerberoshub_username": "Username/Directory (should match Hub username)",
"kerberoshub_description_username": "The username of your Hub account.",
"kerberosvault_apiurl": "Vault API URL",
"kerberosvault_description_apiurl": "The Vault API",
"kerberoshub_username": "Username/Directory (should match Kerberos Hub username)",
"kerberoshub_description_username": "The username of your Kerberos Hub account.",
"kerberosvault_apiurl": "Kerberos Vault API URL",
"kerberosvault_description_apiurl": "The Kerberos Vault API",
"kerberosvault_provider": "Provider",
"kerberosvault_description_provider": "The provider to which your recordings will be sent.",
"kerberosvault_directory": "Directory (should match Hub username)",
"kerberosvault_description_directory": "Subdirectory the recordings will be stored in your provider.",
"kerberosvault_description_provider": "The provider to which your recordings will be send.",
"kerberosvault_directory": "Directory (should match Kerberos Hub username)",
"kerberosvault_description_directory": "Sub directory the recordings will be stored in your provider.",
"kerberosvault_accesskey": "Access key",
"kerberosvault_description_accesskey": "The access key of your Vault account.",
"kerberosvault_description_accesskey": "The access key of your Kerberos Vault account.",
"kerberosvault_secretkey": "Secret key",
"kerberosvault_description_secretkey": "The secret key of your Vault account.",
"kerberosvault_maxretries": "Max retries",
"kerberosvault_description_maxretries": "The maximum number of retries to upload a recording.",
"kerberosvault_timeout": "Timeout",
"kerberosvault_description_timeout": "If a timeout occurs, recordings will be sent directly to the secondary Vault.",
"kerberosvault_description_secretkey": "The secret key of your Kerberos Vault account.",
"dropbox_directory": "Directory",
"dropbox_description_directory": "The subdirectory where the recordings will be stored in your Dropbox account.",
"dropbox_description_directory": "The sub directory where the recordings will be stored in your Dropbox account.",
"dropbox_accesstoken": "Access token",
"dropbox_description_accesstoken": "The access token of your Dropbox account/app.",
"verify_connection": "Verify Connection",
"remove_after_upload": "Once recordings are uploaded to some persistence, you might want to remove them from the local Agent.",
"remove_after_upload": "Once recordings are uploaded to some persistence, you might want to remove them from the local Kerberos Agent.",
"remove_after_upload_description": "Remove recordings after they are uploaded successfully.",
"remove_after_upload_enabled": "Enable delete on upload"
"remove_after_upload_enabled": "Enabled delete on upload"
}
}
}

View File

@@ -226,4 +226,4 @@
"remove_after_upload_enabled": "Abilita cancellazione al caricamento"
}
}
}
}

View File

@@ -226,4 +226,4 @@
"remove_after_upload_enabled": "Enabled delete on upload"
}
}
}
}

View File

@@ -1,234 +0,0 @@
{
"breadcrumb": {
"watch_recordings": "Xem bản ghi",
"configure": "Cấu hình"
},
"buttons": {
"save": "Lưu",
"verify_connection": "Xác minh kết nối"
},
"navigation": {
"profile": "Hồ sơ",
"admin": "Quản trị",
"management": "Quản lý",
"dashboard": "Bảng điều khiển",
"recordings": "Bản ghi",
"settings": "Cài đặt",
"help_support": "Trợ giúp & Hỗ trợ",
"swagger": "API Swagger",
"documentation": "Tài liệu",
"ui_library": "Thư viện UI",
"layout": "Ngôn ngữ & Bố cục",
"choose_language": "Chọn ngôn ngữ"
},
"dashboard": {
"title": "Bảng điều khiển",
"heading": "Tổng quan về giám sát video của bạn",
"number_of_days": "Số ngày",
"total_recordings": "Tổng số bản ghi",
"connected": "Đã kết nối",
"not_connected": "Chưa kết nối",
"offline_mode": "Chế độ ngoại tuyến",
"latest_events": "Sự kiện gần đây",
"configure_connection": "Cấu hình kết nối",
"no_events": "Không có sự kiện",
"no_events_description": "Không tìm thấy bản ghi nào, hãy đảm bảo Kerberos Agent của bạn được cấu hình đúng cách.",
"motion_detected": "Phát hiện chuyển động",
"live_view": "Xem trực tiếp",
"loading_live_view": "Đang tải xem trực tiếp",
"loading_live_view_description": "Vui lòng chờ trong khi chúng tôi tải xem trực tiếp của bạn. Nếu bạn chưa cấu hình kết nối camera, hãy cập nhật trong trang cài đặt.",
"time": "Thời gian",
"description": "Mô tả",
"name": "Tên"
},
"recordings": {
"title": "Bản ghi",
"heading": "Tất cả bản ghi của bạn ở một nơi",
"search_media": "Tìm kiếm phương tiện"
},
"settings": {
"title": "Cài đặt",
"heading": "Thiết lập camera của bạn",
"submenu": {
"all": "Tất cả",
"overview": "Tổng quan",
"camera": "Camera",
"recording": "Ghi hình",
"streaming": "Truyền phát",
"conditions": "Điều kiện",
"persistence": "Lưu trữ"
},
"info": {
"kerberos_hub_demo": "Xem thử môi trường demo của Kerberos Hub để thấy Kerberos Hub hoạt động như thế nào!",
"configuration_updated_success": "Cấu hình của bạn đã được cập nhật thành công.",
"configuration_updated_error": "Đã xảy ra lỗi khi lưu.",
"verify_hub": "Đang xác minh cài đặt Kerberos Hub của bạn.",
"verify_hub_success": "Cài đặt Kerberos Hub đã được xác minh thành công.",
"verify_hub_error": "Đã xảy ra lỗi khi xác minh Kerberos Hub",
"verify_persistence": "Đang xác minh cài đặt lưu trữ.",
"verify_persistence_success": "Cài đặt lưu trữ đã được xác minh thành công.",
"verify_persistence_error": "Đã xảy ra lỗi khi xác minh lưu trữ",
"verify_camera": "Đang xác minh cài đặt camera.",
"verify_camera_success": "Cài đặt camera đã được xác minh thành công.",
"verify_camera_error": "Đã xảy ra lỗi khi xác minh cài đặt camera",
"verify_onvif": "Đang xác minh cài đặt ONVIF.",
"verify_onvif_success": "Cài đặt ONVIF đã được xác minh thành công.",
"verify_onvif_error": "Đã xảy ra lỗi khi xác minh cài đặt ONVIF"
},
"overview": {
"general": "Chung",
"description_general": "Cài đặt chung cho Kerberos Agent của bạn",
"key": "Khóa",
"camera_name": "Tên camera",
"camera_friendly_name": "Tên thân thiện",
"timezone": "Múi giờ",
"select_timezone": "Chọn múi giờ",
"advanced_configuration": "Cấu hình nâng cao",
"description_advanced_configuration": "Tùy chọn cấu hình chi tiết để bật hoặc tắt các phần cụ thể của Kerberos Agent",
"offline_mode": "Chế độ ngoại tuyến",
"description_offline_mode": "Vô hiệu hóa toàn bộ lưu lượng đi",
"encryption": "Mã hóa",
"description_encryption": "Bật mã hóa cho toàn bộ lưu lượng đi. Các tin nhắn MQTT và/hoặc bản ghi sẽ được mã hóa bằng AES-256. Một khóa riêng tư được sử dụng để ký.",
"encryption_enabled": "Bật mã hóa MQTT",
"description_encryption_enabled": "Bật mã hóa cho toàn bộ tin nhắn MQTT.",
"encryption_recordings_enabled": "Bật mã hóa bản ghi",
"description_encryption_recordings_enabled": "Bật mã hóa cho tất cả các bản ghi.",
"encryption_fingerprint": "Dấu vân tay",
"encryption_privatekey": "Khóa riêng tư",
"encryption_symmetrickey": "Khóa đối xứng"
},
"camera": {
"camera": "Camera",
"description_camera": "Cài đặt camera là bắt buộc để kết nối với camera bạn chọn.",
"only_h264": "Hiện tại chỉ hỗ trợ luồng RTSP H264/H265.",
"rtsp_url": "URL RTSP",
"rtsp_h264": "Kết nối RTSP H264/H265 với camera của bạn.",
"sub_rtsp_url": "URL RTSP phụ (dùng để phát trực tiếp)",
"sub_rtsp_h264": "Kết nối RTSP phụ với độ phân giải thấp của camera.",
"onvif": "ONVIF",
"description_onvif": "Thông tin xác thực để giao tiếp với các chức năng ONVIF. Chúng được sử dụng cho PTZ hoặc các khả năng khác do camera cung cấp.",
"onvif_xaddr": "Địa chỉ ONVIF",
"onvif_username": "Tên người dùng ONVIF",
"onvif_password": "Mật khẩu ONVIF",
"verify_connection": "Xác minh kết nối",
"verify_sub_connection": "Xác minh kết nối phụ"
},
"recording": {
"recording": "Ghi hình",
"description_recording": "Chỉ định cách bạn muốn thực hiện ghi hình. Có thể ghi liên tục 24/7 hoặc dựa trên chuyển động.",
"continuous_recording": "Ghi hình liên tục",
"description_continuous_recording": "Ghi hình liên tục 24/7 hoặc dựa trên chuyển động.",
"max_duration": "Thời lượng video tối đa (giây)",
"description_max_duration": "Thời lượng tối đa của một bản ghi.",
"pre_recording": "Ghi trước (khung hình chính được lưu vào bộ đệm)",
"description_pre_recording": "Số giây trước khi sự kiện xảy ra.",
"post_recording": "Ghi sau (giây)",
"description_post_recording": "Số giây sau khi sự kiện xảy ra.",
"threshold": "Ngưỡng ghi hình (pixel)",
"description_threshold": "Số pixel thay đổi cần đạt để bắt đầu ghi hình.",
"autoclean": "Tự động dọn dẹp",
"description_autoclean": "Chỉ định xem Kerberos Agent có thể dọn dẹp các bản ghi khi dung lượng lưu trữ đạt giới hạn nhất định (MB) hay không. Hệ thống sẽ xóa bản ghi cũ nhất khi đạt giới hạn.",
"autoclean_enable": "Bật tự động dọn dẹp",
"autoclean_description_enable": "Xóa bản ghi cũ nhất khi đạt giới hạn dung lượng.",
"autoclean_max_directory_size": "Dung lượng thư mục tối đa (MB)",
"autoclean_description_max_directory_size": "Dung lượng tối đa (MB) của các bản ghi được lưu trữ.",
"fragmentedrecordings": "Ghi hình phân đoạn",
"description_fragmentedrecordings": "Khi các bản ghi được phân đoạn, chúng phù hợp để phát trực tuyến HLS. Khi bật, định dạng MP4 sẽ có một số khác biệt.",
"fragmentedrecordings_enable": "Bật ghi hình phân đoạn",
"fragmentedrecordings_description_enable": "Ghi hình phân đoạn là bắt buộc đối với HLS.",
"fragmentedrecordings_duration": "Thời lượng phân đoạn",
"fragmentedrecordings_description_duration": "Thời lượng của một phân đoạn duy nhất."
},
"streaming": {
"stun_turn": "STUN/TURN cho WebRTC",
"description_stun_turn": "Để phát trực tiếp độ phân giải đầy đủ, chúng tôi sử dụng khái niệm WebRTC. Một trong những tính năng chính là ICE-candidate, cho phép vượt qua NAT bằng STUN/TURN.",
"stun_server": "Máy chủ STUN",
"turn_server": "Máy chủ TURN",
"turn_username": "Tên người dùng",
"turn_password": "Mật khẩu",
"force_turn": "Buộc sử dụng TURN",
"force_turn_description": "Buộc sử dụng TURN ngay cả khi STUN có sẵn.",
"stun_turn_forward": "Chuyển tiếp và mã hóa",
"stun_turn_description_forward": "Tối ưu hóa và cải thiện giao tiếp TURN/STUN.",
"stun_turn_webrtc": "Chuyển tiếp đến WebRTC broker",
"stun_turn_description_webrtc": "Chuyển tiếp luồng H264 qua MQTT",
"stun_turn_transcode": "Chuyển mã luồng",
"stun_turn_description_transcode": "Chuyển đổi luồng sang độ phân giải thấp hơn",
"stun_turn_downscale": "Giảm độ phân giải (theo % của độ phân giải gốc)",
"mqtt": "MQTT",
"description_mqtt": "Một MQTT broker được sử dụng để giao tiếp từ",
"description2_mqtt": "đến Kerberos Agent, nhằm hỗ trợ phát trực tiếp hoặc chức năng ONVIF (PTZ).",
"mqtt_brokeruri": "Broker Uri",
"mqtt_username": "Tên người dùng",
"mqtt_password": "Mật khẩu",
"realtimeprocessing": "Xử lý thời gian thực",
"description_realtimeprocessing": "Bằng cách bật xử lý thời gian thực, bạn sẽ nhận được các khung hình video thời gian thực qua kết nối MQTT đã chỉ định.",
"realtimeprocessing_topic": "Chủ đề để xuất bản",
"realtimeprocessing_enabled": "Bật xử lý thời gian thực",
"description_realtimeprocessing_enabled": "Gửi khung hình video thời gian thực qua MQTT."
},
"conditions": {
"timeofinterest": "Thời gian quan tâm",
"description_timeofinterest": "Chỉ ghi hình trong các khoảng thời gian cụ thể (dựa trên múi giờ).",
"timeofinterest_enabled": "Đã bật",
"timeofinterest_description_enabled": "Nếu bật, bạn có thể chỉ định các khoảng thời gian ghi hình.",
"sunday": "Chủ nhật",
"monday": "Thứ hai",
"tuesday": "Thứ ba",
"wednesday": "Thứ tư",
"thursday": "Thứ năm",
"friday": "Thứ sáu",
"saturday": "Thứ bảy",
"externalcondition": "Điều kiện bên ngoài",
"description_externalcondition": "Tùy thuộc vào một dịch vụ web bên ngoài, việc ghi hình có thể được bật hoặc tắt.",
"regionofinterest": "Khu vực quan tâm",
"description_regionofinterest": "Bằng cách xác định một hoặc nhiều khu vực, hệ thống sẽ chỉ theo dõi chuyển động trong các khu vực bạn đã chọn."
},
"persistence": {
"kerberoshub": "Kerberos Hub",
"description_kerberoshub": "Các Kerberos Agent có thể gửi tín hiệu nhịp tim đến một hệ thống trung tâm",
"description2_kerberoshub": "để đồng bộ hóa thông tin quan trọng với Kerberos Hub, giúp hiển thị trạng thái giám sát video theo thời gian thực.",
"persistence": "Lưu trữ",
"saasoffering": "Kerberos Hub (dịch vụ SAAS)",
"description_persistence": "Khả năng lưu trữ bản ghi là bước khởi đầu của mọi thứ. Bạn có thể chọn giữa dịch vụ của chúng tôi",
"description2_persistence": "hoặc một nhà cung cấp bên thứ ba.",
"select_persistence": "Chọn phương thức lưu trữ",
"kerberoshub_encryption": "Mã hóa",
"kerberoshub_encryption_description": "Tất cả lưu lượng đến/từ Kerberos Hub sẽ được mã hóa bằng AES-256.",
"kerberoshub_proxyurl": "URL Proxy Kerberos Hub",
"kerberoshub_description_proxyurl": "Điểm cuối Proxy để tải bản ghi lên.",
"kerberoshub_apiurl": "URL API Kerberos Hub",
"kerberoshub_description_apiurl": "Điểm cuối API để tải bản ghi lên.",
"kerberoshub_publickey": "Khóa công khai",
"kerberoshub_description_publickey": "Khóa công khai được cấp cho tài khoản Kerberos Hub của bạn.",
"kerberoshub_privatekey": "Khóa riêng tư",
"kerberoshub_description_privatekey": "Khóa riêng tư được cấp cho tài khoản Kerberos Hub của bạn.",
"kerberoshub_site": "Trang web",
"kerberoshub_description_site": "ID trang web mà các Kerberos Agent thuộc về trong Kerberos Hub.",
"kerberoshub_region": "Khu vực",
"kerberoshub_description_region": "Khu vực nơi chúng tôi lưu trữ bản ghi.",
"kerberoshub_bucket": "Kho lưu trữ",
"kerberoshub_description_bucket": "Kho lưu trữ nơi chúng tôi lưu trữ bản ghi.",
"kerberoshub_username": "Tên người dùng / Thư mục (phải khớp với tên người dùng Kerberos Hub)",
"kerberoshub_description_username": "Tên người dùng tài khoản Kerberos Hub của bạn.",
"kerberosvault_apiurl": "URL API Kerberos Vault",
"kerberosvault_description_apiurl": "API của Kerberos Vault",
"kerberosvault_provider": "Nhà cung cấp",
"kerberosvault_description_provider": "Nhà cung cấp nơi bản ghi của bạn sẽ được gửi đến.",
"kerberosvault_directory": "Thư mục (phải khớp với tên người dùng Kerberos Hub)",
"kerberosvault_description_directory": "Thư mục con nơi các bản ghi sẽ được lưu trữ trong nhà cung cấp của bạn.",
"kerberosvault_accesskey": "Khóa truy cập",
"kerberosvault_description_accesskey": "Khóa truy cập của tài khoản Kerberos Vault của bạn.",
"kerberosvault_secretkey": "Khóa bí mật",
"kerberosvault_description_secretkey": "Khóa bí mật của tài khoản Kerberos Vault của bạn.",
"dropbox_directory": "Thư mục",
"dropbox_description_directory": "Thư mục con nơi bản ghi sẽ được lưu trữ trong tài khoản Dropbox của bạn.",
"dropbox_accesstoken": "Mã truy cập",
"dropbox_description_accesstoken": "Mã truy cập của tài khoản / ứng dụng Dropbox của bạn.",
"verify_connection": "Xác minh kết nối",
"remove_after_upload": "Sau khi bản ghi được tải lên một hệ thống lưu trữ, bạn có thể muốn xóa chúng khỏi Kerberos Agent cục bộ.",
"remove_after_upload_description": "Xóa bản ghi sau khi chúng được tải lên thành công.",
"remove_after_upload_enabled": "Bật xóa sau khi tải lên"
}
}
}

View File

@@ -4,7 +4,6 @@ import {
doVerifyOnvif,
doVerifyHub,
doVerifyPersistence,
doVerifySecondaryPersistence,
doGetKerberosAgentTags,
doGetDashboardInformation,
doGetEvents,
@@ -108,28 +107,6 @@ export const verifyPersistence = (config, onSuccess, onError) => {
};
};
export const verifySecondaryPersistence = (config, onSuccess, onError) => {
return (dispatch) => {
doVerifySecondaryPersistence(
config,
() => {
dispatch({
type: 'VERIFY_SECONDARY_PERSISTENCE',
});
if (onSuccess) {
onSuccess();
}
},
(error) => {
const { data } = error.response.data;
if (onError) {
onError(data);
}
}
);
};
};
export const verifyHub = (config, onSuccess, onError) => {
return (dispatch) => {
doVerifyHub(
@@ -194,7 +171,7 @@ export const getDashboardInformation = (onSuccess, onError) => {
};
};
export const getEvents = (eventfilter, onSuccess, onError, append = false) => {
export const getEvents = (eventfilter, onSuccess, onError) => {
return (dispatch) => {
doGetEvents(
eventfilter,
@@ -203,7 +180,6 @@ export const getEvents = (eventfilter, onSuccess, onError, append = false) => {
type: 'GET_EVENTS',
events: data.events,
filter: eventfilter,
append,
});
if (onSuccess) {
onSuccess();

View File

@@ -72,25 +72,6 @@ export function doVerifyPersistence(config, onSuccess, onError) {
});
}
export function doVerifySecondaryPersistence(config, onSuccess, onError) {
const endpoint = API.post(`persistence/secondary/verify`, {
...config,
});
endpoint
.then((res) => {
if (res.status !== 200) {
throw new Error(res.data);
}
return res.data;
})
.then((data) => {
onSuccess(data);
})
.catch((error) => {
onError(error);
});
}
export function doVerifyHub(config, onSuccess, onError) {
const endpoint = API.post(`hub/verify`, {
...config,

View File

@@ -7,7 +7,6 @@ import './ImageCanvas.css';
class ImageCanvas extends React.Component {
componentDidMount() {
this.isUnmounted = false;
this.width = 0;
this.height = 0;
@@ -59,9 +58,6 @@ class ImageCanvas extends React.Component {
const { image } = this.props;
this.loadImage(image, (img) => {
if (this.isUnmounted || !this.editor) {
return;
}
if (this.width !== img.width || this.height !== img.height) {
this.width = img.width;
this.height = img.height;
@@ -75,9 +71,6 @@ class ImageCanvas extends React.Component {
componentDidUpdate() {
const { image } = this.props;
this.loadImage(image, (img) => {
if (this.isUnmounted || !this.editor) {
return;
}
if (this.width !== img.width || this.height !== img.height) {
this.width = img.width;
this.height = img.height;
@@ -89,57 +82,11 @@ class ImageCanvas extends React.Component {
});
}
componentWillUnmount() {
this.isUnmounted = true;
if (this.pendingImage) {
this.pendingImage.onload = null;
this.pendingImage.src = '';
this.pendingImage = null;
}
if (this.editor) {
this.editor.onSelectionEnd = null;
this.editor.onRegionMoveEnd = null;
this.editor.onRegionDelete = null;
if (this.editor.RM) {
this.editor.RM.deleteAllRegions();
}
if (typeof this.editor.dispose === 'function') {
this.editor.dispose();
} else if (typeof this.editor.destroy === 'function') {
this.editor.destroy();
}
this.editor = null;
}
if (this.toolbarContainer) {
this.toolbarContainer.innerHTML = '';
this.toolbarContainer = null;
}
if (this.editorContainer) {
this.editorContainer.innerHTML = '';
this.editorContainer = null;
}
}
loadData = (image) => {
if (!this.editor) {
return;
}
const w = image.width;
const h = image.height;
this.editor.addContentSource(image).then(() => {
if (this.isUnmounted || !this.editor) {
return;
}
// Add exisiting polygons
this.editor.RM.deleteAllRegions();
const { polygons } = this.props;
@@ -205,19 +152,11 @@ class ImageCanvas extends React.Component {
// eslint-disable-next-line class-methods-use-this
loadImage = (path, onready) => {
if (this.pendingImage) {
this.pendingImage.onload = null;
}
const image = new Image();
this.pendingImage = image;
image.onload = (e) => {
if (this.pendingImage === image) {
this.pendingImage = null;
}
onready(e.target);
};
image.src = path;
image.addEventListener('load', (e) => {
onready(e.target);
});
};
// eslint-disable-next-line class-methods-use-this

View File

@@ -9,9 +9,9 @@ const dev = {
ENV: 'dev',
// Comment the below lines, when using codespaces or other special DNS names (which you can't control)
HOSTNAME: hostname,
API_URL: `${protocol}//${hostname}:8080/api`,
URL: `${protocol}//${hostname}:8080`,
WS_URL: `${websocketprotocol}//${hostname}:8080/ws`,
API_URL: `${protocol}//${hostname}:80/api`,
URL: `${protocol}//${hostname}:80`,
WS_URL: `${websocketprotocol}//${hostname}:80/ws`,
MODE: window['env']['mode'],
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
// HOSTNAME: externalHost,

View File

@@ -26,23 +26,6 @@ import {
import './Dashboard.scss';
import ReactTooltip from 'react-tooltip';
import config from '../../config';
import { getConfig } from '../../actions/agent';
function createUUID() {
if (
typeof window !== 'undefined' &&
window.crypto &&
typeof window.crypto.randomUUID === 'function'
) {
return window.crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
const random = Math.floor(Math.random() * 16);
const value = char === 'x' ? random : 8 + Math.floor(random / 4);
return value.toString(16);
});
}
// eslint-disable-next-line react/prefer-stateless-function
class Dashboard extends React.Component {
@@ -50,55 +33,43 @@ class Dashboard extends React.Component {
super();
this.state = {
liveviewLoaded: false,
liveviewMode: 'webrtc',
open: false,
currentRecording: '',
initialised: false,
};
this.videoRef = React.createRef();
this.pendingRemoteCandidates = [];
this.initialiseLiveview = this.initialiseLiveview.bind(this);
this.initialiseSDLiveview = this.initialiseSDLiveview.bind(this);
this.startWebRTCLiveview = this.startWebRTCLiveview.bind(this);
this.handleWebRTCSignalMessage = this.handleWebRTCSignalMessage.bind(this);
this.fallbackToSDLiveview = this.fallbackToSDLiveview.bind(this);
}
componentDidMount() {
const { dispatchGetConfig } = this.props;
dispatchGetConfig(() => this.initialiseLiveview());
const liveview = document.getElementsByClassName('videocard-video');
if (liveview && liveview.length > 0) {
liveview[0].addEventListener('load', () => {
this.setState({
liveviewLoaded: true,
});
});
}
this.initialiseLiveview();
}
componentDidUpdate(prevProps) {
const { images, dashboard } = this.props;
const { liveviewLoaded, liveviewMode } = this.state;
const configLoaded = this.hasAgentConfig(this.props);
const prevConfigLoaded = this.hasAgentConfig(prevProps);
if (!prevConfigLoaded && configLoaded) {
this.initialiseLiveview();
}
if (
liveviewMode === 'sd' &&
!liveviewLoaded &&
prevProps.images !== images &&
images.length > 0
) {
this.setState({
liveviewLoaded: true,
});
}
if (!prevProps.dashboard.cameraOnline && dashboard.cameraOnline) {
this.initialiseLiveview();
}
componentDidUpdate() {
this.initialiseLiveview();
}
componentWillUnmount() {
this.stopSDLiveview();
this.stopWebRTCLiveview();
const liveview = document.getElementsByClassName('videocard-video');
if (liveview && liveview.length > 0) {
liveview[0].remove();
}
if (this.requestStreamSubscription) {
this.requestStreamSubscription.unsubscribe();
}
const { dispatchSend } = this.props;
const message = {
message_type: 'stop-sd',
};
dispatchSend(message);
}
handleClose() {
@@ -108,378 +79,32 @@ class Dashboard extends React.Component {
});
}
// eslint-disable-next-line react/sort-comp
hasAgentConfig(props) {
const currentProps = props || this.props;
const { config: configResponse } = currentProps;
return !!(configResponse && configResponse.config);
}
browserSupportsWebRTC() {
return (
typeof window !== 'undefined' &&
typeof window.RTCPeerConnection !== 'undefined'
);
}
buildPeerConnectionConfig() {
const { config: configResponse } = this.props;
const agentConfig =
configResponse && configResponse.config ? configResponse.config : {};
const iceServers = [];
if (agentConfig.stunuri) {
iceServers.push({
urls: [agentConfig.stunuri],
});
}
if (agentConfig.turnuri) {
const turnServer = {
urls: [agentConfig.turnuri],
};
if (agentConfig.turn_username) {
turnServer.username = agentConfig.turn_username;
}
if (agentConfig.turn_password) {
turnServer.credential = agentConfig.turn_password;
}
iceServers.push(turnServer);
}
return {
iceServers,
iceTransportPolicy: agentConfig.turn_force === 'true' ? 'relay' : 'all',
};
getCurrentTimestamp() {
return Math.round(Date.now() / 1000);
}
initialiseLiveview() {
const { initialised } = this.state;
const { dashboard } = this.props;
if (initialised || !dashboard.cameraOnline) {
return;
}
if (!this.hasAgentConfig()) {
return;
}
if (this.browserSupportsWebRTC()) {
this.startWebRTCLiveview();
} else {
this.fallbackToSDLiveview('WebRTC is not supported in this browser.');
}
}
initialiseSDLiveview() {
if (this.requestStreamSubscription) {
return;
}
const message = {
message_type: 'stream-sd',
};
const { connected, dispatchSend } = this.props;
if (connected) {
dispatchSend(message);
}
const requestStreamInterval = interval(2000);
this.requestStreamSubscription = requestStreamInterval.subscribe(() => {
const { connected: isConnected } = this.props;
if (isConnected) {
if (!initialised) {
const message = {
message_type: 'stream-sd',
};
const { connected, dispatchSend } = this.props;
if (connected) {
dispatchSend(message);
}
});
}
stopSDLiveview() {
if (this.requestStreamSubscription) {
this.requestStreamSubscription.unsubscribe();
this.requestStreamSubscription = null;
}
const { dispatchSend } = this.props;
dispatchSend({
message_type: 'stop-sd',
});
}
stopWebRTCLiveview() {
if (this.webrtcTimeout) {
window.clearTimeout(this.webrtcTimeout);
this.webrtcTimeout = null;
}
if (this.webrtcSocket) {
this.webrtcSocket.onopen = null;
this.webrtcSocket.onmessage = null;
this.webrtcSocket.onerror = null;
this.webrtcSocket.onclose = null;
this.webrtcSocket.close();
this.webrtcSocket = null;
}
if (this.webrtcPeerConnection) {
this.webrtcPeerConnection.ontrack = null;
this.webrtcPeerConnection.onicecandidate = null;
this.webrtcPeerConnection.onconnectionstatechange = null;
this.webrtcPeerConnection.close();
this.webrtcPeerConnection = null;
}
this.pendingRemoteCandidates = [];
this.webrtcOfferStarted = false;
this.webrtcSessionId = null;
this.webrtcClientId = null;
if (this.videoRef.current) {
this.videoRef.current.srcObject = null;
}
}
sendWebRTCMessage(messageType, message = {}) {
if (!this.webrtcSocket || this.webrtcSocket.readyState !== WebSocket.OPEN) {
return;
}
this.webrtcSocket.send(
JSON.stringify({
client_id: this.webrtcClientId,
message_type: messageType,
message,
})
);
}
async handleWebRTCSignalMessage(event) {
let data;
try {
data = JSON.parse(event.data);
} catch (error) {
return;
}
const { message_type: messageType, message = {} } = data;
const { session_id: sessionID, sdp, candidate } = message;
if (messageType === 'hello-back') {
await this.beginWebRTCLiveview();
return;
}
if (sessionID && sessionID !== this.webrtcSessionId) {
return;
}
switch (messageType) {
case 'webrtc-answer':
try {
await this.webrtcPeerConnection.setRemoteDescription({
type: 'answer',
sdp: window.atob(sdp),
});
await this.flushPendingRemoteCandidates();
} catch (error) {
this.fallbackToSDLiveview(
`Unable to apply WebRTC answer: ${error.message}`
);
const requestStreamInterval = interval(2000);
this.requestStreamSubscription = requestStreamInterval.subscribe(() => {
const { connected: isConnected } = this.props;
if (isConnected) {
dispatchSend(message);
}
break;
case 'webrtc-candidate': {
try {
const candidateInit = JSON.parse(candidate);
if (
this.webrtcPeerConnection.remoteDescription &&
this.webrtcPeerConnection.remoteDescription.type
) {
await this.webrtcPeerConnection.addIceCandidate(candidateInit);
} else {
this.pendingRemoteCandidates.push(candidateInit);
}
} catch (error) {
this.fallbackToSDLiveview(
`Unable to apply WebRTC candidate: ${error.message}`
);
}
break;
}
case 'webrtc-error':
this.fallbackToSDLiveview(
message.message || 'The agent could not start the WebRTC liveview.'
);
break;
default:
break;
}
}
async beginWebRTCLiveview() {
if (!this.webrtcPeerConnection || this.webrtcOfferStarted) {
return;
}
try {
this.webrtcOfferStarted = true;
const offer = await this.webrtcPeerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
await this.webrtcPeerConnection.setLocalDescription(offer);
this.sendWebRTCMessage('stream-hd', {
session_id: this.webrtcSessionId,
sdp: window.btoa(this.webrtcPeerConnection.localDescription.sdp),
});
} catch (error) {
this.fallbackToSDLiveview(
`Unable to initialise WebRTC liveview: ${error.message}`,
);
}
}
async flushPendingRemoteCandidates() {
if (
!this.webrtcPeerConnection ||
!this.webrtcPeerConnection.remoteDescription
) {
return;
}
while (this.pendingRemoteCandidates.length > 0) {
const candidateInit = this.pendingRemoteCandidates.shift();
try {
// eslint-disable-next-line no-await-in-loop
await this.webrtcPeerConnection.addIceCandidate(candidateInit);
} catch (error) {
this.fallbackToSDLiveview(
`Unable to add remote ICE candidate: ${error.message}`,
);
return;
}
}
}
startWebRTCLiveview() {
if (this.webrtcPeerConnection || this.webrtcSocket) {
return;
}
this.stopSDLiveview();
this.webrtcClientId = createUUID();
this.webrtcSessionId = createUUID();
this.pendingRemoteCandidates = [];
this.webrtcPeerConnection = new window.RTCPeerConnection(
this.buildPeerConnectionConfig()
);
this.webrtcPeerConnection.ontrack = (event) => {
const [remoteStream] = event.streams;
if (this.videoRef.current && remoteStream) {
this.videoRef.current.srcObject = remoteStream;
const playPromise = this.videoRef.current.play();
if (playPromise && playPromise.catch) {
playPromise.catch(() => {});
}
}
this.setState({
liveviewLoaded: true,
});
};
this.webrtcPeerConnection.onicecandidate = (event) => {
if (!event.candidate) {
return;
}
this.sendWebRTCMessage('webrtc-candidate', {
session_id: this.webrtcSessionId,
candidate: JSON.stringify(event.candidate.toJSON()),
});
};
this.webrtcPeerConnection.onconnectionstatechange = () => {
const { connectionState } = this.webrtcPeerConnection;
if (connectionState === 'connected') {
this.setState({
liveviewLoaded: true,
});
}
if (
connectionState === 'failed' ||
connectionState === 'disconnected' ||
connectionState === 'closed'
) {
this.fallbackToSDLiveview(
`WebRTC connection ${connectionState}, falling back to SD liveview.`,
);
}
};
this.webrtcSocket = new WebSocket(config.WS_URL);
this.webrtcSocket.onopen = () => {
this.sendWebRTCMessage('hello', {});
};
this.webrtcSocket.onmessage = this.handleWebRTCSignalMessage;
this.webrtcSocket.onerror = () => {
this.fallbackToSDLiveview('Unable to open the WebRTC signaling channel.');
};
this.webrtcSocket.onclose = () => {
const { liveviewLoaded } = this.state;
if (!liveviewLoaded) {
this.fallbackToSDLiveview('WebRTC signaling channel closed early.');
}
};
this.webrtcTimeout = window.setTimeout(() => {
const { liveviewLoaded } = this.state;
if (!liveviewLoaded) {
this.fallbackToSDLiveview(
'WebRTC connection timed out, falling back to SD liveview.'
);
}
}, 10000);
this.setState({
initialised: true,
liveviewLoaded: false,
liveviewMode: 'webrtc',
});
}
fallbackToSDLiveview(errorMessage) {
const { liveviewMode } = this.state;
if (liveviewMode === 'sd' && this.requestStreamSubscription) {
return;
}
this.stopWebRTCLiveview();
if (errorMessage) {
// eslint-disable-next-line no-console
console.warn(errorMessage);
}
this.setState(
{
initialised: true,
liveviewLoaded: false,
liveviewMode: 'sd',
},
() => {
this.initialiseSDLiveview();
}
);
});
}
}
openModal(file) {
@@ -491,8 +116,7 @@ class Dashboard extends React.Component {
render() {
const { dashboard, t, images } = this.props;
const { liveviewLoaded, liveviewMode, open, currentRecording } = this.state;
const listenerCount = dashboard.webrtcReaders ? dashboard.webrtcReaders : 0;
const { liveviewLoaded, open, currentRecording } = this.state;
// We check if the camera was getting a valid frame
// during the last 5 seconds, otherwise we assume the camera is offline.
@@ -546,6 +170,7 @@ class Dashboard extends React.Component {
divider="0"
footer={t('dashboard.total_recordings')}
/>
<Link to="/settings">
<Card
title="IP Camera"
@@ -684,9 +309,7 @@ class Dashboard extends React.Component {
)}
</div>
<div>
<h2>
{t('dashboard.live_view')} ({listenerCount})
</h2>
<h2>{t('dashboard.live_view')}</h2>
{(!liveviewLoaded || !isCameraOnline) && (
<SetupBox
btnicon="preferences"
@@ -703,16 +326,12 @@ class Dashboard extends React.Component {
liveviewLoaded && isCameraOnline ? 'visible' : 'hidden',
}}
>
{liveviewMode === 'webrtc' ? (
<video ref={this.videoRef} autoPlay muted playsInline />
) : (
<ImageCard
imageSrc={`data:image/png;base64, ${
images.length ? images[0] : ''
}`}
onerror=""
/>
)}
<ImageCard
imageSrc={`data:image/png;base64, ${
images.length ? images[0] : ''
}`}
onerror=""
/>
</div>
</div>
</div>
@@ -724,25 +343,20 @@ class Dashboard extends React.Component {
const mapStateToProps = (state /* , ownProps */) => ({
dashboard: state.agent.dashboard,
config: state.agent.config,
connected: state.wss.connected,
images: state.wss.images,
});
const mapDispatchToProps = (dispatch) => ({
dispatchSend: (message) => dispatch(send(message)),
dispatchGetConfig: (onSuccess, onError) =>
dispatch(getConfig(onSuccess, onError)),
});
Dashboard.propTypes = {
dashboard: PropTypes.object.isRequired,
config: PropTypes.object.isRequired,
connected: PropTypes.bool.isRequired,
images: PropTypes.array.isRequired,
t: PropTypes.func.isRequired,
dispatchSend: PropTypes.func.isRequired,
dispatchGetConfig: PropTypes.func.isRequired,
};
export default withTranslation()(

View File

@@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import {
Breadcrumb,
ControlBar,
VideoCard,
Button,
Modal,
@@ -17,53 +16,14 @@ import { getEvents } from '../../actions/agent';
import config from '../../config';
import './Media.scss';
function formatDateTimeLocal(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function getDefaultTimeWindow() {
const endDate = new Date();
const startDate = new Date(endDate.getTime() - 60 * 60 * 1000);
return {
startDateTime: formatDateTimeLocal(startDate),
endDateTime: formatDateTimeLocal(endDate),
timestamp_offset_start: Math.floor(startDate.getTime() / 1000),
timestamp_offset_end: Math.floor(endDate.getTime() / 1000) + 59,
};
}
function normalizeInputValue(valueOrEvent) {
if (valueOrEvent && valueOrEvent.target) {
return valueOrEvent.target.value;
}
return valueOrEvent;
}
// eslint-disable-next-line react/prefer-stateless-function
class Media extends React.Component {
constructor() {
super();
const defaultTimeWindow = getDefaultTimeWindow();
const initialFilter = {
timestamp_offset_start: defaultTimeWindow.timestamp_offset_start,
timestamp_offset_end: defaultTimeWindow.timestamp_offset_end,
number_of_elements: 12,
};
this.state = {
appliedFilter: initialFilter,
startDateTime: defaultTimeWindow.startDateTime,
endDateTime: defaultTimeWindow.endDateTime,
timestamp_offset_start: 0,
timestamp_offset_end: 0,
number_of_elements: 12,
isScrolling: false,
open: false,
currentRecording: '',
@@ -72,8 +32,7 @@ class Media extends React.Component {
componentDidMount() {
const { dispatchGetEvents } = this.props;
const { appliedFilter } = this.state;
dispatchGetEvents(appliedFilter);
dispatchGetEvents(this.state);
document.addEventListener('scroll', this.trackScrolling);
}
@@ -90,107 +49,29 @@ class Media extends React.Component {
trackScrolling = () => {
const { events, dispatchGetEvents } = this.props;
const { isScrolling, appliedFilter } = this.state;
const { isScrolling } = this.state;
const wrappedElement = document.getElementById('loader');
if (!wrappedElement || isScrolling || !this.isBottom(wrappedElement)) {
return;
}
this.setState({
isScrolling: true,
});
// Get last element
const lastElement = events[events.length - 1];
if (lastElement) {
dispatchGetEvents(
{
...appliedFilter,
if (!isScrolling && this.isBottom(wrappedElement)) {
this.setState({
isScrolling: true,
});
// Get last element
const lastElement = events[events.length - 1];
if (lastElement) {
this.setState({
timestamp_offset_end: parseInt(lastElement.timestamp, 10),
},
() => {
});
dispatchGetEvents(this.state, () => {
setTimeout(() => {
this.setState({
isScrolling: false,
});
}, 1000);
},
() => {
this.setState({
isScrolling: false,
});
},
true
);
} else {
this.setState({
isScrolling: false,
});
});
}
}
};
buildEventFilter(startDateTime, endDateTime) {
const { appliedFilter } = this.state;
return {
timestamp_offset_start: this.getTimestampFromInput(
startDateTime,
'start'
),
timestamp_offset_end: this.getTimestampFromInput(endDateTime, 'end'),
number_of_elements: appliedFilter.number_of_elements,
};
}
handleDateFilterChange(field, value) {
const { dispatchGetEvents } = this.props;
const { startDateTime, endDateTime } = this.state;
const normalizedValue = normalizeInputValue(value);
const nextStartDateTime =
field === 'startDateTime' ? normalizedValue : startDateTime;
const nextEndDateTime =
field === 'endDateTime' ? normalizedValue : endDateTime;
const nextFilter = this.buildEventFilter(
nextStartDateTime,
nextEndDateTime
);
const shouldApplyFilter =
(nextStartDateTime === '' || nextStartDateTime.length === 16) &&
(nextEndDateTime === '' || nextEndDateTime.length === 16);
this.setState(
{
[field]: normalizedValue,
appliedFilter: shouldApplyFilter
? nextFilter
: this.state.appliedFilter,
isScrolling: false,
},
() => {
if (shouldApplyFilter) {
dispatchGetEvents(nextFilter);
}
}
);
}
getTimestampFromInput(value, boundary) {
if (!value) {
return 0;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return 0;
}
const seconds = Math.floor(date.getTime() / 1000);
if (boundary === 'end') {
return seconds + 59;
}
return seconds;
}
isBottom(el) {
return el.getBoundingClientRect().bottom + 50 <= window.innerHeight;
}
@@ -204,9 +85,7 @@ class Media extends React.Component {
render() {
const { events, eventsLoaded, t } = this.props;
const { isScrolling, open, currentRecording, startDateTime, endDateTime } =
this.state;
const { isScrolling, open, currentRecording } = this.state;
return (
<div id="media">
<Breadcrumb
@@ -223,37 +102,6 @@ class Media extends React.Component {
</Link>
</Breadcrumb>
<div className="media-control-bar">
<ControlBar>
<div className="media-filters">
<div className="media-filters__field">
<label htmlFor="recordings-start-time">Start time</label>
<input
className="media-filters__input"
id="recordings-start-time"
type="datetime-local"
value={startDateTime}
onChange={(value) =>
this.handleDateFilterChange('startDateTime', value)
}
/>
</div>
<div className="media-filters__field">
<label htmlFor="recordings-end-time">End time</label>
<input
className="media-filters__input"
id="recordings-end-time"
type="datetime-local"
value={endDateTime}
onChange={(value) =>
this.handleDateFilterChange('endDateTime', value)
}
/>
</div>
</div>
</ControlBar>
</div>
<div className="stats grid-container --four-columns">
{events.map((event) => (
<div
@@ -275,11 +123,6 @@ class Media extends React.Component {
</div>
))}
</div>
{events.length === 0 && eventsLoaded === 0 && (
<div className="media-empty-state">
No recordings found in the selected time range.
</div>
)}
{open && (
<Modal>
<ModalHeader
@@ -339,13 +182,13 @@ const mapStateToProps = (state /* , ownProps */) => ({
});
const mapDispatchToProps = (dispatch) => ({
dispatchGetEvents: (eventFilter, success, error, append) =>
dispatch(getEvents(eventFilter, success, error, append)),
dispatchGetEvents: (eventFilter, success, error) =>
dispatch(getEvents(eventFilter, success, error)),
});
Media.propTypes = {
t: PropTypes.func.isRequired,
events: PropTypes.arrayOf(PropTypes.object).isRequired,
events: PropTypes.objectOf(PropTypes.object).isRequired,
eventsLoaded: PropTypes.number.isRequired,
dispatchGetEvents: PropTypes.func.isRequired,
};

View File

@@ -1,103 +1,4 @@
#media {
.media-control-bar {
.control-bar {
display: block;
padding: 0 var(--main-content-gutter);
}
.control-bar .filtering {
display: block;
}
.control-bar .filtering > * {
border-right: 0;
flex: 1 1 100% !important;
max-width: none;
width: 100%;
}
}
.media-filters {
align-items: stretch;
display: grid;
gap: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
width: 100%;
}
.media-filters__field {
border-right: 1px solid var(--bg-muted);
min-width: 0;
padding: 16px 24px;
label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
white-space: nowrap;
}
}
.media-filters__field:first-child {
padding-left: 0;
}
.media-filters__input {
appearance: none;
background: var(--white);
border: 1px solid var(--grey-light);
border-radius: 8px;
box-sizing: border-box;
color: var(--black);
font-size: 16px;
min-height: 48px;
padding: 0 14px 0 0;
width: 100%;
}
.media-filters__input::-webkit-datetime-edit,
.media-filters__input::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
.media-filters__input:focus {
border-color: var(--oss);
outline: 0;
}
.media-filters__field:first-child .media-filters__input {
padding-left: 0;
}
.media-filters__field:last-child {
border-right: 0;
padding-right: 0;
}
@media (max-width: 700px) {
.media-filters {
grid-template-columns: 1fr;
}
.media-filters__field {
border-right: 0;
border-bottom: 1px solid var(--bg-muted);
padding-left: 0;
padding-right: 0;
}
.media-filters__field:last-child {
border-bottom: 0;
}
}
.media-empty-state {
margin: 24px 0;
opacity: 0.8;
text-align: center;
}
#loader {
display: flex;

View File

@@ -33,7 +33,6 @@ import {
verifyCamera,
verifyHub,
verifyPersistence,
verifySecondaryPersistence,
getConfig,
updateConfig,
} from '../../actions/agent';
@@ -64,9 +63,6 @@ class Settings extends React.Component {
verifyPersistenceSuccess: false,
verifyPersistenceError: false,
verifyPersistenceMessage: '',
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: false,
verifySecondaryPersistenceMessage: '',
verifyCameraSuccess: false,
verifyCameraError: false,
verifyCameraMessage: '',
@@ -74,7 +70,6 @@ class Settings extends React.Component {
verifyOnvifError: false,
verifyOnvifErrorMessage: '',
loading: false,
loadingSecondary: false,
loadingHub: false,
loadingCamera: false,
};
@@ -130,8 +125,6 @@ class Settings extends React.Component {
this.onUpdateTimeline = this.onUpdateTimeline.bind(this);
this.initialiseLiveview = this.initialiseLiveview.bind(this);
this.verifyPersistenceSettings = this.verifyPersistenceSettings.bind(this);
this.verifySecondaryPersistenceSettings =
this.verifySecondaryPersistenceSettings.bind(this);
this.verifyHubSettings = this.verifyHubSettings.bind(this);
this.verifyCameraSettings = this.verifyCameraSettings.bind(this);
this.verifySubCameraSettings = this.verifySubCameraSettings.bind(this);
@@ -159,10 +152,7 @@ class Settings extends React.Component {
componentWillUnmount() {
document.removeEventListener('keydown', this.escFunction, false);
if (this.requestStreamSubscription) {
this.requestStreamSubscription.unsubscribe();
this.requestStreamSubscription = null;
}
clearInterval(this.interval);
const { dispatchSend } = this.props;
const message = {
@@ -360,8 +350,6 @@ class Settings extends React.Component {
configSuccess: false,
configError: false,
loadingCamera: false,
loading: false,
loadingSecondary: false,
loadingOnvif: true,
});
@@ -402,8 +390,6 @@ class Settings extends React.Component {
configError: false,
verifyPersistenceSuccess: false,
verifyPersistenceError: false,
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: false,
verifyHubSuccess: false,
verifyHubError: false,
verifyHubErrorMessage: '',
@@ -415,8 +401,6 @@ class Settings extends React.Component {
verifyOnvifSuccess: false,
verifyOnvifError: false,
loadingHub: true,
loading: false,
loadingSecondary: false,
});
// .... test fields
@@ -457,8 +441,6 @@ class Settings extends React.Component {
verifyHubError: false,
verifyPersistenceSuccess: false,
verifyPersistenceError: false,
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: false,
persistenceSuccess: false,
persistenceError: false,
verifyCameraSuccess: false,
@@ -467,7 +449,6 @@ class Settings extends React.Component {
verifyOnvifError: false,
verifyCameraErrorMessage: '',
loading: true,
loadingSecondary: false,
});
dispatchVerifyPersistence(
@@ -480,7 +461,6 @@ class Settings extends React.Component {
persistenceSuccess: false,
persistenceError: false,
loading: false,
loadingSecondary: false,
});
},
(error) => {
@@ -491,58 +471,6 @@ class Settings extends React.Component {
persistenceSuccess: false,
persistenceError: false,
loading: false,
loadingSecondary: false,
});
}
);
}
}
verifySecondaryPersistenceSettings() {
const { config, dispatchVerifySecondaryPersistence } = this.props;
if (config) {
this.setState({
configSuccess: false,
configError: false,
verifyHubSuccess: false,
verifyHubError: false,
verifyPersistenceSuccess: false,
verifyPersistenceError: false,
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: false,
persistenceSuccess: false,
persistenceError: false,
verifyCameraSuccess: false,
verifyCameraError: false,
verifyOnvifSuccess: false,
verifyOnvifError: false,
verifyCameraErrorMessage: '',
loading: false,
loadingSecondary: true,
});
dispatchVerifySecondaryPersistence(
config.config,
() => {
this.setState({
verifySecondaryPersistenceSuccess: true,
verifySecondaryPersistenceError: false,
verifySecondaryPersistenceMessage: '',
persistenceSuccess: false,
persistenceError: false,
loading: false,
loadingSecondary: false,
});
},
(error) => {
this.setState({
verifySecondaryPersistenceSuccess: false,
verifySecondaryPersistenceError: true,
verifySecondaryPersistenceMessage: error,
persistenceSuccess: false,
persistenceError: false,
loading: false,
loadingSecondary: false,
});
}
);
@@ -609,9 +537,6 @@ class Settings extends React.Component {
verifyPersistenceSuccess,
verifyPersistenceError,
verifyPersistenceMessage,
verifySecondaryPersistenceSuccess,
verifySecondaryPersistenceError,
verifySecondaryPersistenceMessage,
verifyCameraSuccess,
verifyCameraError,
verifyCameraErrorMessage,
@@ -621,7 +546,6 @@ class Settings extends React.Component {
verifyOnvifErrorMessage,
loadingCamera,
loading,
loadingSecondary,
loadingHub,
} = this.state;
@@ -874,20 +798,6 @@ class Settings extends React.Component {
)} :${verifyPersistenceMessage}`}
/>
)}
{verifySecondaryPersistenceSuccess && (
<InfoBar
type="success"
message={t('settings.info.verify_persistence_success')}
/>
)}
{verifySecondaryPersistenceError && (
<InfoBar
type="alert"
message={`${t(
'settings.info.verify_persistence_error'
)} :${verifySecondaryPersistenceMessage}`}
/>
)}
<div className="stats grid-container --two-columns">
<div>
{/* General settings block */}
@@ -2539,43 +2449,6 @@ class Settings extends React.Component {
)
}
/>
<Input
noPadding
label={t(
'settings.persistence.kerberosvault_maxretries'
)}
placeholder={t(
'settings.persistence.kerberosvault_description_maxretries'
)}
value={
config.kstorage ? config.kstorage.max_retries : ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage',
'max_retries',
value,
config.kstorage
)
}
/>
<Input
noPadding
label={t('settings.persistence.kerberosvault_timeout')}
placeholder={t(
'settings.persistence.kerberosvault_description_timeout'
)}
value={config.kstorage ? config.kstorage.timeout : ''}
onChange={(value) =>
this.onUpdateField(
'kstorage',
'timeout',
value,
config.kstorage
)
}
/>
</>
)}
{config.cloud === this.DROPBOX && (
@@ -2635,140 +2508,6 @@ class Settings extends React.Component {
</BlockFooter>
</Block>
)}
{/* Secondary Vault block */}
{showPersistenceSection && config.cloud === this.KERBEROS_VAULT && (
<Block>
<BlockHeader>
<h4>{t('settings.persistence.secondary_persistence')}</h4>
</BlockHeader>
<BlockBody>
<p>
{t(
'settings.persistence.description_secondary_persistence'
)}
</p>
<Input
noPadding
label={t('settings.persistence.kerberosvault_apiurl')}
placeholder={t(
'settings.persistence.kerberosvault_description_apiurl'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.uri
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'uri',
value,
config.kstorage_secondary
)
}
/>
<Input
noPadding
label={t('settings.persistence.kerberosvault_provider')}
placeholder={t(
'settings.persistence.kerberosvault_description_provider'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.provider
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'provider',
value,
config.kstorage_secondary
)
}
/>
<Input
noPadding
label={t('settings.persistence.kerberosvault_directory')}
placeholder={t(
'settings.persistence.kerberosvault_description_directory'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.directory
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'directory',
value,
config.kstorage_secondary
)
}
/>
<Input
type="password"
iconright="activity"
label={t('settings.persistence.kerberosvault_accesskey')}
placeholder={t(
'settings.persistence.kerberosvault_description_accesskey'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.access_key
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'access_key',
value,
config.kstorage_secondary
)
}
/>
<Input
type="password"
iconright="activity"
label={t('settings.persistence.kerberosvault_secretkey')}
placeholder={t(
'settings.persistence.kerberosvault_description_secretkey'
)}
value={
config.kstorage_secondary
? config.kstorage_secondary.secret_access_key
: ''
}
onChange={(value) =>
this.onUpdateField(
'kstorage_secondary',
'secret_access_key',
value,
config.kstorage_secondary
)
}
/>
</BlockBody>
<BlockFooter>
<Button
label={t('settings.persistence.verify_connection')}
disabled={loadingSecondary}
onClick={this.verifySecondaryPersistenceSettings}
type={loadingSecondary ? 'neutral' : 'default'}
icon="verify"
/>
<Button
label="Save"
type="submit"
onClick={this.saveConfig}
buttonType="submit"
icon="pencil"
/>
</BlockFooter>
</Block>
)}
</div>
</div>
</div>
@@ -2793,8 +2532,6 @@ const mapDispatchToProps = (dispatch /* , ownProps */) => ({
dispatch(verifyHub(config, success, error)),
dispatchVerifyPersistence: (config, success, error) =>
dispatch(verifyPersistence(config, success, error)),
dispatchVerifySecondaryPersistence: (config, success, error) =>
dispatch(verifySecondaryPersistence(config, success, error)),
dispatchGetConfig: (callback) => dispatch(getConfig(callback)),
dispatchUpdateConfig: (field, value) => dispatch(updateConfig(field, value)),
dispatchSaveConfig: (config, success, error) =>
@@ -2812,7 +2549,6 @@ Settings.propTypes = {
images: PropTypes.array.isRequired,
dispatchVerifyHub: PropTypes.func.isRequired,
dispatchVerifyPersistence: PropTypes.func.isRequired,
dispatchVerifySecondaryPersistence: PropTypes.func.isRequired,
dispatchGetConfig: PropTypes.func.isRequired,
dispatchUpdateConfig: PropTypes.func.isRequired,
dispatchSaveConfig: PropTypes.func.isRequired,

View File

@@ -123,12 +123,16 @@ const agent = (
};
case 'GET_EVENTS':
const { timestamp_offset_end } = action.filter;
const { events } = action;
return {
...state,
eventsLoaded: events.length,
events: action.append ? [...state.events, ...events] : [...events],
eventfilter: action.filter,
events:
timestamp_offset_end === 0
? [...events]
: [...state.events, ...events],
eventfilter: action.eventfilter,
};
default:

View File

@@ -1715,10 +1715,10 @@
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@kerberos-io/ui@^1.76.0":
version "1.77.0"
resolved "https://registry.yarnpkg.com/@kerberos-io/ui/-/ui-1.77.0.tgz#b748b2a9abf793ff2a9ba64ee41f84debc0ca9dc"
integrity sha512-CHh4jeLKwrYvJRL5PM3UEN4p2k1fqwMKgSF2U6IR4v0fE2FwPc/2Ry4zGk6pvLDFHbDpR9jUkHX+iNphvStoyQ==
"@kerberos-io/ui@^1.71.0":
version "1.71.0"
resolved "https://registry.yarnpkg.com/@kerberos-io/ui/-/ui-1.71.0.tgz#06914c94e8b0982068d2099acf8158917a511bfc"
integrity sha512-pHCTn/iQTcQEPoCK82eJHGRn6BgzW3wgV4C+mNqdKOtLTquxL+vh7molEgC66tl3DGf7HyjSNa8LuoxYbt9TEg==
dependencies:
"@emotion/react" "^11.10.4"
"@emotion/styled" "^11.10.4"