mirror of
https://github.com/kerberos-io/agent.git
synced 2026-08-23 15:08:32 +00:00
Compare commits
366 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0971ca3b2 | ||
|
|
1a788ebe6c | ||
|
|
a1b4026b4b | ||
|
|
9bc9825bb1 | ||
|
|
e9d2afa228 | ||
|
|
4b0e0eae9c | ||
|
|
e0204e1949 | ||
|
|
3c2a0ce0cf | ||
|
|
a5def2ccd8 | ||
|
|
6ede3c3add | ||
|
|
d5de6ae271 | ||
|
|
2035deaa31 | ||
|
|
5973ba025d | ||
|
|
52a54fbae1 | ||
|
|
5f828262eb | ||
|
|
17c1c5b04b | ||
|
|
bd5df30de3 | ||
|
|
2c063c39c6 | ||
|
|
2f0f29ce8c | ||
|
|
a05acb7fc8 | ||
|
|
2b88c0ff93 | ||
|
|
4aa2b6e51a | ||
|
|
0c439e34c7 | ||
|
|
d57bea3079 | ||
|
|
46a48db080 | ||
|
|
b7fe9947c2 | ||
|
|
f214a09826 | ||
|
|
7059503ac1 | ||
|
|
7ee79cc063 | ||
|
|
9bfbe4ee0f | ||
|
|
b8c05aa3e2 | ||
|
|
5f7ede40ca | ||
|
|
0ef84c5288 | ||
|
|
1a477bf42d | ||
|
|
22c352e946 | ||
|
|
55b0eb54fe | ||
|
|
68a4ca6bb9 | ||
|
|
baaa3f615a | ||
|
|
aeb214689b | ||
|
|
e353d46e73 | ||
|
|
4d163c4b53 | ||
|
|
014f0e312e | ||
|
|
195750a01d | ||
|
|
d203321770 | ||
|
|
51f1a52e17 | ||
|
|
6318c61323 | ||
|
|
5323105a60 | ||
|
|
af6e75426a | ||
|
|
6c2f38679b | ||
|
|
9b60223300 | ||
|
|
efdf8396ab | ||
|
|
d0f13187a1 | ||
|
|
bf46b55c92 | ||
|
|
88edcabf98 | ||
|
|
e77af9e2c0 | ||
|
|
cc5c0253ed | ||
|
|
4c5a107d29 | ||
|
|
3b07c754f8 | ||
|
|
d151d0ce24 | ||
|
|
a32af4fe50 | ||
|
|
434cdf8a7f | ||
|
|
d3f53e4b6b | ||
|
|
8ea84d87db | ||
|
|
860acd3a6e | ||
|
|
c7122ca025 | ||
|
|
3d4e37dfb9 | ||
|
|
36d6591271 | ||
|
|
e8fc4e674b | ||
|
|
011bd9936f | ||
|
|
791add83f9 | ||
|
|
4b935d97c8 | ||
|
|
8657765e5d | ||
|
|
76a136abc9 | ||
|
|
5475b79459 | ||
|
|
2ad768780f | ||
|
|
f64b5fb65b | ||
|
|
bb773316a2 | ||
|
|
fc6fa9d425 | ||
|
|
aa183ee0fb | ||
|
|
730b1b2a40 | ||
|
|
4efc80fecb | ||
|
|
4fbee60e9f | ||
|
|
d6c25df280 | ||
|
|
72a2d28e1e | ||
|
|
eb0972084f | ||
|
|
41a1d221fc | ||
|
|
eaacc93d2f | ||
|
|
0e6a004c23 | ||
|
|
617f854534 | ||
|
|
1bf8006055 | ||
|
|
ca0e426382 | ||
|
|
726d0722d9 | ||
|
|
d8f320b040 | ||
|
|
0131b87692 | ||
|
|
54e8198b65 | ||
|
|
3bfb68f950 | ||
|
|
c05e59c936 | ||
|
|
b42d63b668 | ||
|
|
0ca007e424 | ||
|
|
229d085de7 | ||
|
|
30e2b8318d | ||
|
|
dbcf4e242c | ||
|
|
ccf4034cc8 | ||
|
|
a34836e8f4 | ||
|
|
dd1464d1be | ||
|
|
2c02e0aeb1 | ||
|
|
d5464362bb | ||
|
|
5bcefd0015 | ||
|
|
5bb9def42d | ||
|
|
ff38ccbadf | ||
|
|
f64e899de9 | ||
|
|
b8a81d18af | ||
|
|
8c2e3e4cdd | ||
|
|
11c4ee518d | ||
|
|
51b9d76973 | ||
|
|
f3c1cb9b82 | ||
|
|
a1368361e4 | ||
|
|
abfdea0179 | ||
|
|
8aaeb62fa3 | ||
|
|
e30dd7d4a0 | ||
|
|
ac3f9aa4e8 | ||
|
|
04c568f488 | ||
|
|
e270223968 | ||
|
|
01ab1a9218 | ||
|
|
6f0794b09c | ||
|
|
1ae6a46d88 | ||
|
|
9d83cab5cc | ||
|
|
6f559c2f00 | ||
|
|
c147944f5a | ||
|
|
e8ca776e4e | ||
|
|
de5c4b6e0a | ||
|
|
9ba64de090 | ||
|
|
7ceeebe76e | ||
|
|
bd7dbcfcf2 | ||
|
|
8c7a46e3ae | ||
|
|
57ccfaabf5 | ||
|
|
4a9cb51e95 | ||
|
|
ab6f621e76 | ||
|
|
c365ae5af2 | ||
|
|
b05c3d1baa | ||
|
|
c7c7203fad | ||
|
|
d93f85b4f3 | ||
|
|
031212b98c | ||
|
|
a4837b3cb3 | ||
|
|
77629ac9b8 | ||
|
|
59608394af | ||
|
|
9dfcaa466f | ||
|
|
88442e4525 | ||
|
|
891ae2e5d5 | ||
|
|
32b471f570 | ||
|
|
5d745fc989 | ||
|
|
edfa6ec4c6 | ||
|
|
0c460efea6 | ||
|
|
96df049e59 | ||
|
|
2cb454e618 | ||
|
|
7f2ebb655e | ||
|
|
63857fb5cc | ||
|
|
f4c75f9aa9 | ||
|
|
c3936dc884 | ||
|
|
2868ddc499 | ||
|
|
176610a694 | ||
|
|
f60aff4fd6 | ||
|
|
847f62303a | ||
|
|
f174e2697e | ||
|
|
acac2d5d42 | ||
|
|
f304c2ed3e | ||
|
|
2003a38cdc | ||
|
|
a67c5a1f39 | ||
|
|
b7a87f95e5 | ||
|
|
0aa0b8ad8f | ||
|
|
2bff868de6 | ||
|
|
8b59828126 | ||
|
|
f55e25db07 | ||
|
|
243c969666 | ||
|
|
ec7f2e0303 | ||
|
|
a4a032d994 | ||
|
|
0a84744e49 | ||
|
|
1425430376 | ||
|
|
ca8d88ffce | ||
|
|
af3f8bb639 | ||
|
|
1f9772d472 | ||
|
|
94cf361b55 | ||
|
|
6acdf258e7 | ||
|
|
cc0a810ab3 | ||
|
|
c19bfbe552 | ||
|
|
39aaf5ad6c | ||
|
|
6fba2ff05d | ||
|
|
d78e682759 | ||
|
|
ed582a9d57 | ||
|
|
aa925d5c9b | ||
|
|
08d191e542 | ||
|
|
cc075d7237 | ||
|
|
1974bddfbe | ||
|
|
12cb88e1c1 | ||
|
|
c054526998 | ||
|
|
ffa97598b8 | ||
|
|
f5afbf3a63 | ||
|
|
e666695c96 | ||
|
|
55816e4b7b | ||
|
|
016fb51951 | ||
|
|
550a444650 | ||
|
|
4332e43f27 | ||
|
|
fdc3bfb4a4 | ||
|
|
c17d6b7117 | ||
|
|
5d7a8103c0 | ||
|
|
5d7cb98b8f | ||
|
|
f6046c6a6c | ||
|
|
f59f9d71a9 | ||
|
|
ff72f9647d | ||
|
|
fa604b16cf | ||
|
|
0342869733 | ||
|
|
8685ce31a2 | ||
|
|
0e259f0e7a | ||
|
|
5823abed95 | ||
|
|
86acff58f0 | ||
|
|
d3fc5d4c29 | ||
|
|
50bb40938c | ||
|
|
1977d98ad9 | ||
|
|
448d4a946d | ||
|
|
61ac314bb7 | ||
|
|
c1b144ca28 | ||
|
|
e16987bf9d | ||
|
|
9991597984 | ||
|
|
2c0314cea4 | ||
|
|
0584e52b98 | ||
|
|
1fc90eaee2 | ||
|
|
aef3eacbc9 | ||
|
|
2843568473 | ||
|
|
53ffc8cae0 | ||
|
|
86e654fe19 | ||
|
|
46d57f7664 | ||
|
|
963d8672eb | ||
|
|
9b7a62816a | ||
|
|
237134fe0e | ||
|
|
c8730e8f26 | ||
|
|
acbbe8b444 | ||
|
|
f690016aa5 | ||
|
|
396cfe5d8b | ||
|
|
39fe640ccf | ||
|
|
d389c9b0b6 | ||
|
|
b149686db8 | ||
|
|
c4358cbfad | ||
|
|
cfc5bd3dfe | ||
|
|
c29c1b6a92 | ||
|
|
0f45a2a4b4 | ||
|
|
92edcc13c0 | ||
|
|
5392e2ba90 | ||
|
|
79e1f659c7 | ||
|
|
bf35e5efb6 | ||
|
|
c50137f255 | ||
|
|
f12da749b2 | ||
|
|
a166083423 | ||
|
|
b400d4e773 | ||
|
|
120054d3e5 | ||
|
|
620117c31b | ||
|
|
4e371488c1 | ||
|
|
b154b56308 | ||
|
|
6d92817237 | ||
|
|
b8c1855830 | ||
|
|
a9f7ff4b72 | ||
|
|
b3cd080e14 | ||
|
|
bfde87f888 | ||
|
|
c4453bb8b3 | ||
|
|
40f65a30b3 | ||
|
|
5361de63e0 | ||
|
|
3a8552d362 | ||
|
|
d3840103fc | ||
|
|
d12a9f0612 | ||
|
|
c0d74f7e09 | ||
|
|
8ebea9e4c5 | ||
|
|
89269caf92 | ||
|
|
0c83170f51 | ||
|
|
6081cb4be9 | ||
|
|
ea1dbb3087 | ||
|
|
0523208d36 | ||
|
|
919f21b48b | ||
|
|
2c1c10a2ac | ||
|
|
7e3320b252 | ||
|
|
35ccac8b65 | ||
|
|
dad8165d11 | ||
|
|
ba54188de2 | ||
|
|
3b440c9905 | ||
|
|
42b98b7f20 | ||
|
|
ba3312b57c | ||
|
|
223ba255e9 | ||
|
|
a1df2be207 | ||
|
|
d7f225ca73 | ||
|
|
b3cfabb5df | ||
|
|
5310dd4550 | ||
|
|
cde7dbb58a | ||
|
|
65e68231c7 | ||
|
|
5502555869 | ||
|
|
ad6e7e752f | ||
|
|
63af4660ef | ||
|
|
24fc340001 | ||
|
|
78d786b69d | ||
|
|
756aeaa0eb | ||
|
|
055fb67d7a | ||
|
|
bee522a6bf | ||
|
|
3fbf59c622 | ||
|
|
abd8b8b605 | ||
|
|
abdad47bf3 | ||
|
|
d2c24edf5d | ||
|
|
22f4a7f119 | ||
|
|
a25d3d32e4 | ||
|
|
ed68c32e04 | ||
|
|
4114b3839a | ||
|
|
3f73c009fd | ||
|
|
02fb70c76e | ||
|
|
aaddcb854d | ||
|
|
e73c7a6ecc | ||
|
|
1dc2202f37 | ||
|
|
ac710ae1f5 | ||
|
|
f5ea82ff03 | ||
|
|
ef52325240 | ||
|
|
354855feb1 | ||
|
|
c4cd25b588 | ||
|
|
dbb870229e | ||
|
|
a66fe8c054 | ||
|
|
2352431c79 | ||
|
|
49bc168812 | ||
|
|
98f1ebf20a | ||
|
|
65feb6d182 | ||
|
|
58555d352f | ||
|
|
839a177cf0 | ||
|
|
404517ec40 | ||
|
|
035bd18bc2 | ||
|
|
8bf7a0d244 | ||
|
|
607d8fd0d1 | ||
|
|
12807e289c | ||
|
|
3a984f1c73 | ||
|
|
b84e34da06 | ||
|
|
541d151570 | ||
|
|
4ad97e1286 | ||
|
|
a80b375e89 | ||
|
|
91cb390f6e | ||
|
|
90780dae28 | ||
|
|
ddb08e90e1 | ||
|
|
0d95026819 | ||
|
|
79db3a9dfe | ||
|
|
9f63ffd540 | ||
|
|
9c7116a462 | ||
|
|
dd9b4d43ac | ||
|
|
aa63eca24c | ||
|
|
6df97171d9 | ||
|
|
56f7d69b3d | ||
|
|
3e2b29284e | ||
|
|
18ceca7510 | ||
|
|
5a08d1f3de | ||
|
|
18af6db00c | ||
|
|
6d170c8dc0 | ||
|
|
9c4c3c654d | ||
|
|
6952e387f4 | ||
|
|
66c9ae5c27 | ||
|
|
0fb7601dcb | ||
|
|
07c6e680d1 | ||
|
|
b972bc3040 | ||
|
|
969d42dbca | ||
|
|
6680df9382 | ||
|
|
8877157db5 | ||
|
|
ac814dc357 | ||
|
|
4fcb12c3a3 | ||
|
|
7bcc30f4b7 | ||
|
|
481f917fcf | ||
|
|
700a32e4c8 | ||
|
|
b5a72d904e |
@@ -1,2 +1,26 @@
|
||||
FROM kerberos/devcontainer:5da0fe7
|
||||
LABEL AUTHOR=Kerberos.io
|
||||
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
|
||||
@@ -1,21 +1,24 @@
|
||||
// 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": "A Dockerfile containing FFmpeg, OpenCV, Go and Yarn",
|
||||
"context": "..",
|
||||
"dockerFile": "./Dockerfile",
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
80
|
||||
"name": "go:1.24-bookworm",
|
||||
"runArgs": [
|
||||
"--name=agent",
|
||||
"--network=host"
|
||||
],
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download",
|
||||
"dockerFile": "Dockerfile",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-kubernetes-tools.vscode-kubernetes-tools",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"GitHub.copilot",
|
||||
"golang.go",
|
||||
"ms-vscode.vscode-typescript-next"
|
||||
"ms-azuretools.vscode-docker",
|
||||
"mongodb.mongodb-vscode"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
8080
|
||||
],
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download"
|
||||
}
|
||||
58
.github/workflows/docker-dev.yml
vendored
58
.github/workflows/docker-dev.yml
vendored
@@ -1,58 +0,0 @@
|
||||
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)
|
||||
113
.github/workflows/docker.yml
vendored
113
.github/workflows/docker.yml
vendored
@@ -1,113 +0,0 @@
|
||||
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"
|
||||
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
@@ -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.22"]
|
||||
go-version: ["1.24"]
|
||||
|
||||
steps:
|
||||
- name: Set up Go ${{ matrix.go-version }}
|
||||
|
||||
51
.github/workflows/issue-userstory-create.yml
vendored
Normal file
51
.github/workflows/issue-userstory-create.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
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 }}
|
||||
@@ -1,12 +1,14 @@
|
||||
name: Docker nightly build
|
||||
name: Nightly build
|
||||
|
||||
on:
|
||||
# Triggers the workflow every day at 9PM (CET).
|
||||
schedule:
|
||||
- cron: "0 22 * * *"
|
||||
# Allows manual triggering from the Actions tab.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
nightly-build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -18,7 +20,9 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
run: git clone https://github.com/kerberos-io/agent && cd agent
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
@@ -26,10 +30,10 @@ jobs:
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: Run Buildx
|
||||
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 .
|
||||
run: 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: 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:
|
||||
run: docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
nightly-build-other:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -41,7 +45,9 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
run: git clone https://github.com/kerberos-io/agent && cd agent
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
@@ -49,6 +55,6 @@ jobs:
|
||||
- name: Available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: Run Buildx
|
||||
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 .
|
||||
run: 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: 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)
|
||||
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)
|
||||
48
.github/workflows/pr-build.yml
vendored
Normal file
48
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
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
|
||||
|
||||
24
.github/workflows/pr-description.yaml
vendored
24
.github/workflows/pr-description.yaml
vendored
@@ -4,16 +4,14 @@ on: pull_request
|
||||
|
||||
jobs:
|
||||
openai-pr-description:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Autofill PR description if empty using OpenAI
|
||||
uses: cedricve/azureopenai-pr-description@master
|
||||
with:
|
||||
github_token: ${{ secrets.TOKEN }}
|
||||
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
|
||||
azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
|
||||
azure_openai_version: ${{ secrets.AZURE_OPENAI_VERSION }}
|
||||
overwrite_description: true
|
||||
uses: uug-ai/workflows/.github/workflows/pr-description.yml@main
|
||||
with:
|
||||
pr_number: ${{ github.event.number }}
|
||||
pull_request_url: ""
|
||||
overwrite_description: true
|
||||
secrets:
|
||||
TOKEN: ${{ secrets.TOKEN }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
|
||||
AZURE_OPENAI_VERSION: ${{ secrets.AZURE_OPENAI_VERSION }}
|
||||
168
.github/workflows/release-bump.yml
vendored
Normal file
168
.github/workflows/release-bump.yml
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
name: Bump release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Which part of the version to bump"
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
|
||||
jobs:
|
||||
# Determine the next version, create the GitHub release and expose the tag.
|
||||
bump-release:
|
||||
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
|
||||
with:
|
||||
bump: ${{ github.event.inputs.bump }}
|
||||
secrets: inherit
|
||||
|
||||
# Publish the platform image to the uug-ai GitHub Container Registry
|
||||
# (ghcr.io/uug-ai/agent-platform).
|
||||
release:
|
||||
needs: bump-release
|
||||
uses: uug-ai/workflows/.github/workflows/release-create.yml@main
|
||||
with:
|
||||
organization: uug-ai
|
||||
project: ${{ github.event.repository.name }}
|
||||
tag: ${{ needs.bump-release.outputs.tag }}
|
||||
docker_context: "."
|
||||
create_gitops_pr: false
|
||||
runner_matrix: >-
|
||||
[
|
||||
{"architecture":"amd64","runner":"ubuntu-24.04"},
|
||||
{"architecture":"arm64","runner":"ubuntu-24.04-arm"}
|
||||
]
|
||||
secrets: inherit
|
||||
|
||||
# Everything below mirrors the agent's own release-create.yml pipeline and
|
||||
# publishes the multi-arch image to the kerberos/agent Docker Hub repo, driven
|
||||
# by the freshly bumped tag instead of a `release: created` event.
|
||||
build-amd64:
|
||||
needs: bump-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [amd64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- uses: benjlevesque/short-sha@v2.1
|
||||
id: short-sha
|
||||
with:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
build-arm64:
|
||||
needs: bump-release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- uses: benjlevesque/short-sha@v2.1
|
||||
id: short-sha
|
||||
with:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} -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"
|
||||
|
||||
|
||||
130
.github/workflows/release-create.yml
vendored
Normal file
130
.github/workflows/release-create.yml
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
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"
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
ui/node_modules
|
||||
ui/build
|
||||
ui/public/assets/env.js
|
||||
.DS_Store
|
||||
__debug*
|
||||
.idea
|
||||
machinery/www
|
||||
yarn.lock
|
||||
@@ -10,6 +12,9 @@ machinery/data/recordings
|
||||
machinery/data/snapshots
|
||||
machinery/test*
|
||||
machinery/init-dev.sh
|
||||
machinery/.env
|
||||
machinery/.env.local
|
||||
machinery/vendor
|
||||
deployments/docker/private-docker-compose.yaml
|
||||
machinery/go.work
|
||||
machinery/go.work.sum
|
||||
deployments/docker/private-docker-compose.yaml
|
||||
video.mp4
|
||||
19
.travis.yml
19
.travis.yml
@@ -1,19 +0,0 @@
|
||||
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)
|
||||
9
.vscode/launch.json
vendored
9
.vscode/launch.json
vendored
@@ -12,10 +12,15 @@
|
||||
"program": "${workspaceFolder}/machinery/main.go",
|
||||
"args": [
|
||||
"-action",
|
||||
"run"
|
||||
"run",
|
||||
"-port",
|
||||
"8080"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/machinery/.env",
|
||||
"envFile": "${workspaceFolder}/machinery/.env.local",
|
||||
"buildFlags": "--tags dynamic",
|
||||
"env": {
|
||||
"GOWORK": "off"
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Launch React",
|
||||
|
||||
38
Dockerfile
38
Dockerfile
@@ -1,6 +1,13 @@
|
||||
|
||||
FROM kerberos/base:5d5e86b AS build-machinery
|
||||
LABEL AUTHOR=Kerberos.io
|
||||
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
|
||||
|
||||
ENV GOROOT=/usr/local/go
|
||||
ENV GOPATH=/go
|
||||
@@ -33,7 +40,10 @@ RUN cat /go/src/github.com/kerberos-io/agent/machinery/version
|
||||
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
|
||||
go mod download && \
|
||||
go build -tags timetzdata,netgo,osusergo --ldflags '-s -w -extldflags "-static -latomic"' main.go && \
|
||||
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
|
||||
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
|
||||
fi && \
|
||||
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 && \
|
||||
@@ -43,8 +53,7 @@ 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 && \
|
||||
rm -rf /go/src/gitlab.com/
|
||||
mkdir -p /agent/data/config
|
||||
|
||||
####################################
|
||||
# Let's create a /dist folder containing just the files necessary for runtime.
|
||||
@@ -58,19 +67,7 @@ RUN cp -r /agent ./
|
||||
|
||||
RUN /dist/agent/main version
|
||||
|
||||
###############################################
|
||||
# Build Bento4 -> we want fragmented mp4 files
|
||||
|
||||
ENV BENTO4_VERSION 1.6.0-641
|
||||
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
|
||||
FROM node:22-alpine AS build-ui
|
||||
|
||||
RUN apk update && apk upgrade --available && sync
|
||||
|
||||
@@ -105,13 +102,12 @@ 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 libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
|
||||
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 cp /home/agent/mp4fragment /usr/local/bin/
|
||||
RUN /home/agent/main version
|
||||
|
||||
#######################
|
||||
@@ -148,4 +144,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"]
|
||||
147
Dockerfile.arm64
Normal file
147
Dockerfile.arm64
Normal file
@@ -0,0 +1,147 @@
|
||||
|
||||
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"]
|
||||
139
README.md
139
README.md
@@ -65,6 +65,7 @@ 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)
|
||||
@@ -190,68 +191,78 @@ 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_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. | "" |
|
||||
| `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 |
|
||||
|
||||
|
||||
## Encryption
|
||||
|
||||
@@ -291,6 +302,10 @@ 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...
|
||||
|
||||
40
SECURITY.md
Normal file
40
SECURITY.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 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
10
build.sh
@@ -1,10 +0,0 @@
|
||||
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
|
||||
@@ -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 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).
|
||||
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).
|
||||
|
||||
### 2. Running multiple containers with Docker compose
|
||||
|
||||
|
||||
BIN
machinery/.DS_Store
vendored
Normal file
BIN
machinery/.DS_Store
vendored
Normal file
Binary file not shown.
38
machinery/.env
Normal file
38
machinery/.env
Normal file
@@ -0,0 +1,38 @@
|
||||
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=
|
||||
|
||||
# Resumable (tus) uploads to Kerberos Vault are enabled by default.
|
||||
# Set to true to fall back to the legacy single-shot POST /storage upload.
|
||||
#AGENT_DISABLE_RESUMABLE_UPLOAD=true
|
||||
# Bytes sent per PATCH request (default 1 MiB = 1048576). 0 disables chunking
|
||||
# and sends the whole file in a single PATCH.
|
||||
AGENT_TUS_CHUNK_SIZE_BYTES=1048576
|
||||
|
||||
# Open telemetry tracing endpoint
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
@@ -22,4 +22,8 @@ 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).
|
||||
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).
|
||||
|
||||
425
machinery/cmd/mp4analyze/main.go
Normal file
425
machinery/cmd/mp4analyze/main.go
Normal file
@@ -0,0 +1,425 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Eyevinn/mp4ff/avc"
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("usage: mp4analyze <file.mp4>")
|
||||
os.Exit(1)
|
||||
}
|
||||
f, err := os.Open(os.Args[1])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
parsed, err := mp4ff.DecodeFile(f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Movie-level info
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
moov := parsed.Init.Moov
|
||||
fmt.Printf("ftyp/moov present. timescale(mvhd)=%d duration(mvhd)=%d\n",
|
||||
moov.Mvhd.Timescale, moov.Mvhd.Duration)
|
||||
for _, trak := range moov.Traks {
|
||||
ts := trak.Mdia.Mdhd.Timescale
|
||||
fmt.Printf(" trak id=%d handler=%s mdhd.timescale=%d mdhd.duration=%d\n",
|
||||
trak.Tkhd.TrackID, trak.Mdia.Hdlr.HandlerType, ts, trak.Mdia.Mdhd.Duration)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("no Init/Moov (pure fragmented stream?)")
|
||||
}
|
||||
|
||||
// sidx vs actual segment layout. MSE players use sidx to map presentation
|
||||
// time -> byte ranges; if sidx references disagree with the real segment
|
||||
// sizes/durations (e.g. after an early/short flush) the player fetches the
|
||||
// wrong bytes and fails to decode — a failure that "heals" on seek.
|
||||
fmt.Println("=== sidx references vs actual segments ===")
|
||||
var sidxRefs []mp4ff.SidxRef
|
||||
for _, c := range parsed.Children {
|
||||
if s, ok := c.(*mp4ff.SidxBox); ok {
|
||||
fmt.Printf(" sidx: timescale=%d earliestPresTime=%d firstOffset=%d refCount=%d anchor(after sidx)=%d\n",
|
||||
s.Timescale, s.EarliestPresentationTime, s.FirstOffset, len(s.SidxRefs), s.AnchorPoint)
|
||||
sidxRefs = s.SidxRefs
|
||||
}
|
||||
}
|
||||
// Actual segment sizes (styp+moof+mdat) and fragment durations.
|
||||
type segInfo struct {
|
||||
size uint64
|
||||
dur uint64
|
||||
}
|
||||
var actual []segInfo
|
||||
for _, seg := range parsed.Segments {
|
||||
var sz uint64
|
||||
if seg.Styp != nil {
|
||||
sz += seg.Styp.Size()
|
||||
}
|
||||
if seg.Sidx != nil {
|
||||
sz += seg.Sidx.Size()
|
||||
}
|
||||
var dur uint64
|
||||
for _, fr := range seg.Fragments {
|
||||
sz += fr.Moof.Size()
|
||||
if fr.Mdat != nil {
|
||||
sz += fr.Mdat.Size()
|
||||
}
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
dur += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
actual = append(actual, segInfo{size: sz, dur: dur})
|
||||
}
|
||||
for i := range actual {
|
||||
refStr := "(no sidx ref)"
|
||||
if i < len(sidxRefs) {
|
||||
r := sidxRefs[i]
|
||||
mark := ""
|
||||
if uint64(r.ReferencedSize) != actual[i].size {
|
||||
mark += fmt.Sprintf(" SIZE MISMATCH actual=%d", actual[i].size)
|
||||
}
|
||||
if uint64(r.SubSegmentDuration) != actual[i].dur {
|
||||
mark += fmt.Sprintf(" DUR MISMATCH actual=%d", actual[i].dur)
|
||||
}
|
||||
refStr = fmt.Sprintf("sidx.size=%d sidx.dur=%d type=%d sap=%d/%d%s",
|
||||
r.ReferencedSize, r.SubSegmentDuration, r.ReferenceType, r.StartsWithSAP, r.SAPType, mark)
|
||||
}
|
||||
fmt.Printf(" seg%02d actual.size=%d actual.dur=%d | %s\n", i, actual[i].size, actual[i].dur, refStr)
|
||||
}
|
||||
|
||||
fmt.Println("=== fragments ===")
|
||||
fragIdx := 0
|
||||
var allKeyGlobal []uint64 // global keyframe decode times (track timescale units)
|
||||
var prevTfdtEnd = map[uint32]uint64{}
|
||||
for si, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
tid := traf.Tfhd.TrackID
|
||||
tfdt := traf.Tfdt.BaseMediaDecodeTime()
|
||||
offset := uint64(0)
|
||||
var keys []uint64 // keyframe offset-from-tfdt
|
||||
var durs []uint64
|
||||
zeroDur := 0
|
||||
nSamples := 0
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
nSamples++
|
||||
if (s.Flags>>24)&0x03 == 0x02 { // sample_depends_on==2 => IDR/sync
|
||||
keys = append(keys, offset)
|
||||
if tid == 1 {
|
||||
allKeyGlobal = append(allKeyGlobal, tfdt+offset)
|
||||
}
|
||||
}
|
||||
if s.Dur == 0 {
|
||||
zeroDur++
|
||||
}
|
||||
durs = append(durs, uint64(s.Dur))
|
||||
offset += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
cont := ""
|
||||
if pe, ok := prevTfdtEnd[tid]; ok {
|
||||
if tfdt != pe {
|
||||
cont = fmt.Sprintf(" <-- tfdt GAP/JUMP prev_end=%d delta=%d", pe, int64(tfdt)-int64(pe))
|
||||
}
|
||||
}
|
||||
prevTfdtEnd[tid] = tfdt + offset
|
||||
if tid == 1 {
|
||||
// in-fragment keyframe gaps
|
||||
var gaps []int64
|
||||
for i := 1; i < len(keys); i++ {
|
||||
gaps = append(gaps, int64(keys[i])-int64(keys[i-1]))
|
||||
}
|
||||
fmt.Printf("seg%d frag%d trk%d tfdt=%d dur=%d nSamp=%d zeroDur=%d keys=%v inFragKeyGaps=%v%s\n",
|
||||
si, fragIdx, tid, tfdt, offset, nSamples, zeroDur, keys, gaps, cont)
|
||||
}
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== global video keyframe decode times & gaps ===")
|
||||
for i, k := range allKeyGlobal {
|
||||
gap := int64(0)
|
||||
if i > 0 {
|
||||
gap = int64(k) - int64(allKeyGlobal[i-1])
|
||||
}
|
||||
flag := ""
|
||||
if i > 1 {
|
||||
prevGap := int64(allKeyGlobal[i-1]) - int64(allKeyGlobal[i-2])
|
||||
if gap > 0 && prevGap > 0 && gap*2 < prevGap {
|
||||
flag = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, flag)
|
||||
}
|
||||
|
||||
// Full sample timeline: DTS, CTS (=DTS+cto), composition offset, NAL types,
|
||||
// to detect PTS non-monotonicity / gaps / param-set changes at the seam.
|
||||
fmt.Println("=== per-sample timeline (full) — checking PTS monotonicity & nal types ===")
|
||||
var trex *mp4ff.TrexBox
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil && parsed.Init.Moov.Mvex != nil {
|
||||
for _, t := range parsed.Init.Moov.Mvex.Trexs {
|
||||
if t.TrackID == 1 {
|
||||
trex = t
|
||||
}
|
||||
}
|
||||
}
|
||||
var lastCTS int64 = -1
|
||||
var lastDTS int64 = -1
|
||||
sampIdx := 0
|
||||
fragIdx = 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fmt.Printf(" frag%d GetFullSamples err: %v\n", fragIdx, err)
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
dts := int64(s.DecodeTime)
|
||||
cts := dts + int64(s.CompositionTimeOffset)
|
||||
nals := nalTypes(s.Data)
|
||||
anomaly := ""
|
||||
if lastCTS >= 0 && cts < lastCTS {
|
||||
anomaly += fmt.Sprintf(" <== CTS BACKWARDS (prev=%d)", lastCTS)
|
||||
}
|
||||
if lastDTS >= 0 && dts < lastDTS {
|
||||
anomaly += fmt.Sprintf(" <== DTS BACKWARDS (prev=%d)", lastDTS)
|
||||
}
|
||||
isSync := s.Flags&0x02000000 == 0 && (s.Flags>>24)&0x03 == 0x02
|
||||
// Only print near the seam region and any anomalies, to keep output small.
|
||||
near := dts >= 7800 && dts <= 8700
|
||||
if near || anomaly != "" {
|
||||
fmt.Printf(" s%04d frag%d dts=%d cts=%d cto=%d dur=%d size=%d sync=%v nal=%v%s\n",
|
||||
sampIdx, fragIdx, dts, cts, s.CompositionTimeOffset, s.Dur, len(s.Data), isSync, nals, anomaly)
|
||||
}
|
||||
lastCTS = cts
|
||||
lastDTS = dts
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Compare parameter sets: avcC (in moov) vs inline SPS/PPS at every IDR.
|
||||
// A looping source that restarts may re-emit SPS/PPS that differ from the
|
||||
// ones the player configured its decoder with from avcC — a classic cause
|
||||
// of a freeze that "heals" when you seek past the seam.
|
||||
fmt.Println("=== parameter set comparison (avcC vs inline IDR) ===")
|
||||
var avccSPS, avccPPS [][]byte
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
|
||||
continue
|
||||
}
|
||||
stsd := trak.Mdia.Minf.Stbl.Stsd
|
||||
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
|
||||
continue
|
||||
}
|
||||
avccSPS = stsd.AvcX.AvcC.SPSnalus
|
||||
avccPPS = stsd.AvcX.AvcC.PPSnalus
|
||||
}
|
||||
}
|
||||
for i, s := range avccSPS {
|
||||
fmt.Printf(" avcC SPS[%d] = %x\n", i, s)
|
||||
}
|
||||
for i, p := range avccPPS {
|
||||
fmt.Printf(" avcC PPS[%d] = %x\n", i, p)
|
||||
}
|
||||
fragIdx = 0
|
||||
sampIdx = 0
|
||||
var baseSPS, basePPS []byte
|
||||
if len(avccSPS) > 0 {
|
||||
baseSPS = avccSPS[0]
|
||||
}
|
||||
if len(avccPPS) > 0 {
|
||||
basePPS = avccPPS[0]
|
||||
}
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
spsList := nalsByType(s.Data, 7)
|
||||
ppsList := nalsByType(s.Data, 8)
|
||||
if len(spsList) > 0 || len(ppsList) > 0 {
|
||||
dts := int64(s.DecodeTime)
|
||||
note := ""
|
||||
if len(spsList) > 0 {
|
||||
if baseSPS == nil {
|
||||
baseSPS = spsList[0]
|
||||
} else if !bytesEqual(baseSPS, spsList[0]) {
|
||||
note += " <== SPS CHANGED vs base/avcC"
|
||||
}
|
||||
}
|
||||
if len(ppsList) > 0 {
|
||||
if basePPS == nil {
|
||||
basePPS = ppsList[0]
|
||||
} else if !bytesEqual(basePPS, ppsList[0]) {
|
||||
note += " <== PPS CHANGED vs base/avcC"
|
||||
}
|
||||
}
|
||||
var spsHex, ppsHex string
|
||||
if len(spsList) > 0 {
|
||||
spsHex = fmt.Sprintf("%x", spsList[0])
|
||||
}
|
||||
if len(ppsList) > 0 {
|
||||
ppsHex = fmt.Sprintf("%x", ppsList[0])
|
||||
}
|
||||
fmt.Printf(" IDR s%04d frag%d dts=%d SPS=%s PPS=%s%s\n",
|
||||
sampIdx, fragIdx, dts, spsHex, ppsHex, note)
|
||||
}
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
sliceHeaders(parsed, trex)
|
||||
}
|
||||
|
||||
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
|
||||
// Build SPS/PPS maps from avcC.
|
||||
spsMap := map[uint32]*avc.SPS{}
|
||||
ppsMap := map[uint32]*avc.PPS{}
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
|
||||
continue
|
||||
}
|
||||
stsd := trak.Mdia.Minf.Stbl.Stsd
|
||||
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
|
||||
continue
|
||||
}
|
||||
for _, s := range stsd.AvcX.AvcC.SPSnalus {
|
||||
if sps, err := avc.ParseSPSNALUnit(s, true); err == nil {
|
||||
spsMap[uint32(sps.ParameterID)] = sps
|
||||
}
|
||||
}
|
||||
for _, p := range stsd.AvcX.AvcC.PPSnalus {
|
||||
if pps, err := avc.ParsePPSNALUnit(p, spsMap); err == nil {
|
||||
ppsMap[pps.PicParameterSetID] = pps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== slice headers near seam (frame_num / poc / idr_pic_id) ===")
|
||||
fragIdx := 0
|
||||
sampIdx := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
dts := int64(s.DecodeTime)
|
||||
if dts < 6800 || dts > 9400 {
|
||||
sampIdx++
|
||||
continue
|
||||
}
|
||||
for _, nal := range splitAVCC(s.Data) {
|
||||
t := nal[0] & 0x1f
|
||||
if t == 1 || t == 5 { // non-IDR or IDR slice
|
||||
sh, err := avc.ParseSliceHeader(nal, spsMap, ppsMap)
|
||||
if err != nil {
|
||||
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceHeader ERR: %v\n", sampIdx, fragIdx, dts, t, err)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceType=%v frameNum=%d idrPicId=%d pocLsb=%d\n",
|
||||
sampIdx, fragIdx, dts, t, sh.SliceType, sh.FrameNum, sh.IDRPicID, sh.PicOrderCntLsb)
|
||||
break
|
||||
}
|
||||
}
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitAVCC splits a length-prefixed (4-byte) AVCC buffer into NAL units.
|
||||
func splitAVCC(b []byte) [][]byte {
|
||||
var out [][]byte
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
out = append(out, b[i:i+n])
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// nalTypes returns the list of H.264 NAL unit types present in an AVCC
|
||||
// (length-prefixed) sample buffer.
|
||||
func nalTypes(b []byte) []int {
|
||||
var out []int
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
out = append(out, int(b[i]&0x1f))
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nalsByType returns the raw NAL payloads (without length prefix) of the given
|
||||
// type from an AVCC (length-prefixed) sample buffer.
|
||||
func nalsByType(b []byte, want int) [][]byte {
|
||||
var out [][]byte
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
if int(b[i]&0x1f) == want {
|
||||
nal := make([]byte, n)
|
||||
copy(nal, b[i:i+n])
|
||||
out = append(out, nal)
|
||||
}
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
"ipcamera": {
|
||||
"rtsp": "",
|
||||
"sub_rtsp": "",
|
||||
"fps": ""
|
||||
"fps": "",
|
||||
"base_width": 640,
|
||||
"base_height": 0
|
||||
},
|
||||
"usbcamera": {
|
||||
"device": ""
|
||||
@@ -26,6 +28,7 @@
|
||||
"recording": "true",
|
||||
"snapshots": "true",
|
||||
"liveview": "true",
|
||||
"liveview_chunking": "false",
|
||||
"motion": "true",
|
||||
"postrecording": 20,
|
||||
"prerecording": 10,
|
||||
@@ -98,6 +101,7 @@
|
||||
"region": "eu-west-1"
|
||||
},
|
||||
"kstorage": {},
|
||||
"kstorage_secondary": {},
|
||||
"dropbox": {},
|
||||
"mqtturi": "tcp://mqtt.kerberos.io:1883",
|
||||
"mqtt_username": "",
|
||||
@@ -115,6 +119,7 @@
|
||||
"hub_site": "",
|
||||
"condition_uri": "",
|
||||
"encryption": {},
|
||||
"signing": {},
|
||||
"realtimeprocessing": "false",
|
||||
"realtimeprocessing_topic": ""
|
||||
}
|
||||
|
||||
169
machinery/go.mod
169
machinery/go.mod
@@ -1,147 +1,115 @@
|
||||
module github.com/kerberos-io/agent/machinery
|
||||
|
||||
go 1.23.1
|
||||
go 1.24.2
|
||||
|
||||
replace google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250519155744-55703ea1f237
|
||||
|
||||
require (
|
||||
github.com/Eyevinn/mp4ff v0.48.0
|
||||
github.com/InVisionApp/conjungo v1.1.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/appleboy/gin-jwt/v2 v2.10.3
|
||||
github.com/bluenviron/gortsplib/v4 v4.14.1
|
||||
github.com/bluenviron/mediacommon v1.14.0
|
||||
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6
|
||||
github.com/dromara/carbon/v2 v2.5.2
|
||||
github.com/dromara/carbon/v2 v2.6.8
|
||||
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.0
|
||||
github.com/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/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/gofrs/uuid v4.4.0+incompatible
|
||||
github.com/golang-jwt/jwt/v4 v4.5.1
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
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/rtp v1.8.10
|
||||
github.com/pion/webrtc/v4 v4.0.7
|
||||
github.com/pion/interceptor v0.1.40
|
||||
github.com/pion/rtp v1.8.19
|
||||
github.com/pion/webrtc/v4 v4.1.2
|
||||
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.1
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.70.1
|
||||
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
|
||||
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/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/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/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // 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/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elgs/gostrgen v0.0.0-20161222160715-9d61ae07eeae // indirect
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
|
||||
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/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/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.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/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // 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/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/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||
github.com/icholy/digest v0.1.23 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/juju/errors v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.17.1 // indirect
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/cpuid v1.2.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // 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.2 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // 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.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/dtls/v3 v3.0.6 // indirect
|
||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||
github.com/pion/logging v0.2.3 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // 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/sctp v1.8.39 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.13 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.5 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/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
|
||||
@@ -149,34 +117,23 @@ 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/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
|
||||
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
|
||||
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
|
||||
|
||||
1842
machinery/go.sum
1842
machinery/go.sum
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -11,48 +12,62 @@ 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 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()
|
||||
}
|
||||
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() {
|
||||
|
||||
// Start the show ;)
|
||||
// We'll parse the flags (named variables), and start the agent.
|
||||
|
||||
@@ -86,35 +101,39 @@ 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
|
||||
{
|
||||
// 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)
|
||||
}
|
||||
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 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
|
||||
}
|
||||
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)
|
||||
utils.Decrypt(flag.Arg(0), symmetricKey)
|
||||
}
|
||||
|
||||
case "run":
|
||||
{
|
||||
// Print Kerberos.io ASCII art
|
||||
// Print Agent ASCII art
|
||||
utils.PrintASCIIArt()
|
||||
|
||||
// Print the environment variables which include "AGENT_" as prefix.
|
||||
@@ -127,12 +146,29 @@ func main() {
|
||||
configuration.Name = name
|
||||
configuration.Port = port
|
||||
|
||||
// Open this configuration either from Kerberos Agent or Kerberos Factory.
|
||||
// Open this configuration either from Agent or 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)
|
||||
|
||||
@@ -175,12 +211,14 @@ func main() {
|
||||
HandleBootstrap: make(chan string, 1),
|
||||
}
|
||||
|
||||
go components.Bootstrap(configDirectory, &configuration, &communication, &capture)
|
||||
go components.Bootstrap(ctx, 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 :(")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,11 @@ 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
|
||||
@@ -81,6 +84,212 @@ 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
|
||||
@@ -103,12 +312,26 @@ func init() {
|
||||
}
|
||||
|
||||
// Connect to the RTSP server.
|
||||
func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
func (g *Golibrtsp) Connect(ctx context.Context, ctxOtel context.Context) (err error) {
|
||||
|
||||
_, span := tracer.Start(ctxOtel, "Connect")
|
||||
defer span.End()
|
||||
|
||||
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
|
||||
@@ -131,8 +354,9 @@ func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Iniatlise the mutex.
|
||||
// Initialize the mutex and FPS calculation.
|
||||
g.VideoDecoderMutex = &sync.Mutex{}
|
||||
g.initFPSCalculation()
|
||||
|
||||
// find the H264 media and format
|
||||
var formaH264 *format.H264
|
||||
@@ -156,7 +380,9 @@ func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
// 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,
|
||||
@@ -168,7 +394,9 @@ func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
IsBackChannel: false,
|
||||
})
|
||||
} else {
|
||||
streamIndex := len(g.Streams)
|
||||
g.Streams = append(g.Streams, packets.Stream{
|
||||
Index: streamIndex,
|
||||
Name: formaH264.Codec(),
|
||||
IsVideo: true,
|
||||
IsAudio: false,
|
||||
@@ -216,8 +444,9 @@ func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
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,
|
||||
@@ -265,8 +494,9 @@ func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
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,
|
||||
@@ -300,8 +530,9 @@ func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
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,
|
||||
@@ -328,11 +559,15 @@ func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
// 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
|
||||
@@ -352,7 +587,11 @@ func (g *Golibrtsp) Connect(ctx context.Context) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (g *Golibrtsp) ConnectBackChannel(ctx context.Context) (err error) {
|
||||
func (g *Golibrtsp) ConnectBackChannel(ctx context.Context, ctxRunAgent context.Context) (err error) {
|
||||
|
||||
_, span := tracer.Start(ctxRunAgent, "ConnectBackChannel")
|
||||
defer span.End()
|
||||
|
||||
// Transport TCP
|
||||
transport := gortsplib.TransportTCP
|
||||
g.Client = gortsplib.Client{
|
||||
@@ -397,7 +636,9 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context) (err error) {
|
||||
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,
|
||||
@@ -410,10 +651,45 @@ func (g *Golibrtsp) ConnectBackChannel(ctx context.Context) (err error) {
|
||||
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) {
|
||||
@@ -439,6 +715,7 @@ 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,
|
||||
@@ -480,6 +757,7 @@ 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,
|
||||
@@ -493,7 +771,9 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
var filteredAU [][]byte
|
||||
if g.VideoH264Media != nil && g.VideoH264Forma != nil {
|
||||
|
||||
//dtsExtractor := h264.NewDTSExtractor2()
|
||||
// Extracts DTS from the bitstream to support B-frame H264 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
h264DTSExtractor := h264.NewDTSExtractor2()
|
||||
|
||||
g.Client.OnPacketRTP(g.VideoH264Media, g.VideoH264Forma, func(rtppkt *rtp.Packet) {
|
||||
|
||||
@@ -507,18 +787,17 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// 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")
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Extract access units from RTP packets.
|
||||
// We need a complete access unit to determine whether
|
||||
// this is a keyframe.
|
||||
au, errDecode := g.VideoH264Decoder.Decode(rtppkt)
|
||||
if errDecode != nil {
|
||||
if errDecode != rtph264.ErrNonStartingPacketAndNoPrevious && errDecode != rtph264.ErrMorePacketsNeeded {
|
||||
@@ -527,6 +806,18 @@ 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{
|
||||
@@ -537,8 +828,10 @@ 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
|
||||
@@ -551,6 +844,9 @@ 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" {
|
||||
@@ -565,21 +861,51 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
} else if streamType == "sub" {
|
||||
configuration.Config.Capture.IPCamera.SubHeight = sps.Height()
|
||||
}
|
||||
// Get FPS
|
||||
g.Streams[g.VideoH264Index].FPS = sps.FPS()
|
||||
// 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))
|
||||
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 {
|
||||
@@ -587,12 +913,10 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
return
|
||||
}
|
||||
|
||||
// Extract DTS from RTP packets
|
||||
//dts2, err := dtsExtractor.Extract(filteredAU, pts2)
|
||||
//if err != nil {
|
||||
// log.Log.Error("capture.golibrtsp.Start(): " + err.Error())
|
||||
// return
|
||||
//}
|
||||
// Composition time offset (PTS - DTS) in milliseconds. Non-zero
|
||||
// only for streams with B-frames; the MP4 writer uses it to keep a
|
||||
// monotonic decode timeline and present frames in PTS order.
|
||||
compositionOffset := compositionOffsetMs(h264DTSExtractor, au, pts2, g.VideoH264Forma.ClockRate())
|
||||
|
||||
pkt := packets.Packet{
|
||||
IsKeyFrame: idrPresent,
|
||||
@@ -600,24 +924,67 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
Data: enc,
|
||||
Time: pts2,
|
||||
TimeLegacy: pts,
|
||||
CompositionTime: pts2,
|
||||
CurrentTime: time.Now().UnixMilli(),
|
||||
CompositionTime: compositionOffset,
|
||||
Idx: g.VideoH264Index,
|
||||
IsVideo: true,
|
||||
IsAudio: false,
|
||||
Codec: "H264",
|
||||
}
|
||||
|
||||
pkt.Data = pkt.Data[4:]
|
||||
if pkt.IsKeyFrame {
|
||||
annexbNALUStartCode := func() []byte { return []byte{0x00, 0x00, 0x00, 0x01} }
|
||||
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
|
||||
pkt.Data = append(g.VideoH264Forma.PPS, pkt.Data...)
|
||||
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
|
||||
pkt.Data = append(g.VideoH264Forma.SPS, pkt.Data...)
|
||||
pkt.Data = append(annexbNALUStartCode(), pkt.Data...)
|
||||
// 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
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -649,6 +1016,11 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
|
||||
// called when a video RTP packet arrives for H265
|
||||
if g.VideoH265Media != nil && g.VideoH265Forma != nil {
|
||||
|
||||
// Extracts DTS from the bitstream to support B-frame H265 streams.
|
||||
// Created once per stream (tracks reorder state across access units).
|
||||
h265DTSExtractor := h265.NewDTSExtractor2()
|
||||
|
||||
g.Client.OnPacketRTP(g.VideoH265Media, g.VideoH265Forma, func(rtppkt *rtp.Packet) {
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
@@ -661,18 +1033,17 @@ func (g *Golibrtsp) Start(ctx context.Context, streamType string, queue *packets
|
||||
|
||||
if len(rtppkt.Payload) > 0 {
|
||||
|
||||
// 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")
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Extract access units from RTP packets.
|
||||
// We need a complete access unit to determine whether
|
||||
// this is a keyframe.
|
||||
au, errDecode := g.VideoH265Decoder.Decode(rtppkt)
|
||||
if errDecode != nil {
|
||||
if errDecode != rtph265.ErrNonStartingPacketAndNoPrevious && errDecode != rtph265.ErrMorePacketsNeeded {
|
||||
@@ -681,6 +1052,22 @@ 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},
|
||||
}
|
||||
@@ -723,20 +1110,47 @@ 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,
|
||||
CompositionTime: pts2,
|
||||
CurrentTime: time.Now().UnixMilli(),
|
||||
CompositionTime: compositionOffset,
|
||||
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.
|
||||
@@ -778,7 +1192,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) (err error) {
|
||||
func (g *Golibrtsp) StartBackChannel(ctx context.Context, ctxRunAgent 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)
|
||||
@@ -860,8 +1274,8 @@ func (g *Golibrtsp) DecodePacketRaw(pkt packets.Packet) (image.Gray, error) {
|
||||
}
|
||||
|
||||
// Get a list of streams from the RTSP server.
|
||||
func (j *Golibrtsp) GetStreams() ([]packets.Stream, error) {
|
||||
return j.Streams, nil
|
||||
func (g *Golibrtsp) GetStreams() ([]packets.Stream, error) {
|
||||
return g.Streams, nil
|
||||
}
|
||||
|
||||
// Get a list of video streams from the RTSP server.
|
||||
@@ -887,7 +1301,11 @@ func (g *Golibrtsp) GetAudioStreams() ([]packets.Stream, error) {
|
||||
}
|
||||
|
||||
// Close the connection to the RTSP server.
|
||||
func (g *Golibrtsp) Close() error {
|
||||
func (g *Golibrtsp) Close(ctxOtel context.Context) error {
|
||||
|
||||
_, span := tracer.Start(ctxOtel, "Close")
|
||||
defer span.End()
|
||||
|
||||
// Close the demuxer.
|
||||
g.Client.Close()
|
||||
|
||||
@@ -1089,7 +1507,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: forma.Config.Type,
|
||||
Type: mpeg4audio.ObjectType(forma.Config.Type),
|
||||
SampleRate: forma.Config.SampleRate,
|
||||
ChannelCount: forma.Config.ChannelCount,
|
||||
AU: au,
|
||||
@@ -1101,3 +1519,149 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ 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/yapingcat/gomedia/go-mp4"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func CleanupRecordingDirectory(configDirectory string, configuration *models.Configuration) {
|
||||
@@ -63,26 +64,46 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
} else {
|
||||
log.Log.Debug("capture.main.HandleRecordStream(): started")
|
||||
|
||||
recordingPeriod := config.Capture.PostRecording // number of seconds to record.
|
||||
maxRecordingPeriod := config.Capture.MaxLengthRecording // maximum number of seconds to record.
|
||||
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.
|
||||
|
||||
// Synchronise the last synced time
|
||||
now := time.Now().Unix()
|
||||
startRecording := now
|
||||
timestamp := now
|
||||
// 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
|
||||
}
|
||||
|
||||
if config.FriendlyName != "" {
|
||||
config.Name = config.FriendlyName
|
||||
}
|
||||
|
||||
// For continuous and motion based recording we will use a single file.
|
||||
var file *os.File
|
||||
// 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
|
||||
}
|
||||
|
||||
// Check if continuous recording.
|
||||
if config.Capture.Continuous == "true" {
|
||||
|
||||
//var cws *cacheWriterSeeker
|
||||
var myMuxer *mp4.Movmuxer
|
||||
var mp4Video *video.MP4
|
||||
var videoTrack uint32
|
||||
var audioTrack uint32
|
||||
var name string
|
||||
@@ -90,15 +111,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
|
||||
recordingPeriod = maxRecordingPeriod
|
||||
postRecording = 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
|
||||
@@ -114,43 +135,65 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
|
||||
nextPkt, cursorError = recordingCursor.ReadPacket()
|
||||
|
||||
now := time.Now().Unix()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if start && // If already recording and current frame is a keyframe and we should stop recording
|
||||
nextPkt.IsKeyFrame && (timestamp+recordingPeriod-now <= 0 || now-startRecording >= maxRecordingPeriod) {
|
||||
nextPkt.IsKeyFrame && (startRecording+postRecording-now <= 0 || now-startRecording > maxRecordingPeriod-500) {
|
||||
|
||||
// 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.")
|
||||
}
|
||||
// Write the last packet before closing the recording.
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
|
||||
// Close mp4
|
||||
if len(mp4Video.SPSNALUs) == 0 && len(configuration.Config.Capture.IPCamera.SPSNALUs) > 0 {
|
||||
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
|
||||
|
||||
// Check if need to convert to fragmented using bento
|
||||
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
|
||||
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
|
||||
// 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 we need to encrypt the recording.
|
||||
@@ -197,7 +240,6 @@ 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
|
||||
@@ -208,13 +250,17 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
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"
|
||||
startRecording = pkt.CurrentTime
|
||||
startRecordingSeconds := startRecording / 1000 // convert to seconds
|
||||
startRecordingMilliseconds := startRecording % 1000 // convert to milliseconds
|
||||
s := strconv.FormatInt(startRecordingSeconds, 10) + "_" + // start timestamp in seconds
|
||||
strconv.Itoa(len(strconv.FormatInt(startRecordingMilliseconds, 10))) + "-" + // length of milliseconds
|
||||
strconv.FormatInt(startRecordingMilliseconds, 10) + "_" + // milliseconds
|
||||
config.Name + "_" + // device name
|
||||
"0-0-0-0" + "_" + // region coordinates, we will not use this for continuous recording
|
||||
"0" + "_" + // token
|
||||
"0" + "_" //+ // duration of recording in milliseconds
|
||||
//utils.VERSION // version of the agent
|
||||
|
||||
name = s + ".mp4"
|
||||
fullName = configDirectory + "/data/recordings/" + name
|
||||
@@ -222,62 +268,41 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// Running...
|
||||
log.Log.Info("capture.main.HandleRecordStream(continuous): recording started")
|
||||
|
||||
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.")
|
||||
}
|
||||
// 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.")
|
||||
}
|
||||
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
recordingStatus = "started"
|
||||
|
||||
} else if start {
|
||||
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)
|
||||
}
|
||||
pkt = nextPkt
|
||||
}
|
||||
|
||||
@@ -285,21 +310,43 @@ 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
|
||||
|
||||
// Check if need to convert to fragmented using bento
|
||||
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
|
||||
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
|
||||
// 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 we need to encrypt the recording.
|
||||
@@ -337,33 +384,44 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): Start motion based recording ")
|
||||
|
||||
var lastDuration int64
|
||||
var lastRecordingTime int64
|
||||
var lastRecordingTime int64 = 0 // last recording timestamp in milliseconds
|
||||
var displayTime int64 = 0 // display time in milliseconds
|
||||
|
||||
//var cws *cacheWriterSeeker
|
||||
var myMuxer *mp4.Movmuxer
|
||||
var videoTrack uint32
|
||||
var audioTrack uint32
|
||||
|
||||
for motion := range communication.HandleMotion {
|
||||
|
||||
timestamp = time.Now().Unix()
|
||||
startRecording = time.Now().Unix() // we mark the current time when the record started.
|
||||
numberOfChanges := motion.NumberOfChanges
|
||||
// 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)
|
||||
|
||||
// If we have prerecording we will substract the number of seconds.
|
||||
// Taking into account FPS = GOP size (Keyfram interval)
|
||||
if config.Capture.PreRecording > 0 {
|
||||
now := time.Now().UnixMilli()
|
||||
motionTimestamp := now
|
||||
|
||||
// Might be that recordings are coming short after each other.
|
||||
// Therefore we do some math with the current time and the last recording time.
|
||||
start := false
|
||||
|
||||
timeBetweenNowAndLastRecording := startRecording - lastRecordingTime
|
||||
if timeBetweenNowAndLastRecording > int64(config.Capture.PreRecording) {
|
||||
startRecording = startRecording - int64(config.Capture.PreRecording) + 1
|
||||
} else {
|
||||
startRecording = startRecording - timeBetweenNowAndLastRecording
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
@@ -375,48 +433,47 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
s := strconv.FormatInt(startRecording, 10) + "_" +
|
||||
"6" + "-" +
|
||||
"967003" + "_" +
|
||||
config.Name + "_" +
|
||||
"200-200-400-400" + "_" +
|
||||
strconv.Itoa(numberOfChanges) + "_" +
|
||||
"769"
|
||||
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
|
||||
|
||||
name := s + ".mp4"
|
||||
fullName := configDirectory + "/data/recordings/" + name
|
||||
|
||||
// Running...
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): recording started")
|
||||
file, _ = os.Create(fullName)
|
||||
myMuxer, _ = mp4.CreateMp4Muxer(file)
|
||||
log.Log.Info("capture.main.HandleRecordStream(motiondetection): recording started (" + name + ")" + " at " + strconv.FormatInt(displayTimeSeconds, 10) + " unix")
|
||||
|
||||
// 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()
|
||||
// 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")
|
||||
}
|
||||
// Create the MP4 only once the first keyframe arrives.
|
||||
var mp4Video *video.MP4
|
||||
|
||||
for cursorError == nil {
|
||||
|
||||
@@ -425,68 +482,111 @@ func HandleRecordStream(queue *packets.Queue, configDirectory string, configurat
|
||||
log.Log.Error("capture.main.HandleRecordStream(motiondetection): " + cursorError.Error())
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
now = time.Now().UnixMilli()
|
||||
select {
|
||||
case motion := <-communication.HandleMotion:
|
||||
timestamp = now
|
||||
motionTimestamp = 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 (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))
|
||||
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))
|
||||
break
|
||||
}
|
||||
if pkt.IsKeyFrame && !start && pkt.Time >= lastDuration {
|
||||
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.
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
writeSampleToMP4(mp4Video, videoTrack, audioTrack, pkt)
|
||||
}
|
||||
|
||||
pkt = nextPkt
|
||||
}
|
||||
|
||||
// This will write the trailer a well.
|
||||
myMuxer.WriteTrailer()
|
||||
// Update the last duration and last recording time.
|
||||
// This is used to determine if we need to start a new recording.
|
||||
lastRecordingTime = pkt.CurrentTime
|
||||
|
||||
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)
|
||||
|
||||
lastDuration = pkt.Time
|
||||
lastRecordingTime = time.Now().Unix()
|
||||
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
|
||||
|
||||
// Check if need to convert to fragmented using bento
|
||||
if config.Capture.Fragmented == "true" && config.Capture.FragmentedDuration > 0 {
|
||||
utils.CreateFragmentedMP4(fullName, config.Capture.FragmentedDuration)
|
||||
// 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 we need to encrypt the recording.
|
||||
@@ -534,6 +634,10 @@ 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)
|
||||
|
||||
@@ -559,12 +663,11 @@ func VerifyCamera(c *gin.Context) {
|
||||
Url: rtspUrl,
|
||||
}
|
||||
|
||||
err := rtspClient.Connect(ctx)
|
||||
err := rtspClient.Connect(ctx, ctxVerifyCamera)
|
||||
if err == nil {
|
||||
|
||||
// Get the streams from the rtsp client.
|
||||
streams, _ := rtspClient.GetStreams()
|
||||
|
||||
videoIdx := -1
|
||||
audioIdx := -1
|
||||
for i, stream := range streams {
|
||||
@@ -575,7 +678,7 @@ func VerifyCamera(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
err := rtspClient.Close()
|
||||
err := rtspClient.Close(ctxVerifyCamera)
|
||||
if err == nil {
|
||||
if videoIdx > -1 {
|
||||
c.JSON(200, models.APIResponse{
|
||||
@@ -604,7 +707,7 @@ func VerifyCamera(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func Base64Image(captureDevice *Capture, communication *models.Communication) string {
|
||||
func Base64Image(captureDevice *Capture, communication *models.Communication, configuration *models.Configuration) string {
|
||||
// We'll try to get a snapshot from the camera.
|
||||
var queue *packets.Queue
|
||||
var cursor *packets.QueueCursor
|
||||
@@ -634,7 +737,8 @@ func Base64Image(captureDevice *Capture, communication *models.Communication) st
|
||||
var img image.YCbCr
|
||||
img, err = (*rtspClient).DecodePacket(pkt)
|
||||
if err == nil {
|
||||
bytes, _ := utils.ImageToBytes(&img)
|
||||
imageResized, _ := utils.ResizeImage(&img, uint(configuration.Config.Capture.IPCamera.BaseWidth), uint(configuration.Config.Capture.IPCamera.BaseHeight))
|
||||
bytes, _ := utils.ImageToBytes(imageResized)
|
||||
encodedImage = base64.StdEncoding.EncodeToString(bytes)
|
||||
break
|
||||
} else {
|
||||
@@ -695,6 +799,41 @@ func convertPTS(v time.Duration) uint64 {
|
||||
return uint64(v.Milliseconds())
|
||||
}
|
||||
|
||||
func convertPTS2(v int64) uint64 {
|
||||
return uint64(v) / 100
|
||||
// writeSampleToMP4 writes a single capture packet to the fragmented MP4.
|
||||
//
|
||||
// For video it derives the decode timestamp (DTS) from the packet PTS using the
|
||||
// per-packet composition offset (PTS - DTS), which is non-zero only for streams
|
||||
// that contain B-frames. Passing the monotonic DTS as the sample timestamp keeps
|
||||
// the fragment timeline (tfdt/sidx) monotonic, while the composition offset is
|
||||
// forwarded so frames are still presented in PTS order.
|
||||
func writeSampleToMP4(mp4Video *video.MP4, videoTrack, audioTrack uint32, pkt packets.Packet) {
|
||||
if mp4Video == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pts := convertPTS(pkt.TimeLegacy)
|
||||
|
||||
if pkt.IsVideo {
|
||||
compositionOffset := pkt.CompositionTime
|
||||
dts := pts
|
||||
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
|
||||
dts = pts - uint64(compositionOffset)
|
||||
}
|
||||
if err := mp4Video.AddSampleToTrack(videoTrack, pkt.IsKeyFrame, pkt.Data, dts, compositionOffset); err != nil {
|
||||
log.Log.Error("capture.main.writeSampleToMP4(): " + err.Error())
|
||||
}
|
||||
} else if pkt.IsAudio {
|
||||
if pkt.Codec == "AAC" {
|
||||
if err := mp4Video.AddSampleToTrack(audioTrack, pkt.IsKeyFrame, pkt.Data, pts, 0); err != nil {
|
||||
log.Log.Error("capture.main.writeSampleToMP4(): " + err.Error())
|
||||
}
|
||||
} else if pkt.Codec == "PCM_MULAW" {
|
||||
// TODO: transcode to AAC, some work to do..
|
||||
log.Log.Debug("capture.main.writeSampleToMP4(): no AAC audio codec detected, skipping audio track.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*func convertPTS2(v int64) uint64 {
|
||||
return uint64(v) / 100
|
||||
}*/
|
||||
|
||||
@@ -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) error
|
||||
Connect(ctx context.Context, otelContext context.Context) error
|
||||
|
||||
// Connect to a backchannel RTSP server.
|
||||
ConnectBackChannel(ctx context.Context) error
|
||||
ConnectBackChannel(ctx context.Context, otelContext 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) (err error)
|
||||
StartBackChannel(ctx context.Context, otelContext context.Context) 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() error
|
||||
Close(ctx context.Context) error
|
||||
|
||||
// Get a list of streams from the RTSP server.
|
||||
GetStreams() ([]packets.Stream, error)
|
||||
@@ -131,7 +131,7 @@ func HandleUpload(configDirectory string, configuration *models.Configuration, c
|
||||
log.Log.Error("HandleUpload: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
delay = 20 * time.Second // slow down
|
||||
delay = 5 * time.Second // slow down
|
||||
if err != nil {
|
||||
log.Log.Error("HandleUpload: " + err.Error())
|
||||
}
|
||||
@@ -672,6 +672,7 @@ 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
|
||||
@@ -705,25 +706,79 @@ func HandleLiveStreamSD(livestreamCursor *packets.QueueCursor, configuration *mo
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): Sending base64 encoded images to MQTT.")
|
||||
img, err := rtspClient.DecodePacket(pkt)
|
||||
if err == nil {
|
||||
bytes, _ := utils.ImageToBytes(&img)
|
||||
encoded := base64.StdEncoding.EncodeToString(bytes)
|
||||
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
|
||||
bytes, _ := utils.ImageToBytes(imageResized)
|
||||
|
||||
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)
|
||||
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))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("cloud.HandleLiveStreamSD(): something went wrong while sending acknowledge config to hub: " + string(payload))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
time.Sleep(1000 * time.Millisecond) // Sleep to avoid flooding the MQTT broker with messages
|
||||
}
|
||||
|
||||
} else {
|
||||
@@ -745,11 +800,19 @@ func HandleLiveStreamHD(livestreamCursor *packets.QueueCursor, configuration *mo
|
||||
// Check if we need to enable the live stream
|
||||
if config.Capture.Liveview != "false" {
|
||||
|
||||
// Should create a track here.
|
||||
// Create per-peer broadcasters instead of shared tracks.
|
||||
// Each viewer gets its own track with independent, non-blocking writes
|
||||
// so a slow/congested peer cannot stall the others.
|
||||
streams, _ := rtspClient.GetStreams()
|
||||
videoTrack := webrtc.NewVideoTrack(streams)
|
||||
audioTrack := webrtc.NewAudioTrack(streams)
|
||||
go webrtc.WriteToTrack(livestreamCursor, configuration, communication, mqttClient, videoTrack, audioTrack, rtspClient)
|
||||
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)
|
||||
|
||||
if config.Capture.ForwardWebRTC == "true" {
|
||||
|
||||
@@ -757,7 +820,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, videoTrack, audioTrack, handshake)
|
||||
go webrtc.InitializeWebRTCConnection(configuration, communication, mqttClient, videoBroadcaster, audioBroadcaster, handshake)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,7 +873,8 @@ 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 {
|
||||
bytes, _ := utils.ImageToBytes(&img)
|
||||
imageResized, _ := utils.ResizeImage(&img, uint(config.Capture.IPCamera.BaseWidth), uint(config.Capture.IPCamera.BaseHeight))
|
||||
bytes, _ := utils.ImageToBytes(imageResized)
|
||||
encoded := base64.StdEncoding.EncodeToString(bytes)
|
||||
|
||||
valueMap := make(map[string]interface{})
|
||||
@@ -1162,3 +1226,184 @@ 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,11 @@ func UploadDropbox(configuration *models.Configuration, fileName string) (bool,
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
log.Log.Error("UploadDropbox: Error closing file: " + cerr.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
@@ -34,6 +34,29 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
|
||||
log.Log.Info("UploadKerberosHub: Uploading to Kerberos Hub (" + config.HubURI + ")")
|
||||
log.Log.Info("UploadKerberosHub: Upload started for " + fileName)
|
||||
|
||||
// Prefer the resumable (tus) upload when enabled (the default). Kerberos Hub
|
||||
// authenticates the agent with its Hub public/private key and proxies the
|
||||
// resumable upload to the Kerberos Vault. When Hub does not expose a tus
|
||||
// endpoint (older deployments) we transparently fall back to the legacy
|
||||
// single-POST upload below.
|
||||
if resumableUploadsEnabled() {
|
||||
uploaded, _, supported, body, rerr := uploadHubResumable(&config, fileName, "UploadKerberosHub", "hub")
|
||||
if supported {
|
||||
if uploaded {
|
||||
log.Log.Info("UploadKerberosHub: Upload Finished (resumable), " + body)
|
||||
return true, true, nil
|
||||
}
|
||||
if rerr != nil {
|
||||
log.Log.Info("UploadKerberosHub: resumable upload failed, " + rerr.Error())
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosHub: resumable upload incomplete, " + body)
|
||||
}
|
||||
return false, true, rerr
|
||||
}
|
||||
log.Log.Info("UploadKerberosHub: resumable (tus) endpoint not available, falling back to legacy upload")
|
||||
}
|
||||
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
// Check if we still have the file otherwise we abort the request.
|
||||
@@ -68,9 +91,9 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
client = &http.Client{Transport: tr, CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
client = &http.Client{CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -129,3 +152,20 @@ func UploadKerberosHub(configuration *models.Configuration, fileName string) (bo
|
||||
log.Log.Info(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
// stripHubCredentialsOnCrossHostRedirect removes the custom Kerberos Hub
|
||||
// credential headers on a redirect that crosses to a different host. net/http
|
||||
// already strips the standard sensitive headers (Authorization, Cookie,
|
||||
// WWW-Authenticate) on a cross-host redirect, but it does NOT strip
|
||||
// custom-named headers, so without this the Hub private/public keys would be
|
||||
// forwarded to any host the configured HubURI redirects to.
|
||||
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del("X-Kerberos-Hub-PrivateKey")
|
||||
req.Header.Del("X-Kerberos-Hub-PublicKey")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,14 +3,20 @@ package cloud
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"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
|
||||
@@ -19,11 +25,20 @@ 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)
|
||||
}
|
||||
|
||||
// If the recording no longer exists on disk there is nothing to upload.
|
||||
// This can happen when the file was already removed (e.g. cleanup, or an
|
||||
// earlier successful upload). Skip it so the watcher drops the marker
|
||||
// instead of retrying indefinitely.
|
||||
if _, err := os.Stat("data/recordings/" + fileName); err != nil {
|
||||
log.Log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
@@ -35,71 +50,166 @@ func UploadKerberosVault(configuration *models.Configuration, fileName string) (
|
||||
// KerberosCloud, this means storage is disabled and proxy enabled.
|
||||
log.Log.Info("UploadKerberosVault: Uploading to Kerberos Vault (" + config.KStorage.URI + ")")
|
||||
log.Log.Info("UploadKerberosVault: Upload started for " + fileName)
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if err != nil {
|
||||
err := "UploadKerberosVault: Upload Failed, file doesn't exists anymore."
|
||||
log.Log.Info(err)
|
||||
return false, false, errors.New(err)
|
||||
}
|
||||
|
||||
publicKey := config.KStorage.CloudKey
|
||||
// This is the new way ;)
|
||||
if config.HubKey != "" {
|
||||
publicKey = config.HubKey
|
||||
}
|
||||
|
||||
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},
|
||||
// We need to check if we are in a retry timeout.
|
||||
if kstorageRetryTimeout <= time.Now().Unix() {
|
||||
uploaded, responded, body, err := sendToVault(*config.KStorage, publicKey, config.Key, fileName, "UploadKerberosVault", "primary")
|
||||
if uploaded {
|
||||
kstorageRetryCount = 0
|
||||
log.Log.Info("UploadKerberosVault: Upload Finished, " + body)
|
||||
return true, true, nil
|
||||
}
|
||||
client = &http.Client{Transport: tr}
|
||||
} else {
|
||||
client = &http.Client{}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + err.Error())
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + body)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if resp != nil {
|
||||
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
|
||||
}
|
||||
// We only advance the retry policy when the vault gave a definitive
|
||||
// response (mirroring the original behaviour where transient network
|
||||
// errors did not consume retries). When the retry count reaches the
|
||||
// configured maximum we back off for the configured timeout.
|
||||
if responded {
|
||||
if kstorageRetryCount < config.KStorage.MaxRetries {
|
||||
kstorageRetryCount = (kstorageRetryCount + 1)
|
||||
}
|
||||
if kstorageRetryCount == config.KStorage.MaxRetries {
|
||||
kstorageRetryTimeout = time.Now().Add(time.Duration(config.KStorage.Timeout) * time.Second).Unix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage := "UploadKerberosVault: Upload Failed, " + err.Error()
|
||||
log.Log.Info(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
// 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.")
|
||||
} else {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Uploading to Secondary Kerberos Vault (" + config.KStorageSecondary.URI + ")")
|
||||
|
||||
uploaded, _, body, err := sendToVault(*config.KStorageSecondary, publicKey, config.Key, fileName, "UploadKerberosVault (Secondary)", "secondary")
|
||||
if uploaded {
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Upload Finished to secondary, " + body)
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Upload Failed to secondary, " + err.Error())
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosVault (Secondary): Upload Failed to secondary, " + body)
|
||||
}
|
||||
}
|
||||
|
||||
return false, true, nil
|
||||
}
|
||||
|
||||
// sendToVault uploads a single recording to one Kerberos Vault. When resumable
|
||||
// uploads are enabled (the default) it attempts the tus protocol first and, if
|
||||
// the vault does not expose a tus endpoint (older deployments), transparently
|
||||
// falls back to the legacy single-shot POST.
|
||||
//
|
||||
// It returns whether the upload succeeded, whether the vault gave a definitive
|
||||
// HTTP response (so the caller can advance its retry policy), a short message
|
||||
// for logging, and a transport error if any.
|
||||
func sendToVault(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, string, error) {
|
||||
if resumableUploadsEnabled() {
|
||||
uploaded, responded, supported, body, err := uploadVaultResumable(vault, publicKey, deviceKey, fileName, label, slot)
|
||||
if supported {
|
||||
return uploaded, responded, body, err
|
||||
}
|
||||
log.Log.Info(label + ": resumable (tus) endpoint not available, falling back to legacy upload")
|
||||
}
|
||||
return uploadVaultLegacy(vault, publicKey, deviceKey, fileName, label)
|
||||
}
|
||||
|
||||
// uploadVaultLegacy performs the original single-request upload: the whole file
|
||||
// is sent as the body of a POST to {URI}/storage. Kept for backwards
|
||||
// compatibility with vault deployments that do not support resumable uploads.
|
||||
func uploadVaultLegacy(vault models.KStorage, publicKey, deviceKey, fileName, label string) (bool, bool, string, error) {
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.Open(fullname)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if err != nil {
|
||||
msg := label + ": Upload Failed, file doesn't exists anymore"
|
||||
log.Log.Info(msg)
|
||||
return false, false, "", errors.New(msg)
|
||||
}
|
||||
|
||||
uri := vault.URI
|
||||
for len(uri) > 0 && uri[len(uri)-1] == '/' {
|
||||
uri = uri[:len(uri)-1]
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", uri+"/storage", file)
|
||||
if err != nil {
|
||||
errorMessage := label + ": error reading request, " + uri + "/storage: " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, false, "", errors.New(errorMessage)
|
||||
}
|
||||
req.Header.Set("Content-Type", "video/mp4")
|
||||
setVaultHeaders(req.Header, vault, publicKey, deviceKey, fileName)
|
||||
|
||||
client := newVaultHTTPClient(0)
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
|
||||
body, rerr := io.ReadAll(resp.Body)
|
||||
if rerr != nil {
|
||||
return false, false, "", rerr
|
||||
}
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
return true, true, resp.Status + ", " + string(body), nil
|
||||
}
|
||||
return false, true, resp.Status + ", " + string(body), nil
|
||||
}
|
||||
|
||||
// setVaultHeaders sets the standard Kerberos Vault headers used by the legacy
|
||||
// single-POST upload.
|
||||
func setVaultHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
|
||||
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
|
||||
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
|
||||
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
|
||||
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
h.Set("X-Kerberos-Storage-Device", deviceKey)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
|
||||
}
|
||||
|
||||
// newVaultHTTPClient builds an HTTP client honouring the AGENT_TLS_INSECURE
|
||||
// escape hatch. A timeout of 0 disables the client-level timeout, which is
|
||||
// required for streaming large upload bodies.
|
||||
func newVaultHTTPClient(timeout time.Duration) *http.Client {
|
||||
client := &http.Client{}
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
}
|
||||
if timeout > 0 {
|
||||
client.Timeout = timeout
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
546
machinery/src/cloud/tus_client.go
Normal file
546
machinery/src/cloud/tus_client.go
Normal file
@@ -0,0 +1,546 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// tusResumableVersion is the tus protocol version implemented by this client.
|
||||
const tusResumableVersion = "1.0.0"
|
||||
|
||||
// tusUploadPath is appended to the configured Kerberos Vault URI to reach the
|
||||
// resumable upload endpoint. It mirrors how the legacy uploader appends
|
||||
// "/storage".
|
||||
const tusUploadPath = "/storage/tus/"
|
||||
|
||||
// tusResumeState is persisted in a sidecar file next to the agent data so an
|
||||
// interrupted upload can be resumed across retries and even agent restarts.
|
||||
type tusResumeState struct {
|
||||
UploadURL string `json:"upload_url"`
|
||||
VaultURI string `json:"vault_uri"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// resumableUploadsEnabled reports whether the resumable (tus) upload path should
|
||||
// be attempted. It is enabled by default and can be disabled (falling back to
|
||||
// the legacy single POST) by setting AGENT_DISABLE_RESUMABLE_UPLOAD=true.
|
||||
func resumableUploadsEnabled() bool {
|
||||
return os.Getenv("AGENT_DISABLE_RESUMABLE_UPLOAD") != "true"
|
||||
}
|
||||
|
||||
// tusDefaultChunkSize is the number of bytes uploaded per PATCH request when no
|
||||
// explicit size is configured. Splitting the upload into chunks keeps each HTTP
|
||||
// request small enough for intermediary proxies/load balancers and checkpoints
|
||||
// progress frequently, so an interruption resumes with minimal re-upload.
|
||||
const tusDefaultChunkSize int64 = 1 << 20 // 1 MiB
|
||||
|
||||
const tusProgressBucketPercent int64 = 10
|
||||
|
||||
// tusChunkSize returns the number of bytes to send per PATCH request. It
|
||||
// defaults to tusDefaultChunkSize (1 MiB) and can be overridden with the
|
||||
// AGENT_TUS_CHUNK_SIZE_BYTES environment variable. A value of 0 (or negative)
|
||||
// disables chunking and sends the remaining bytes in a single PATCH.
|
||||
func tusChunkSize() int64 {
|
||||
v := os.Getenv("AGENT_TUS_CHUNK_SIZE_BYTES")
|
||||
if v == "" {
|
||||
return tusDefaultChunkSize
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return tusDefaultChunkSize
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0 // chunking disabled: send everything in one PATCH
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func tusProgressBucket(offset, size int64) int64 {
|
||||
if size <= 0 {
|
||||
return 100
|
||||
}
|
||||
percent := (offset * 100) / size
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
return percent / tusProgressBucketPercent
|
||||
}
|
||||
|
||||
func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64) {
|
||||
bucket := tusProgressBucket(offset, size)
|
||||
if bucket <= *loggedBucket {
|
||||
return
|
||||
}
|
||||
*loggedBucket = bucket
|
||||
percent := bucket * tusProgressBucketPercent
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
log.Log.Infof("%s: resumable upload progress %d%% (%d/%d bytes)", label, percent, offset, size)
|
||||
}
|
||||
|
||||
// tusHeaderFunc sets the authentication and routing headers required on every
|
||||
// tus request for a particular upload target (Kerberos Vault directly, or
|
||||
// Kerberos Hub which proxies to a vault). fileName is only meaningful on the
|
||||
// creation request; it is empty on HEAD/PATCH/DELETE.
|
||||
type tusHeaderFunc func(h http.Header, fileName string)
|
||||
|
||||
// runTusUpload performs a resumable (tus) upload of data/recordings/<fileName>
|
||||
// to baseURL, sending target-specific authentication/routing headers via
|
||||
// setHeaders on every request. It encapsulates the create/resume/chunk/finalize
|
||||
// state machine shared by the Kerberos Vault (direct) and Kerberos Hub (proxied)
|
||||
// upload paths.
|
||||
//
|
||||
// Return values:
|
||||
// - uploaded: the recording was fully received and persisted by the server.
|
||||
// - responded: the server returned a definitive HTTP response (used by the
|
||||
// caller to advance its retry/secondary-failover policy).
|
||||
// - supported: the server exposes a tus endpoint. When false, the caller
|
||||
// should fall back to the legacy single-POST upload (older deployments).
|
||||
// - body: a short message for logging.
|
||||
func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tusHeaderFunc) (uploaded bool, responded bool, supported bool, body string, err error) {
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, ferr := os.Open(fullname)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if ferr != nil {
|
||||
msg := label + ": resumable upload failed, file doesn't exist anymore"
|
||||
log.Log.Info(msg)
|
||||
// The file is gone, so the legacy path cannot help either. Report it as
|
||||
// "supported" to avoid a pointless fallback attempt.
|
||||
return false, false, true, "", errors.New(msg)
|
||||
}
|
||||
|
||||
info, serr := file.Stat()
|
||||
if serr != nil {
|
||||
return false, false, true, "", serr
|
||||
}
|
||||
size := info.Size()
|
||||
|
||||
client := newVaultHTTPClient(0)
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
for k := range req.Header {
|
||||
if strings.HasPrefix(http.CanonicalHeaderKey(k), "X-Kerberos-") {
|
||||
req.Header.Del(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
sidecar := tusSidecarPath(fileName, slot)
|
||||
uploadURL := loadTusResumeState(sidecar, baseURL)
|
||||
|
||||
const maxAttempts = 4
|
||||
restartedAfterComplete := false
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// (1) Ensure we have an active upload URL, creating one if needed.
|
||||
if uploadURL == "" {
|
||||
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
|
||||
if cerr != nil {
|
||||
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
|
||||
// The vault does not implement tus; let the caller fall back.
|
||||
return false, false, false, "", cerr
|
||||
}
|
||||
log.Log.Info(label + ": resumable create failed, " + cerr.Error())
|
||||
tusBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
uploadURL = created
|
||||
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
|
||||
}
|
||||
|
||||
// (2) Query the current server-side offset.
|
||||
offset, status, herr := tusHead(client, uploadURL, setHeaders)
|
||||
if herr != nil {
|
||||
if status == http.StatusNotFound || status == http.StatusGone {
|
||||
// The upload expired/was removed server-side; start over.
|
||||
removeTusResumeState(sidecar)
|
||||
uploadURL = ""
|
||||
continue
|
||||
}
|
||||
log.Log.Info(label + ": resumable head failed, " + herr.Error())
|
||||
tusBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
|
||||
// (3) All bytes are present but the upload was not finalized (e.g. the
|
||||
// completion hook failed). A completed tus upload cannot be re-finalized
|
||||
// with another PATCH, so delete it and re-upload to force a clean finalize.
|
||||
if offset >= size {
|
||||
if restartedAfterComplete {
|
||||
return false, true, true, "resumable finalize did not complete", errors.New(label + ": resumable finalize did not complete")
|
||||
}
|
||||
tusTerminate(client, uploadURL, setHeaders)
|
||||
removeTusResumeState(sidecar)
|
||||
uploadURL = ""
|
||||
restartedAfterComplete = true
|
||||
continue
|
||||
}
|
||||
|
||||
// (4) Stream the remaining bytes to the vault via PATCH, reading directly
|
||||
// from disk so the recording is never fully buffered in memory. When a chunk
|
||||
// size is configured the data is sent across several PATCH requests,
|
||||
// checkpointing the offset after each one so an interruption resumes from the
|
||||
// last completed chunk instead of re-uploading everything.
|
||||
chunkSize := tusChunkSize()
|
||||
progressed := false
|
||||
patchFailed := false
|
||||
var lastBody string
|
||||
loggedProgressBucket := tusProgressBucket(offset, size)
|
||||
for offset < size {
|
||||
// Re-seek every chunk so the on-disk position always matches the
|
||||
// server-acknowledged offset, even if a PATCH was partially accepted.
|
||||
if _, sErr := file.Seek(offset, io.SeekStart); sErr != nil {
|
||||
return false, false, true, "", sErr
|
||||
}
|
||||
patchLen := size - offset
|
||||
if chunkSize > 0 && chunkSize < patchLen {
|
||||
patchLen = chunkSize
|
||||
}
|
||||
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
|
||||
if perr != nil {
|
||||
if status >= 400 {
|
||||
// Definitive rejection (e.g. provider push failed during finalize).
|
||||
// Re-evaluate via HEAD on the next iteration to decide retry/restart.
|
||||
log.Log.Info(label + ": resumable patch rejected, " + perr.Error())
|
||||
} else {
|
||||
log.Log.Info(label + ": resumable patch failed, " + perr.Error())
|
||||
}
|
||||
tusBackoff(attempt)
|
||||
patchFailed = true
|
||||
break
|
||||
}
|
||||
if newOffset > offset {
|
||||
progressed = true
|
||||
}
|
||||
offset = newOffset
|
||||
lastBody = respBody
|
||||
logTusUploadProgress(label, offset, size, &loggedProgressBucket)
|
||||
if offset < size {
|
||||
// Partial progress: persist so a later retry resumes from here.
|
||||
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
|
||||
}
|
||||
}
|
||||
if patchFailed {
|
||||
if progressed {
|
||||
// Forward progress refreshes the retry budget: maxAttempts bounds the
|
||||
// number of consecutive failures, not the number of chunks needed for
|
||||
// a large recording.
|
||||
attempt = -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// All declared bytes have been sent and acknowledged: the upload is done.
|
||||
removeTusResumeState(sidecar)
|
||||
return true, true, true, lastBody, nil
|
||||
}
|
||||
|
||||
return false, true, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
|
||||
}
|
||||
|
||||
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
|
||||
// the tus resumable upload protocol. Credentials travel in the
|
||||
// X-Kerberos-Storage-* headers on every request and routing (directory/provider)
|
||||
// is additionally carried in the tus Upload-Metadata.
|
||||
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, bool, string, error) {
|
||||
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
|
||||
metadata := encodeTusMetadata(map[string]string{
|
||||
"filename": fileName,
|
||||
"device": deviceKey,
|
||||
"directory": vault.Directory,
|
||||
"provider": vault.Provider,
|
||||
"capture": "IPCamera",
|
||||
"cloudkey": publicKey,
|
||||
})
|
||||
setHeaders := func(h http.Header, fn string) {
|
||||
setVaultTusHeaders(h, vault, publicKey, deviceKey, fn)
|
||||
}
|
||||
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
|
||||
}
|
||||
|
||||
// uploadHubResumable uploads a recording to Kerberos Hub's tus endpoint, which
|
||||
// authenticates the agent with its Hub public/private key and proxies the
|
||||
// resumable upload to the Kerberos Vault on the agent's behalf. The vault
|
||||
// directory and provider are resolved and injected by Kerberos Hub, so they are
|
||||
// intentionally omitted from the metadata here.
|
||||
func uploadHubResumable(config *models.Config, fileName, label, slot string) (bool, bool, bool, string, error) {
|
||||
baseURL := strings.TrimRight(config.HubURI, "/") + tusUploadPath
|
||||
metadata := encodeTusMetadata(map[string]string{
|
||||
"filename": fileName,
|
||||
"device": config.Key,
|
||||
"capture": "IPCamera",
|
||||
})
|
||||
setHeaders := func(h http.Header, fn string) {
|
||||
setHubTusHeaders(h, config, fn)
|
||||
}
|
||||
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
|
||||
}
|
||||
|
||||
// tusCreate performs the tus "creation" request (POST). On success it returns
|
||||
// the resolved upload URL the agent should use for subsequent HEAD/PATCH calls.
|
||||
func tusCreate(client *http.Client, baseURL string, size int64, metadata string, setHeaders tusHeaderFunc, fileName string) (string, int, error) {
|
||||
req, err := http.NewRequest("POST", baseURL, nil)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
req.Header.Set("Upload-Length", strconv.FormatInt(size, 10))
|
||||
if metadata != "" {
|
||||
req.Header.Set("Upload-Metadata", metadata)
|
||||
}
|
||||
setHeaders(req.Header, fileName)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s", resp.Status)
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return "", resp.StatusCode, errors.New("missing Location header in create response")
|
||||
}
|
||||
return resolveTusLocation(baseURL, location), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// tusHead performs the tus "offset" request (HEAD) and returns the current
|
||||
// server-side upload offset.
|
||||
func tusHead(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) (int64, int, error) {
|
||||
req, err := http.NewRequest("HEAD", uploadURL, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return 0, resp.StatusCode, fmt.Errorf("unexpected status on HEAD: %s", resp.Status)
|
||||
}
|
||||
offsetStr := resp.Header.Get("Upload-Offset")
|
||||
offset, perr := strconv.ParseInt(offsetStr, 10, 64)
|
||||
if perr != nil {
|
||||
return 0, resp.StatusCode, fmt.Errorf("invalid Upload-Offset header: %q", offsetStr)
|
||||
}
|
||||
return offset, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// tusPatch streams up to length bytes of the file (starting at offset) to the
|
||||
// upload URL using a single PATCH request. The body is read straight from the
|
||||
// *os.File, so the recording is never fully buffered in memory.
|
||||
func tusPatch(client *http.Client, uploadURL string, offset, length int64, file io.Reader, setHeaders tusHeaderFunc) (int64, int, string, error) {
|
||||
req, err := http.NewRequest("PATCH", uploadURL, io.LimitReader(file, length))
|
||||
if err != nil {
|
||||
return offset, 0, "", err
|
||||
}
|
||||
req.ContentLength = length
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
req.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return offset, 0, "", err
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
respBody := string(bodyBytes)
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return offset, resp.StatusCode, respBody, fmt.Errorf("unexpected status on PATCH: %s, %s", resp.Status, respBody)
|
||||
}
|
||||
newOffsetStr := resp.Header.Get("Upload-Offset")
|
||||
newOffset, perr := strconv.ParseInt(newOffsetStr, 10, 64)
|
||||
if perr != nil {
|
||||
// A 204 without a parseable offset means this PATCH was fully accepted.
|
||||
return offset + length, resp.StatusCode, respBody, nil
|
||||
}
|
||||
return newOffset, resp.StatusCode, respBody, nil
|
||||
}
|
||||
|
||||
// tusTerminate best-effort deletes an upload server-side (DELETE).
|
||||
func tusTerminate(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) {
|
||||
req, err := http.NewRequest("DELETE", uploadURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, derr := client.Do(req)
|
||||
if resp != nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
_ = derr
|
||||
}
|
||||
|
||||
// setVaultTusHeaders sets the Kerberos Vault authentication and routing headers
|
||||
// on every tus request. Credentials are sent on each request (and never stored
|
||||
// server-side in the upload metadata). When fileName is empty it is omitted, as
|
||||
// it is only useful on the creation request (routing also travels in the tus
|
||||
// Upload-Metadata).
|
||||
func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
|
||||
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
|
||||
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
|
||||
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
|
||||
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
|
||||
h.Set("X-Kerberos-Storage-Device", deviceKey)
|
||||
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// setHubTusHeaders sets the Kerberos Hub authentication headers on every tus
|
||||
// request of a hub-proxied resumable upload. The agent authenticates with its
|
||||
// Hub public/private key (exactly as the legacy single-POST hub upload does);
|
||||
// Kerberos Hub validates the subscription and injects the vault credentials and
|
||||
// directory/provider on the agent's behalf.
|
||||
func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
|
||||
h.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
h.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
h.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
h.Set("X-Kerberos-Storage-Device", config.Key)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeTusMetadata serializes a map into the tus Upload-Metadata header format:
|
||||
// a comma separated list of "key base64(value)" pairs. Keys are sorted for a
|
||||
// deterministic header value. Empty values are skipped.
|
||||
func encodeTusMetadata(pairs map[string]string) string {
|
||||
parts := make([]string, 0, len(pairs))
|
||||
for k, v := range pairs {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, k+" "+base64.StdEncoding.EncodeToString([]byte(v)))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// resolveTusLocation turns the Location header returned by the create request
|
||||
// into an absolute URL. To keep talking to the agent's configured vault host
|
||||
// (and avoid issues when the vault sits behind a proxy that rewrites the host),
|
||||
// it keeps the configured base URL and only appends the server-assigned upload
|
||||
// id taken from the Location.
|
||||
func resolveTusLocation(baseURL, location string) string {
|
||||
if ref, err := url.Parse(location); err == nil {
|
||||
trimmed := strings.Trim(ref.Path, "/")
|
||||
if trimmed != "" {
|
||||
segments := strings.Split(trimmed, "/")
|
||||
id := segments[len(segments)-1]
|
||||
if id != "" {
|
||||
return strings.TrimRight(baseURL, "/") + "/" + id
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: resolve the reference against the base URL as-is.
|
||||
if base, err := url.Parse(baseURL); err == nil {
|
||||
if ref, err := url.Parse(location); err == nil {
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
// tusSidecarDir is the directory where resume state files are kept. It is
|
||||
// intentionally separate from data/cloud (which is scanned for recordings to
|
||||
// upload) so the sidecar files are never mistaken for recordings.
|
||||
func tusSidecarDir() string {
|
||||
return "data/tus"
|
||||
}
|
||||
|
||||
func tusSidecarPath(fileName, slot string) string {
|
||||
safe := strings.ReplaceAll(fileName, "/", "_")
|
||||
safe = strings.ReplaceAll(safe, string(os.PathSeparator), "_")
|
||||
return filepath.Join(tusSidecarDir(), safe+"."+slot+".json")
|
||||
}
|
||||
|
||||
// loadTusResumeState returns a previously stored upload URL for the given
|
||||
// sidecar, but only if it was created against the same vault base URL. Any
|
||||
// mismatch or read/parse error yields an empty string (start fresh).
|
||||
func loadTusResumeState(path, baseURL string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var state tusResumeState
|
||||
if err := json.Unmarshal(b, &state); err != nil {
|
||||
return ""
|
||||
}
|
||||
if state.UploadURL == "" || state.VaultURI != baseURL {
|
||||
return ""
|
||||
}
|
||||
return state.UploadURL
|
||||
}
|
||||
|
||||
func saveTusResumeState(path string, state tusResumeState) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
func removeTusResumeState(path string) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
// tusBackoff sleeps for an exponentially increasing duration (capped) between
|
||||
// resume attempts to avoid hammering a temporarily unavailable vault.
|
||||
func tusBackoff(attempt int) {
|
||||
delay := time.Duration(500*(1<<uint(attempt))) * time.Millisecond
|
||||
if delay > 3*time.Second {
|
||||
delay = 3 * time.Second
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
608
machinery/src/cloud/tus_client_test.go
Normal file
608
machinery/src/cloud/tus_client_test.go
Normal file
@@ -0,0 +1,608 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// fakeUpload tracks the state of a single resumable upload on the fake server.
|
||||
type fakeUpload struct {
|
||||
size int64
|
||||
offset int64
|
||||
}
|
||||
|
||||
// recordedRequest captures the method and headers of a request received by the
|
||||
// fake tus server, so tests can assert the client's per-method auth headers.
|
||||
type recordedRequest struct {
|
||||
method string
|
||||
header http.Header
|
||||
}
|
||||
|
||||
// fakeTus is a tiny in-memory implementation of the tus 1.0.0 server protocol,
|
||||
// sufficient to exercise the agent's resumable client.
|
||||
type fakeTus struct {
|
||||
mu sync.Mutex
|
||||
uploads map[string]*fakeUpload
|
||||
counter int
|
||||
creates int
|
||||
lastPatchBytes int64
|
||||
patchSizes []int64
|
||||
|
||||
// unsupported makes the creation endpoint return 404, simulating an older
|
||||
// vault without a tus endpoint.
|
||||
unsupported bool
|
||||
// failFinalize causes the next N completing PATCH requests to return 502
|
||||
// after storing the bytes, simulating a failed completion hook.
|
||||
failFinalize int
|
||||
|
||||
// requests records the headers of every received request (in order) so
|
||||
// tests can assert which auth/routing headers the client sent per method.
|
||||
requests []recordedRequest
|
||||
}
|
||||
|
||||
func newFakeTus() *fakeTus {
|
||||
return &fakeTus{uploads: map[string]*fakeUpload{}}
|
||||
}
|
||||
|
||||
func (s *fakeTus) seed(size, offset int64) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.counter++
|
||||
id := fmt.Sprintf("seed-%d", s.counter)
|
||||
s.uploads[id] = &fakeUpload{size: size, offset: offset}
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *fakeTus) totalBytes() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var total int64
|
||||
for _, u := range s.uploads {
|
||||
total += u.offset
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *fakeTus) lastPatch() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastPatchBytes
|
||||
}
|
||||
|
||||
// patchCounts returns the number of PATCH requests received and the size of each.
|
||||
func (s *fakeTus) patchCounts() (int, []int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sizes := make([]int64, len(s.patchSizes))
|
||||
copy(sizes, s.patchSizes)
|
||||
return len(s.patchSizes), sizes
|
||||
}
|
||||
|
||||
func (s *fakeTus) createCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.creates
|
||||
}
|
||||
|
||||
// requestsForMethod returns the recorded requests for the given HTTP method.
|
||||
func (s *fakeTus) requestsForMethod(method string) []recordedRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []recordedRequest
|
||||
for _, req := range s.requests {
|
||||
if req.method == method {
|
||||
out = append(out, req)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *fakeTus) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, tusUploadPath)
|
||||
w.Header().Set("Tus-Resumable", tusResumableVersion)
|
||||
|
||||
s.mu.Lock()
|
||||
s.requests = append(s.requests, recordedRequest{method: r.Method, header: r.Header.Clone()})
|
||||
s.mu.Unlock()
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
if s.unsupported {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
length, _ := strconv.ParseInt(r.Header.Get("Upload-Length"), 10, 64)
|
||||
s.mu.Lock()
|
||||
s.counter++
|
||||
s.creates++
|
||||
newID := fmt.Sprintf("up-%d", s.counter)
|
||||
s.uploads[newID] = &fakeUpload{size: length}
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Location", tusUploadPath+newID)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
case http.MethodHead:
|
||||
s.mu.Lock()
|
||||
u, ok := s.uploads[id]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(u.offset, 10))
|
||||
w.Header().Set("Upload-Length", strconv.FormatInt(u.size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
case http.MethodPatch:
|
||||
s.mu.Lock()
|
||||
u, ok := s.uploads[id]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, r.Body)
|
||||
s.mu.Lock()
|
||||
u.offset += n
|
||||
s.lastPatchBytes = n
|
||||
s.patchSizes = append(s.patchSizes, n)
|
||||
complete := u.offset >= u.size
|
||||
failNow := complete && s.failFinalize > 0
|
||||
if failNow {
|
||||
s.failFinalize--
|
||||
}
|
||||
offset := u.offset
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||||
if failNow {
|
||||
// Bytes are stored but the (simulated) completion hook failed.
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case http.MethodDelete:
|
||||
s.mu.Lock()
|
||||
delete(s.uploads, id)
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// withRecording switches into a fresh temp working directory containing a
|
||||
// recording at data/recordings/<fileName>. The working directory is restored on
|
||||
// cleanup. Tests using this helper must not run in parallel.
|
||||
func withRecording(t *testing.T, fileName string, payload []byte) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
if err := os.MkdirAll("data/recordings", 0o755); err != nil {
|
||||
t.Fatalf("mkdir recordings: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("data/recordings", fileName), payload, 0o644); err != nil {
|
||||
t.Fatalf("write recording: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testVault(uri string) models.KStorage {
|
||||
return models.KStorage{
|
||||
URI: uri,
|
||||
AccessKey: "ak",
|
||||
SecretAccessKey: "sk",
|
||||
Provider: "gcp",
|
||||
Directory: "dir",
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_HappyPath(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("x"), 4096)
|
||||
withRecording(t, fileName, payload)
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !responded || !supported {
|
||||
t.Fatalf("uploaded/responded/supported = %v/%v/%v, want all true", uploaded, responded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected sidecar to be removed after success, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_Chunked(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
// 10 KiB payload uploaded in 4 KiB chunks => 3 PATCH requests (4096+4096+2048).
|
||||
payload := bytes.Repeat([]byte("c"), 10240)
|
||||
withRecording(t, fileName, payload)
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected chunked upload success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
count, sizes := srv.patchCounts()
|
||||
if count != 3 {
|
||||
t.Fatalf("expected 3 chunked PATCH requests, got %d (sizes=%v)", count, sizes)
|
||||
}
|
||||
want := []int64{4096, 4096, 2048}
|
||||
for i, w := range want {
|
||||
if sizes[i] != w {
|
||||
t.Fatalf("chunk %d size = %d, want %d (sizes=%v)", i, sizes[i], w, sizes)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected sidecar removed after success, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_ChunkingDisabled(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("d"), 10240)
|
||||
withRecording(t, fileName, payload)
|
||||
// 0 disables chunking: the whole file should go out in a single PATCH.
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "0")
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
count, sizes := srv.patchCounts()
|
||||
if count != 1 {
|
||||
t.Fatalf("expected a single PATCH when chunking is disabled, got %d (sizes=%v)", count, sizes)
|
||||
}
|
||||
if sizes[0] != int64(len(payload)) {
|
||||
t.Fatalf("single PATCH size = %d, want %d", sizes[0], len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusChunkSize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string
|
||||
set bool
|
||||
want int64
|
||||
}{
|
||||
{name: "default when unset", set: false, want: tusDefaultChunkSize},
|
||||
{name: "default on invalid", env: "notanumber", set: true, want: tusDefaultChunkSize},
|
||||
{name: "explicit value", env: "65536", set: true, want: 65536},
|
||||
{name: "zero disables", env: "0", set: true, want: 0},
|
||||
{name: "negative disables", env: "-5", set: true, want: 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.set {
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", tc.env)
|
||||
} else {
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "")
|
||||
}
|
||||
if got := tusChunkSize(); got != tc.want {
|
||||
t.Fatalf("tusChunkSize() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_Unsupported(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.unsupported = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "f.mp4"
|
||||
withRecording(t, fileName, []byte("hello"))
|
||||
|
||||
uploaded, _, supported, _, _ := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false against a vault without a tus endpoint")
|
||||
}
|
||||
if supported {
|
||||
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_FinalizeRetry(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.failFinalize = 1
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("y"), 2048)
|
||||
withRecording(t, fileName, payload)
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected success after a failed finalize + restart, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.createCount(); got < 2 {
|
||||
t.Fatalf("expected at least 2 create requests (restart after failed finalize), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_ResumeFromSidecar(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
total := 8192
|
||||
half := 4096
|
||||
payload := bytes.Repeat([]byte("z"), total)
|
||||
withRecording(t, fileName, payload)
|
||||
|
||||
// Simulate a previous run that uploaded half the file before being interrupted.
|
||||
id := srv.seed(int64(total), int64(half))
|
||||
baseURL := strings.TrimRight(ts.URL, "/") + tusUploadPath
|
||||
saveTusResumeState(tusSidecarPath(fileName, "primary"), tusResumeState{
|
||||
UploadURL: strings.TrimRight(baseURL, "/") + "/" + id,
|
||||
VaultURI: baseURL,
|
||||
Size: int64(total),
|
||||
})
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected resume success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.lastPatch(); got != int64(total-half) {
|
||||
t.Fatalf("resume should only send the remaining %d bytes, sent %d", total-half, got)
|
||||
}
|
||||
if srv.createCount() != 0 {
|
||||
t.Fatalf("resume should not create a new upload, got %d creates", srv.createCount())
|
||||
}
|
||||
}
|
||||
|
||||
func testHubConfig(hubURI string) *models.Config {
|
||||
return &models.Config{
|
||||
Key: "device-key",
|
||||
HubURI: hubURI,
|
||||
HubKey: "hubpub",
|
||||
HubPrivateKey: "hubpriv",
|
||||
S3: &models.S3{Region: "eu-west"},
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTusMetadata parses a tus Upload-Metadata header value ("key b64,key b64")
|
||||
// back into a map of decoded key/value pairs.
|
||||
func decodeTusMetadata(meta string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if meta == "" {
|
||||
return out
|
||||
}
|
||||
for _, pair := range strings.Split(meta, ",") {
|
||||
parts := strings.SplitN(strings.TrimSpace(pair), " ", 2)
|
||||
if parts[0] == "" {
|
||||
continue
|
||||
}
|
||||
val := ""
|
||||
if len(parts) == 2 {
|
||||
if b, err := base64.StdEncoding.DecodeString(parts[1]); err == nil {
|
||||
val = string(b)
|
||||
}
|
||||
}
|
||||
out[parts[0]] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestUploadHubResumable_HappyPath(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("h"), 4096)
|
||||
withRecording(t, fileName, payload)
|
||||
|
||||
uploaded, _, supported, _, err := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("uploaded/supported = %v/%v, want both true", uploaded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
|
||||
// The Hub auth headers must be present on every request type (POST/HEAD/PATCH),
|
||||
// because Kerberos Hub validates them on each proxied request. Conversely the
|
||||
// vault credentials/routing are injected by Kerberos Hub on the agent's behalf
|
||||
// and must never be sent by the agent on the hub path.
|
||||
for _, method := range []string{http.MethodPost, http.MethodHead, http.MethodPatch} {
|
||||
reqs := srv.requestsForMethod(method)
|
||||
if len(reqs) == 0 {
|
||||
t.Fatalf("expected at least one %s request", method)
|
||||
}
|
||||
for _, req := range reqs {
|
||||
if got := req.header.Get("X-Kerberos-Hub-PublicKey"); got != "hubpub" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-PublicKey = %q, want %q", method, got, "hubpub")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Hub-PrivateKey"); got != "hubpriv" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-PrivateKey = %q, want %q", method, got, "hubpriv")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Hub-Region"); got != "eu-west" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-Region = %q, want %q", method, got, "eu-west")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Storage-Device"); got != "device-key" {
|
||||
t.Errorf("%s: X-Kerberos-Storage-Device = %q, want %q", method, got, "device-key")
|
||||
}
|
||||
for _, h := range []string{
|
||||
"X-Kerberos-Storage-AccessKey",
|
||||
"X-Kerberos-Storage-SecretAccessKey",
|
||||
"X-Kerberos-Storage-CloudKey",
|
||||
"X-Kerberos-Storage-Provider",
|
||||
"X-Kerberos-Storage-Directory",
|
||||
} {
|
||||
if got := req.header.Get(h); got != "" {
|
||||
t.Errorf("%s: %s should be empty on the hub path, got %q", method, h, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The creation request carries the upload metadata; on the hub path it must
|
||||
// omit directory/provider/cloudkey (Hub resolves those) but include
|
||||
// filename/device/capture. The filename header is also set on create.
|
||||
posts := srv.requestsForMethod(http.MethodPost)
|
||||
if got := posts[0].header.Get("X-Kerberos-Storage-FileName"); got != fileName {
|
||||
t.Errorf("POST X-Kerberos-Storage-FileName = %q, want %q", got, fileName)
|
||||
}
|
||||
meta := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
|
||||
for _, omitted := range []string{"directory", "provider", "cloudkey"} {
|
||||
if _, ok := meta[omitted]; ok {
|
||||
t.Errorf("hub metadata must omit %q, got %v", omitted, meta)
|
||||
}
|
||||
}
|
||||
if meta["filename"] != fileName {
|
||||
t.Errorf("hub metadata filename = %q, want %q", meta["filename"], fileName)
|
||||
}
|
||||
if meta["device"] != "device-key" {
|
||||
t.Errorf("hub metadata device = %q, want %q", meta["device"], "device-key")
|
||||
}
|
||||
if meta["capture"] != "IPCamera" {
|
||||
t.Errorf("hub metadata capture = %q, want %q", meta["capture"], "IPCamera")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadHubResumable_Unsupported(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.unsupported = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "f.mp4"
|
||||
withRecording(t, fileName, []byte("hello"))
|
||||
|
||||
uploaded, _, supported, _, _ := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false against a hub without a tus endpoint")
|
||||
}
|
||||
if supported {
|
||||
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTusMetadata(t *testing.T) {
|
||||
got := encodeTusMetadata(map[string]string{
|
||||
"b": "2",
|
||||
"a": "1",
|
||||
"empty": "",
|
||||
})
|
||||
// keys sorted, empty values skipped, values base64-encoded.
|
||||
want := "a MQ==,b Mg=="
|
||||
if got != want {
|
||||
t.Fatalf("encodeTusMetadata = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTusLocation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
location string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "absolute path location",
|
||||
base: "http://host/storage/tus/",
|
||||
location: "/storage/tus/abc",
|
||||
want: "http://host/storage/tus/abc",
|
||||
},
|
||||
{
|
||||
name: "absolute url keeps configured host",
|
||||
base: "http://host/storage/tus/",
|
||||
location: "http://internal:8080/storage/tus/xyz",
|
||||
want: "http://host/storage/tus/xyz",
|
||||
},
|
||||
{
|
||||
name: "relative id",
|
||||
base: "http://host/api/storage/tus/",
|
||||
location: "abc",
|
||||
want: "http://host/api/storage/tus/abc",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := resolveTusLocation(tc.base, tc.location); got != tc.want {
|
||||
t.Fatalf("resolveTusLocation(%q, %q) = %q, want %q", tc.base, tc.location, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusResumeStateRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(old)
|
||||
|
||||
path := tusSidecarPath("file.mp4", "primary")
|
||||
state := tusResumeState{UploadURL: "http://host/storage/tus/abc", VaultURI: "http://host/storage/tus/", Size: 123}
|
||||
saveTusResumeState(path, state)
|
||||
|
||||
if got := loadTusResumeState(path, state.VaultURI); got != state.UploadURL {
|
||||
t.Fatalf("loadTusResumeState = %q, want %q", got, state.UploadURL)
|
||||
}
|
||||
// A mismatched vault URI must not be reused.
|
||||
if got := loadTusResumeState(path, "http://other/storage/tus/"); got != "" {
|
||||
t.Fatalf("loadTusResumeState with mismatched vault = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package components
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
@@ -9,6 +10,7 @@ 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"
|
||||
@@ -20,12 +22,19 @@ 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"
|
||||
)
|
||||
|
||||
func Bootstrap(configDirectory string, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
|
||||
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) {
|
||||
|
||||
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()
|
||||
@@ -78,6 +87,8 @@ func Bootstrap(configDirectory string, configuration *models.Configuration, comm
|
||||
// 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 {
|
||||
@@ -114,6 +125,9 @@ func Bootstrap(configDirectory string, configuration *models.Configuration, comm
|
||||
|
||||
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
|
||||
|
||||
@@ -124,10 +138,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
rtspUrl := config.Capture.IPCamera.RTSP
|
||||
rtspClient := captureDevice.SetMainClient(rtspUrl)
|
||||
if rtspUrl != "" {
|
||||
err := rtspClient.Connect(context.Background())
|
||||
err := rtspClient.Connect(ctx, ctxRunAgent)
|
||||
if err != nil {
|
||||
log.Log.Error("components.Kerberos.RunAgent(): error connecting to RTSP stream: " + err.Error())
|
||||
rtspClient.Close()
|
||||
rtspClient.Close(ctxRunAgent)
|
||||
rtspClient = nil
|
||||
time.Sleep(time.Second * 3)
|
||||
return status
|
||||
@@ -145,7 +159,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()
|
||||
rtspClient.Close(ctxRunAgent)
|
||||
time.Sleep(time.Second * 3)
|
||||
return status
|
||||
}
|
||||
@@ -161,6 +175,27 @@ 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
|
||||
|
||||
@@ -182,7 +217,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
rtspSubClient := captureDevice.SetSubClient(subRtspUrl)
|
||||
captureDevice.RTSPSubClient = rtspSubClient
|
||||
|
||||
err := rtspSubClient.Connect(context.Background())
|
||||
err := rtspSubClient.Connect(ctx, ctxRunAgent)
|
||||
if err != nil {
|
||||
log.Log.Error("components.Kerberos.RunAgent(): error connecting to RTSP sub stream: " + err.Error())
|
||||
time.Sleep(time.Second * 3)
|
||||
@@ -194,7 +229,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()
|
||||
rtspSubClient.Close(ctxRunAgent)
|
||||
time.Sleep(time.Second * 3)
|
||||
return status
|
||||
}
|
||||
@@ -208,6 +243,22 @@ 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
|
||||
@@ -217,28 +268,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(int(config.Capture.PreRecording) + 1) // GOP time frame is set to prerecording (we'll add 2 gops to leave some room).
|
||||
queue.SetMaxGopCount(1) // We will adjust this later on, when we have the GOP size.
|
||||
queue.WriteHeader(videoStreams)
|
||||
go rtspClient.Start(context.Background(), "main", queue, configuration, communication)
|
||||
go rtspClient.Start(ctx, "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(context.Background())
|
||||
err = rtspBackChannelClient.ConnectBackChannel(ctx, ctxRunAgent)
|
||||
if err == nil {
|
||||
log.Log.Info("components.Kerberos.RunAgent(): opened RTSP backchannel stream: " + rtspUrl)
|
||||
go rtspBackChannelClient.StartBackChannel(context.Background())
|
||||
go rtspBackChannelClient.StartBackChannel(ctx, ctxRunAgent)
|
||||
}
|
||||
|
||||
rtspSubClient := captureDevice.RTSPSubClient
|
||||
if subStreamEnabled && rtspSubClient != nil {
|
||||
subQueue = packets.NewQueue()
|
||||
communication.SubQueue = subQueue
|
||||
subQueue.SetMaxGopCount(3) // GOP time frame is set to prerecording (we'll add 2 gops to leave some room).
|
||||
subQueue.SetMaxGopCount(1) // GOP time frame is set to 1 for motion detection and livestreaming.
|
||||
subQueue.WriteHeader(videoSubStreams)
|
||||
go rtspSubClient.Start(context.Background(), "sub", subQueue, configuration, communication)
|
||||
go rtspSubClient.Start(ctx, "sub", subQueue, configuration, communication)
|
||||
|
||||
// Sub stream is connected and ready to go.
|
||||
communication.SubStreamConnected = true
|
||||
@@ -254,7 +305,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
}
|
||||
|
||||
// Handle livestream HD (high resolution over WEBRTC)
|
||||
communication.HandleLiveHDHandshake = make(chan models.RequestHDStreamPayload, 1)
|
||||
communication.HandleLiveHDHandshake = make(chan models.LiveHDHandshake, 100)
|
||||
if subStreamEnabled {
|
||||
livestreamHDCursor := subQueue.Latest()
|
||||
go cloud.HandleLiveStreamHD(livestreamHDCursor, configuration, communication, mqttClient, rtspSubClient)
|
||||
@@ -267,7 +318,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, 1)
|
||||
communication.HandleMotion = make(chan models.MotionDataPartial, 10)
|
||||
if subStreamEnabled {
|
||||
motionCursor := subQueue.Latest()
|
||||
go computervision.ProcessMotion(motionCursor, configuration, communication, mqttClient, rtspSubClient)
|
||||
@@ -289,10 +340,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
go cloud.HandleUpload(configDirectory, configuration, communication)
|
||||
|
||||
// Handle ONVIF actions
|
||||
communication.HandleONVIF = make(chan models.OnvifAction, 1)
|
||||
communication.HandleONVIF = make(chan models.OnvifAction, 10)
|
||||
go onvif.HandleONVIFActions(configuration, communication)
|
||||
|
||||
communication.HandleAudio = make(chan models.AudioDataPartial, 1)
|
||||
communication.HandleAudio = make(chan models.AudioDataPartial, 10)
|
||||
if rtspBackChannelClient.HasBackChannel {
|
||||
communication.HasBackChannel = true
|
||||
go WriteAudioToBackchannel(communication, rtspBackChannelClient)
|
||||
@@ -301,6 +352,9 @@ 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.
|
||||
@@ -344,7 +398,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
|
||||
time.Sleep(time.Second * 3)
|
||||
|
||||
err = rtspClient.Close()
|
||||
err = rtspClient.Close(ctxRunAgent)
|
||||
if err != nil {
|
||||
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP stream: " + err.Error())
|
||||
time.Sleep(time.Second * 3)
|
||||
@@ -356,7 +410,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
communication.Queue = nil
|
||||
|
||||
if subStreamEnabled {
|
||||
err = rtspSubClient.Close()
|
||||
err = rtspSubClient.Close(ctxRunAgent)
|
||||
if err != nil {
|
||||
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP sub stream: " + err.Error())
|
||||
time.Sleep(time.Second * 3)
|
||||
@@ -367,7 +421,7 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
communication.SubQueue = nil
|
||||
}
|
||||
|
||||
err = rtspBackChannelClient.Close()
|
||||
err = rtspBackChannelClient.Close(ctxRunAgent)
|
||||
if err != nil {
|
||||
log.Log.Error("components.Kerberos.RunAgent(): error closing RTSP backchannel stream: " + err.Error())
|
||||
}
|
||||
@@ -393,6 +447,37 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
|
||||
return status
|
||||
}
|
||||
|
||||
// packetAgeString returns a human readable age (e.g. "12s") since the last
|
||||
// packet timestamp stored in the given atomic.Value, or "unknown" when no
|
||||
// packet has been received yet. Used to add context to watchdog restart logs.
|
||||
func packetAgeString(timer *atomic.Value) string {
|
||||
if timer == nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// atomic.Value panics on Load() if it was never initialized via Store().
|
||||
var v any
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
v = nil
|
||||
}
|
||||
}()
|
||||
v = timer.Load()
|
||||
}()
|
||||
|
||||
last, ok := v.(int64)
|
||||
if !ok || last == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
age := time.Now().Unix() - last
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return strconv.FormatInt(age, 10) + "s"
|
||||
}
|
||||
|
||||
// ControlAgent will check if the camera is still connected, if not it will restart the agent.
|
||||
// In the other thread we are keeping track of the number of packets received, and particular the keyframe packets.
|
||||
// Once we are not receiving any packets anymore, we will restart the agent.
|
||||
@@ -427,7 +512,8 @@ func ControlAgent(communication *models.Communication) {
|
||||
|
||||
// After 15 seconds without activity this is thrown..
|
||||
if occurence == 3 {
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream.")
|
||||
log.Log.Info(fmt.Sprintf("components.Kerberos.ControlAgent(): Restarting machinery because of blocking mainstream. (stalledKeyframeCounter=%d, lastPacket=%s ago, isConfiguring=%t)",
|
||||
packetsR, packetAgeString(communication.LastPacketTimer), communication.IsConfiguring.IsSet()))
|
||||
select {
|
||||
case communication.HandleBootstrap <- "restart":
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
|
||||
@@ -454,6 +540,8 @@ func ControlAgent(communication *models.Communication) {
|
||||
|
||||
// After 15 seconds without activity this is thrown..
|
||||
if occurenceSub == 3 {
|
||||
log.Log.Info(fmt.Sprintf("components.Kerberos.ControlAgent(): substream stalled (stalledKeyframeCounter=%d, lastPacket=%s ago, isConfiguring=%t)",
|
||||
packetsSubR, packetAgeString(communication.LastPacketTimerSub), communication.IsConfiguring.IsSet()))
|
||||
select {
|
||||
case communication.HandleBootstrap <- "restart":
|
||||
log.Log.Info("components.Kerberos.ControlAgent(): Restarting machinery because of blocking substream.")
|
||||
@@ -500,6 +588,11 @@ 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{}
|
||||
@@ -522,6 +615,8 @@ func GetDashboard(c *gin.Context, configDirectory string, configuration *models.
|
||||
"cameraOnline": cameraIsOnline,
|
||||
"cloudOnline": cloudIsOnline,
|
||||
"numberOfRecordings": numberOfRecordings,
|
||||
"webrtcReaders": activeWebRTCReaders,
|
||||
"webrtcPending": pendingWebRTCHandshakes,
|
||||
"days": days,
|
||||
"latestEvents": latestEvents,
|
||||
})
|
||||
@@ -655,7 +750,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)
|
||||
base64Image := capture.Base64Image(captureDevice, communication, configuration)
|
||||
if base64Image != "" {
|
||||
communication.Image = base64Image
|
||||
}
|
||||
@@ -677,7 +772,8 @@ func GetSnapshotRaw(c *gin.Context, captureDevice *capture.Capture, configuratio
|
||||
image := capture.JpegImage(captureDevice, communication)
|
||||
|
||||
// encode image to jpeg
|
||||
bytes, _ := utils.ImageToBytes(&image)
|
||||
imageResized, _ := utils.ResizeImage(&image, uint(configuration.Config.Capture.IPCamera.BaseWidth), uint(configuration.Config.Capture.IPCamera.BaseHeight))
|
||||
bytes, _ := utils.ImageToBytes(imageResized)
|
||||
|
||||
// Return image/jpeg
|
||||
c.Data(200, "image/jpeg", bytes)
|
||||
@@ -691,10 +787,24 @@ func GetSnapshotRaw(c *gin.Context, captureDevice *capture.Capture, configuratio
|
||||
// @Description Get the current configuration.
|
||||
// @Success 200
|
||||
func GetConfig(c *gin.Context, captureDevice *capture.Capture, configuration *models.Configuration, communication *models.Communication) {
|
||||
// We'll try to get a snapshot from the camera.
|
||||
base64Image := capture.Base64Image(captureDevice, communication)
|
||||
if base64Image != "" {
|
||||
communication.Image = base64Image
|
||||
// We'll try to get a fresh snapshot from the camera. Capturing a snapshot
|
||||
// reads a keyframe from the live stream, which blocks until one arrives.
|
||||
// When the camera is offline or the stream is stalled (no packets being
|
||||
// received) this would block the /config endpoint indefinitely, making the
|
||||
// agent appear unreachable even though its HTTP server is healthy. We
|
||||
// therefore bound the snapshot fetch with a short timeout and fall back to
|
||||
// the last cached snapshot, so /config always responds promptly.
|
||||
snapshot := make(chan string, 1)
|
||||
go func() {
|
||||
snapshot <- capture.Base64Image(captureDevice, communication, configuration)
|
||||
}()
|
||||
select {
|
||||
case base64Image := <-snapshot:
|
||||
if base64Image != "" {
|
||||
communication.Image = base64Image
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
log.Log.Info("components.Kerberos.GetConfig(): snapshot timed out (stream stalled or camera offline), returning configuration with the last cached snapshot.")
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
@@ -62,16 +63,34 @@ 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
|
||||
y := c.Y
|
||||
x := c.X * baseWidthRatio
|
||||
y := c.Y * baseHeightRatio
|
||||
p := geo.NewPoint(x, y)
|
||||
if !poly.Contains(p) {
|
||||
poly.Add(p)
|
||||
@@ -132,7 +151,7 @@ func ProcessMotion(motionCursor *packets.QueueCursor, configuration *models.Conf
|
||||
if detectMotion {
|
||||
|
||||
// Remember additional information about the result of findmotion
|
||||
isPixelChangeThresholdReached, changesToReturn = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
|
||||
isPixelChangeThresholdReached, changesToReturn, motionRectangle = FindMotion(imageArray, coordinatesToCheck, pixelThreshold)
|
||||
if isPixelChangeThresholdReached {
|
||||
|
||||
// If offline mode is disabled, send a message to the hub
|
||||
@@ -164,6 +183,7 @@ 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
|
||||
}
|
||||
@@ -185,24 +205,58 @@ 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) {
|
||||
func FindMotion(imageArray [3]*image.Gray, coordinatesToCheck []int, pixelChangeThreshold int) (thresholdReached bool, changesDetected int, motionRectangle models.MotionRectangle) {
|
||||
image1 := imageArray[0]
|
||||
image2 := imageArray[1]
|
||||
image3 := imageArray[2]
|
||||
threshold := 60
|
||||
changes := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
|
||||
return changes > pixelChangeThreshold, changes
|
||||
changes, motionRectangle := AbsDiffBitwiseAndThreshold(image1, image2, image3, threshold, coordinatesToCheck)
|
||||
return changes > pixelChangeThreshold, changes, motionRectangle
|
||||
}
|
||||
|
||||
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) int {
|
||||
func AbsDiffBitwiseAndThreshold(img1 *image.Gray, img2 *image.Gray, img3 *image.Gray, threshold int, coordinatesToCheck []int) (int, models.MotionRectangle) {
|
||||
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()})
|
||||
}
|
||||
}
|
||||
return changes
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -135,6 +135,12 @@ 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)
|
||||
@@ -183,19 +189,120 @@ func OpenConfig(configDirectory string, configuration *models.Configuration) {
|
||||
}
|
||||
jsonFile.Close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// This function will override the configuration with environment variables.
|
||||
// OverrideWithEnvironmentVariables builds the effective configuration from the
|
||||
// environment variables.
|
||||
//
|
||||
// In ConfigMap/standalone mode (DEPLOYMENT empty or "agent") the global
|
||||
// configuration is delivered as GLOBAL_AGENT_* environment variables and the
|
||||
// per-agent configuration as AGENT_* environment variables. We parse them into
|
||||
// the separate global and custom configurations and build the effective
|
||||
// configuration as "global overridden by custom", mirroring the MongoDB-backed
|
||||
// factory behaviour. This keeps the global and per-agent (custom) configuration
|
||||
// separated so the factory edit page can distinguish inherited global settings
|
||||
// from per-agent overrides.
|
||||
func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
if os.Getenv("DEPLOYMENT") == "" || os.Getenv("DEPLOYMENT") == "agent" {
|
||||
initConfigPointers(&configuration.Config)
|
||||
|
||||
// Parse the global configuration from the GLOBAL_AGENT_* variables.
|
||||
globalWrap := &models.Configuration{Config: configuration.GlobalConfig}
|
||||
initConfigPointers(&globalWrap.Config)
|
||||
applyAgentEnvVars(globalWrap, "GLOBAL_", false)
|
||||
configuration.GlobalConfig = globalWrap.Config
|
||||
|
||||
// Parse the per-agent (custom) configuration from the AGENT_* variables.
|
||||
// In ConfigMap mode the per-agent overrides are delivered exclusively
|
||||
// through AGENT_* environment variables, so we must start from an empty
|
||||
// configuration rather than the bundled config.json that OpenConfig loaded
|
||||
// into CustomConfig. Otherwise defaults from that file (e.g. cloud="s3")
|
||||
// would leak into the custom config and be mistaken for explicit per-agent
|
||||
// overrides, hiding inherited global settings (the factory edit page would
|
||||
// show the local default instead of the inherited global persistence).
|
||||
customBase := configuration.CustomConfig
|
||||
if isConfigMapMode() {
|
||||
customBase = models.Config{}
|
||||
}
|
||||
customWrap := &models.Configuration{Config: customBase}
|
||||
initConfigPointers(&customWrap.Config)
|
||||
applyAgentEnvVars(customWrap, "", false)
|
||||
configuration.CustomConfig = customWrap.Config
|
||||
|
||||
// Build the effective configuration: global base, then per-agent
|
||||
// overrides on top. Defaults (e.g. signing) are applied on the last
|
||||
// pass only.
|
||||
applyAgentEnvVars(configuration, "GLOBAL_", false)
|
||||
applyAgentEnvVars(configuration, "", true)
|
||||
} else {
|
||||
// Factory/MongoDB mode: the global and custom configurations are already
|
||||
// loaded and merged from MongoDB; we only override the effective
|
||||
// configuration with any AGENT_* environment variables.
|
||||
applyAgentEnvVars(configuration, "", true)
|
||||
}
|
||||
}
|
||||
|
||||
// isConfigMapMode reports whether the agent is running in ConfigMap mode, i.e.
|
||||
// whether a global configuration layer is delivered separately through
|
||||
// GLOBAL_AGENT_* environment variables. In that mode the per-agent (custom)
|
||||
// configuration must be built solely from the AGENT_* overrides and must not be
|
||||
// seeded with the bundled config.json defaults, so that inherited global
|
||||
// settings remain distinguishable from explicit per-agent overrides.
|
||||
func isConfigMapMode() bool {
|
||||
for _, env := range os.Environ() {
|
||||
if strings.HasPrefix(env, "GLOBAL_AGENT_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// initConfigPointers ensures all pointer sub-structs are non-nil so that the
|
||||
// environment-variable parsing can assign into them without dereferencing a nil
|
||||
// pointer.
|
||||
func initConfigPointers(config *models.Config) {
|
||||
if config.KStorage == nil {
|
||||
config.KStorage = &models.KStorage{}
|
||||
}
|
||||
if config.KStorageSecondary == nil {
|
||||
config.KStorageSecondary = &models.KStorage{}
|
||||
}
|
||||
if config.S3 == nil {
|
||||
config.S3 = &models.S3{}
|
||||
}
|
||||
if config.Encryption == nil {
|
||||
config.Encryption = &models.Encryption{}
|
||||
}
|
||||
if config.Signing == nil {
|
||||
config.Signing = &models.Signing{}
|
||||
}
|
||||
if config.Dropbox == nil {
|
||||
config.Dropbox = &models.Dropbox{}
|
||||
}
|
||||
if config.Region == nil {
|
||||
config.Region = &models.Region{}
|
||||
}
|
||||
}
|
||||
|
||||
// applyAgentEnvVars applies the AGENT_* environment variables (optionally
|
||||
// carrying the given prefix, e.g. "GLOBAL_") onto configuration.Config. When
|
||||
// applyDefaults is true, defaults (such as the signing key) are applied after
|
||||
// parsing; this should only be done for the effective configuration.
|
||||
func applyAgentEnvVars(configuration *models.Configuration, prefix string, applyDefaults bool) {
|
||||
environmentVariables := os.Environ()
|
||||
|
||||
// Initialize the configuration for some new fields.
|
||||
if configuration.Config.KStorageSecondary == nil {
|
||||
configuration.Config.KStorageSecondary = &models.KStorage{}
|
||||
}
|
||||
|
||||
for _, env := range environmentVariables {
|
||||
if strings.Contains(env, "AGENT_") {
|
||||
key := strings.Split(env, "=")[0]
|
||||
value := os.Getenv(key)
|
||||
fullKey := strings.SplitN(env, "=", 2)[0]
|
||||
if strings.HasPrefix(fullKey, prefix+"AGENT_") && !(prefix == "" && strings.HasPrefix(fullKey, "GLOBAL_AGENT_")) {
|
||||
key := strings.TrimPrefix(fullKey, prefix)
|
||||
value := os.Getenv(fullKey)
|
||||
switch key {
|
||||
|
||||
/* General configuration */
|
||||
@@ -229,7 +336,15 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
configuration.Config.Capture.IPCamera.SubRTSP = value
|
||||
break
|
||||
|
||||
/* ONVIF connnection settings */
|
||||
/* 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 */
|
||||
case "AGENT_CAPTURE_IPCAMERA_ONVIF":
|
||||
configuration.Config.Capture.IPCamera.ONVIF = value
|
||||
break
|
||||
@@ -382,6 +497,11 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
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
|
||||
@@ -436,7 +556,7 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
configuration.Config.S3.Region = value
|
||||
break
|
||||
|
||||
/* When storing in a Kerberos Vault */
|
||||
/* When storing in a Vault */
|
||||
case "AGENT_KERBEROSVAULT_URI":
|
||||
configuration.Config.KStorage.URI = value
|
||||
break
|
||||
@@ -453,6 +573,37 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
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
|
||||
@@ -478,9 +629,33 @@ func OverrideWithEnvironmentVariables(configuration *models.Configuration) {
|
||||
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 {
|
||||
@@ -520,6 +695,10 @@ 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
|
||||
|
||||
@@ -118,6 +118,16 @@ 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":
|
||||
@@ -138,6 +148,16 @@ 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":
|
||||
|
||||
@@ -8,6 +8,17 @@ 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 {
|
||||
@@ -27,7 +38,7 @@ type Communication struct {
|
||||
HandleHeartBeat chan string
|
||||
HandleLiveSD chan int64
|
||||
HandleLiveHDKeepalive chan string
|
||||
HandleLiveHDHandshake chan RequestHDStreamPayload
|
||||
HandleLiveHDHandshake chan LiveHDHandshake
|
||||
HandleLiveHDPeers chan string
|
||||
HandleONVIF chan OnvifAction
|
||||
IsConfiguring *abool.AtomicBool
|
||||
@@ -28,6 +28,7 @@ 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"`
|
||||
@@ -45,6 +46,7 @@ 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"`
|
||||
}
|
||||
@@ -60,9 +62,11 @@ 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"`
|
||||
@@ -75,18 +79,28 @@ 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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// USBCamera configuration, such as the device path (/dev/video*)
|
||||
@@ -158,6 +172,8 @@ 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
|
||||
@@ -174,3 +190,9 @@ 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"`
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package models
|
||||
|
||||
type MotionDataPartial struct {
|
||||
Timestamp int64 `json:"timestamp" bson:"timestamp"`
|
||||
NumberOfChanges int `json:"numberOfChanges" bson:"numberOfChanges"`
|
||||
Timestamp int64 `json:"timestamp" bson:"timestamp"`
|
||||
NumberOfChanges int `json:"numberOfChanges" bson:"numberOfChanges"`
|
||||
Rectangle MotionRectangle `json:"rectangle" bson:"rectangle"`
|
||||
}
|
||||
|
||||
type MotionDataFull struct {
|
||||
@@ -14,3 +15,10 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ 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"`
|
||||
@@ -14,8 +14,10 @@ type Packet struct {
|
||||
IsKeyFrame bool // video packet is key frame
|
||||
Idx int8 // stream index in container format
|
||||
Codec string // codec name
|
||||
CompositionTime int64 // packet presentation time minus decode time for H264 B-Frame
|
||||
CompositionTime int64 // composition offset (PTS - DTS) in milliseconds, non-zero for H264/H265 B-frames
|
||||
Time int64 // packet decode time
|
||||
TimeLegacy time.Duration
|
||||
CurrentTime int64 // current time in milliseconds (UNIX timestamp)
|
||||
Data []byte // packet data
|
||||
Gopsize int // size of the GOP
|
||||
}
|
||||
|
||||
@@ -45,6 +45,11 @@ 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()
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package packets
|
||||
|
||||
type Stream struct {
|
||||
// The ID of the stream.
|
||||
Index int `json:"index" bson:"index"`
|
||||
|
||||
// The name of the stream.
|
||||
Name string
|
||||
|
||||
@@ -39,4 +42,13 @@ 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
|
||||
}
|
||||
|
||||
@@ -14,7 +14,14 @@ import (
|
||||
func JWTMiddleWare() jwt.GinJWTMiddleware {
|
||||
|
||||
identityKey := "id"
|
||||
myKey := "TOBECHANGED"
|
||||
// Allow the JWT signing secret to be configured through an environment
|
||||
// variable so that tokens issued by another service (e.g. the Kerberos
|
||||
// Factory) can be validated by the agent. Falls back to the historic
|
||||
// default to preserve backwards compatibility.
|
||||
myKey := os.Getenv("AGENT_JWT_SECRET")
|
||||
if myKey == "" {
|
||||
myKey = "TOBECHANGED"
|
||||
}
|
||||
|
||||
m := jwt.GinJWTMiddleware{
|
||||
Realm: "kerberosio",
|
||||
@@ -106,7 +113,11 @@ func JWTMiddleWare() jwt.GinJWTMiddleware {
|
||||
// - "query:<name>"
|
||||
// - "cookie:<name>"
|
||||
// - "param:<name>"
|
||||
TokenLookup: "header: Authorization, query: token, cookie: jwt",
|
||||
// X-Authorization is included because requests proxied through the
|
||||
// Kubernetes apiserver service-proxy have their Authorization header
|
||||
// consumed by the apiserver; the original bearer token is forwarded in
|
||||
// the X-Authorization header instead.
|
||||
TokenLookup: "header: Authorization, header: X-Authorization, query: token, cookie: jwt",
|
||||
// TokenLookup: "query:token",
|
||||
// TokenLookup: "cookie:token",
|
||||
|
||||
|
||||
@@ -15,96 +15,98 @@ 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, communication, captureDevice)
|
||||
websocket.WebsocketHandler(c, configuration, communication, captureDevice)
|
||||
})
|
||||
|
||||
// This is legacy should be removed in future! Now everything
|
||||
// lives under the /api prefix.
|
||||
r.GET("/config", func(c *gin.Context) {
|
||||
r.GET("/config", authMiddleware.MiddlewareFunc(), 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", func(c *gin.Context) {
|
||||
r.POST("/config", authMiddleware.MiddlewareFunc(), func(c *gin.Context) {
|
||||
components.UpdateConfig(c, configDirectory, configuration, communication)
|
||||
})
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// Public endpoints (no authentication required)
|
||||
api.POST("/login", authMiddleware.LoginHandler)
|
||||
|
||||
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..
|
||||
// Apply JWT authentication middleware.
|
||||
// All routes registered below this line require a valid JWT token.
|
||||
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
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
@@ -90,10 +91,32 @@ 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(true)
|
||||
opts.SetCleanSession(false)
|
||||
opts.SetResumeSubs(true)
|
||||
opts.SetStore(mqtt.NewMemoryStore())
|
||||
opts.SetConnectRetry(true)
|
||||
//opts.SetAutoReconnect(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.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 ;)
|
||||
@@ -123,7 +146,6 @@ 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!
|
||||
@@ -134,10 +156,14 @@ func ConfigureMQTT(configDirectory string, configuration *models.Configuration,
|
||||
}
|
||||
}
|
||||
mqc := mqtt.NewClient(opts)
|
||||
if token := mqc.Connect(); token.WaitTimeout(3 * time.Second) {
|
||||
if token := mqc.Connect(); token.WaitTimeout(30 * 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
|
||||
}
|
||||
@@ -145,12 +171,52 @@ 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)
|
||||
mqttClient.Subscribe(agentListener, 1, func(c mqtt.Client, msg mqtt.Message) {
|
||||
token := mqttClient.Subscribe(agentListener, 1, func(c mqtt.Client, msg mqtt.Message) {
|
||||
|
||||
// Decode the message, we are expecting following format.
|
||||
// {
|
||||
@@ -250,6 +316,15 @@ 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)
|
||||
@@ -277,6 +352,16 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,13 +474,6 @@ 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,11 +568,24 @@ 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
|
||||
select {
|
||||
case communication.HandleLiveHDHandshake <- requestHDStreamPayload:
|
||||
default:
|
||||
if communication.HandleLiveHDHandshake == nil {
|
||||
log.Log.Error("routers.mqtt.main.HandleRequestHDStream(): handshake channel is nil, dropping request")
|
||||
return
|
||||
}
|
||||
|
||||
communication.HandleLiveHDHandshake <- models.LiveHDHandshake{
|
||||
Payload: requestHDStreamPayload,
|
||||
}
|
||||
log.Log.Info("routers.mqtt.main.HandleRequestHDStream(): received request to setup webrtc.")
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"image"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -14,6 +15,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/webrtc"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
@@ -28,6 +30,23 @@ 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()
|
||||
@@ -49,7 +68,7 @@ var upgrader = websocket.Upgrader{
|
||||
},
|
||||
}
|
||||
|
||||
func WebsocketHandler(c *gin.Context, communication *models.Communication, captureDevice *capture.Capture) {
|
||||
func WebsocketHandler(c *gin.Context, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
|
||||
w := c.Writer
|
||||
r := c.Request
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
@@ -112,9 +131,85 @@ func WebsocketHandler(c *gin.Context, communication *models.Communication, captu
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
sockets[clientID].Cancels["stream-sd"] = cancel
|
||||
go ForwardSDStream(ctx, clientID, sockets[clientID], communication, captureDevice)
|
||||
go ForwardSDStream(ctx, clientID, sockets[clientID], configuration, 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)
|
||||
@@ -131,7 +226,7 @@ func WebsocketHandler(c *gin.Context, communication *models.Communication, captu
|
||||
}
|
||||
}
|
||||
|
||||
func ForwardSDStream(ctx context.Context, clientID string, connection *Connection, communication *models.Communication, captureDevice *capture.Capture) {
|
||||
func ForwardSDStream(ctx context.Context, clientID string, connection *Connection, configuration *models.Configuration, communication *models.Communication, captureDevice *capture.Capture) {
|
||||
|
||||
var queue *packets.Queue
|
||||
var cursor *packets.QueueCursor
|
||||
@@ -159,7 +254,10 @@ logreader:
|
||||
var img image.YCbCr
|
||||
img, err = (*rtspClient).DecodePacket(pkt)
|
||||
if err == nil {
|
||||
bytes, _ := utils.ImageToBytes(&img)
|
||||
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)
|
||||
encodedImage = base64.StdEncoding.EncodeToString(bytes)
|
||||
} else {
|
||||
continue
|
||||
|
||||
@@ -21,9 +21,15 @@ 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"
|
||||
)
|
||||
|
||||
const VERSION = "3.3.5"
|
||||
// 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 letterBytes = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
|
||||
@@ -193,6 +199,13 @@ 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
|
||||
@@ -401,9 +414,31 @@ 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: 15})
|
||||
err := jpeg.Encode(w, *img, &jpeg.Options{Quality: 35})
|
||||
log.Log.Debug("ImageToBytes() - buffer size: " + strconv.Itoa(buffer.Len()))
|
||||
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
|
||||
}
|
||||
|
||||
54
machinery/src/utils/main_test.go
Normal file
54
machinery/src/utils/main_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
1522
machinery/src/video/mp4.go
Normal file
1522
machinery/src/video/mp4.go
Normal file
File diff suppressed because it is too large
Load Diff
176
machinery/src/video/mp4_duration_test.go
Normal file
176
machinery/src/video/mp4_duration_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
194
machinery/src/video/mp4_loopseam_test.go
Normal file
194
machinery/src/video/mp4_loopseam_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package video
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// runLoopSeamScenario builds a fragmented MP4 that reproduces the loop-seam
|
||||
// pattern observed in the failing virtual-rtsp recordings (e.g.
|
||||
// thales_1781196512_3-138_2top_0-0-0-0_-1_30219.mp4): a steady GOP cadence, but
|
||||
// at the source-MP4 loop boundary the source restarts and emits a fresh IDR far
|
||||
// sooner than a normal GOP. In the real recordings the short tail GOP left just
|
||||
// before that premature IDR contains a truncated inter-frame - software decoders
|
||||
// conceal the missing macroblocks, but hardware decoders (macOS VideoToolbox,
|
||||
// kVTVideoDecoderBadDataErr / -12909) and MSE players reject it and freeze
|
||||
// playback at the seam (~10s in the original file).
|
||||
//
|
||||
// The fix detects the premature seam IDR and drops the truncated tail GOP that
|
||||
// precedes it. The seam IDR is itself a clean random-access point, so playback
|
||||
// resumes seamlessly. This scenario asserts that the tail GOP is removed -
|
||||
// exactly one GOP fewer than emitted - while every healthy GOP is preserved in
|
||||
// full and no two IDRs are left bunched in a fragment.
|
||||
//
|
||||
// gopFrames is the number of frames per GOP, so the same scenario can be
|
||||
// exercised at different (configurable) camera GOP sizes. The fix derives its
|
||||
// threshold from the observed keyframe cadence, so the truncated tail GOP is
|
||||
// dropped regardless of GOP size.
|
||||
func runLoopSeamScenario(t *testing.T, gopFrames int) {
|
||||
t.Helper()
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "test_loop_seam_*.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
sps := []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
|
||||
pps := []byte{0x68, 0xce, 0x38, 0x80}
|
||||
mp4Video := NewMP4(tmpFile.Name(), [][]byte{sps}, [][]byte{pps}, nil, 60)
|
||||
mp4Video.SetWidth(1920)
|
||||
mp4Video.SetHeight(1080)
|
||||
v := mp4Video.AddVideoTrack("H264")
|
||||
|
||||
mk := func(k bool) []byte {
|
||||
nt := byte(0x01)
|
||||
if k {
|
||||
nt = 0x65
|
||||
}
|
||||
f := []byte{0, 0, 0, 1, nt}
|
||||
for i := 0; i < 200; i++ {
|
||||
f = append(f, byte(i))
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
frameDur := uint64(33)
|
||||
normalGOPms := uint64(gopFrames) * frameDur
|
||||
pts := uint64(0)
|
||||
emitFrame := func(isKey bool) {
|
||||
// compositionOffset is 0: synthetic stream has no B-frames.
|
||||
mp4Video.AddSampleToTrack(v, isKey, mk(isKey), pts, 0)
|
||||
pts += frameDur
|
||||
}
|
||||
emitP := func(n int) {
|
||||
for i := 0; i < n; i++ {
|
||||
emitFrame(false)
|
||||
}
|
||||
}
|
||||
// emitGOP emits one GOP: a leading keyframe followed by gopFrames-1 P-frames.
|
||||
emitGOP := func() {
|
||||
emitFrame(true)
|
||||
emitP(gopFrames - 1)
|
||||
}
|
||||
|
||||
// Several healthy GOPs to establish the cadence and fill a couple of
|
||||
// fragments, then the truncated tail GOP: a keyframe followed by only a few
|
||||
// P-frames before the source loops. This is the GOP that must be dropped.
|
||||
for g := 0; g < 9; g++ {
|
||||
emitGOP()
|
||||
}
|
||||
emitFrame(true)
|
||||
seamLead := gopFrames / 5 // tail GOP is only ~20% of a normal GOP before the loop
|
||||
if seamLead < 1 {
|
||||
seamLead = 1
|
||||
}
|
||||
emitP(seamLead)
|
||||
// Loop seam: the source recording restarts, emitting a fresh IDR far sooner
|
||||
// than the normal GOP. The short tail GOP emitted just above is the truncated
|
||||
// one that must be dropped; this seam IDR opens a fresh, healthy GOP.
|
||||
emitFrame(true)
|
||||
emitP(gopFrames - 1)
|
||||
// The recording continues with normal GOPs to the end.
|
||||
for g := 0; g < 10; g++ {
|
||||
emitGOP()
|
||||
}
|
||||
|
||||
mp4Video.Close(&models.Config{Signing: &models.Signing{PrivateKey: ""}})
|
||||
|
||||
f, err := os.Open(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
parsed, err := mp4ff.DecodeFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
// After the fix, the truncated tail GOP that precedes the premature seam IDR
|
||||
// is dropped entirely (its first inter-frame is the incomplete one that
|
||||
// freezes hardware decoders), while every other GOP is preserved in full.
|
||||
//
|
||||
// 9 lead GOPs + the seam's own (healthy) GOP + 10 trailing GOPs = 20 committed
|
||||
// GOPs. The standalone "tail" keyframe and its seamLead P-frames are the
|
||||
// dropped truncated GOP, so the output must contain exactly one GOP fewer than
|
||||
// emitted and a whole number of complete GOPs.
|
||||
const committedGOPs = 9 + 1 + 10
|
||||
wantSync := committedGOPs
|
||||
wantSamples := committedGOPs * gopFrames
|
||||
|
||||
// A healthy fragment only ever contains keyframes spaced ~normalGOPms apart.
|
||||
// If any fragment contains two keyframes closer than half a normal GOP, the
|
||||
// premature seam IDR was not dropped and the file will freeze on playback.
|
||||
maxBunchMs := normalGOPms / 2
|
||||
|
||||
totalSamples := 0
|
||||
totalSync := 0
|
||||
fragIdx := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
tfdt := traf.Tfdt.BaseMediaDecodeTime()
|
||||
offset := uint64(0)
|
||||
var keys []uint64
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
totalSamples++
|
||||
// sample_depends_on == 2 => "does not depend on others" => IDR/sync.
|
||||
if (s.Flags>>24)&0x03 == 0x02 {
|
||||
keys = append(keys, offset)
|
||||
totalSync++
|
||||
}
|
||||
offset += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
t.Logf("gop=%dframes frag %d tfdt=%d samples_dur=%d keys@%v", gopFrames, fragIdx, tfdt, offset, keys)
|
||||
for i := 1; i < len(keys); i++ {
|
||||
gap := keys[i] - keys[i-1]
|
||||
if gap < maxBunchMs {
|
||||
t.Errorf("gop=%dframes frag %d (tfdt=%d): two IDRs only %d ms apart in same fragment (< %d) - seam was not dropped",
|
||||
gopFrames, fragIdx, tfdt, gap, maxBunchMs)
|
||||
}
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalSync != wantSync {
|
||||
t.Errorf("gop=%dframes: got %d keyframes in output, want %d - the truncated seam GOP was not dropped exactly once",
|
||||
gopFrames, totalSync, wantSync)
|
||||
}
|
||||
if totalSamples != wantSamples {
|
||||
t.Errorf("gop=%dframes: got %d video samples in output, want %d (= %d committed GOPs x %d frames) - the seam GOP drop removed the wrong frames",
|
||||
gopFrames, totalSamples, wantSamples, committedGOPs, gopFrames)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMP4LoopSeamDrop exercises the ~1s GOP case (30 frames @ ~33ms),
|
||||
// matching the original failing recording.
|
||||
func TestMP4LoopSeamDrop(t *testing.T) {
|
||||
runLoopSeamScenario(t, 30)
|
||||
}
|
||||
|
||||
// TestMP4LoopSeamDropLargeGOP exercises a larger ~2s GOP (60 frames). The
|
||||
// GOP size is configurable per camera; this guards against regressing to a
|
||||
// fixed-millisecond threshold that would only work for ~1s GOPs.
|
||||
func TestMP4LoopSeamDropLargeGOP(t *testing.T) {
|
||||
runLoopSeamScenario(t, 60)
|
||||
}
|
||||
|
||||
// TestMP4LoopSeamDropShortGOP exercises a short ~0.5s GOP (15 frames),
|
||||
// where a fixed ~1s threshold would misfire on every keyframe. The relative
|
||||
// detection must only drop the genuine premature seam's truncated tail GOP.
|
||||
func TestMP4LoopSeamDropShortGOP(t *testing.T) {
|
||||
runLoopSeamScenario(t, 15)
|
||||
}
|
||||
270
machinery/src/webrtc/aac_transcoder_ffmpeg.go
Normal file
270
machinery/src/webrtc/aac_transcoder_ffmpeg.go
Normal file
@@ -0,0 +1,270 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
205
machinery/src/webrtc/aac_transcoder_stub.go
Normal file
205
machinery/src/webrtc/aac_transcoder_stub.go
Normal file
@@ -0,0 +1,205 @@
|
||||
// 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.Debug("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.Debug("webrtc.aac_transcoder: wrote AAC bytes to ffmpeg, input=" + strconv.Itoa(len(adtsData)))
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(75 * time.Millisecond)
|
||||
for {
|
||||
data := t.readAvailable()
|
||||
if len(data) > 0 {
|
||||
log.Log.Debug("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.Debug("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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
137
machinery/src/webrtc/broadcaster.go
Normal file
137
machinery/src/webrtc/broadcaster.go
Normal file
@@ -0,0 +1,137 @@
|
||||
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
@@ -1,2 +1,2 @@
|
||||
#!/bin/bash
|
||||
swag init -g ./src/routers/http/Server.go
|
||||
swag init -g ./src/routers/http/server.go
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
"crypto": false
|
||||
},
|
||||
"scripts": {
|
||||
"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",
|
||||
"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",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "eslint --debug 'src/**/*.{js,jsx,ts,tsx}'",
|
||||
"format": "prettier --write \"**/*.{js,jsx,json,md}\""
|
||||
|
||||
@@ -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 where found, make sure your Kerberos Agent is properly configured.",
|
||||
"no_events_description": "No recordings were found, make sure your 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 Kerberos Hub demo environment, to see Kerberos Hub in action!",
|
||||
"configuration_updated_success": "Your configuration have been updated successfully.",
|
||||
"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.",
|
||||
"configuration_updated_error": "Something went wrong while saving.",
|
||||
"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_hub": "Verifying your Hub settings.",
|
||||
"verify_hub_success": "Hub settings are successfully verified.",
|
||||
"verify_hub_error": "Something went wrong while verifying 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 Kerberos Agent",
|
||||
"description_general": "General settings for your 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 Kerberos Agent",
|
||||
"description_advanced_configuration": "Detailed configuration options to enable or disable specific parts of the 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 Kerberos Agent can cleanup 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 Agent can clean up 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 MB's of recordings stored.",
|
||||
"autoclean_description_max_directory_size": "The maximum MBs 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": "Optimisations and enhancements for TURN/STUN communication.",
|
||||
"stun_turn_description_forward": "Optimizations 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": "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",
|
||||
"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",
|
||||
"mqtt_username": "Username",
|
||||
"mqtt_password": "Password",
|
||||
"realtimeprocessing": "Realtime Processing",
|
||||
@@ -180,55 +180,61 @@
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday",
|
||||
"externalcondition": "External Condition",
|
||||
"description_externalcondition": "Depending on an external webservice recording can be enabled or disabled.",
|
||||
"description_externalcondition": "Depending on an external web service, 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": "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.",
|
||||
"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.",
|
||||
"persistence": "Persistence",
|
||||
"saasoffering": "Kerberos Hub (SAAS offering)",
|
||||
"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)",
|
||||
"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 Kerberos Hub will encrypted using AES-256.",
|
||||
"kerberoshub_proxyurl": "Kerberos Hub Proxy URL",
|
||||
"kerberoshub_encryption_description": "All traffic from/to Hub will be encrypted using AES-256.",
|
||||
"kerberoshub_proxyurl": "Hub Proxy URL",
|
||||
"kerberoshub_description_proxyurl": "The Proxy endpoint for uploading your recordings.",
|
||||
"kerberoshub_apiurl": "Kerberos Hub API URL",
|
||||
"kerberoshub_apiurl": "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 Kerberos Hub account.",
|
||||
"kerberoshub_description_publickey": "The public key granted to your Hub account.",
|
||||
"kerberoshub_privatekey": "Private key",
|
||||
"kerberoshub_description_privatekey": "The private key granted to your Kerberos Hub account.",
|
||||
"kerberoshub_description_privatekey": "The private key granted to your Hub account.",
|
||||
"kerberoshub_site": "Site",
|
||||
"kerberoshub_description_site": "The site ID the Kerberos Agents are belonging to in Kerberos Hub.",
|
||||
"kerberoshub_description_site": "The site ID the Agents belong to in 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 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",
|
||||
"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",
|
||||
"kerberosvault_provider": "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_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_accesskey": "Access key",
|
||||
"kerberosvault_description_accesskey": "The access key of your Kerberos Vault account.",
|
||||
"kerberosvault_description_accesskey": "The access key of your Vault account.",
|
||||
"kerberosvault_secretkey": "Secret key",
|
||||
"kerberosvault_description_secretkey": "The secret key of your Kerberos Vault account.",
|
||||
"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.",
|
||||
"dropbox_directory": "Directory",
|
||||
"dropbox_description_directory": "The sub directory where the recordings will be stored in your Dropbox account.",
|
||||
"dropbox_description_directory": "The subdirectory 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 Kerberos Agent.",
|
||||
"remove_after_upload": "Once recordings are uploaded to some persistence, you might want to remove them from the local Agent.",
|
||||
"remove_after_upload_description": "Remove recordings after they are uploaded successfully.",
|
||||
"remove_after_upload_enabled": "Enabled delete on upload"
|
||||
"remove_after_upload_enabled": "Enable delete on upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,4 +226,4 @@
|
||||
"remove_after_upload_enabled": "Abilita cancellazione al caricamento"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,4 +226,4 @@
|
||||
"remove_after_upload_enabled": "Enabled delete on upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
234
ui/public/locales/vi/translation.json
Normal file
234
ui/public/locales/vi/translation.json
Normal file
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
doVerifyOnvif,
|
||||
doVerifyHub,
|
||||
doVerifyPersistence,
|
||||
doVerifySecondaryPersistence,
|
||||
doGetKerberosAgentTags,
|
||||
doGetDashboardInformation,
|
||||
doGetEvents,
|
||||
@@ -107,6 +108,28 @@ 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(
|
||||
@@ -171,7 +194,7 @@ export const getDashboardInformation = (onSuccess, onError) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getEvents = (eventfilter, onSuccess, onError) => {
|
||||
export const getEvents = (eventfilter, onSuccess, onError, append = false) => {
|
||||
return (dispatch) => {
|
||||
doGetEvents(
|
||||
eventfilter,
|
||||
@@ -180,6 +203,7 @@ export const getEvents = (eventfilter, onSuccess, onError) => {
|
||||
type: 'GET_EVENTS',
|
||||
events: data.events,
|
||||
filter: eventfilter,
|
||||
append,
|
||||
});
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
|
||||
@@ -72,6 +72,25 @@ 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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import './ImageCanvas.css';
|
||||
|
||||
class ImageCanvas extends React.Component {
|
||||
componentDidMount() {
|
||||
this.isUnmounted = false;
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
|
||||
@@ -58,6 +59,9 @@ 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;
|
||||
@@ -71,6 +75,9 @@ 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;
|
||||
@@ -82,11 +89,57 @@ 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;
|
||||
@@ -152,11 +205,19 @@ 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();
|
||||
image.src = path;
|
||||
image.addEventListener('load', (e) => {
|
||||
this.pendingImage = image;
|
||||
image.onload = (e) => {
|
||||
if (this.pendingImage === image) {
|
||||
this.pendingImage = null;
|
||||
}
|
||||
onready(e.target);
|
||||
});
|
||||
};
|
||||
image.src = path;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
|
||||
@@ -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}:80/api`,
|
||||
URL: `${protocol}//${hostname}:80`,
|
||||
WS_URL: `${websocketprotocol}//${hostname}:80/ws`,
|
||||
API_URL: `${protocol}//${hostname}:8080/api`,
|
||||
URL: `${protocol}//${hostname}:8080`,
|
||||
WS_URL: `${websocketprotocol}//${hostname}:8080/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,
|
||||
|
||||
@@ -26,6 +26,23 @@ 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 {
|
||||
@@ -33,43 +50,55 @@ 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 liveview = document.getElementsByClassName('videocard-video');
|
||||
if (liveview && liveview.length > 0) {
|
||||
liveview[0].addEventListener('load', () => {
|
||||
this.setState({
|
||||
liveviewLoaded: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
const { dispatchGetConfig } = this.props;
|
||||
dispatchGetConfig(() => this.initialiseLiveview());
|
||||
this.initialiseLiveview();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
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);
|
||||
this.stopSDLiveview();
|
||||
this.stopWebRTCLiveview();
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
@@ -79,32 +108,378 @@ class Dashboard extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentTimestamp() {
|
||||
return Math.round(Date.now() / 1000);
|
||||
// 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',
|
||||
};
|
||||
}
|
||||
|
||||
initialiseLiveview() {
|
||||
const { initialised } = this.state;
|
||||
if (!initialised) {
|
||||
const message = {
|
||||
message_type: 'stream-sd',
|
||||
};
|
||||
const { connected, dispatchSend } = this.props;
|
||||
if (connected) {
|
||||
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) {
|
||||
dispatchSend(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const requestStreamInterval = interval(2000);
|
||||
this.requestStreamSubscription = requestStreamInterval.subscribe(() => {
|
||||
const { connected: isConnected } = this.props;
|
||||
if (isConnected) {
|
||||
dispatchSend(message);
|
||||
}
|
||||
});
|
||||
this.setState({
|
||||
initialised: true,
|
||||
});
|
||||
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}`
|
||||
);
|
||||
}
|
||||
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) {
|
||||
@@ -116,7 +491,8 @@ class Dashboard extends React.Component {
|
||||
|
||||
render() {
|
||||
const { dashboard, t, images } = this.props;
|
||||
const { liveviewLoaded, open, currentRecording } = this.state;
|
||||
const { liveviewLoaded, liveviewMode, open, currentRecording } = this.state;
|
||||
const listenerCount = dashboard.webrtcReaders ? dashboard.webrtcReaders : 0;
|
||||
|
||||
// We check if the camera was getting a valid frame
|
||||
// during the last 5 seconds, otherwise we assume the camera is offline.
|
||||
@@ -170,7 +546,6 @@ class Dashboard extends React.Component {
|
||||
divider="0"
|
||||
footer={t('dashboard.total_recordings')}
|
||||
/>
|
||||
|
||||
<Link to="/settings">
|
||||
<Card
|
||||
title="IP Camera"
|
||||
@@ -309,7 +684,9 @@ class Dashboard extends React.Component {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2>{t('dashboard.live_view')}</h2>
|
||||
<h2>
|
||||
{t('dashboard.live_view')} ({listenerCount})
|
||||
</h2>
|
||||
{(!liveviewLoaded || !isCameraOnline) && (
|
||||
<SetupBox
|
||||
btnicon="preferences"
|
||||
@@ -326,12 +703,16 @@ class Dashboard extends React.Component {
|
||||
liveviewLoaded && isCameraOnline ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<ImageCard
|
||||
imageSrc={`data:image/png;base64, ${
|
||||
images.length ? images[0] : ''
|
||||
}`}
|
||||
onerror=""
|
||||
/>
|
||||
{liveviewMode === 'webrtc' ? (
|
||||
<video ref={this.videoRef} autoPlay muted playsInline />
|
||||
) : (
|
||||
<ImageCard
|
||||
imageSrc={`data:image/png;base64, ${
|
||||
images.length ? images[0] : ''
|
||||
}`}
|
||||
onerror=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,20 +724,25 @@ 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()(
|
||||
|
||||
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||
import { withTranslation } from 'react-i18next';
|
||||
import {
|
||||
Breadcrumb,
|
||||
ControlBar,
|
||||
VideoCard,
|
||||
Button,
|
||||
Modal,
|
||||
@@ -16,14 +17,53 @@ 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();
|
||||
this.state = {
|
||||
timestamp_offset_start: 0,
|
||||
timestamp_offset_end: 0,
|
||||
|
||||
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,
|
||||
isScrolling: false,
|
||||
open: false,
|
||||
currentRecording: '',
|
||||
@@ -32,7 +72,8 @@ class Media extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
const { dispatchGetEvents } = this.props;
|
||||
dispatchGetEvents(this.state);
|
||||
const { appliedFilter } = this.state;
|
||||
dispatchGetEvents(appliedFilter);
|
||||
document.addEventListener('scroll', this.trackScrolling);
|
||||
}
|
||||
|
||||
@@ -49,29 +90,107 @@ class Media extends React.Component {
|
||||
|
||||
trackScrolling = () => {
|
||||
const { events, dispatchGetEvents } = this.props;
|
||||
const { isScrolling } = this.state;
|
||||
const { isScrolling, appliedFilter } = this.state;
|
||||
const wrappedElement = document.getElementById('loader');
|
||||
if (!isScrolling && this.isBottom(wrappedElement)) {
|
||||
this.setState({
|
||||
isScrolling: true,
|
||||
});
|
||||
// Get last element
|
||||
const lastElement = events[events.length - 1];
|
||||
if (lastElement) {
|
||||
this.setState({
|
||||
if (!wrappedElement || isScrolling || !this.isBottom(wrappedElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isScrolling: true,
|
||||
});
|
||||
|
||||
// Get last element
|
||||
const lastElement = events[events.length - 1];
|
||||
if (lastElement) {
|
||||
dispatchGetEvents(
|
||||
{
|
||||
...appliedFilter,
|
||||
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;
|
||||
}
|
||||
@@ -85,7 +204,9 @@ class Media extends React.Component {
|
||||
|
||||
render() {
|
||||
const { events, eventsLoaded, t } = this.props;
|
||||
const { isScrolling, open, currentRecording } = this.state;
|
||||
const { isScrolling, open, currentRecording, startDateTime, endDateTime } =
|
||||
this.state;
|
||||
|
||||
return (
|
||||
<div id="media">
|
||||
<Breadcrumb
|
||||
@@ -102,6 +223,37 @@ 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
|
||||
@@ -123,6 +275,11 @@ 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
|
||||
@@ -182,13 +339,13 @@ const mapStateToProps = (state /* , ownProps */) => ({
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
dispatchGetEvents: (eventFilter, success, error) =>
|
||||
dispatch(getEvents(eventFilter, success, error)),
|
||||
dispatchGetEvents: (eventFilter, success, error, append) =>
|
||||
dispatch(getEvents(eventFilter, success, error, append)),
|
||||
});
|
||||
|
||||
Media.propTypes = {
|
||||
t: PropTypes.func.isRequired,
|
||||
events: PropTypes.objectOf(PropTypes.object).isRequired,
|
||||
events: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
eventsLoaded: PropTypes.number.isRequired,
|
||||
dispatchGetEvents: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,103 @@
|
||||
#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;
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
verifyCamera,
|
||||
verifyHub,
|
||||
verifyPersistence,
|
||||
verifySecondaryPersistence,
|
||||
getConfig,
|
||||
updateConfig,
|
||||
} from '../../actions/agent';
|
||||
@@ -63,6 +64,9 @@ class Settings extends React.Component {
|
||||
verifyPersistenceSuccess: false,
|
||||
verifyPersistenceError: false,
|
||||
verifyPersistenceMessage: '',
|
||||
verifySecondaryPersistenceSuccess: false,
|
||||
verifySecondaryPersistenceError: false,
|
||||
verifySecondaryPersistenceMessage: '',
|
||||
verifyCameraSuccess: false,
|
||||
verifyCameraError: false,
|
||||
verifyCameraMessage: '',
|
||||
@@ -70,6 +74,7 @@ class Settings extends React.Component {
|
||||
verifyOnvifError: false,
|
||||
verifyOnvifErrorMessage: '',
|
||||
loading: false,
|
||||
loadingSecondary: false,
|
||||
loadingHub: false,
|
||||
loadingCamera: false,
|
||||
};
|
||||
@@ -125,6 +130,8 @@ 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);
|
||||
@@ -152,7 +159,10 @@ class Settings extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.escFunction, false);
|
||||
clearInterval(this.interval);
|
||||
if (this.requestStreamSubscription) {
|
||||
this.requestStreamSubscription.unsubscribe();
|
||||
this.requestStreamSubscription = null;
|
||||
}
|
||||
|
||||
const { dispatchSend } = this.props;
|
||||
const message = {
|
||||
@@ -350,6 +360,8 @@ class Settings extends React.Component {
|
||||
configSuccess: false,
|
||||
configError: false,
|
||||
loadingCamera: false,
|
||||
loading: false,
|
||||
loadingSecondary: false,
|
||||
loadingOnvif: true,
|
||||
});
|
||||
|
||||
@@ -390,6 +402,8 @@ class Settings extends React.Component {
|
||||
configError: false,
|
||||
verifyPersistenceSuccess: false,
|
||||
verifyPersistenceError: false,
|
||||
verifySecondaryPersistenceSuccess: false,
|
||||
verifySecondaryPersistenceError: false,
|
||||
verifyHubSuccess: false,
|
||||
verifyHubError: false,
|
||||
verifyHubErrorMessage: '',
|
||||
@@ -401,6 +415,8 @@ class Settings extends React.Component {
|
||||
verifyOnvifSuccess: false,
|
||||
verifyOnvifError: false,
|
||||
loadingHub: true,
|
||||
loading: false,
|
||||
loadingSecondary: false,
|
||||
});
|
||||
|
||||
// .... test fields
|
||||
@@ -441,6 +457,8 @@ class Settings extends React.Component {
|
||||
verifyHubError: false,
|
||||
verifyPersistenceSuccess: false,
|
||||
verifyPersistenceError: false,
|
||||
verifySecondaryPersistenceSuccess: false,
|
||||
verifySecondaryPersistenceError: false,
|
||||
persistenceSuccess: false,
|
||||
persistenceError: false,
|
||||
verifyCameraSuccess: false,
|
||||
@@ -449,6 +467,7 @@ class Settings extends React.Component {
|
||||
verifyOnvifError: false,
|
||||
verifyCameraErrorMessage: '',
|
||||
loading: true,
|
||||
loadingSecondary: false,
|
||||
});
|
||||
|
||||
dispatchVerifyPersistence(
|
||||
@@ -461,6 +480,7 @@ class Settings extends React.Component {
|
||||
persistenceSuccess: false,
|
||||
persistenceError: false,
|
||||
loading: false,
|
||||
loadingSecondary: false,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
@@ -471,6 +491,58 @@ 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,
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -537,6 +609,9 @@ class Settings extends React.Component {
|
||||
verifyPersistenceSuccess,
|
||||
verifyPersistenceError,
|
||||
verifyPersistenceMessage,
|
||||
verifySecondaryPersistenceSuccess,
|
||||
verifySecondaryPersistenceError,
|
||||
verifySecondaryPersistenceMessage,
|
||||
verifyCameraSuccess,
|
||||
verifyCameraError,
|
||||
verifyCameraErrorMessage,
|
||||
@@ -546,6 +621,7 @@ class Settings extends React.Component {
|
||||
verifyOnvifErrorMessage,
|
||||
loadingCamera,
|
||||
loading,
|
||||
loadingSecondary,
|
||||
loadingHub,
|
||||
} = this.state;
|
||||
|
||||
@@ -798,6 +874,20 @@ 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 */}
|
||||
@@ -2449,6 +2539,43 @@ 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 && (
|
||||
@@ -2508,6 +2635,140 @@ 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>
|
||||
@@ -2532,6 +2793,8 @@ 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) =>
|
||||
@@ -2549,6 +2812,7 @@ 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,
|
||||
|
||||
@@ -123,16 +123,12 @@ const agent = (
|
||||
};
|
||||
|
||||
case 'GET_EVENTS':
|
||||
const { timestamp_offset_end } = action.filter;
|
||||
const { events } = action;
|
||||
return {
|
||||
...state,
|
||||
eventsLoaded: events.length,
|
||||
events:
|
||||
timestamp_offset_end === 0
|
||||
? [...events]
|
||||
: [...state.events, ...events],
|
||||
eventfilter: action.eventfilter,
|
||||
events: action.append ? [...state.events, ...events] : [...events],
|
||||
eventfilter: action.filter,
|
||||
};
|
||||
|
||||
default:
|
||||
|
||||
@@ -1715,10 +1715,10 @@
|
||||
"@jridgewell/resolve-uri" "^3.0.3"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||
|
||||
"@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==
|
||||
"@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==
|
||||
dependencies:
|
||||
"@emotion/react" "^11.10.4"
|
||||
"@emotion/styled" "^11.10.4"
|
||||
|
||||
Reference in New Issue
Block a user