Compare commits
1139 Commits
v1.0.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203d7b5518 | ||
|
|
d0a7efff85 | ||
|
|
95ea92b9ce | ||
|
|
6890d1889c | ||
|
|
6c71ff5039 | ||
|
|
efc90c76c9 | ||
|
|
7459eb02ee | ||
|
|
fc46da1398 | ||
|
|
51a11edb71 | ||
|
|
29e7f26c0e | ||
|
|
01d270fcfa | ||
|
|
92d3311192 | ||
|
|
97a8c1fcaf | ||
|
|
de5b0666bd | ||
|
|
ffdb8b6f22 | ||
|
|
250e3b0b20 | ||
|
|
2bb8144e79 | ||
|
|
72d4fca63c | ||
|
|
81cd95379b | ||
|
|
cd50f58138 | ||
|
|
b2f029117e | ||
|
|
db135acea9 | ||
|
|
7b589b53f9 | ||
|
|
c1740c752e | ||
|
|
5862786381 | ||
|
|
e8dd64f54b | ||
|
|
ba96b63002 | ||
|
|
c7c6bcbdf2 | ||
|
|
faa3b4eabb | ||
|
|
f0a6eb7d98 | ||
|
|
33a58cddf7 | ||
|
|
fea6d81246 | ||
|
|
dbff9fbc8e | ||
|
|
63b352b5e2 | ||
|
|
2ffb210ccb | ||
|
|
ff643d21ef | ||
|
|
8f04a6d42f | ||
|
|
4395fe2417 | ||
|
|
18392e136e | ||
|
|
ed916eb042 | ||
|
|
72b8160dc4 | ||
|
|
4f41786038 | ||
|
|
8fb186fd6d | ||
|
|
420b8b8a01 | ||
|
|
5a13416bed | ||
|
|
704011c20b | ||
|
|
6683c9b994 | ||
|
|
2092f3e49d | ||
|
|
d815e39e1d | ||
|
|
e2e1f8cfa8 | ||
|
|
ba5992378e | ||
|
|
2163a8e146 | ||
|
|
1fec49500e | ||
|
|
8175908073 | ||
|
|
1bcce4694d | ||
|
|
b26f0190c6 | ||
|
|
91194f5c1a | ||
|
|
57cfc90c4b | ||
|
|
357cc719a5 | ||
|
|
4f2a96b5e1 | ||
|
|
ed85261c8e | ||
|
|
5a58808f20 | ||
|
|
94b26cf096 | ||
|
|
57ef7ebaaf | ||
|
|
ddf58fe633 | ||
|
|
6f2d35cdf1 | ||
|
|
c836cef28d | ||
|
|
c97bb70cb5 | ||
|
|
96b145b046 | ||
|
|
09a697e00b | ||
|
|
1d0714f199 | ||
|
|
42e91867ec | ||
|
|
155c4a7e44 | ||
|
|
4fe4977559 | ||
|
|
67e66e863a | ||
|
|
bd34e9d836 | ||
|
|
1a0e6bf153 | ||
|
|
52aef0870e | ||
|
|
012ed3b658 | ||
|
|
7ced8a3044 | ||
|
|
f043be5371 | ||
|
|
b85d9858d1 | ||
|
|
434730b970 | ||
|
|
94df7298e3 | ||
|
|
0f76baec1f | ||
|
|
6ae61ea046 | ||
|
|
93e17ac73e | ||
|
|
0037f5a0ab | ||
|
|
79f225ad3c | ||
|
|
b6358ab56f | ||
|
|
bde5cf58eb | ||
|
|
6725411e8f | ||
|
|
675a8a4fb9 | ||
|
|
a77843fffc | ||
|
|
2dd9d50954 | ||
|
|
9c0a9452a7 | ||
|
|
61692e8346 | ||
|
|
e12f403fb9 | ||
|
|
484de49689 | ||
|
|
450d10acf7 | ||
|
|
8a0b5337f3 | ||
|
|
3590a0b39e | ||
|
|
976834cdfd | ||
|
|
d3ede93053 | ||
|
|
58a79f8278 | ||
|
|
422279985f | ||
|
|
99ff750c40 | ||
|
|
13c84a0f36 | ||
|
|
cb6bbe1609 | ||
|
|
b839cd985b | ||
|
|
476207c1bf | ||
|
|
fcd8ef8ff4 | ||
|
|
645b6aa0be | ||
|
|
67ee78dab5 | ||
|
|
5936c6eaae | ||
|
|
dafcd06696 | ||
|
|
02d60c71e4 | ||
|
|
52647d7f1d | ||
|
|
e1fa7d9d7e | ||
|
|
06e2694763 | ||
|
|
c0971ca3b2 | ||
|
|
1a788ebe6c | ||
|
|
a1b4026b4b | ||
|
|
9bc9825bb1 | ||
|
|
e9d2afa228 | ||
|
|
4b0e0eae9c | ||
|
|
e0204e1949 | ||
|
|
3c2a0ce0cf | ||
|
|
a5def2ccd8 | ||
|
|
6ede3c3add | ||
|
|
d5de6ae271 | ||
|
|
2035deaa31 | ||
|
|
5973ba025d | ||
|
|
52a54fbae1 | ||
|
|
5f828262eb | ||
|
|
17c1c5b04b | ||
|
|
bd5df30de3 | ||
|
|
2c063c39c6 | ||
|
|
2f0f29ce8c | ||
|
|
a05acb7fc8 | ||
|
|
2b88c0ff93 | ||
|
|
4aa2b6e51a | ||
|
|
0c439e34c7 | ||
|
|
d57bea3079 | ||
|
|
46a48db080 | ||
|
|
b7fe9947c2 | ||
|
|
f214a09826 | ||
|
|
7059503ac1 | ||
|
|
7ee79cc063 | ||
|
|
9bfbe4ee0f | ||
|
|
b8c05aa3e2 | ||
|
|
5f7ede40ca | ||
|
|
0ef84c5288 | ||
|
|
1a477bf42d | ||
|
|
22c352e946 | ||
|
|
55b0eb54fe | ||
|
|
68a4ca6bb9 | ||
|
|
baaa3f615a | ||
|
|
aeb214689b | ||
|
|
e353d46e73 | ||
|
|
4d163c4b53 | ||
|
|
014f0e312e | ||
|
|
195750a01d | ||
|
|
d203321770 | ||
|
|
51f1a52e17 | ||
|
|
6318c61323 | ||
|
|
5323105a60 | ||
|
|
af6e75426a | ||
|
|
6c2f38679b | ||
|
|
9b60223300 | ||
|
|
efdf8396ab | ||
|
|
d0f13187a1 | ||
|
|
bf46b55c92 | ||
|
|
88edcabf98 | ||
|
|
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 | ||
|
|
cf3e491462 | ||
|
|
6068705c07 | ||
|
|
37beaa64d7 | ||
|
|
8c5b03487b | ||
|
|
360ae0c0db | ||
|
|
6aad8b7b35 | ||
|
|
9ce037fdc0 | ||
|
|
0eb77ccd16 | ||
|
|
fb876bd216 | ||
|
|
865aec88fc | ||
|
|
9792bdf494 | ||
|
|
d836e89e7f | ||
|
|
53a52b3594 | ||
|
|
ba6ce25b21 | ||
|
|
8c9e18475f | ||
|
|
4548d5328b | ||
|
|
da870fe890 | ||
|
|
66b660e688 | ||
|
|
08f8ca78d6 | ||
|
|
1e61e99005 | ||
|
|
c272e1ab5c | ||
|
|
5cff11c0af | ||
|
|
28b213779f | ||
|
|
666ff202ad | ||
|
|
9cb3c9753a | ||
|
|
c4577e94b1 | ||
|
|
9756183d3b | ||
|
|
83c65fe3d8 | ||
|
|
e6717c87cd | ||
|
|
5a3c1d6c9d | ||
|
|
81045ea955 | ||
|
|
9f9fe3bd37 | ||
|
|
84f7f844c9 | ||
|
|
4fde419db9 | ||
|
|
78cad6cf06 | ||
|
|
4763e5a92e | ||
|
|
50939ee4ce | ||
|
|
884bc2acc1 | ||
|
|
11fd041fa9 | ||
|
|
a6d5c2b614 | ||
|
|
9e3d705c6f | ||
|
|
1004731903 | ||
|
|
9f2ec91688 | ||
|
|
185135ed94 | ||
|
|
27e7d98c68 | ||
|
|
79f56771e3 | ||
|
|
a7839147d6 | ||
|
|
834d82d532 | ||
|
|
989f2f5943 | ||
|
|
3af1df5b19 | ||
|
|
acf06e6e63 | ||
|
|
3f43e15cc2 | ||
|
|
c14683ec0d | ||
|
|
213aaa5c15 | ||
|
|
9fb00c32d5 | ||
|
|
57ec08066c | ||
|
|
e0c6375261 | ||
|
|
79205abe29 | ||
|
|
24326558d0 | ||
|
|
3f981c0f2f | ||
|
|
b6eb7b8317 | ||
|
|
4267ae6305 | ||
|
|
0cb40bd93a | ||
|
|
d2a8890a43 | ||
|
|
e5a5a5326b | ||
|
|
61febd55c8 | ||
|
|
3eac752654 | ||
|
|
df4f1863fc | ||
|
|
acee2784d3 | ||
|
|
8ecb2f94a9 | ||
|
|
8657baf641 | ||
|
|
13d1948c9f | ||
|
|
8e8d51b719 | ||
|
|
ca2413363e | ||
|
|
b067758915 | ||
|
|
b2b8485b28 | ||
|
|
c69d635431 | ||
|
|
a305ca36ce | ||
|
|
a6a97b09f0 | ||
|
|
4d17a15633 | ||
|
|
5fdb4b712e | ||
|
|
3d39251ac6 | ||
|
|
9e59cd1596 | ||
|
|
0ada943699 | ||
|
|
ecadf7a4db | ||
|
|
413ed12fe2 | ||
|
|
6195fa5b9c | ||
|
|
d31524ae52 | ||
|
|
472a40a5f6 | ||
|
|
fb9de04002 | ||
|
|
3f29d1c46f | ||
|
|
b67a72ba9a | ||
|
|
8fc9bc264d | ||
|
|
b2589f498d | ||
|
|
b1ff5134f2 | ||
|
|
3551d02d50 | ||
|
|
4c413012a4 | ||
|
|
74ea2f6cdd | ||
|
|
2a7d9b62d4 | ||
|
|
21d81b94dd | ||
|
|
091662ff26 | ||
|
|
803e8f55ef | ||
|
|
14d38ecf08 | ||
|
|
34d945055b | ||
|
|
8c44da8233 | ||
|
|
a8b79947ef | ||
|
|
7c653f809d | ||
|
|
49f1603f40 | ||
|
|
b4369ea932 | ||
|
|
83ba7baa4b | ||
|
|
9339ae30fd | ||
|
|
c18f2bd445 | ||
|
|
319876bbb0 | ||
|
|
442ba97c61 | ||
|
|
00e0b0b547 | ||
|
|
145f478249 | ||
|
|
aac2150a3a | ||
|
|
9b713637b9 | ||
|
|
699660d472 | ||
|
|
751aa17534 | ||
|
|
2681bd2fe3 | ||
|
|
93adb3dabc | ||
|
|
0e15e58a88 | ||
|
|
ef2ea999df | ||
|
|
ca367611d7 | ||
|
|
eb8f073856 | ||
|
|
3ae43eba16 | ||
|
|
9719a08eaa | ||
|
|
1e165cbeb8 | ||
|
|
8be8cafd00 | ||
|
|
e74d2aadb5 | ||
|
|
9c97422f43 | ||
|
|
deb0a3ff1f | ||
|
|
95ed1f0e97 | ||
|
|
6a111dadd6 | ||
|
|
95b3623c04 | ||
|
|
326d62a640 | ||
|
|
9d990650f3 | ||
|
|
4bc891b640 | ||
|
|
1f133afb89 | ||
|
|
8da34a6a1a | ||
|
|
57c49a8325 | ||
|
|
f739d52505 | ||
|
|
793022eb0f | ||
|
|
6b1fd739f4 | ||
|
|
4efa7048dc | ||
|
|
4931700d06 | ||
|
|
4bd49dbee1 | ||
|
|
c278a66f0e | ||
|
|
d64e6b631c | ||
|
|
fa91e84977 | ||
|
|
8c231d3b63 | ||
|
|
775c1b7051 | ||
|
|
fb23815210 | ||
|
|
5261c1cbfc | ||
|
|
f2aa3d9176 | ||
|
|
113b02d665 | ||
|
|
957d2fd095 | ||
|
|
78e7fb595a | ||
|
|
b5415284e2 | ||
|
|
e94a9a1000 | ||
|
|
60bb9a521c | ||
|
|
3ac34a366f | ||
|
|
77449a29e7 | ||
|
|
242ff48ab6 | ||
|
|
b71dbddc1a | ||
|
|
6407f3da3d | ||
|
|
776571c7b3 | ||
|
|
2df35a1999 | ||
|
|
b1ab6bf522 | ||
|
|
e7fd0bd8a3 | ||
|
|
4f5597c441 | ||
|
|
400457af9f | ||
|
|
c48e3a5683 | ||
|
|
67064879e4 | ||
|
|
698b9c6b54 | ||
|
|
0e8a89c4c3 | ||
|
|
b0bcf73b52 | ||
|
|
15a51e7987 | ||
|
|
b5f5567bcf | ||
|
|
9151b38e7f | ||
|
|
898b3a52c2 | ||
|
|
be6eb6165c | ||
|
|
e95f545bf4 | ||
|
|
fd01fc640e | ||
|
|
8cfcfe4643 | ||
|
|
60d7b4b356 | ||
|
|
9b796c049d | ||
|
|
c8c9f6dff1 | ||
|
|
8293d29ee8 | ||
|
|
34a0d8f5c4 | ||
|
|
0a195a0dfb | ||
|
|
c82ead31f2 | ||
|
|
3ab4b5b54b | ||
|
|
5765f7c4f6 | ||
|
|
d1dd30577b | ||
|
|
1145008c62 | ||
|
|
3f1e01e665 | ||
|
|
ced9355b78 | ||
|
|
6e7ade036e | ||
|
|
976fbb65aa | ||
|
|
ba7f870d4b | ||
|
|
cb3dce5ffd | ||
|
|
b317a6a9db | ||
|
|
e42f430bb8 | ||
|
|
bd984ea1c7 | ||
|
|
6798569b7f | ||
|
|
df3183ec1c | ||
|
|
25c35ba91b | ||
|
|
68b9c5f679 | ||
|
|
9757bc9b18 | ||
|
|
1e4affbf5c | ||
|
|
22f4a7e08a | ||
|
|
044e167dd2 | ||
|
|
bffd377461 | ||
|
|
677c9e334b | ||
|
|
df38784a8d | ||
|
|
dae2c1b5c4 | ||
|
|
fd6449b377 | ||
|
|
cd09ed3321 | ||
|
|
e7dc9aa64d | ||
|
|
fec2587b6d | ||
|
|
7c285d36a1 | ||
|
|
ed46cbe35a | ||
|
|
0a8f097c76 | ||
|
|
bce5d443d5 | ||
|
|
19bf456bda | ||
|
|
1359858e42 | ||
|
|
55b1abe243 | ||
|
|
c6428d8c5a | ||
|
|
e241a03fc4 | ||
|
|
ac2b99a3dd | ||
|
|
341a6a7fae | ||
|
|
e74facfb7f | ||
|
|
54bc1989f9 | ||
|
|
94b71a0868 | ||
|
|
c071057eec | ||
|
|
e8a355d992 | ||
|
|
ca84664071 | ||
|
|
dd7fcb31b1 | ||
|
|
324fffde6b | ||
|
|
cd8347d20f | ||
|
|
efcbf52b06 | ||
|
|
c33469a7b3 | ||
|
|
3717535f0b | ||
|
|
8eb2de5e28 | ||
|
|
96f6bcb1dd | ||
|
|
860077a3eb | ||
|
|
8be9343314 | ||
|
|
dac04fbb57 | ||
|
|
b9acf4c150 | ||
|
|
6608018f86 | ||
|
|
552f5dbea6 | ||
|
|
2844a5a419 | ||
|
|
c4b9610f58 | ||
|
|
6a44498730 | ||
|
|
a2cebaf90b | ||
|
|
3f58f26dfd | ||
|
|
a8d5f56f1e | ||
|
|
1eb62d80c7 | ||
|
|
e474a62dbc | ||
|
|
f29b952001 | ||
|
|
38247ac9f6 | ||
|
|
580f17028a | ||
|
|
48d933a561 | ||
|
|
0c70ab6158 | ||
|
|
839185dac8 | ||
|
|
ba6cdef9d5 | ||
|
|
bedb3c0d7f | ||
|
|
2539255940 | ||
|
|
24136f8b15 | ||
|
|
910bb3c079 | ||
|
|
47f4c19617 | ||
|
|
280a81809a | ||
|
|
59358acb30 | ||
|
|
ebd655ac73 | ||
|
|
6325e37aae | ||
|
|
ecabc47847 | ||
|
|
31cc3d8939 | ||
|
|
c71cb71d08 | ||
|
|
65a739ea75 | ||
|
|
410a62e9ef | ||
|
|
aa76dd1ec8 | ||
|
|
384448d123 | ||
|
|
414f74758c | ||
|
|
25403ccdab | ||
|
|
4c03132b83 | ||
|
|
470f8f1cb6 | ||
|
|
5308376a67 | ||
|
|
2b112d29cf | ||
|
|
20d2517e74 | ||
|
|
12902e2482 | ||
|
|
baca44beef | ||
|
|
d7580744e2 | ||
|
|
04f4bc9bf2 | ||
|
|
d879174f4c | ||
|
|
5a1a62a723 | ||
|
|
c519b01092 | ||
|
|
c2ff7ff785 | ||
|
|
44ec8c0534 | ||
|
|
21c0e01137 | ||
|
|
f7ced6056d | ||
|
|
00917e3f88 | ||
|
|
bcfed04a07 | ||
|
|
bf97bd72f1 | ||
|
|
4b8b6bf66a | ||
|
|
4b6c25bb85 | ||
|
|
729b38999e | ||
|
|
4cbf0323f1 | ||
|
|
1f5cb8ca88 | ||
|
|
8be0a04502 | ||
|
|
bdc0039a24 | ||
|
|
756b893ecd | ||
|
|
36323b076f | ||
|
|
95f43b6444 | ||
|
|
5c23a62ac3 | ||
|
|
2b425a2ddd | ||
|
|
abeeb95204 | ||
|
|
6aed20c466 | ||
|
|
d2dd3dfa62 | ||
|
|
6672535544 | ||
|
|
ed397b6ecc | ||
|
|
530e4c654e | ||
|
|
913bd1ba12 | ||
|
|
84e532be47 | ||
|
|
3341e99af1 | ||
|
|
ced6e678ec | ||
|
|
340a5d7ef6 | ||
|
|
60e8edc876 | ||
|
|
9cf9babd73 | ||
|
|
229c246e1c | ||
|
|
15d9bcda4f | ||
|
|
068063695e | ||
|
|
b1722844f3 | ||
|
|
eb5ab48d6c | ||
|
|
b64f1039d7 | ||
|
|
6fcd6e53a1 | ||
|
|
25537b5f02 | ||
|
|
2fad541e06 | ||
|
|
afefd32a1f | ||
|
|
89e01e065c | ||
|
|
02f3e6a1e2 | ||
|
|
ec5a00f3df | ||
|
|
2860775954 | ||
|
|
d2e8e04833 | ||
|
|
fad90390a6 | ||
|
|
d6ba875473 | ||
|
|
e9ea34c20f | ||
|
|
99cc7d419f | ||
|
|
1a6dc27535 | ||
|
|
93f40a8d34 | ||
|
|
d7f7de97b4 | ||
|
|
50babedcbf | ||
|
|
4352d993ed | ||
|
|
1e144e1c60 | ||
|
|
d4e37e0bae | ||
|
|
026bf93980 | ||
|
|
43c166666c | ||
|
|
f3bda88f3e | ||
|
|
b0af6b2e2b | ||
|
|
2925e19b90 | ||
|
|
e67b6a1800 | ||
|
|
bbbed49887 | ||
|
|
65fd400d4d | ||
|
|
87f681cfe1 | ||
|
|
f935360fda | ||
|
|
71cd315142 | ||
|
|
d9694ac1a3 | ||
|
|
08f589586d | ||
|
|
192f78ae78 | ||
|
|
ef20d4c0b1 | ||
|
|
af95c0f798 | ||
|
|
0e32a10ff5 | ||
|
|
e59c2b179d | ||
|
|
e5d03f19de | ||
|
|
58c3e73f6f | ||
|
|
8ca2c44422 | ||
|
|
a2e584a225 | ||
|
|
c4cda0afb0 | ||
|
|
adbb923e92 | ||
|
|
f444ae4ad6 | ||
|
|
9fa9538320 | ||
|
|
943e81000e | ||
|
|
b16d028293 | ||
|
|
07646e483d | ||
|
|
36b93a34b4 | ||
|
|
b0d2409524 | ||
|
|
be7a231950 | ||
|
|
31a0b9efa4 | ||
|
|
d70a3ed343 | ||
|
|
56cebb6451 | ||
|
|
99f61bc5e8 | ||
|
|
a5d02e3275 | ||
|
|
354ab7db05 | ||
|
|
dc817f8c26 | ||
|
|
a90097731c | ||
|
|
5b3bbbb37e | ||
|
|
4a4aabd71c | ||
|
|
b058c1e742 | ||
|
|
7671b1c2c3 | ||
|
|
4cc8135e1a | ||
|
|
3cb38099ea | ||
|
|
deb0308dc4 | ||
|
|
24c729eea3 | ||
|
|
c59d511ea3 | ||
|
|
6f8745dc3a | ||
|
|
65d3d649b9 | ||
|
|
b4a8028c04 | ||
|
|
9d7077813a | ||
|
|
2feda33808 | ||
|
|
ec42b9ea85 | ||
|
|
a2b4ee12ec | ||
|
|
a0f99a5167 | ||
|
|
9aff467afc | ||
|
|
926f9ea32c | ||
|
|
43d12ee82f | ||
|
|
677c985b3d | ||
|
|
24a5ca04ca | ||
|
|
f81ae57395 | ||
|
|
7e6b69819e | ||
|
|
4aa8ce7513 | ||
|
|
30d59f2613 | ||
|
|
be0277432b | ||
|
|
a3006f3b9f | ||
|
|
d4e10083c4 | ||
|
|
898a11868e | ||
|
|
a098052ee8 | ||
|
|
6f79086a27 | ||
|
|
6ee07d4c12 | ||
|
|
a69869440f | ||
|
|
dcd4e08c6f | ||
|
|
22e7a0fec9 | ||
|
|
8daebd434d | ||
|
|
8381cd1a5c | ||
|
|
051299f331 | ||
|
|
20f2c764fe | ||
|
|
07619c2bce | ||
|
|
fb86efe715 | ||
|
|
4eafe6db84 | ||
|
|
797f986d5d | ||
|
|
864a8ecffa | ||
|
|
7e48be06e3 | ||
|
|
27861aa0a5 | ||
|
|
6d45cb7e6d | ||
|
|
1964b893a5 | ||
|
|
e30c8fe39c | ||
|
|
b183561dc6 | ||
|
|
caa4952e99 | ||
|
|
5e15b9ac75 | ||
|
|
4fadd770f8 | ||
|
|
7ac2f71de1 | ||
|
|
b49eef2d1c | ||
|
|
b8735aba01 | ||
|
|
c73584a743 | ||
|
|
ef4fc0a184 | ||
|
|
8cada20b1e | ||
|
|
ba4ac215b5 | ||
|
|
947ff473a0 | ||
|
|
14dc1a2fa3 | ||
|
|
df69cdc6a4 | ||
|
|
dd54e08e10 | ||
|
|
7e3bb2359e | ||
|
|
5d00053b6f | ||
|
|
d573c2b829 | ||
|
|
eda9eb90d8 | ||
|
|
34849e8250 | ||
|
|
d069a606d7 | ||
|
|
f0b042a2d1 | ||
|
|
8a3ec65ec1 | ||
|
|
e53715b9b0 | ||
|
|
9ef1339f8f | ||
|
|
afaa51aac3 | ||
|
|
fb803df15e | ||
|
|
de0c4fa657 | ||
|
|
18484f407c | ||
|
|
f4a7bc5650 | ||
|
|
9410289b4f | ||
|
|
8eecae6f9a | ||
|
|
63cf88b88d | ||
|
|
cb9403cbf9 | ||
|
|
27da1dbc74 | ||
|
|
98d9185c20 | ||
|
|
dec367a8f5 | ||
|
|
8a3c1e8f08 | ||
|
|
89c385ae2f | ||
|
|
e920cb2a6b | ||
|
|
3f6204e2eb | ||
|
|
ae9d42e821 | ||
|
|
31400bd5e7 | ||
|
|
7973559502 | ||
|
|
d4bfc8677a | ||
|
|
155f1c7cdf | ||
|
|
9643a79603 | ||
|
|
3cc67b485e | ||
|
|
2175d76f94 | ||
|
|
2a069f8881 | ||
|
|
c46765ee8f | ||
|
|
427fad262c | ||
|
|
a895b24425 | ||
|
|
659ba3e67d | ||
|
|
7e394e1c93 | ||
|
|
886522039a | ||
|
|
155f99597a | ||
|
|
d67ac25e87 | ||
|
|
1fdfddeb43 | ||
|
|
770192ac57 | ||
|
|
228cfd844a | ||
|
|
4fbcfbc99d | ||
|
|
bfb72e8268 | ||
|
|
e375c107ef | ||
|
|
99ce644e5d | ||
|
|
05c2d4c583 | ||
|
|
2265eb88e7 | ||
|
|
926e392076 | ||
|
|
7c0cbd4771 | ||
|
|
70d27dc502 | ||
|
|
fece388ae9 | ||
|
|
5543be51da | ||
|
|
cf28d69cce | ||
|
|
9ea4a96165 | ||
|
|
7bd4934ee7 | ||
|
|
fb5919af99 | ||
|
|
8cf8a2e253 | ||
|
|
8dfd15bc3a | ||
|
|
05fab6c7f7 | ||
|
|
9ec52d445d | ||
|
|
a68aaaa214 | ||
|
|
97d2f8fb26 | ||
|
|
b2bc10865c | ||
|
|
7b6ac61a71 | ||
|
|
27b2842481 | ||
|
|
8507e84ea0 | ||
|
|
66df22709f | ||
|
|
c8c9a0fd6e | ||
|
|
f6b95ce7f3 | ||
|
|
6b31ddcbfc | ||
|
|
73892ee86f | ||
|
|
1a0458de11 | ||
|
|
42647172b9 | ||
|
|
e29577e1c5 | ||
|
|
9012bf2bf0 | ||
|
|
0fa1943701 | ||
|
|
a0c1acfd85 | ||
|
|
6413cc82c7 | ||
|
|
e57908c9b4 | ||
|
|
c36007ab27 | ||
|
|
928604e12b | ||
|
|
e33ee6cfa6 | ||
|
|
517b4b26f2 | ||
|
|
06b3d7e67f | ||
|
|
508422d461 | ||
|
|
528638f69c | ||
|
|
70e7507a15 | ||
|
|
df019cf347 | ||
|
|
e88a786116 | ||
|
|
7242212dd0 | ||
|
|
e3dd5fd34b | ||
|
|
2b90bdbc0b | ||
|
|
cd4e9eed3d | ||
|
|
0658f9afbb | ||
|
|
541630a65f | ||
|
|
4e46f7f4bd | ||
|
|
4da3c7a50c | ||
|
|
47fb5b81ab | ||
|
|
b27b2f011b | ||
|
|
40ea660609 | ||
|
|
d70314d4ab | ||
|
|
8867c1dde5 | ||
|
|
a36d53a89b | ||
|
|
f634b98552 | ||
|
|
3942eb5ec1 | ||
|
|
f45413dab9 | ||
|
|
5795a41f92 | ||
|
|
ddc6491058 | ||
|
|
9c940e18d7 | ||
|
|
80b12e229a | ||
|
|
b9a81a31c9 | ||
|
|
1bc063fdbb | ||
|
|
98b897e4fe | ||
|
|
312e501da4 | ||
|
|
0d2e68af3f | ||
|
|
e16d933e56 | ||
|
|
8ee5b42fde | ||
|
|
05447abe93 | ||
|
|
6c91f233e3 | ||
|
|
feaba1afb5 | ||
|
|
e70a66f7b4 | ||
|
|
a78ccd023c | ||
|
|
87ac0932c3 | ||
|
|
8e1dcd7002 | ||
|
|
1b96d01964 | ||
|
|
c013308afe | ||
|
|
e28d133ef0 | ||
|
|
e8f03cd7d7 | ||
|
|
31f7eb5a9a | ||
|
|
89c39aa853 | ||
|
|
f349926c77 | ||
|
|
bf0ae4cb64 | ||
|
|
fb666bb622 | ||
|
|
b69b52d292 | ||
|
|
d11180f26e | ||
|
|
69461c8c7a | ||
|
|
8a373adc2c | ||
|
|
eb3355da9a | ||
|
|
2cda3ad929 | ||
|
|
7a344a987e | ||
|
|
3ce9adc95c | ||
|
|
e5c71bb6b2 | ||
|
|
a021a9e332 | ||
|
|
d3bd8031a7 | ||
|
|
bb18fff7d9 | ||
|
|
9ed2873adb | ||
|
|
f891ac375b | ||
|
|
630b34ca27 | ||
|
|
ecc7514d2b | ||
|
|
9e395bb8a7 | ||
|
|
85fe51f0f7 | ||
|
|
cb247e0312 | ||
|
|
9f933ac9ed | ||
|
|
b0455c6bda | ||
|
|
bac247f15e | ||
|
|
50823c8f8a | ||
|
|
2b81efb2c5 | ||
|
|
013e93be67 | ||
|
|
934ea927b2 | ||
|
|
e54a4097a1 | ||
|
|
bc98b87dc2 | ||
|
|
1c7883b76a | ||
|
|
3850a91737 | ||
|
|
6e8599efda | ||
|
|
1f66eceb3d | ||
|
|
5b551d128f | ||
|
|
77373dfd1d | ||
|
|
c4fdf7de01 | ||
|
|
89875358b1 | ||
|
|
70bc2a3af1 | ||
|
|
b228d808db | ||
|
|
7fe3f5fb0f | ||
|
|
4daf48a39c | ||
|
|
20a1802bdd | ||
|
|
b812b49e11 | ||
|
|
30fb0b675b | ||
|
|
e6257f4ec6 | ||
|
|
38b28b965c | ||
|
|
16e4c68fb3 | ||
|
|
784fe73a55 | ||
|
|
3a83ffc7b1 | ||
|
|
b6ef1249b3 | ||
|
|
c8a863a88d | ||
|
|
da1f7ab574 | ||
|
|
6938ecfca2 | ||
|
|
9461fe8874 | ||
|
|
a770825648 |
@@ -5,11 +5,11 @@ version: 2
|
||||
jobs:
|
||||
machinery:
|
||||
docker:
|
||||
- image: kerberos/base:6e68480
|
||||
- image: kerberos/base:0a50dc9
|
||||
working_directory: /go/src/github.com/{{ORG_NAME}}/{{REPO_NAME}}
|
||||
steps:
|
||||
- checkout
|
||||
- run: apt-get install -y --no-install-recommends libavcodec-dev libavformat-dev libswscale-dev
|
||||
- run: apt-get update && apt-get install -y --no-install-recommends libavcodec-dev libavformat-dev libswscale-dev libjpeg62-turbo-dev
|
||||
- run: cd machinery && go mod download
|
||||
- run: cd machinery && go test -v ./...
|
||||
- run: cd machinery && go vet
|
||||
|
||||
26
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM mcr.microsoft.com/devcontainers/go:1.25-trixie
|
||||
|
||||
# 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
|
||||
24
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +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": "go:1.24-trixie",
|
||||
"runArgs": [
|
||||
"--name=agent",
|
||||
"--network=host"
|
||||
],
|
||||
"dockerFile": "Dockerfile",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"GitHub.copilot",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"mongodb.mongodb-vscode"
|
||||
]
|
||||
}
|
||||
},
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
8080
|
||||
],
|
||||
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
|
||||
}
|
||||
57
.github/workflows/docker-dev.yml
vendored
@@ -1,57 +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.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- 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]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- 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)
|
||||
54
.github/workflows/docker-nightly.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: Docker Nightly build
|
||||
|
||||
on:
|
||||
# Triggers the workflow every day at 9PM (CET).
|
||||
schedule:
|
||||
- cron: "0 22 * * *"
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [amd64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- 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-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
|
||||
- name: Create new and append to manifest
|
||||
run: docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
build-other:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64, arm/v7]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- 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-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
|
||||
- name: Create new and append to manifest
|
||||
run: docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
57
.github/workflows/docker.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: Docker Production build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [amd64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- 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: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:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
- name: Create new and append to manifest latest
|
||||
run: docker buildx imagetools create -t kerberos/agent:latest kerberos/agent:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
build-other:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64, arm/v7]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- 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: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:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent: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:latest kerberos/agent:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
43
.github/workflows/go.yml
vendored
@@ -2,36 +2,37 @@ name: Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ develop, master ]
|
||||
branches: [develop, master]
|
||||
pull_request:
|
||||
branches: [ develop, master ]
|
||||
branches: [develop, master]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: kerberos/base:6e68480
|
||||
image: kerberos/base:eb6b088
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.17, 1.18]
|
||||
#No longer supported Go versions.
|
||||
#go-version: ['1.17', '1.18', '1.19', '1.20', '1.21']
|
||||
go-version: ["1.24"]
|
||||
|
||||
steps:
|
||||
- name: Set up Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: apt update -y && apt install -y --no-install-recommends git build-essential cmake pkg-config unzip libgtk2.0-dev curl ca-certificates libcurl4-openssl-dev libssl-dev libavcodec-dev libavformat-dev libswscale-dev libtbb2 libtbb-dev libjpeg-dev libpng-dev libtiff-dev libdc1394-22-dev
|
||||
- name: Get dependencies
|
||||
run: cd machinery && go mod download
|
||||
- name: Build
|
||||
run: cd machinery && go build -v ./...
|
||||
- name: Vet
|
||||
run: cd machinery && go vet -v ./...
|
||||
- name: Test
|
||||
run: cd machinery && go test -v ./...
|
||||
- name: Set up Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up git ownershi
|
||||
run: git config --system --add safe.directory /__w/agent/agent
|
||||
- name: Get dependencies
|
||||
run: cd machinery && go mod download
|
||||
- name: Build
|
||||
run: cd machinery && go build -v ./...
|
||||
- name: Vet
|
||||
run: cd machinery && go vet -v ./...
|
||||
- name: Test
|
||||
run: cd machinery && go test -v ./...
|
||||
|
||||
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 }}
|
||||
60
.github/workflows/nightly-build.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
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:
|
||||
nightly-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@v4
|
||||
with:
|
||||
ref: master
|
||||
- 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-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
|
||||
- name: Create new and append to manifest
|
||||
run: docker buildx imagetools create -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
nightly-build-other:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64, arm/v7, arm/v6]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@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-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7) --push .
|
||||
- name: Create new and append to manifest
|
||||
run: docker buildx imagetools create --append -t kerberos/agent-nightly:$(echo $GITHUB_SHA | cut -c1-7) kerberos/agent-nightly:arch-$(echo ${{matrix.architecture}} | tr / -)-$(echo $GITHUB_SHA | cut -c1-7)
|
||||
46
.github/workflows/pr-build.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
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
|
||||
- architecture: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
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 }} .
|
||||
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
|
||||
|
||||
17
.github/workflows/pr-description.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Autofill PR description
|
||||
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
openai-pr-description:
|
||||
uses: uug-ai/workflows/.github/workflows/pr-description.yml@main
|
||||
with:
|
||||
pr_number: ${{ github.event.number }}
|
||||
pull_request_url: ""
|
||||
overwrite_description: true
|
||||
secrets:
|
||||
TOKEN: ${{ secrets.TOKEN }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
|
||||
AZURE_OPENAI_VERSION: ${{ secrets.AZURE_OPENAI_VERSION }}
|
||||
168
.github/workflows/release-bump.yml
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
name: Bump release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: "Which part of the version to bump"
|
||||
required: true
|
||||
default: patch
|
||||
type: choice
|
||||
options:
|
||||
- major
|
||||
- minor
|
||||
- patch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
REPO: kerberos/agent
|
||||
|
||||
jobs:
|
||||
# Determine the next version, create the GitHub release and expose the tag.
|
||||
bump-release:
|
||||
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
|
||||
with:
|
||||
bump: ${{ github.event.inputs.bump }}
|
||||
secrets: inherit
|
||||
|
||||
# Publish the platform image to the uug-ai GitHub Container Registry
|
||||
# (ghcr.io/uug-ai/agent-platform).
|
||||
#release:
|
||||
# needs: bump-release
|
||||
# uses: uug-ai/workflows/.github/workflows/release-create.yml@main
|
||||
# with:
|
||||
# organization: uug-ai
|
||||
# project: ${{ github.event.repository.name }}
|
||||
# tag: ${{ needs.bump-release.outputs.tag }}
|
||||
# docker_context: "."
|
||||
# create_gitops_pr: false
|
||||
# runner_matrix: >-
|
||||
# [
|
||||
# {"architecture":"amd64","runner":"ubuntu-24.04"},
|
||||
# {"architecture":"arm64","runner":"ubuntu-24.04-arm"}
|
||||
# ]
|
||||
# secrets: inherit
|
||||
|
||||
# Everything below mirrors the agent's own release-create.yml pipeline and
|
||||
# publishes the multi-arch image to the kerberos/agent Docker Hub repo, driven
|
||||
# by the freshly bumped tag instead of a `release: created` event.
|
||||
build-amd64:
|
||||
needs: bump-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [amd64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- uses: benjlevesque/short-sha@v2.1
|
||||
id: short-sha
|
||||
with:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
build-arm64:
|
||||
needs: bump-release
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- uses: benjlevesque/short-sha@v2.1
|
||||
id: short-sha
|
||||
with:
|
||||
length: 7
|
||||
- name: Run Build
|
||||
run: |
|
||||
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
|
||||
CID=$(docker create ${{matrix.architecture}})
|
||||
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
|
||||
docker rm ${CID}
|
||||
- name: Strip binary
|
||||
run: tar -cf agent-${{matrix.architecture}}.tar -C output-${{matrix.architecture}} . && rm -rf output-${{matrix.architecture}}
|
||||
- name: Build and push Docker image
|
||||
run: |
|
||||
docker tag ${{matrix.architecture}} $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
docker push $REPO-arch:arch-${{matrix.architecture}}-${{ needs.bump-release.outputs.tag }}
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: agent-${{matrix.architecture}}.tar
|
||||
path: agent-${{matrix.architecture}}.tar
|
||||
|
||||
create-manifest:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [bump-release, build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Create and push multi-arch manifest
|
||||
run: |
|
||||
docker manifest create $REPO:${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
|
||||
docker manifest push $REPO:${{ needs.bump-release.outputs.tag }}
|
||||
- name: Create and push latest manifest
|
||||
run: |
|
||||
docker manifest create $REPO:latest \
|
||||
$REPO-arch:arch-amd64-${{ needs.bump-release.outputs.tag }} \
|
||||
$REPO-arch:arch-arm64-${{ needs.bump-release.outputs.tag }}
|
||||
docker manifest push $REPO:latest
|
||||
|
||||
create-release:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [bump-release, build-amd64, build-arm64]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Create a release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
latest: true
|
||||
allowUpdates: true
|
||||
name: ${{ needs.bump-release.outputs.tag }}
|
||||
tag: ${{ needs.bump-release.outputs.tag }}
|
||||
generateReleaseNotes: false
|
||||
omitBodyDuringUpdate: true
|
||||
artifacts: "agent-*.tar/agent-*.tar"
|
||||
|
||||
|
||||
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}} .
|
||||
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"
|
||||
11
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
ui/node_modules
|
||||
ui/build
|
||||
ui/public/assets/env.js
|
||||
.DS_Store
|
||||
__debug*
|
||||
.idea
|
||||
machinery/www
|
||||
yarn.lock
|
||||
@@ -8,4 +10,11 @@ machinery/data/config
|
||||
machinery/data/cloud
|
||||
machinery/data/recordings
|
||||
machinery/data/snapshots
|
||||
machinery/test*
|
||||
machinery/test*
|
||||
machinery/init-dev.sh
|
||||
machinery/.env.local
|
||||
machinery/vendor
|
||||
machinery/go.work
|
||||
machinery/go.work.sum
|
||||
deployments/docker/private-docker-compose.yaml
|
||||
video.mp4
|
||||
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)
|
||||
25
.vscode/launch.json
vendored
@@ -5,11 +5,32 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"name": "Launch Golang",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${fileDirname}"
|
||||
"program": "${workspaceFolder}/machinery/main.go",
|
||||
"args": [
|
||||
"-action",
|
||||
"run",
|
||||
"-port",
|
||||
"8080"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/machinery/.env.local",
|
||||
"buildFlags": "--tags dynamic,moq",
|
||||
"env": {
|
||||
"GOWORK": "off"
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Launch React",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/ui",
|
||||
"runtimeExecutable": "yarn",
|
||||
"runtimeArgs": [
|
||||
"start"
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
14
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "agent: moq verify",
|
||||
"type": "shell",
|
||||
"command": "bash ./verify-moq-devcontainer.sh",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/machinery"
|
||||
},
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
140
Dockerfile
@@ -1,5 +1,15 @@
|
||||
FROM kerberos/base:977706d AS builder
|
||||
LABEL AUTHOR=Kerberos.io
|
||||
|
||||
ARG GO_IMAGE=golang:1.25-trixie
|
||||
ARG RUNTIME_IMAGE=debian:trixie-slim
|
||||
ARG VERSION=0.0.0
|
||||
FROM ${GO_IMAGE} AS build-machinery
|
||||
LABEL AUTHOR=uug.ai
|
||||
|
||||
# Re-declare VERSION inside this stage so the value passed via
|
||||
# `--build-arg VERSION=...` (e.g. the release tag) is available below.
|
||||
# ARGs declared before the first FROM are not visible inside build stages.
|
||||
ARG VERSION
|
||||
ARG TARGETARCH
|
||||
|
||||
ENV GOROOT=/usr/local/go
|
||||
ENV GOPATH=/go
|
||||
@@ -9,11 +19,10 @@ ENV GOSUMDB=off
|
||||
##########################################
|
||||
# Installing some additional dependencies.
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
|
||||
git build-essential cmake pkg-config unzip libgtk2.0-dev \
|
||||
curl ca-certificates libcurl4-openssl-dev libssl-dev \
|
||||
libavcodec-dev libavformat-dev libswscale-dev libtbb2 libtbb-dev \
|
||||
libjpeg-dev libpng-dev libtiff-dev libdc1394-22-dev && \
|
||||
curl ca-certificates libavcodec-dev libavutil-dev libcurl4-openssl-dev \
|
||||
libssl-dev libjpeg62-turbo-dev libswscale-dev && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
##############################################################################
|
||||
@@ -21,39 +30,35 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
RUN mkdir -p /go/src/github.com/kerberos-io/agent
|
||||
COPY machinery /go/src/github.com/kerberos-io/agent/machinery
|
||||
COPY ui /go/src/github.com/kerberos-io/agent/ui
|
||||
|
||||
########################
|
||||
# Download NPM and Yarns
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && curl -sL https://deb.nodesource.com/setup_16.x | bash - && \
|
||||
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
|
||||
apt update && apt install yarn -y
|
||||
RUN rm -rf /go/src/github.com/kerberos-io/agent/machinery/.env
|
||||
|
||||
##################################################################
|
||||
# Build Web
|
||||
# this will move the /build directory to ../machinery/www
|
||||
|
||||
RUN cd /go/src/github.com/kerberos-io/agent/ui && yarn && yarn build
|
||||
# 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 && \
|
||||
go build main.go && \
|
||||
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \
|
||||
VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
|
||||
fi && \
|
||||
BUILD_TAGS=timetzdata,netgo,osusergo && \
|
||||
case "${TARGETARCH:-$(go env GOARCH)}" in amd64|arm64) BUILD_TAGS="moq,${BUILD_TAGS}" ;; esac && \
|
||||
go build -tags "${BUILD_TAGS}" --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION}" main.go && \
|
||||
mkdir -p /agent && \
|
||||
mv main /agent && \
|
||||
mv www /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 && \
|
||||
rm -rf /go/src/gitlab.com/
|
||||
mkdir -p /agent/data/config
|
||||
|
||||
####################################
|
||||
# Let's create a /dist folder containing just the files necessary for runtime.
|
||||
@@ -65,68 +70,47 @@ RUN cp -r /agent ./
|
||||
####################################################################################
|
||||
# This will collect dependent libraries so they're later copied to the final image.
|
||||
|
||||
RUN /agent/main version
|
||||
RUN ldd /agent/main | tr -s '[:blank:]' '\n'
|
||||
RUN ldd /agent/main | tr -s '[:blank:]' '\n' | grep '^/' | \
|
||||
xargs -I % sh -c 'mkdir -p $(dirname ./%); cp % ./%;'
|
||||
RUN /dist/agent/main version
|
||||
|
||||
##########################################################
|
||||
# LDD doesnt always work in docker buildx (no idea why..)
|
||||
# Therefore we are moving some libraries manually
|
||||
FROM node:22-alpine AS build-ui
|
||||
|
||||
RUN mkdir -p ./usr/lib
|
||||
RUN apk update && apk upgrade --available && sync
|
||||
|
||||
RUN [ -f /lib64/ld-linux-x86-64.so.2 ] && $(mkdir -p lib64 && \
|
||||
cp /lib64/ld-linux-x86-64.so.2 lib64/) || echo "nothing to do here x86"
|
||||
########################
|
||||
# Build Web (React app)
|
||||
|
||||
RUN [ -f /lib/ld-linux-aarch64.so.1 ] && $(mkdir -p lib/aarch64-linux-gnu && \
|
||||
cp /lib/ld-linux-aarch64.so.1 lib/ && \
|
||||
cp /lib/aarch64-linux-gnu/lib* lib/aarch64-linux-gnu/ && \
|
||||
cp /usr/lib/aarch64-linux-gnu/libopencv* usr/lib && \
|
||||
cp /usr/lib/aarch64-linux-gnu/libstdc* usr/lib && \
|
||||
cp /usr/lib/aarch64-linux-gnu/libx264* usr/lib ) || echo "nothing to do here arm64"
|
||||
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
|
||||
|
||||
RUN [ -f /usr/lib/arm-linux-gnueabihf/vfp/neon/libvpx.so.6 ] && \
|
||||
$(cp /usr/lib/arm-linux-gnueabihf/vfp/neon/libvpx.so.6 ./usr/lib/) || echo "nothing to do here armv7"
|
||||
####################################
|
||||
# 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.
|
||||
|
||||
RUN cp -r /usr/local/lib/libavcodec* ./usr/lib && \
|
||||
cp -r /usr/local/lib/libavformat* ./usr/lib && \
|
||||
cp -r /usr/local/lib/libavfilter* ./usr/lib && \
|
||||
cp -r /usr/local/lib/libavutil* ./usr/lib && \
|
||||
cp -r /usr/local/lib/libavresample* ./usr/lib && \
|
||||
cp -r /usr/local/lib/libavdevice* ./usr/lib && \
|
||||
cp -r /usr/local/lib/libswscale* ./usr/lib && \
|
||||
cp -r /usr/local/lib/libswresample* ./usr/lib && \
|
||||
cp -r /usr/local/lib/libpostproc* ./usr/lib
|
||||
WORKDIR /dist
|
||||
RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www ./agent/
|
||||
|
||||
# As mentioned before, above is really a hack as LDD
|
||||
# doesn't work always in docker buildx. You might not need this
|
||||
# when doing a local build.
|
||||
################################################################
|
||||
############################################
|
||||
# Publish main binary to GitHub release
|
||||
|
||||
FROM alpine:latest
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
|
||||
############################
|
||||
# Protect by non-root user.
|
||||
|
||||
RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent video
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl ffmpeg libatomic1 libcap2-bin libstdc++6 && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd --system kerberosio && \
|
||||
useradd --system --gid kerberosio --groups video --create-home agent && \
|
||||
chmod 0755 /home/agent
|
||||
|
||||
#################################
|
||||
# Copy files from previous images
|
||||
|
||||
COPY --chown=0:0 --from=builder /dist /
|
||||
COPY --chown=0:0 --from=builder /usr/local/go/lib/time/zoneinfo.zip /zoneinfo.zip
|
||||
|
||||
ENV ZONEINFO=/zoneinfo.zip
|
||||
|
||||
RUN apk update && apk add ca-certificates --no-cache && \
|
||||
apk add tzdata curl --no-cache && rm -rf /var/cache/apk/*
|
||||
|
||||
#################
|
||||
# Install Bento4
|
||||
RUN cd && wget https://www.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-639.x86_64-unknown-linux.zip && \
|
||||
unzip Bento4-SDK-1-6-0-639.x86_64-unknown-linux.zip && rm Bento4-SDK-1-6-0-639.x86_64-unknown-linux.zip && \
|
||||
cp ~/Bento4-SDK-1-6-0-639.x86_64-unknown-linux/bin/mp4fragment /usr/bin/
|
||||
COPY --chown=0:0 --from=build-machinery /dist /
|
||||
COPY --chown=0:0 --from=build-ui /dist /
|
||||
|
||||
##################
|
||||
# Try running agent
|
||||
@@ -134,10 +118,20 @@ RUN cd && wget https://www.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-639.x86_64-u
|
||||
RUN mv /agent/* /home/agent/
|
||||
RUN /home/agent/main version
|
||||
|
||||
#######################
|
||||
# Make template config
|
||||
|
||||
RUN cp /home/agent/data/config/config.json /home/agent/data/config.template.json
|
||||
|
||||
###########################
|
||||
# Set permissions correctly
|
||||
|
||||
RUN chown -R agent:kerberosio /home/agent/data
|
||||
RUN chown -R agent:kerberosio /home/agent/www
|
||||
|
||||
###########################
|
||||
# Grant the necessary root capabilities to the process trying to bind to the privileged port
|
||||
RUN setcap 'cap_net_bind_service=+ep' /home/agent/main
|
||||
|
||||
###################
|
||||
# Run non-root user
|
||||
@@ -145,17 +139,17 @@ RUN chown -R agent:kerberosio /home/agent/data
|
||||
USER agent
|
||||
|
||||
######################################
|
||||
# By default the app runs on port 8080
|
||||
# By default the app runs on port 80
|
||||
|
||||
EXPOSE 8080
|
||||
EXPOSE 80
|
||||
|
||||
######################################
|
||||
# Check if agent is still running
|
||||
|
||||
HEALTHCHECK CMD curl --fail http://localhost:8080 || exit 1
|
||||
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", "run", "opensource", "8080"]
|
||||
CMD ["./main", "-action", "run", "-port", "80"]
|
||||
645
README-RTSPS-TLS.md
Normal file
@@ -0,0 +1,645 @@
|
||||
# RTSPS and TLS certificates
|
||||
|
||||
This guide explains how Kerberos Agent connects to an IP camera over RTSPS,
|
||||
how to issue a camera certificate with a private CA, and how to validate the
|
||||
complete trust path. It also explains why some apparently corrupted trust
|
||||
bundles can still allow a connection.
|
||||
|
||||
The camera-specific steps were verified with a Bosch FLEXIDOME micro 3100i.
|
||||
Other Bosch firmware versions may use different labels or ports.
|
||||
|
||||
The commands were tested with Smallstep CLI `0.30.6` and OpenSSL `3.5.6` on
|
||||
Debian. Check `step certificate sign --help` when using an older Smallstep CLI.
|
||||
The OpenSSL isolation flags `-no-CApath` and `-no-CAstore` require a version that
|
||||
lists them in `openssl s_client -help`.
|
||||
|
||||
## Tested configuration
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Camera | Bosch FLEXIDOME micro 3100i |
|
||||
| Example camera address | `10.0.30.11` |
|
||||
| RTSPS port | `9554` |
|
||||
| Main stream | `rtsps://<user>:<password>@10.0.30.11:9554/?inst=1` |
|
||||
| Sub stream | `rtsps://<user>:<password>@10.0.30.11:9554/?inst=2` |
|
||||
| Certificate SAN | `IP Address:10.0.30.11` |
|
||||
| Bosch certificate usage | `HTTPS` |
|
||||
| Agent trust input | Issuing intermediate plus root CA |
|
||||
|
||||
Replace the example address and certificate names throughout this guide. Keep
|
||||
camera credentials out of source control and percent-encode reserved URL
|
||||
characters in usernames and passwords.
|
||||
|
||||
## Mental model
|
||||
|
||||
### RTSPS, SRTSP, TLS, and SRTP
|
||||
|
||||
- The standard URL scheme is `rtsps://`. Do not use `srtsp://`.
|
||||
- Bosch interfaces and documentation may use SRTSP or Secure RTSP as product
|
||||
terminology.
|
||||
- RTSPS carries the RTSP control connection over TLS. With gortsplib, media is
|
||||
normally interleaved over the same TCP/TLS connection for this camera.
|
||||
- SRTP is a separate media protection mechanism and is negotiated only when the
|
||||
camera advertises an appropriate secure RTP profile.
|
||||
|
||||
Encryption alone does not prove which camera the Agent reached. Verified TLS
|
||||
also checks that:
|
||||
|
||||
1. The camera certificate is signed by a trusted authority.
|
||||
2. The certificate is valid at the current time.
|
||||
3. The URL host matches a certificate Subject Alternative Name (SAN).
|
||||
|
||||
Modern Go verification uses SANs for identity. A Common Name alone is not
|
||||
sufficient. Connecting to `10.0.30.11` requires an IP SAN with that exact value,
|
||||
not `DNS:10.0.30.11` and not only a device-name DNS SAN.
|
||||
|
||||
### Agent behavior
|
||||
|
||||
Kerberos Agent uses gortsplib for RTSP and RTSPS. With the normal configuration,
|
||||
gortsplib receives a nil custom TLS configuration and Go performs standard
|
||||
certificate and hostname verification with the process trust pool.
|
||||
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` is an explicit escape hatch that
|
||||
sets `InsecureSkipVerify` for camera clients. It should be false in a verified
|
||||
deployment.
|
||||
|
||||
## Communication and certificate flow
|
||||
|
||||
The certificate is used during the TLS handshake, before the first RTSP command
|
||||
is exchanged. It is not attached to `DESCRIBE`, `SETUP`, or `PLAY`, and the CA
|
||||
trust bundle is never sent to the camera.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Agent as Kerberos Agent
|
||||
participant Trust as Go trust pool
|
||||
participant Camera as Camera RTSPS :9554
|
||||
|
||||
Agent->>Trust: Load system roots and append AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE
|
||||
Agent->>Camera: Open TCP connection
|
||||
Agent->>Camera: Send TLS ClientHello
|
||||
Camera-->>Agent: Send TLS ServerHello and camera certificate
|
||||
Agent->>Trust: Verify chain, validity, serverAuth, and URL host against SAN
|
||||
Trust-->>Agent: Accept or reject the camera identity
|
||||
Agent->>Camera: Complete TLS handshake
|
||||
Note over Agent,Camera: All following traffic is encrypted by TLS
|
||||
Agent->>Camera: DESCRIBE with RTSP authentication
|
||||
Camera-->>Agent: Return SDP and available media tracks
|
||||
Agent->>Camera: SETUP selected video and audio tracks over TCP
|
||||
Agent->>Camera: PLAY
|
||||
Camera-->>Agent: Send interleaved RTP and RTCP media over TLS
|
||||
```
|
||||
|
||||
The files and keys have distinct roles:
|
||||
|
||||
| Material | Location | Purpose | Sent over the connection |
|
||||
| --- | --- | --- | --- |
|
||||
| Camera leaf certificate | Camera | Identifies the camera and binds its public key to its SAN | Yes, by the camera during the TLS handshake |
|
||||
| Camera private key | Camera | Proves that the camera owns the presented certificate | No |
|
||||
| Intermediate and root CA PEM bundle | Agent | Lets Go build and trust the camera certificate chain | No |
|
||||
| RTSP username and password | Agent configuration or URL | Authenticates the Agent to the RTSP service after TLS succeeds | An authentication response is sent inside TLS; its form depends on the RTSP authentication method |
|
||||
|
||||
For an `rtsps://` URL, the Agent parses the URL and gives gortsplib the host and
|
||||
TLS settings. gortsplib opens the TCP connection and starts TLS. Go compares the
|
||||
certificate presented by the camera with the local trust pool, checks its
|
||||
validity period and server usage, and matches the URL hostname or IP address to
|
||||
the certificate SAN. Only a successful handshake creates the encrypted channel
|
||||
needed for the RTSP exchange.
|
||||
|
||||
The Agent then sends `DESCRIBE`, selects the advertised video and audio tracks,
|
||||
sends `SETUP`, and starts delivery with `PLAY`. For the tested camera, gortsplib
|
||||
uses interleaved TCP, so the RTSP control messages and RTP/RTCP media remain
|
||||
inside the same encrypted TLS connection. Main stream, sub stream, and enabled
|
||||
audio backchannel clients each establish and verify their own connection.
|
||||
|
||||
If certificate verification fails, the TLS handshake does not complete and no
|
||||
usable RTSP session is established. Setting
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` keeps traffic encrypted but skips
|
||||
certificate-chain and hostname verification, so an attacker could impersonate
|
||||
the camera. It is not equivalent to trusting the camera certificate.
|
||||
|
||||
## Decide the certificate identity first
|
||||
|
||||
Choose the stable name used in every Agent URL before creating the certificate:
|
||||
|
||||
- For an IP URL, add that address as an IP SAN.
|
||||
- For a DNS URL, add the exact hostname as a DNS SAN.
|
||||
- Add both when clients legitimately use both forms.
|
||||
|
||||
A certificate stops matching if the camera IP changes. Use a static address,
|
||||
DHCP reservation, or stable DNS name.
|
||||
|
||||
## Configure RTSPS in the Bosch UI
|
||||
|
||||
1. Sign in to the camera as an administrator.
|
||||
2. Open **Configuration**.
|
||||
3. Open **Network > Network Services**.
|
||||
4. Enable **RTSPS**.
|
||||
5. Confirm port `9554`, or record the configured alternative.
|
||||
6. Click **Set**.
|
||||
|
||||
RTSP on port `554` and RTSPS on port `9554` are separate services. Enabling
|
||||
RTSPS does not make an `rtsp://` URL secure.
|
||||
|
||||
## Generate the private key and CSR on the camera
|
||||
|
||||
Keeping the TLS private key on the camera avoids exporting it to an operator
|
||||
workstation or deployment system.
|
||||
|
||||
1. Open **Service > Certificates**.
|
||||
2. Click **Add**.
|
||||
3. Select **Generate signing request**.
|
||||
4. Select `RSA 2048bit` or the stronger option supported by all clients.
|
||||
5. Enter a unique file name, such as `agent-rtsps`.
|
||||
6. Enter a descriptive Common Name and any required organization fields.
|
||||
7. Click **Generate**.
|
||||
8. Download the resulting CSR from the certificate table.
|
||||
|
||||
On the tested firmware, this form contains no SAN field. The downloaded CSR
|
||||
therefore has no IP SAN. The CA must add the SAN while signing.
|
||||
|
||||
Inspect the CSR before signing:
|
||||
|
||||
```bash
|
||||
openssl req -in camera.csr.pem -noout -verify -subject
|
||||
openssl req -in camera.csr.pem -noout -text
|
||||
```
|
||||
|
||||
The first command must report `Certificate request self-signature verify OK`.
|
||||
An absent `Subject Alternative Name` section is expected for this firmware.
|
||||
|
||||
## Prepare Smallstep
|
||||
|
||||
Use an existing organizational CA when one is available. Creating a new CA
|
||||
creates a new long-lived trust domain that must be distributed, protected,
|
||||
backed up, and eventually rotated.
|
||||
|
||||
### Install the CLI on Debian amd64
|
||||
|
||||
```bash
|
||||
curl -fsSL \
|
||||
https://dl.smallstep.com/cli/docs-ca-install/latest/step-cli_amd64.deb \
|
||||
-o /tmp/step-cli_amd64.deb
|
||||
sudo dpkg -i /tmp/step-cli_amd64.deb
|
||||
rm /tmp/step-cli_amd64.deb
|
||||
step version
|
||||
```
|
||||
|
||||
Use the official package matching the host architecture on other systems.
|
||||
|
||||
### Create a dedicated offline CA
|
||||
|
||||
Skip this section when using an existing CA.
|
||||
|
||||
```bash
|
||||
umask 077
|
||||
mkdir -p "$HOME/.step/secrets" "$HOME/.step/camera"
|
||||
|
||||
openssl rand -base64 48 > "$HOME/.step/secrets/camera_ca_password"
|
||||
chmod 600 "$HOME/.step/secrets/camera_ca_password"
|
||||
|
||||
step ca init \
|
||||
--pki \
|
||||
--name "UUG Camera CA" \
|
||||
--password-file "$HOME/.step/secrets/camera_ca_password"
|
||||
```
|
||||
|
||||
This produces:
|
||||
|
||||
```text
|
||||
$HOME/.step/certs/root_ca.crt
|
||||
$HOME/.step/certs/intermediate_ca.crt
|
||||
$HOME/.step/secrets/root_ca_key
|
||||
$HOME/.step/secrets/intermediate_ca_key
|
||||
$HOME/.step/secrets/camera_ca_password
|
||||
```
|
||||
|
||||
The files under `secrets/` are sensitive. Keep them mode `600`, never commit
|
||||
them, and back them up to encrypted persistent storage. A devcontainer can be
|
||||
rebuilt or deleted; it is not sufficient as the only CA backup.
|
||||
|
||||
## Add the SAN while signing
|
||||
|
||||
Copy the camera CSR into a protected working directory:
|
||||
|
||||
```bash
|
||||
cp camera.csr.pem "$HOME/.step/camera/camera.csr.pem"
|
||||
```
|
||||
|
||||
Create `$HOME/.step/camera/bosch-rtsps.tpl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": {
|
||||
"commonName": {{ toJson .Insecure.CR.Subject.CommonName }}
|
||||
},
|
||||
"ipAddresses": ["10.0.30.11"],
|
||||
"keyUsage": ["keyEncipherment", "digitalSignature"],
|
||||
"extKeyUsage": ["serverAuth", "clientAuth"]
|
||||
}
|
||||
```
|
||||
|
||||
The template preserves the camera CSR public key, sets the IP identity, and
|
||||
creates a TLS leaf rather than a CA certificate.
|
||||
|
||||
Sign it with a validity period that ends before the intermediate CA expires.
|
||||
A one-year leaf is preferable to a ten-year leaf when automated renewal is
|
||||
available:
|
||||
|
||||
```bash
|
||||
step certificate sign \
|
||||
--template "$HOME/.step/camera/bosch-rtsps.tpl" \
|
||||
--bundle \
|
||||
--not-after 8760h \
|
||||
--password-file "$HOME/.step/secrets/camera_ca_password" \
|
||||
"$HOME/.step/camera/camera.csr.pem" \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/secrets/intermediate_ca_key" \
|
||||
> "$HOME/.step/camera/bosch-rtsps-chain.pem"
|
||||
```
|
||||
|
||||
For an online `step-ca`, do not assume `step ca sign` accepts a `--san` flag. It
|
||||
does not. Authorize SANs in the one-time token or configure a provisioner
|
||||
template that produces the required SANs.
|
||||
|
||||
## Validate before upload
|
||||
|
||||
Inspect the leaf certificate, which is the first PEM block in the chain file:
|
||||
|
||||
```bash
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -subject -issuer -dates -ext subjectAltName -ext extendedKeyUsage
|
||||
```
|
||||
|
||||
Confirm the SAN separately because some OpenSSL versions display only the last
|
||||
requested extension:
|
||||
|
||||
```bash
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -ext subjectAltName
|
||||
```
|
||||
|
||||
Verify the path and IP identity:
|
||||
|
||||
```bash
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-untrusted "$HOME/.step/certs/intermediate_ca.crt" \
|
||||
-verify_ip 10.0.30.11 \
|
||||
"$HOME/.step/camera/bosch-rtsps-chain.pem"
|
||||
```
|
||||
|
||||
Confirm that the signed leaf uses the exact public key from the camera CSR:
|
||||
|
||||
```bash
|
||||
csr_key=$(
|
||||
openssl req -in "$HOME/.step/camera/camera.csr.pem" -pubkey -noout |
|
||||
openssl pkey -pubin -outform DER 2>/dev/null |
|
||||
sha256sum | cut -d' ' -f1
|
||||
)
|
||||
|
||||
cert_key=$(
|
||||
openssl x509 -in "$HOME/.step/camera/bosch-rtsps-chain.pem" -pubkey -noout |
|
||||
openssl pkey -pubin -outform DER 2>/dev/null |
|
||||
sha256sum | cut -d' ' -f1
|
||||
)
|
||||
|
||||
test "$csr_key" = "$cert_key"
|
||||
```
|
||||
|
||||
Do not upload a certificate when any of these checks fail.
|
||||
|
||||
## Upload and assign the certificate
|
||||
|
||||
1. Return to **Service > Certificates**.
|
||||
2. Click **Add > Upload certificate**.
|
||||
3. Select the leaf-plus-intermediate PEM chain.
|
||||
4. Click **Upload** and wait for `100%`.
|
||||
5. Confirm that the former CSR row is now a `Certificate`.
|
||||
6. Confirm that the key icon is present. It proves that the camera associated
|
||||
the certificate with its retained private key.
|
||||
7. Open the new certificate's **Usage** selector.
|
||||
8. Select only **HTTPS**.
|
||||
9. Leave **CBS client** assigned to the original Bosch `DeviceCertificate`.
|
||||
10. Click **Set** and wait for the table to reload.
|
||||
|
||||
On the tested firmware, there is no separate SRTSP usage. RTSPS presents the
|
||||
certificate assigned to HTTPS. Reassigning HTTPS therefore changes both the
|
||||
web interface and RTSPS certificate.
|
||||
|
||||
After saving, the expected split is:
|
||||
|
||||
| Certificate | Usage |
|
||||
| --- | --- |
|
||||
| Private-CA camera certificate | `HTTPS` |
|
||||
| Bosch `DeviceCertificate` | `CBS client` |
|
||||
|
||||
The browser may warn about the new HTTPS certificate until the private root CA
|
||||
is trusted by the workstation.
|
||||
|
||||
## Account for the Bosch chain behavior
|
||||
|
||||
The tested firmware served only the leaf certificate on ports `443` and `9554`,
|
||||
even when the uploaded file contained the leaf and intermediate. Uploading the
|
||||
intermediate separately as a trusted camera certificate did not change the
|
||||
served chain.
|
||||
|
||||
Confirm the behavior:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-showcerts </dev/null 2>/dev/null |
|
||||
grep -c '^-----BEGIN CERTIFICATE-----$'
|
||||
```
|
||||
|
||||
A result of `1` means the client must already have the issuing intermediate.
|
||||
Create a portable trust bundle containing the intermediate and root:
|
||||
|
||||
```bash
|
||||
step certificate bundle \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
"$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
|
||||
chmod 644 "$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
```
|
||||
|
||||
The trust bundle is public material. The CA private keys and password are not.
|
||||
|
||||
## Configure Kerberos Agent
|
||||
|
||||
For a process running directly in the same environment:
|
||||
|
||||
```dotenv
|
||||
AGENT_CAPTURE_IPCAMERA_RTSP="rtsps://<user>:<password>@10.0.30.11:9554/?inst=1"
|
||||
AGENT_CAPTURE_IPCAMERA_SUB_RTSP="rtsps://<user>:<password>@10.0.30.11:9554/?inst=2"
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem
|
||||
```
|
||||
|
||||
For a container, mount the public bundle read-only at the exact path visible
|
||||
inside the container. The Agent image creates `/home/agent/data/config` and
|
||||
includes Debian's `ca-certificates` package:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v /secure/config/uug-camera-trust-bundle.pem:/home/agent/data/config/uug-camera-trust-bundle.pem:ro \
|
||||
-e AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/home/agent/data/config/uug-camera-trust-bundle.pem \
|
||||
-e AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
|
||||
-e 'AGENT_CAPTURE_IPCAMERA_RTSP=rtsps://<user>:<password>@10.0.30.11:9554/?inst=1' \
|
||||
-e 'AGENT_CAPTURE_IPCAMERA_SUB_RTSP=rtsps://<user>:<password>@10.0.30.11:9554/?inst=2' \
|
||||
kerberos/agent:latest
|
||||
```
|
||||
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` starts with the operating system's roots
|
||||
and appends the camera bundle only to the gortsplib TLS configuration. Other
|
||||
clients, including MoQ, Hub, and Vault, retain the normal public CA chain.
|
||||
Restart the Agent after changing trust files.
|
||||
|
||||
Do not set `SSL_CERT_FILE` or `SSL_CERT_DIR` in production solely for camera
|
||||
trust. They are process-wide and can prevent other clients from validating
|
||||
public services. For a deliberate process-wide isolation test, mount an empty
|
||||
directory and set `SSL_CERT_DIR` to its path:
|
||||
|
||||
```bash
|
||||
-v /secure/config/empty-ca-dir:/home/agent/data/config/empty-ca-dir:ro \
|
||||
-e SSL_CERT_DIR=/home/agent/data/config/empty-ca-dir
|
||||
```
|
||||
|
||||
Do not use `SSL_CERT_DIR=`. Go treats an empty value as unset and scans its
|
||||
default certificate directories.
|
||||
|
||||
Only use that mode when the Agent does not need public roots for other TLS
|
||||
connections.
|
||||
|
||||
## Validate the live endpoints
|
||||
|
||||
### Strict TLS and identity check
|
||||
|
||||
Use only the specified bundle, without OpenSSL's default CA locations:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-brief \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-verify_ip 10.0.30.11 \
|
||||
-verify_return_error \
|
||||
-CAfile "$HOME/.step/camera/uug-camera-trust-bundle.pem" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
</dev/null
|
||||
```
|
||||
|
||||
Repeat with port `443`. Both must report `Verification: OK`.
|
||||
|
||||
Confirm that identity checking is active by repeating the command with a wrong
|
||||
address, such as `-verify_ip 10.0.30.12`. It must fail with an IP address
|
||||
mismatch.
|
||||
|
||||
### Confirm the live leaf is the generated leaf
|
||||
|
||||
```bash
|
||||
live_fingerprint=$(
|
||||
openssl s_client -connect 10.0.30.11:9554 -showcerts </dev/null 2>/dev/null |
|
||||
openssl x509 -noout -fingerprint -sha256 |
|
||||
cut -d= -f2
|
||||
)
|
||||
|
||||
local_fingerprint=$(
|
||||
openssl x509 \
|
||||
-in "$HOME/.step/camera/bosch-rtsps-chain.pem" \
|
||||
-noout -fingerprint -sha256 |
|
||||
cut -d= -f2
|
||||
)
|
||||
|
||||
test "$live_fingerprint" = "$local_fingerprint"
|
||||
```
|
||||
|
||||
### Validate the media path
|
||||
|
||||
A successful TLS handshake does not prove that RTSP authentication, DESCRIBE,
|
||||
SETUP, PLAY, and RTP delivery work. Start a fresh Agent with verified TLS and
|
||||
confirm that it connects without an x509 error and receives frames. During the
|
||||
verified setup described here, a gortsplib probe completed all RTSP operations
|
||||
and received an RTP packet over TCP.
|
||||
|
||||
## Why a tampered bundle may still connect
|
||||
|
||||
Editing PEM text is not always a useful negative TLS test.
|
||||
|
||||
### A certificate can still parse after a byte change
|
||||
|
||||
Base64 can remain syntactically valid when one character changes. OpenSSL may
|
||||
still list the certificate subject and issuer even though a signature is now
|
||||
invalid. Parsing and signature verification are different operations.
|
||||
|
||||
### Trust anchors are not validated through a parent
|
||||
|
||||
Every certificate loaded into Go's root pool is a trust anchor, including a
|
||||
non-self-signed intermediate CA. Verification can terminate at that certificate.
|
||||
|
||||
If tampering changes only the intermediate's signature from its parent root,
|
||||
but does not change its public key, that intermediate can still validate the
|
||||
camera leaf when it is trusted directly. Its now-invalid parent signature is
|
||||
not consulted at the trust boundary.
|
||||
|
||||
This is equivalent to OpenSSL's partial-chain behavior:
|
||||
|
||||
```bash
|
||||
openssl s_client \
|
||||
-connect 10.0.30.11:9554 \
|
||||
-verify_ip 10.0.30.11 \
|
||||
-verify_return_error \
|
||||
-partial_chain \
|
||||
-CAfile tampered-bundle.pem \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
</dev/null
|
||||
```
|
||||
|
||||
### `SSL_CERT_FILE` does not isolate Go from CA directories
|
||||
|
||||
On Unix, Go uses `SSL_CERT_FILE` instead of its default aggregate CA file, but it
|
||||
still scans default certificate directories such as `/etc/ssl/certs`. Setting
|
||||
`SSL_CERT_FILE` alone therefore does not remove CA certificates installed with
|
||||
`update-ca-certificates`. `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` is appended
|
||||
after this system pool is loaded; it does not replace the system roots.
|
||||
|
||||
Blank values do not select empty trust sources. Both `SSL_CERT_FILE=` and
|
||||
`SSL_CERT_DIR=` are treated as unset, so Go falls back to its default aggregate
|
||||
CA file and certificate directories. To test with no trusted certificates on
|
||||
Linux, use a non-empty file path that contains no certificates and a non-empty
|
||||
directory path that contains no certificates:
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/empty-ca-dir
|
||||
SSL_CERT_FILE=/dev/null \
|
||||
SSL_CERT_DIR=/tmp/empty-ca-dir \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE= \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
|
||||
GOWORK=off \
|
||||
go run -tags moq . -action run -port 8080
|
||||
```
|
||||
|
||||
That fresh process must fail with `x509: certificate signed by unknown
|
||||
authority`.
|
||||
|
||||
Use exactly one camera trust-distribution approach when possible:
|
||||
|
||||
1. Mount a private trust bundle and set
|
||||
`AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE`; or
|
||||
2. Install the CA certificates into the operating-system trust store.
|
||||
|
||||
Using both is valid, but makes isolation tests less obvious.
|
||||
|
||||
### Running processes can retain old roots
|
||||
|
||||
A long-running Go process may already have loaded and cached the trust pool.
|
||||
Always start a new process after changing trust configuration during a negative
|
||||
test.
|
||||
|
||||
## Perform a meaningful negative test
|
||||
|
||||
Do not corrupt only the root or intermediate signature. Instead, give a fresh
|
||||
Agent process a completely unrelated CA and hide the default CA directories.
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/empty-ca-dir
|
||||
|
||||
openssl req \
|
||||
-x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-subj '/CN=Unrelated Test Root' \
|
||||
-keyout /tmp/unrelated-test-root.key \
|
||||
-out /tmp/unrelated-test-root.crt
|
||||
|
||||
SSL_CERT_FILE=/dev/null \
|
||||
SSL_CERT_DIR=/tmp/empty-ca-dir \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE=/tmp/unrelated-test-root.crt \
|
||||
AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false \
|
||||
GOWORK=off \
|
||||
go run -tags moq . -action run -port 8080
|
||||
```
|
||||
|
||||
The connection must fail with an unknown-authority or chain-building error.
|
||||
Delete the temporary test key and certificate afterward.
|
||||
|
||||
To test bundle integrity rather than client distrust, validate the intermediate
|
||||
against the root explicitly:
|
||||
|
||||
```bash
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
"$HOME/.step/certs/intermediate_ca.crt"
|
||||
```
|
||||
|
||||
Store and compare approved SHA-256 fingerprints when detecting unauthorized
|
||||
certificate-file changes is a requirement.
|
||||
|
||||
Restore an accidentally edited bundle from the protected CA certificates, then
|
||||
restart the Agent:
|
||||
|
||||
```bash
|
||||
step certificate bundle -f \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
"$HOME/.step/camera/uug-camera-trust-bundle.pem"
|
||||
|
||||
openssl verify \
|
||||
-CAfile "$HOME/.step/certs/root_ca.crt" \
|
||||
-no-CApath \
|
||||
-no-CAstore \
|
||||
"$HOME/.step/certs/intermediate_ca.crt"
|
||||
```
|
||||
|
||||
## Optional system trust installation
|
||||
|
||||
On Debian, install both public CA certificates when every process in the system
|
||||
should trust this camera PKI:
|
||||
|
||||
```bash
|
||||
sudo install -m 0644 \
|
||||
"$HOME/.step/certs/root_ca.crt" \
|
||||
/usr/local/share/ca-certificates/uug-camera-ca.crt
|
||||
|
||||
sudo install -m 0644 \
|
||||
"$HOME/.step/certs/intermediate_ca.crt" \
|
||||
/usr/local/share/ca-certificates/uug-camera-intermediate-ca.crt
|
||||
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
This creates links below `/etc/ssl/certs`. Remove those files and rerun
|
||||
`update-ca-certificates` before attempting an isolated trust-bundle test.
|
||||
|
||||
## Renewal and recovery
|
||||
|
||||
- Renew before the leaf or intermediate expires.
|
||||
- Generate a new camera CSR if the firmware cannot renew the existing key.
|
||||
- Sign the new CSR with all required SANs.
|
||||
- Upload and validate the new certificate before deleting the old one.
|
||||
- Preserve an alternate administrative access path while changing HTTPS usage.
|
||||
- Back up the CA certificates, encrypted CA keys, and password separately.
|
||||
- If the CA private keys are lost, create a new CA and redistribute its trust
|
||||
before replacing camera certificates.
|
||||
|
||||
## Production checklist
|
||||
|
||||
- [ ] The Agent URL uses `rtsps://`, not `rtsp://` or `srtsp://`.
|
||||
- [ ] RTSPS is enabled on the camera and the configured port is reachable.
|
||||
- [ ] The certificate SAN exactly matches the Agent URL host.
|
||||
- [ ] The leaf public key matches the camera-generated CSR.
|
||||
- [ ] The certificate has `serverAuth` extended key usage.
|
||||
- [ ] The certificate expires before its issuer.
|
||||
- [ ] HTTPS is assigned to the private-CA certificate.
|
||||
- [ ] CBS client remains assigned to the Bosch device certificate.
|
||||
- [ ] The Agent has the intermediate and root CA certificates it needs.
|
||||
- [ ] `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=false`.
|
||||
- [ ] The Agent was restarted after trust changes.
|
||||
- [ ] A strict TLS check reports `Verification: OK`.
|
||||
- [ ] A real Agent connection receives RTP packets.
|
||||
- [ ] CA private keys and passwords are backed up outside the devcontainer.
|
||||
471
README.md
@@ -2,8 +2,6 @@
|
||||
|
||||
<a target="_blank" href="https://kerberos.io"><img src="https://img.shields.io/badge/kerberos-website-gray.svg?longCache=true&colorB=brightgreen" alt="Kerberos Agent"></a>
|
||||
<a target="_blank" href="https://doc.kerberos.io"><img src="https://img.shields.io/badge/kerberos-documentation-gray.svg?longCache=true&colorB=brightgreen" alt="Kerberos Agent"></a>
|
||||
<a target="_blank" href="https://twitter.com/kerberosio?ref_src=twsrc%5Etfw"><img src="https://img.shields.io/twitter/url.svg?label=Follow%20%40kerberosio&style=social&url=https%3A%2F%2Ftwitter.com%2Fkerberosio" alt="Twitter Widget"></a>
|
||||
<a target="_blank" href="https://join.slack.com/t/kerberosio/shared_invite/zt-1a5oj4pwm-O4qCAN9c5r2um0Ns0ge8ww"><img src="https://img.shields.io/badge/slack-@kerberosio-yellow.svg?logo=slack " alt="Kerberos.io"></a>
|
||||
|
||||
<a target="_blank" href="https://circleci.com/gh/kerberos-io/agent"><img src="https://circleci.com/gh/kerberos-io/agent.svg?style=svg"/></a>
|
||||
<img src="https://github.com/kerberos-io/agent/workflows/Go/badge.svg"/>
|
||||
@@ -17,100 +15,367 @@
|
||||
<a target="_blank" href="https://www.figma.com/proto/msuYC6sv2cOCqZeDtBxNy7/%5BNEW%5D-Kerberos.io-Apps?node-id=1%3A1788&viewport=-490%2C191%2C0.34553584456443787&scaling=min-zoom&page-id=1%3A2%3Ffuid%3D449684443467913607" alt="Kerberos Agent"></a>
|
||||
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
||||
[](
|
||||
https://brianmacdonald.github.io/Ethonate/address#0xf4a759C9436E2280Ea9cdd23d3144D95538fF4bE)
|
||||
[](https://brianmacdonald.github.io/Ethonate/address#0xf4a759C9436E2280Ea9cdd23d3144D95538fF4bE)
|
||||
<a target="_blank" href="https://twitter.com/kerberosio?ref_src=twsrc%5Etfw"><img src="https://img.shields.io/twitter/url.svg?label=Follow%20%40kerberosio&style=social&url=https%3A%2F%2Ftwitter.com%2Fkerberosio" alt="Twitter Widget"></a>
|
||||
[](https://snapcraft.io/kerberosio)
|
||||
|
||||
[**Docker Hub**](https://hub.docker.com/r/kerberos/agent) | [**Documentation**](https://doc.kerberos.io) | [**Website**](https://kerberos.io)
|
||||
[](https://joinslack.kerberos.io/)
|
||||
|
||||
Kerberos Agent 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 Agent can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
|
||||
[**Docker Hub**](https://hub.docker.com/r/kerberos/agent) | [**Documentation**](https://doc.kerberos.io) | [**Website**](https://kerberos.io) | [**View Demo**](https://demo.kerberos.io)
|
||||
|
||||
> Before you continue, this repository discusses one of the components of the Kerberos.io stack, the Kerberos Agent, in depth. If you are [looking for an end-to-end deployment guide have a look here](https://github.com/kerberos-io/deployment).
|
||||
|
||||
Kerberos Agent is an isolated and scalable video (surveillance) management agent 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 Agent can be used for commercial usage (which was not the case for v2). Read more [about the license here](LICENSE).
|
||||
|
||||

|
||||
|
||||
## :thinking: Prerequisites
|
||||
|
||||
- An IP camera which supports a RTSP H264 or H265 encoded stream,
|
||||
- (or) a USB camera, Raspberry Pi camera or other camera, that [you can transform to a valid RTSP H264 or H265 stream](https://github.com/kerberos-io/camera-to-rtsp).
|
||||
- Any hardware (ARMv6, ARMv7, ARM64, AMD64) that can run a binary or container, for example: a Raspberry Pi, NVidia Jetson, Intel NUC, a VM, Bare metal machine or a full blown Kubernetes cluster.
|
||||
|
||||
## :video_camera: Is my camera working?
|
||||
|
||||
There are a myriad of cameras out there (USB, IP and other cameras), and it might be daunting to know if Kerberos Agent will work for your camera. [Therefore we are listing all the camera models that are acknowlegded by the community](https://github.com/kerberos-io/agent/issues/59). Feel free to add your camera to the list as well!
|
||||
|
||||
## :books: Overview
|
||||
|
||||
### Up and running in no time
|
||||
|
||||
1. [Quickstart - Docker](#quickstart---docker)
|
||||
2. [Quickstart - Balena](#quickstart---balena)
|
||||
3. [Quickstart - Snap](#quickstart---snap)
|
||||
|
||||
### Introduction
|
||||
|
||||
1. [A world of Kerberos Agents](#a-world-of-kerberos-agents)
|
||||
|
||||
### Running and automation
|
||||
|
||||
1. [How to run and deploy a Kerberos Agent](#how-to-run-and-deploy-a-kerberos-agent)
|
||||
2. [Access the Kerberos Agent](#access-the-kerberos-agent)
|
||||
3. [Configure and persist with volume mounts](#configure-and-persist-with-volume-mounts)
|
||||
4. [Configure with environment variables](#configure-with-environment-variables)
|
||||
|
||||
### Insights
|
||||
|
||||
1. [Encryption](#encryption)
|
||||
2. [H264 vs H265](#h264-vs-h265)
|
||||
|
||||
### 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)
|
||||
4. [Building for Docker](#building-for-docker)
|
||||
|
||||
### Varia
|
||||
|
||||
1. [Support our project](#support-our-project)
|
||||
1. [What is new?](#what-is-new)
|
||||
1. [Contributors](#contributors)
|
||||
|
||||
## Quickstart - Docker
|
||||
|
||||
The easiest to get your Kerberos Agent up and running is to use our Docker image on [Docker hub](https://hub.docker.com/r/kerberos/agent). Once you selected a specific tag, run below command, which will open the web interface of your Kerberos agent on port `8080`. For persisting your configuration and/or recordings [attach a volume](#attach-a-volume).
|
||||
|
||||
docker run -p 8080:8080 --name mycamera -d kerberos/agent:latest
|
||||
The easiest way to get your Kerberos Agent up and running is to use our public image on [Docker hub](https://hub.docker.com/r/kerberos/agent). Once you have selected a specific tag, run `docker` command below, which will open the web interface of your Kerberos agent on port `80`, and off you go. For a more configurable and persistent deployment have a look at [Running and automating a Kerberos Agent](#running-and-automating-a-kerberos-agent).
|
||||
|
||||
docker run -p 80:80 --name mycamera -d --restart=always kerberos/agent:latest
|
||||
|
||||
If you want to connect to a USB or Raspberry Pi camera, [you'll need to run our side car container](https://github.com/kerberos-io/camera-to-rtsp) which proxies the camera to an RTSP stream. In that case you'll want to configure the Kerberos Agent container to run in the host network, so it can connect directly to the RTSP sidecar.
|
||||
|
||||
docker run --network=host --name mycamera -d --restart=always kerberos/agent:latest
|
||||
|
||||
## Quickstart - Balena
|
||||
|
||||
To be written
|
||||
|
||||
Run Kerberos Agent with [Balena Cloud](https://www.balena.io/) super powers. Monitor your Kerberos Agent with seamless remote access, over the air updates, an encrypted public `https` endpoint and much more. Checkout our application `video-surveillance` on [Balena Hub](https://hub.balena.io/apps/2064752/video-surveillance), and create your first or fleet of Kerberos Agent(s).
|
||||
|
||||
[](https://dashboard.balena-cloud.com/deploy?repoUrl=https://github.com/kerberos-io/balena-agent)
|
||||
|
||||
## Quickstart - Snap
|
||||
|
||||
To be written
|
||||
Run Kerberos Agent with our [Snapcraft package](https://snapcraft.io/kerberosio).
|
||||
|
||||
## Introduction
|
||||
snap install kerberosio
|
||||
|
||||
Kerberos.io is a cutting edge video surveillance system with a strong focus on user experience, scalability, resilience, extension and integration. Kerberos.io provides different solutions, but from a high level point of view it comes into two flavours: Kerberos Agent and Kerberos Enterprise Suite. Bottom line Kerberos Enterprise Suite extends Kerberos Agent with additional components such as Kerberos Factory, Kerberos Vault and Kerberos Hub.
|
||||
Once installed you can find your Kerberos Agent configration at `/var/snap/kerberosio/common`. Run the Kerberos Agent as following
|
||||
|
||||
### Kerberos Agent
|
||||
sudo kerberosio.agent -action=run -port=80
|
||||
|
||||
- Installation in seconds (Docker, Balena, Snap, etc).
|
||||
## A world of Kerberos Agents
|
||||
|
||||
The Kerberos Agent is an isolated and scalable video (surveillance) management agent with a strong focus on user experience, scalability, resilience, extension and integration. Next to the Kerberos Agent, Kerberos.io provides many other tools such as [Kerberos Factory](https://github.com/kerberos-io/factory), [Kerberos Vault](https://github.com/kerberos-io/vault), and [Kerberos Hub](https://github.com/kerberos-io/hub) to provide additional capabilities: bring your own cloud, bring your own storage, central overview, live streaming, machine learning, etc.
|
||||
|
||||
[](https://github.com/kerberos-io/deployment)
|
||||
|
||||
As mentioned above Kerberos.io applies the concept of agents. An agent is running next to (or on) your camera, and is processing a single camera feed. It applies motion based or continuous recording and makes those recordings available through a user friendly web interface. A Kerberos Agent allows you to connect to other cloud services or integrate with custom applications. Kerberos Agent is used for personal applications and scales to enterprise production level deployments. Learn more about the [deployment strategies here](<(https://github.com/kerberos-io/deployment)>).
|
||||
|
||||
This repository contains everything you'll need to know about our core product, Kerberos Agent. Below you'll find a brief list of features and functions.
|
||||
|
||||
- Low memory and CPU usage.
|
||||
- Simplified and modern user interface.
|
||||
- Multi architecture (ARMv7, ARMv8, amd64, etc).
|
||||
- Multi camera support: IP Cameras (MJPEG/H264), USB cameras and Raspberry Pi Cameras through a RTSP proxy.
|
||||
- Single camera per instance (e.g. One Docker container per camera).
|
||||
- Integrations (Webhooks, MQTT, Script, etc).
|
||||
- Cloud storage (Kerberos Hub, Kerberos Vault, Minio, Storj, etc).
|
||||
- Multi architecture (ARMv6, ARMv7, ARM64, AMD64)
|
||||
- Multi stream, for example recording in H265, live streaming and motion detection in H264.
|
||||
- Multi camera support: IP Cameras (H264 and H265), USB cameras and Raspberry Pi Cameras [through a RTSP proxy](https://github.com/kerberos-io/camera-to-rtsp).
|
||||
- Single camera per instance (e.g. one container per camera).
|
||||
- Low resolution streaming through MQTT and high resolution streaming through WebRTC (only supports H264/PCM).
|
||||
- Backchannel audio from Kerberos Hub to IP camera (requires PCM ULAW codec)
|
||||
- Audio (AAC) and video (H264/H265) recording in MP4 container.
|
||||
- End-to-end encryption through MQTT using RSA and AES (livestreaming, ONVIF, remote configuration, etc)
|
||||
- Conditional recording: offline mode, motion region, time table, continuous recording, webhook condition etc.
|
||||
- Post- and pre-recording for motion detection.
|
||||
- Encryption at rest using AES-256-CBC.
|
||||
- Ability to create fragmented recordings, and streaming through HLS fMP4.
|
||||
- [Deploy where you want](#how-to-run-and-deploy-a-kerberos-agent) with the tools you use: `docker`, `docker compose`, `ansible`, `terraform`, `kubernetes`, etc.
|
||||
- Cloud storage/persistance: Kerberos Hub, Kerberos Vault and Dropbox. [(WIP: Minio, Storj, Google Drive, FTP etc.)](https://github.com/kerberos-io/agent/issues/95)
|
||||
- Outputs: trigger an integration (Webhooks, MQTT, Script, etc) when a specific event (motion detection or start recording ) occurs
|
||||
- REST API access and documentation through Swagger (trigger recording, update configuration, etc).
|
||||
- MIT License
|
||||
|
||||
### Kerberos Factory (part of [Kerberos Enterprise suite](https://doc.kerberos.io/enterprise/first-things-first/))
|
||||
## How to run and deploy a Kerberos Agent
|
||||
|
||||
- Installation on top of Kubernetes (K8S).
|
||||
- Camera support for IP camera only (RTSP/H264).
|
||||
- Massive horizontal scaling, thanks to Kubernetes.
|
||||
- Management of multiple Kerberos Agents through a single pane of glass.
|
||||
- Low memory and CPU intensive.
|
||||
- Modular and extensible design for building own extensions and integrations (e.g. a video analytics platform).
|
||||
- Commercial licensed and closed source.
|
||||
A Kerberos Agent, as previously mentioned, is a container. You can deploy it using various methods and automation tools, including `docker`, `docker compose`, `kubernetes` and more. To streamline your Kerberos.io experience, we provide concrete deployment examples to speed up your Kerberos.io journey”
|
||||
|
||||
## How it works: A world of Agents 🕵🏼♂️
|
||||
We have documented the different deployment models [in the `deployments` directory](https://github.com/kerberos-io/agent/tree/master/deployments) of this repository. There you'll learn and find how to deploy using:
|
||||
|
||||
Kerberos.io applies the concept of agents. An agent is running next to or on your camera, and is processing a single camera feed. It applies motion based recording and make those recordings available through a user friendly web interface. Kerberos Agent allows you to connect to other cloud services or custom applications. Kerberos Agent is perfect for personal usage and/or is a great tool if you only have a couple of surveillance cameras to be processed.
|
||||
- [Static binary](https://github.com/kerberos-io/agent/tree/master/deployments#0-static-binary)
|
||||
- [Docker](https://github.com/kerberos-io/agent/tree/master/deployments#1-docker)
|
||||
- [Docker Compose](https://github.com/kerberos-io/agent/tree/master/deployments#2-docker-compose)
|
||||
- [Kubernetes](https://github.com/kerberos-io/agent/tree/master/deployments#3-kubernetes)
|
||||
- [Red Hat OpenShift with Ansible](https://github.com/kerberos-io/agent/tree/master/deployments#4-red-hat-ansible-and-openshift)
|
||||
- [Terraform](https://github.com/kerberos-io/agent/tree/master/deployments#5-terraform)
|
||||
- [Salt](https://github.com/kerberos-io/agent/tree/master/deployments#6-salt)
|
||||
- [Balena](https://github.com/kerberos-io/agent/tree/master/deployments#8-balena)
|
||||
- [Snap](https://github.com/kerberos-io/agent/tree/master/deployments#9-snap)
|
||||
|
||||
If you are looking for a solution that scales better with your video surveillance and/or video analytics requirements, [Kerberos Enterprise Suite might be a better fit](https://doc.kerberos.io/enterprise/first-things-first).
|
||||
By default, your Kerberos Agents store all configuration and recordings within the container. To help you automate and have a more consistent data governance, you can attach volumes to configure and persist data of your Kerberos Agents and/or configure each Kerberos Agent through environment variables.
|
||||
|
||||
## Running as a container
|
||||
## Access the Kerberos Agent
|
||||
|
||||
We are creating Docker images as part of our CI/CD process. You'll find our Docker images on [Docker hub](https://hub.docker.com/r/kerberos/agent). Pick a specific tag of choice, or use latest. Once done run below command, this will open the web interface of your Kerberos agent on port 8080.
|
||||
|
||||
docker run -p 8080:8080 --name mycamera -d kerberos/agent:latest
|
||||
Once you have deployed the Kerberos Agent, using one of the deployment models described above, you will be able to access the Kerberos Agent user interface. A login page is presented asking for some credentials.
|
||||
|
||||
Or for a develop build:
|
||||
The default username and password for the Kerberos Agent is:
|
||||
|
||||
docker run -p 8080:8080 --name mycamera -d kerberos/agent-dev:latest
|
||||
- Username: `root`
|
||||
- Password: `root`
|
||||
|
||||
Feel free to use another port if your host system already has a workload running on `8080`. For example `8082`.
|
||||
**_Please note that you change the username and password for a final installation, see [Configure with environment variables](#configure-with-environment-variables) below._**
|
||||
|
||||
docker run -p 8082:8080 --name mycamera -d kerberos/agent:latest
|
||||
## Configure and persist with volume mounts
|
||||
|
||||
## Attach a volume
|
||||
An example of how to mount a host directory is shown below using `docker`, but is applicable for [all of the deployment models and tools described above](#running-and-automating-a-kerberos-agent).
|
||||
|
||||
By default your Kerberos agent will store all its configuration and recordings inside the container. It might be interesting to store both configuration and your recordings outside the container, on your local disk. This helps persisting your storage even after you decide to wipe out your Kerberos agent.
|
||||
You attach a volume to your container by leveraging the `-v` option. To mount your own configuration file and recordings folder, run the following commands:
|
||||
|
||||
You attach a volume to your container by leveraging the `-v` option. To mount your own configuration file, execute as following:
|
||||
docker run -p 80:80 --name mycamera \
|
||||
-v $(pwd)/agent/config:/home/agent/data/config \
|
||||
-v $(pwd)/agent/recordings:/home/agent/data/recordings \
|
||||
-d --restart=always kerberos/agent:latest
|
||||
|
||||
1. Decide where you would like to store your configuration and recordings; create a new directory for the config file and recordings folder accordingly.
|
||||
More examples for each deployment and automation tool [can be found in the deployment section](https://github.com/kerberos-io/agent/tree/master/deployments). Be sure to verify the permissions of the directory/volume you are attaching. More information in [this issue](https://github.com/kerberos-io/agent/issues/80).
|
||||
|
||||
mkdir agent
|
||||
mkdir agent/config
|
||||
mkdir agent/recordings
|
||||
chmod -R 755 kerberos-agent/
|
||||
chown 100:101 kerberos-agent/ -R
|
||||
|
||||
2. Once you have located your desired directory, copy the latest [`config.json`](https://github.com/kerberos-io/agent/blob/master/machinery/data/config/config.json) file into your config directory.
|
||||
## Configure with environment variables
|
||||
|
||||
wget https://raw.githubusercontent.com/kerberos-io/agent/master/machinery/data/config/config.json -O agent/config/config.json
|
||||
Next to attaching the configuration file, it is also possible to override the configuration with environment variables. This makes deploying with `docker compose` or `kubernetes` much easier and more scalable. Using this approach, we simplify automation through `ansible` and `terraform`.
|
||||
|
||||
3. Run the docker command as following to attach your config directory and recording directory.
|
||||
docker run -p 80:80 --name mycamera \
|
||||
-e AGENT_NAME=mycamera \
|
||||
-e AGENT_TIMEZONE=Europe/Brussels \
|
||||
-e AGENT_CAPTURE_IPCAMERA_RTSP=rtsp://fake.kerberos.io/stream \
|
||||
-e AGENT_CAPTURE_CONTINUOUS=true \
|
||||
-d --restart=always kerberos/agent:latest
|
||||
|
||||
### Secure camera streams (RTSPS)
|
||||
|
||||
The Agent accepts `rtsps://` camera URLs. Do not use `srtsp://`; RTSPS is RTSP over TLS. For a Bosch FLEXIDOME micro 3100i, enable **Secure RTSP** under **Network > Network Services** and use port `9554`:
|
||||
|
||||
```bash
|
||||
AGENT_CAPTURE_IPCAMERA_RTSP='rtsps://username:password@camera.example:9554/?inst=1'
|
||||
AGENT_CAPTURE_IPCAMERA_SUB_RTSP='rtsps://username:password@camera.example:9554/?inst=2'
|
||||
```
|
||||
|
||||
Certificate verification is enabled by default. The URL hostname or IP address must match the camera certificate SAN. On this Bosch firmware, RTSPS presents the certificate assigned to **HTTPS**; there is no separate SRTSP certificate usage. Leave **CBS client** assigned to the Bosch device certificate.
|
||||
|
||||
For a private CA, mount a PEM trust bundle containing every CA certificate needed to build the camera certificate chain and set `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` to its path inside the Agent. The bundle is appended to the system roots for camera RTSPS connections only. This Bosch firmware presents only its leaf certificate, so include both the issuing intermediate and root certificates in the bundle. As a temporary fallback, `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE=true` disables certificate verification for camera streams only.
|
||||
|
||||
See [RTSPS and TLS certificates](README-RTSPS-TLS.md) for the complete Bosch UI, private-CA, deployment, validation, and troubleshooting procedure.
|
||||
|
||||
| Name | Description | Default Value |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||
| `LOG_LEVEL` | Level for logging, could be "info", "warning", "debug", "error" or "fatal". | "info" |
|
||||
| `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, cap the recordings directory at this size (in MB). When unset/0, recordings may use the whole disk instead (see `AGENT_AUTO_CLEAN_MIN_FREE_SPACE`). | "100" |
|
||||
| `AGENT_AUTO_CLEAN_MIN_FREE_SPACE` | When `AUTO_CLEAN` is enabled and no `MAX_SIZE` is set, keep at least this much free space (in MB) on the recordings disk before deleting the oldest (already-uploaded first) recordings. Defaults to 5% of the disk. | "" |
|
||||
| `AGENT_TIME` | Enable the timetable for Kerberos Agent | "false" |
|
||||
| `AGENT_TIMETABLE` | A (weekly) time table to specify when to make recordings "start1,end1,start2,end2;start1.. | "" |
|
||||
| `AGENT_REGION_POLYGON` | A single polygon set for motion detection: "x1,y1;x2,y2;x3,y3;... | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSP` | Full-HD RTSP or RTSPS endpoint for the target camera. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_SUB_RTSP` | RTSP or RTSPS sub-stream endpoint used for livestreaming (WebRTC). | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSPS_CA_FILE` | PEM CA bundle appended to the system roots for RTSPS camera certificate verification. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_RTSPS_INSECURE` | Disable RTSPS camera certificate verification; use only when a trusted CA cannot be installed. | "false" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_BASE_WIDTH` | Force a specific width resolution for live view processing. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_BASE_HEIGHT` | Force a specific height resolution for live view processing. | "" |
|
||||
| `AGENT_CAPTURE_IPCAMERA_ONVIF` | Mark as a compliant ONVIF device. | "" |
|
||||
| `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-fra1.kerberos.io:3478"|
|
||||
| `AGENT_FORCE_TURN` | Force using a TURN server, by generating relay candidates only. | "false" |
|
||||
| `AGENT_TURN_URI` | When using WebRTC, you'll need to provide a TURN server. | "turn:turn-fra1.kerberos.io:3478"|
|
||||
| `AGENT_TURN_USERNAME` | TURN username used for WebRTC. | "username1" |
|
||||
| `AGENT_TURN_PASSWORD` | TURN password used for WebRTC. | "password1" |
|
||||
| `AGENT_CLOUD` | Store recordings in Kerberos Hub (s3), Kerberos Vault (kstorage), or Dropbox (dropbox). | "s3" |
|
||||
| `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
|
||||
|
||||
You can encrypt your recordings and outgoing MQTT messages with your own AES and RSA keys by enabling the encryption settings. Once enabled, all your recordings will be encrypted using AES-256-CBC and your symmetric key. You can use the default `openssl` toolchain to decrypt the recordings with your AES key, as following:
|
||||
|
||||
openssl aes-256-cbc -d -md md5 -in encrypted.mp4 -out decrypted.mp4 -k your-key-96ab185xxxxxxxcxxxxxxxx6a59c62e8
|
||||
|
||||
Or you can decrypt a folder of recordings, using the Kerberos Agent binary as following:
|
||||
|
||||
go run main.go -action decrypt ./data/recordings your-key-96ab185xxxxxxxcxxxxxxxx6a59c62e8
|
||||
|
||||
Or for a single file:
|
||||
|
||||
go run main.go -action decrypt ./data/recordings/video.mp4 your-key-96ab185xxxxxxxcxxxxxxxx6a59c62e8
|
||||
|
||||
## H264 vs H265
|
||||
|
||||
If we talk about video encoders and decoders (codecs) there are 2 major video codecs on the market: H264 and H265. Taking into account your use case, you might use one over the other. We will provide an (not complete) overview of the advantages and disadvantages of each codec in the field of video surveillance and video analytics. If you would like to know more, you should look for additional resources on the internet (or if you like to read physical items, books still exists nowadays).
|
||||
|
||||
- H264 (also known as AVC or MPEG-4 Part 10)
|
||||
|
||||
- Is the most common one and most widely supported for IP cameras.
|
||||
- Supported in the majority of browsers, operating system, and third-party applications.
|
||||
- Can be embedded in commercial and 3rd party applications.
|
||||
- Different levels of compression (high, medium, low, ..)
|
||||
- Better quality / compression ratio, shows less artifacts at medium compression ratios.
|
||||
- Does support technologies such as WebRTC
|
||||
|
||||
- H265 (also known as HEVC)
|
||||
- Is not supported on legacy cameras, though becoming rapidly available on "newer" IP cameras.
|
||||
- Might not always be supported due to licensing. For example not supported in browers on a Linux distro.
|
||||
- Requires licensing when embedding in a commercial product (be careful).
|
||||
- Higher levels of compression (50% more than H264).
|
||||
- H265 shows artifacts in motion based environments (which is less with H264).
|
||||
- Recording the same video (resolution, duration and FPS) in H264 and H265 will result in approx 50% the file size.
|
||||
- Not supported in technologies such as WebRTC
|
||||
|
||||
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...
|
||||
|
||||
By leveraging codespaces, which the Kerberos Agent repo supports, you will be able to set up the required development environment in a few minutes. By opening the `<> Code` tab on the top of the page, you will be able to create a codespace, [using the Kerberos Devcontainer](https://github.com/kerberos-io/devcontainer) base image. This image requires all the relevant dependencies: FFmpeg, OpenCV, Golang, Node, Yarn, etc.
|
||||
|
||||

|
||||
|
||||
After a few minutes, you will see a beautiful `Visual Studio Code` shown in your browser, and you are ready to code!
|
||||
|
||||

|
||||
|
||||
On opening of the GitHub Codespace, some dependencies will be installed. Once this is done go ahead to the `ui/src/config.json` file, and (un)comment following section. Make sure to replace the `externalHost` variable with the DNS name you will retrieve from the next step.
|
||||
|
||||
// Uncomment this when using codespaces or other special DNS names (which you can't control)
|
||||
// replace this with the DNS name of the kerberos agent server (the codespace url)
|
||||
const externalHost = 'cedricve-automatic-computing-machine-v647rxvj4whx9qp-80.preview.app.github.dev';
|
||||
|
||||
const dev = {
|
||||
ENV: 'dev',
|
||||
HOSTNAME: externalHost,
|
||||
//API_URL: `${protocol}//${hostname}:80/api`,
|
||||
//URL: `${protocol}//${hostname}:80`,
|
||||
//WS_URL: `${websocketprotocol}//${hostname}:80/ws`,
|
||||
|
||||
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
|
||||
API_URL: `${protocol}//${externalHost}/api`,
|
||||
URL: `${protocol}//${externalHost}`,
|
||||
WS_URL: `${websocketprotocol}//${externalHost}/ws`,
|
||||
};
|
||||
|
||||
Go and open two terminals: one for the `ui` project and one for the `machinery` project.
|
||||
|
||||
1. Terminal A:
|
||||
|
||||
cd machinery/
|
||||
go run main.go -action run -port 80
|
||||
|
||||
2. Terminal B:
|
||||
|
||||
cd ui/
|
||||
yarn start
|
||||
|
||||
Once executed, a popup will show up mentioning `portforwarding`. You should see two ports being opened, one for the ui `3000` and one for the machinery `80`. `Right-click` on the port `80` and change visibility from `private` to `public`, this is required to avoid `CORS` errors.
|
||||
|
||||

|
||||
|
||||
As mentioned above, copy the hostname of the `machinery` DNS name, and paste it in the `ui/src/config.json` file. Once done, reload the `ui` page in your browser, and you should be able to access the login page with the default credentials `root` and `root`.
|
||||
|
||||
docker run -p 8080:8080 --name mycamera -v $(pwd)/agent/config:/home/agent/data/config -v $(pwd)/agent/recordings:/home/agent/data/recordings -d kerberos/agent:latest
|
||||
|
||||
## Develop and build
|
||||
|
||||
Kerberos Agent is divided in two parts a `machinery` and `web`. Both parts live in this repository in their relative folders. For development or running the application on your local machine, you have to run both the `machinery` and the `web` as described below. When running in production everything is shipped as only one artifact, read more about this at [Building for production](#building-for-production).
|
||||
The Kerberos Agent is divided in two parts: a `machinery` and `web` part. Both parts live in this repository in their relative folders. For development or running the application on your local machine, you have to run both the `machinery` and the `web` as described below. When running in production everything is shipped as only one artifact, read more about this at [Building for production](#building-for-production).
|
||||
|
||||
### UI
|
||||
|
||||
@@ -124,27 +389,27 @@ This will start a webserver and launches the web app on port `3000`.
|
||||
|
||||

|
||||
|
||||
Once signed in you'll see the dashboard page showing up. After successfull configuration of your agent, you'll should see a live view and possible events recorded to disk.
|
||||
Once signed in you'll see the dashboard page. After successfull configuration of your agent, you'll should see a live view and possible events recorded to disk.
|
||||
|
||||

|
||||
|
||||
### Machinery
|
||||
|
||||
The `machinery` is a **Golang** project which delivers two functions: it acts as the Kerberos Agent which is doing all the heavy lifting with camera processing and other kinds of logic, on the other hand it acts as a webserver (Rest API) that allows communication from the web (React) or any other custom application. The API is documented using `swagger`.
|
||||
The `machinery` is a **Golang** project which delivers two functions: it acts as the Kerberos Agent which is doing all the heavy lifting with camera processing and other kinds of logic and on the other hand it acts as a webserver (Rest API) that allows communication from the web (React) or any other custom application. The API is documented using `swagger`.
|
||||
|
||||
You can simply run the `machinery` using following commands.
|
||||
|
||||
git clone https://github.com/kerberos-io/agent
|
||||
cd machinery
|
||||
go run main.go run mycameraname 8080
|
||||
go run main.go -action run -port 80
|
||||
|
||||
This will launch the Kerberos Agent and run a webserver on port `8080`. You can change the port by your own preference. We strongly support the usage of [Goland](https://www.jetbrains.com/go/) or [Visual Studio Code](https://code.visualstudio.com/), as it comes with all the debugging and linting features builtin.
|
||||
This will launch the Kerberos Agent and run a webserver on port `80`. You can change the port by your own preference. We strongly support the usage of [Goland](https://www.jetbrains.com/go/) or [Visual Studio Code](https://code.visualstudio.com/), as it comes with all the debugging and linting features built in.
|
||||
|
||||

|
||||

|
||||
|
||||
## Building for Production
|
||||
## Building from source
|
||||
|
||||
Running Kerberos Agent in production only require a single binary to run. Nevertheless, we have two parts, the `machinery` and the `web`, we merge them during build time. So this is what happens.
|
||||
Running Kerberos Agent in production only requires a single binary to run. Nevertheless, we have two parts: the `machinery` and the `web`, we merge them during build time. So this is what happens.
|
||||
|
||||
### UI
|
||||
|
||||
@@ -155,31 +420,63 @@ To build the Kerberos Agent web app, you simply have to run the `build` command
|
||||
|
||||
### Machinery
|
||||
|
||||
Building the `machinery` is also super easy 🚀, by using `go build` you can create a single binary which ships it all; thank you Golang. After building you will endup with a binary called `main`, this is what contains everything you need to run Kerberos Agent.
|
||||
Building the `machinery` is also super easy 🚀, by using `go build` you can create a single binary which ships it all; thank you Golang. After building you will end up with a binary called `main`, this is what contains everything you need to run Kerberos Agent.
|
||||
|
||||
Remember the build step of the `web` part, during build time we move the build directory to the `machinery` directory. Inside the `machinery` web server [we reference the](https://github.com/kerberos-io/agent/blob/master/machinery/src/routers/http/Server.go#L44) `build` directory. This makes it possible to just a have single web server that runs it all.
|
||||
Remember the build step of the `web` part, during build time we move the build directory to the `machinery` directory. Inside the `machinery` web server [we reference the](https://github.com/kerberos-io/agent/blob/master/machinery/src/routers/http/Server.go#L44) `build` directory. This makes it possible to just a have single web server that runs it all.
|
||||
|
||||
cd machinery
|
||||
go build
|
||||
|
||||
## Building for Docker
|
||||
|
||||
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping **Kerberos Agent**. Important to note is that start from a prebuild base image, `kerberos/debian-opencv-ffmpeg:1.0.xxx`.
|
||||
This base image contains already a couple of tools, such as Golang, FFmpeg and OpenCV. We do this for faster compilation times.
|
||||
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping a **Kerberos Agent**. It uses Debian Trixie to support the native dependencies used by the Agent, including Media over QUIC.
|
||||
|
||||
By running the `docker build` command, you will create the Kerberos Agent Docker image. After building you can simply run the image as a Docker container.
|
||||
|
||||
docker build -t kerberos/agent .
|
||||
|
||||
## Support our project
|
||||
### Media over QUIC
|
||||
|
||||
If you like our product please feel free to execute an Ethereum donation. All donations will flow back and split to our Open Source contributors, as they are the heart of this community.
|
||||
|
||||
<img width="272" alt="Ethereum donation linke" src="https://user-images.githubusercontent.com/1546779/173443671-3d773068-ae10-4862-a990-dc7c89f3d9c2.png">
|
||||
The standard AMD64 and ARM64 images include the optional MoQ publisher. Its Rust
|
||||
FFI archive requires CGO and glibc 2.38 or newer, which is why the standard image
|
||||
uses Debian Trixie. The publisher is disabled unless explicitly enabled at runtime:
|
||||
|
||||
Ethereum Address: `0xf4a759C9436E2280Ea9cdd23d3144D95538fF4bE`
|
||||
|
||||
## Previous releases
|
||||
docker run --rm -p 80:80 \
|
||||
-e AGENT_LIVE_MOQ_ENABLED=true \
|
||||
-e AGENT_LIVE_MOQ_URL=https://relay.uug.ai/anon \
|
||||
kerberos/agent
|
||||
|
||||
`AGENT_LIVE_MOQ_BROADCAST_PREFIX` defaults to `devices`. MoQ viewers subscribe to
|
||||
a relay and never negotiate with the Agent, so every quality tier is published as
|
||||
its own broadcast and switching quality is simply a resubscribe:
|
||||
|
||||
| Tier | Broadcast | Source |
|
||||
| ------ | ------------------------------------- | ------------------------------------------ |
|
||||
| `high` | `devices/<agent-key>/live.hang` | highest-resolution camera stream |
|
||||
| `low` | `devices/<agent-key>/live-low.hang` | sub stream (main stream when none is set) |
|
||||
|
||||
Each tier only uploads while it has at least one subscriber, so the tier nobody
|
||||
watches costs virtually no bandwidth. `AGENT_LIVE_MOQ_QUALITY` accepts `high` or
|
||||
`low` to pin the Agent to a single tier; viewers requesting the other tier then
|
||||
find no broadcast. Any other value (including the default) publishes both. The
|
||||
initial implementation publishes H.264 video only.
|
||||
|
||||
The `/anon` relay route is intended for interoperability testing. Production
|
||||
deployments must set `AGENT_LIVE_MOQ_URL` to a short-lived, device-scoped
|
||||
publisher URL issued by Hub API.
|
||||
|
||||
To verify the native SDK in a development container, rebuild the Agent or shared
|
||||
monorepo devcontainer so it uses the Trixie base, then run the VS Code task
|
||||
`agent: moq verify`. The same check is available from a terminal:
|
||||
|
||||
cd machinery
|
||||
bash ./verify-moq-devcontainer.sh
|
||||
|
||||
The check requires glibc 2.38 or newer, runs the tagged package tests, links the
|
||||
complete Agent with `-tags moq`, and executes the resulting binary's version
|
||||
command. Both devcontainers also run this check during their post-create setup.
|
||||
|
||||
## What is new?
|
||||
|
||||
This repository contains the next generation of Kerberos.io, **Kerberos Agent (v3)**, and is the successor of the machinery and web repositories. A switch in technologies and architecture has been made. This version is still under active development and can be followed on the [develop branch](https://github.com/kerberos-io/agent/tree/develop) and [project overview](https://github.com/kerberos-io/agent/projects/1).
|
||||
|
||||
@@ -187,37 +484,9 @@ Read more about this [at the FAQ](#faq) below.
|
||||
|
||||

|
||||
|
||||
## FAQ
|
||||
|
||||
#### 1. Why a mono repo?
|
||||
|
||||
We have noticed in the past (v1 and v2) splitting the repositories (machinery and web), created a lot of confusion within our community. People didn't understand the different versions and so on. This caused a lack of collaboration, and made it impossible for some people to collaborate and contribute.
|
||||
|
||||
Having a mono repo, which is well organised, simplifies the entry point for new people who would like to use, understand and/or contribute to Kerberos Agent.
|
||||
|
||||
#### 2. Why a change in technologies?
|
||||
|
||||
In previous versions (v1 and v2) we used technologies like C++, PHP and BackboneJS. 7 years ago this was still acceptable, however time has changed and new technologies such as React and Golang became very popular.
|
||||
|
||||
Due to previous reason we have decided to rebuild the Kerberos Agent technology from scratch, taking into account all the feedback we acquired over the years. Having these technologies available, we will enable more people to contribute and use our technology.
|
||||
|
||||
#### 3. What is the difference with Kerberos Enterprise?
|
||||
|
||||
We started the developments of Kerberos Enterprise a year ago (January, 2020), our focus here was scalability, and fast development and easy deployment. We noticed that with technologies such as Golang and React, we can still provide a highly performant video surveillance system.
|
||||
|
||||
Kerberos Agent uses the same technology stack, and some code pieces, of Kerberos Enterprise which we have already build. We have a very clear now, of how a well developed and documented video surveillance system needs to look like.
|
||||
|
||||
#### 4. When are we going to be able to install the first version?
|
||||
|
||||
We plan to ship the first version by the end of Q1, afterwards we will add more and more features as usual.
|
||||
|
||||
#### 5. Change in License
|
||||
|
||||
Kerberos Agent (v3) is now available under the MIT license.
|
||||
|
||||
## Contributors
|
||||
|
||||
This project exists thanks to all the people who contribute.
|
||||
This project exists thanks to all the people who contribute. Bravo!
|
||||
|
||||
<a href="https://github.com/kerberos-io/agent/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=kerberos-io/agent" />
|
||||
|
||||
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.
|
||||
BIN
assets/img/codespace-make-public.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
assets/img/codespace-vscode.png
Normal file
|
After Width: | Height: | Size: 769 KiB |
BIN
assets/img/codespace.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
2958
assets/img/edge-deployment-agent.svg
Normal file
|
After Width: | Height: | Size: 696 KiB |
BIN
assets/img/logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/img/vscode-desktop.png
Normal file
|
After Width: | Height: | Size: 966 KiB |
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
|
||||
79
deployments/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Kerberos Agent Deployments
|
||||
|
||||
Great to see you here, you just arrived at the real stuff! As you may have understood Kerberos Agent is a containerized solution. A Kerberos Agent, or container equivalent, is running for each camera. This approach makes it scalable, isolated and probably the most important thing an exceptional workload governance.
|
||||
|
||||
Due to it's nature, of acting as a micro service, there are many different ways how to get this Kerberos Agent up and running. This part of the Kerberos Agent repository contains example configurations, for all the different deployments and automations you can leverage to deploy and scale your video landscape.
|
||||
|
||||
We will discuss following deployment models.
|
||||
|
||||
- [0. Static binary](#0-static-binary)
|
||||
- [1. Docker](#1-docker)
|
||||
- [2. Docker Compose](#2-docker-compose)
|
||||
- [3. Kubernetes](#3-kubernetes)
|
||||
- [4. Red Hat Ansible and OpenShift](#4-red-hat-ansible-and-openshift)
|
||||
- [5. Kerberos Factory](#5-kerberos-factory)
|
||||
- [6. Terraform](#6-terraform)
|
||||
- [7. Salt](#7-salt)
|
||||
- [8. Balena](#8-balena)
|
||||
|
||||
## 0. Static binary
|
||||
|
||||
Kerberos Agents are now also shipped as static binaries. Within the Docker image build, we are extracting the Kerberos Agent binary and are [uploading them to the releases page](https://github.com/kerberos-io/agent/releases) in the repository. By opening a release you'll find a `.tar` with the relevant files.
|
||||
|
||||
> Learn more [about the Kerberos Agent binary here](https://github.com/kerberos-io/agent/tree/master/deployments/binary).
|
||||
|
||||
## 1. Docker
|
||||
|
||||
Leveraging `docker` is probably one of the easiest way to run and test the Kerberos Agent. Thanks to it's multi-architecture images you could run it on almost every machine. The `docker` approach is perfect for running one or two cameras in a (single machine) home deployment, a POC to verify its capabilities, or testing if your old/new IP camera is operational with our Kerberos Agent.
|
||||
|
||||
> Learn more [about Kerberos Agent on Docker here](https://github.com/kerberos-io/agent/tree/master/deployments/docker#1-running-a-single-container).
|
||||
|
||||
## 2. Docker Compose
|
||||
|
||||
If you consider `docker` as "your way to go", but require to run a bigger (single machine) deployment at home or inside your store then `docker compose` would be more suitable. By specifying a single `docker-compose.yaml` file, you can define all your Kerberos Agents (and thus cameras) in a single file, with a custom configuration to fit your needs.
|
||||
|
||||
> Learn more [about Kerberos Agent with Docker Compose here](https://github.com/kerberos-io/agent/tree/master/deployments/docker#2-running-multiple-containers-with-docker-compose).
|
||||
|
||||
## 3. Kubernetes
|
||||
|
||||
As described above, `docker` is a great tool for smaller deployments, where you are just running on a single machine and want to ramp up quickly. As you might expect, this is a not an ideal situation for production deployments. Kubernetes can help you to build a scalable, flexible and resilient deployment.
|
||||
|
||||
> Learn more [about Kerberos Agent in a Kubernetes cluster here](https://github.com/kerberos-io/agent/tree/master/deployments/kubernetes).
|
||||
|
||||
## 4. Red Hat Ansible and OpenShift
|
||||
|
||||
If you running an alternative distribution such as Red Hat OpenShift, things will work out exactly as mentioned before with the `Kubernetes` deployment. You'll have all the benefints of Red Hat OpenShift on top. One of the things we provide here is an Ansible playbook to deploy the Kerberos Agent in the OpenShift cluster.
|
||||
|
||||
> Learn more [about Kerberos Agent in OpenShift with Ansible](https://github.com/kerberos-io/agent/tree/master/deployments/ansible-openshift).
|
||||
|
||||
## 5. Kerberos Factory
|
||||
|
||||
All of the previously deployments, `docker`, `kubernetes` and `openshift` are great for a technical audience. However for business users, it might be more convenient to have a clean web ui, that one can leverage to add one or more cameras (Kerberos Agents), without the hassle of the technical resources.
|
||||
|
||||
> Learn more [about Kerberos Agent with Kerberos Factory](https://github.com/kerberos-io/agent/tree/master/deployments/factory).
|
||||
|
||||
## 6. Terraform
|
||||
|
||||
Terraform is a tool for infrastructure provisioning to build infrastructure through code, often called Infrastructure as Code. So, Terraform allows you to automate and manage your infrastructure, your platform, and the services that run on that platform. By using Terraform you can deploy your Kerberos Agents remotely at scale.
|
||||
|
||||
> Learn more [about Kerberos Agent with Terraform](https://github.com/kerberos-io/agent/tree/master/deployments/terraform).
|
||||
|
||||
## 7. Salt
|
||||
|
||||
To be written
|
||||
|
||||
## 8. Balena
|
||||
|
||||
Balena Cloud provide a seamless way of building and deploying applications at scale through the conceps of `blocks`, `apps` and `fleets`. Once you have your `app` deployed, for example our Kerberos Agent, you can benefit from features such as: remote access, over the air updates, an encrypted public `https` endpoint and many more.
|
||||
|
||||
Together with the Balena.io team we've build a Balena App, called [`video-surveillance`](https://hub.balena.io/apps/2064752/video-surveillance), which any can use to deploy a video surveillance system in a matter of minutes with all the expected management features you can think of.
|
||||
|
||||
> Learn more [about Kerberos Agent with Balena](https://github.com/kerberos-io/agent/tree/master/deployments/balena).
|
||||
|
||||
## 9. Snap
|
||||
|
||||
The Snap Store, also known as the Ubuntu Store , is a commercial centralized software store operated by Canonical. Similar to AppImage or Flatpak the Snap Store is able to provide up to date software no matter what version of Linux you are running and how old your libraries are.
|
||||
|
||||
We have published our own snap `Kerberos Agent` on the Snap Store, allowing you to seamless install a Kerberos Agent on your Linux devive.
|
||||
|
||||
> Learn more [about Kerberos Agent with Snap](https://github.com/kerberos-io/agent/tree/master/deployments/snap).
|
||||
49
deployments/ansible-openshift/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Deploy to a Red Hat OpenShift cluster with Ansible
|
||||
|
||||
Kubernetes is great, but you might love OpenShift even more. In this directory you'll find some resources to deploy your Kerberos Agent in an OpenShift cluster using Ansible playbook. We'll review the different tasks of the Ansible playbook step by step; find the complete `playbook.yaml` here.
|
||||
|
||||
## Variabeles
|
||||
|
||||
We'll have a few `variables` in our `playbook.yml` that will help us to setup secure connection with the OpenShift cluster. We need the `cluster_url` and the `username` and `password` of the OpenShift cluster. If you don't know where to find this, you can find this in the OpenShift web ui.
|
||||
|
||||
vars:
|
||||
- oc_cluster_url: ""
|
||||
- oc_username: ""
|
||||
- oc_password: ""
|
||||
|
||||
## Tasks
|
||||
|
||||
Once we have supplied the `variables` we will define following tasks:
|
||||
|
||||
- name: Print Variables
|
||||
- name: Try to login to OCP cluster
|
||||
- name: Create a Namespace
|
||||
- name: Create a Persistent volume claim
|
||||
- name: Deploy Kerberos Agent
|
||||
- name: Expose Kerberos Agent
|
||||
|
||||
1. Print variables: this is a validation step, where we make sure we have the correct variables supplied to the `ansible-playbook` command. This confirms we are using the right credentials to setup a secure connection with the OpenShift cluster.
|
||||
|
||||
2. Setup a connection with OpenShift using the defined variabeles. If successfull an `api_key` will become available in the `k8s_auth_result` variable. This variabele will be used with every subsequent operation against the OpenShift cluster.
|
||||
|
||||
3. A best practice is to isolate your workloads in namespaces. Therefore we'll create a new namespace in our OpenShift cluster.
|
||||
|
||||
4. (Optional) Create a persistent volume to persist the configuration file and recordings in a volume.
|
||||
|
||||
5. Deploy Kerberos Agent through a `deployment`.
|
||||
|
||||
6. Expose the Kerberos Agent web interface through a `LoadBalancer`; public internet accessible IP address.
|
||||
|
||||
## Run the playbook
|
||||
|
||||
Now you understand what is happening in the playbook, let's run it. Make sure you have `ansible` install on your `host` or `deploy` machine.
|
||||
|
||||
Specify the `environment` input variable as a `JSON` with all required variables defined in step 1. Reference the `playbook.yml` file and execute.
|
||||
|
||||
ansible-playbook -e '{ \
|
||||
"oc_cluster_url":"https://api.j5z0adui.westeurope.aroapp.io:6443", \
|
||||
"oc_username":"kubeadmin",\
|
||||
"oc_password":"xxx" \
|
||||
}' playbook.yml
|
||||
|
||||
If everything runs as expected you should see you Kerberos Agent deployed, together with an assigned public ip address. Paste the ip address in your browser, the Kerberos Agent web interface will show up. You can use [the default username and password to sign-in](https://github.com/kerberos-io/agent#access-the-kerberos-agent), or if changed to your own (which is recommended).
|
||||
140
deployments/ansible-openshift/playbook.yml
Normal file
@@ -0,0 +1,140 @@
|
||||
- hosts: localhost
|
||||
vars:
|
||||
- oc_cluster_url: ""
|
||||
- oc_username: ""
|
||||
- oc_password: ""
|
||||
tasks:
|
||||
- name: Print Variables
|
||||
debug:
|
||||
msg: "OpenShift url: {{ oc_cluster_url }}, OpenShift username: {{ oc_username }}, OpenShift password: {{ oc_password }}"
|
||||
|
||||
- name: Try to login to OCP cluster
|
||||
k8s_auth:
|
||||
host: "{{ oc_cluster_url }}"
|
||||
username: "{{ oc_username }}"
|
||||
password: "{{ oc_password }}"
|
||||
validate_certs: no
|
||||
register: k8s_auth_result
|
||||
|
||||
- name: Create a Namespace
|
||||
k8s:
|
||||
state: present
|
||||
host: "{{ oc_cluster_url }}"
|
||||
api_key: "{{ k8s_auth_result.k8s_auth.api_key }}"
|
||||
validate_certs: no
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: kerberos
|
||||
register: k8s_namespace_result
|
||||
|
||||
- name: Create a Persistent volume claim
|
||||
k8s:
|
||||
state: present
|
||||
host: "{{ oc_cluster_url }}"
|
||||
api_key: "{{ k8s_auth_result.k8s_auth.api_key }}"
|
||||
validate_certs: no
|
||||
namespace: kerberos
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: kerberos-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
storageClassName: managed-premium
|
||||
register: pvc_result
|
||||
|
||||
- name: Deploy Kerberos Agent
|
||||
k8s:
|
||||
state: present
|
||||
apply: yes
|
||||
namespace: kerberos
|
||||
host: "{{ oc_cluster_url }}"
|
||||
api_key: "{{ k8s_auth_result.k8s_auth.api_key }}"
|
||||
validate_certs: no
|
||||
definition:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: agent
|
||||
labels:
|
||||
name: agent
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: agent
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: agent
|
||||
spec:
|
||||
volumes:
|
||||
- name: kerberos-data
|
||||
persistentVolumeClaim:
|
||||
claimName: kerberos-data
|
||||
|
||||
initContainers:
|
||||
- name: download-config
|
||||
image: kerberos/agent:latest
|
||||
volumeMounts:
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/config
|
||||
subPath: config
|
||||
command:
|
||||
[
|
||||
"cp",
|
||||
"/home/agent/data/config.template.json",
|
||||
"/home/agent/data/config/config.json",
|
||||
]
|
||||
|
||||
containers:
|
||||
- name: agent
|
||||
image: kerberos/agent:latest
|
||||
volumeMounts:
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/config
|
||||
subPath: config
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/recordings
|
||||
subPath: recordings
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/snapshots
|
||||
subPath: snapshots
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/cloud
|
||||
subPath: cloud
|
||||
ports:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
|
||||
- name: Expose Kerberos Agent
|
||||
k8s:
|
||||
state: present
|
||||
apply: yes
|
||||
namespace: kerberos
|
||||
host: "{{ oc_cluster_url }}"
|
||||
api_key: "{{ k8s_auth_result.k8s_auth.api_key }}"
|
||||
validate_certs: no
|
||||
definition:
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: agent-svc
|
||||
labels:
|
||||
name: agent-svc
|
||||
spec:
|
||||
selector:
|
||||
app: agent
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
protocol: TCP
|
||||
31
deployments/balena/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Deployment with Balena
|
||||
|
||||
Balena Cloud provide a seamless way of building and deploying applications at scale through the conceps of `blocks`, `apps` and `fleets`. Once you have your `app` deployed, for example our Kerberos Agent, you can benefit from features such as: remote access, over the air updates, an encrypted public `https` endpoint and many more.
|
||||
|
||||
We provide two mechanisms to deploy Kerberos Agent to a Balena Cloud fleet:
|
||||
|
||||
1. Use Kerberos Agent as [a block part of your application](https://github.com/kerberos-io/balena-agent-block).
|
||||
2. Use Kerberos Agent as [a stand-alone application](https://github.com/kerberos-io/balena-agent).
|
||||
|
||||
## Block
|
||||
|
||||
Within Balena you can build the concept of a block, which is the equivalent of container image or a function in a typical programming language. The idea of blocks, you can find a more thorough explanation [here](https://docs.balena.io/learn/develop/blocks/), is that you can compose and combine multiple `blocks` to level up to the concept an `app`.
|
||||
|
||||
You as a developer can choose which `blocks` you would like to use, to build the desired `application` state you prefer. For example you can use the [Kerberos Agent block](https://hub.balena.io/blocks/2064662/agent) to compose a video surveillance system as part of your existing set of blocks.
|
||||
|
||||
You can the `Kerberos Agent` block by defining following elements in your `compose` file.
|
||||
|
||||
agent:
|
||||
image: bh.cr/kerberos_io/agent
|
||||
|
||||
## App
|
||||
|
||||
Next to building individual `blocks` you as a developer can also decide to build up an application, composed of one or more `blocks` or third-party containers, and publish it as an `app` to the Balena Hub. This is exactly [what we've done..](https://hub.balena.io/apps/2064752/video-surveillance)
|
||||
|
||||
On Balena Hub we have created the []`video-surveillance` application](https://hub.balena.io/apps/2064752/video-surveillance) that utilises the [Kerberos Agent `block`](https://hub.balena.io/blocks/2064662/agent). The idea of this application is that utilises the foundation of our Kerberos Agent, but that it might include more `blocks` over time to increase and improve functionalities from other community projects.
|
||||
|
||||
To deploy the application you can simply press below `Deploy button` or you can navigate to the [Balena Hub apps page](https://hub.balena.io/apps/2064752/video-surveillance).
|
||||
|
||||
[](https://dashboard.balena-cloud.com/deploy?repoUrl=https://github.com/kerberos-io/agent)
|
||||
|
||||
You can find the source code, `balena.yaml` and `docker-compose.yaml` files in the [`balena-agent` repository](https://github.com/kerberos-io/balena-agent).
|
||||
34
deployments/binary/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Binary
|
||||
|
||||
Kerberos Agents are now also shipped as static binaries. Within the Docker image build, we are extracting the Kerberos Agent binary and are [uploading them to the releases page](https://github.com/kerberos-io/agent/releases) in the repository. By opening a release you'll find a `.tar` with the relevant files.
|
||||
|
||||
- `main`: this is the Kerberos Agent binary.
|
||||
- `data`: the folder containing the recorded video, configuration, etc.
|
||||
- `mp4fragment`: a binary to transform MP4s to Fragmented MP4s.
|
||||
- `www`: the Kerberos Agent ui (compiled React app).
|
||||
|
||||
You can run the binary as following on port `8080`:
|
||||
|
||||
main -action=run -port=80
|
||||
|
||||
## Systemd
|
||||
|
||||
When running on a Linux OS you might consider to auto-start the Kerberos Agent using systemd. Create a file called `/etc/systemd/system/kerberos-agent.service` and copy-paste following configuration. Update the `WorkingDirectory` and `ExecStart` accordingly.
|
||||
|
||||
[Unit]
|
||||
Wants=network.target
|
||||
[Service]
|
||||
ExecStart=/home/pi/agent/main -action=run -port=80
|
||||
WorkingDirectory=/home/pi/agent/
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
To load your new service, we'll execute following commands.
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable kerberos-agent
|
||||
sudo systemctl start kerberos-agent
|
||||
|
||||
Confirm the service is running:
|
||||
|
||||
sudo systemctl status kerberos-agent
|
||||
92
deployments/docker/README.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Deployment with Docker
|
||||
|
||||
The easiest, and let's say most natural, deployment is done [by utilising `docker`](#1-running-a-single-container). Docker can run a stand-alone, single, Kerberos Agent (or container) and a bigger set of Kerberos Agents (or containers) [through `docker compose`](#2-running-multiple-containers-with-docker-compose).
|
||||
|
||||
## 1. Running a single container
|
||||
|
||||
We are creating Docker images as part of our CI/CD process. You'll find our Docker images on [Docker hub](https://hub.docker.com/r/kerberos/agent). Pick a specific tag of choice, or use latest. Once done run below command, this will open the web interface of your Kerberos agent on port 80.
|
||||
|
||||
docker run -p 80:80 --name mycamera -d kerberos/agent:latest
|
||||
|
||||
Or for a develop build:
|
||||
|
||||
docker run -p 80:80 --name mycamera -d kerberos/agent-dev:latest
|
||||
|
||||
Feel free to use another port if your host system already has a workload running on `80`. For example `8082`.
|
||||
|
||||
docker run -p 8082:80 --name mycamera -d kerberos/agent:latest
|
||||
|
||||
### Attach a volume
|
||||
|
||||
By default your Kerberos agent will store all its configuration and recordings inside the container. It might be interesting to store both configuration and your recordings outside the container, on your local disk. This helps persisting your storage even after you decide to wipe out your Kerberos agent.
|
||||
|
||||
You attach a volume to your container by leveraging the `-v` option. To mount your own configuration file, execute as following:
|
||||
|
||||
1. Decide where you would like to store your configuration and recordings; create a new directory for the config file and recordings folder accordingly.
|
||||
|
||||
mkdir agent
|
||||
mkdir agent/config
|
||||
mkdir agent/recordings
|
||||
|
||||
2. Once you have located your desired directory, copy the latest [`config.json`](https://github.com/kerberos-io/agent/blob/master/machinery/data/config/config.json) file into your config directory.
|
||||
|
||||
wget https://raw.githubusercontent.com/kerberos-io/agent/master/machinery/data/config/config.json -O agent/config/config.json
|
||||
|
||||
3. Run the docker command as following to attach your config directory and recording directory.
|
||||
|
||||
docker run -p 80:80 --name mycamera \
|
||||
-v $(pwd)/agent/config:/home/agent/data/config \
|
||||
-v $(pwd)/agent/recordings:/home/agent/data/recordings \
|
||||
-d --restart=always kerberos/agent:latest
|
||||
|
||||
### 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
|
||||
|
||||
When running multiple containers, you could execute the above process multiple times, or a better way is to run a `docker compose` with predefined configuration file, a `docker-compose.yaml`.
|
||||
|
||||
You'll find [an example `docker-compose.yaml` file here](https://github.com/kerberos-io/agent/blob/master/deployments/docker/docker-compose.yaml). This configuration file includes a definition for running 3 Kerberos Agents (or containers). By specifying environment variables you can override the internal configuration. To add more Kerberos Agents to your deployment, just `copy-paste` a `service` block and modify the name, exposed port, and settings accordingly.
|
||||
|
||||
kerberos-agent2:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8082:80"
|
||||
environment:
|
||||
- AGENT_NAME=agent2
|
||||
- AGENT_CAPTURE_IPCAMERA_RTSP=rtsp://x.x.x.x:554/Streaming/Channels/101
|
||||
- AGENT_HUB_KEY=yyy
|
||||
- AGENT_HUB_PRIVATE_KEY=yyy
|
||||
|
||||
#### Attaching volumes
|
||||
|
||||
As described in [1. Running a single container](#1-running-a-single-container) you can also assign volumes to your `docker compose` services. A volume can be added to persist the recordings of your Kerberos Agents on the host machine, or to provide more accurate configurations.
|
||||
|
||||
When attaching a volume for persisting recordings or mounting configuration files from the host system. the `docker-compose.yaml` would look like this.
|
||||
|
||||
Let's start by creating some directories on your host system. We'll consider 3 Kerberos Agents in this example.
|
||||
|
||||
mkdir -p agent1/config agent1/recordings
|
||||
mkdir -p agent2/config agent2/recordings
|
||||
mkdir -p agent3/config agent3/recordings
|
||||
|
||||
Download the configuration file in each Kerberos Agent configuration directory.
|
||||
|
||||
wget https://raw.githubusercontent.com/kerberos-io/agent/master/machinery/data/config/config.json -O agent1/config/config.json
|
||||
wget https://raw.githubusercontent.com/kerberos-io/agent/master/machinery/data/config/config.json -O agent2/config/config.json
|
||||
wget https://raw.githubusercontent.com/kerberos-io/agent/master/machinery/data/config/config.json -O agent3/config/config.json
|
||||
|
||||
Next we'll add a `volumes:` section to each Kerberos Agent (service) in the `docker-compose-with-volumes.yaml` file.
|
||||
|
||||
volumes:
|
||||
- ./agent1/config:/home/agent/data/config
|
||||
- ./agent1/recordings:/home/agent/data/recordings
|
||||
|
||||
We'll repeat that for the other Kerberos Agents as well. You can review [the final result over here](https://github.com/kerberos-io/agent/blob/master/deployments/docker/docker-compose-with-volumes.yaml).
|
||||
|
||||
Run the `docker compose` command by providing a different configuration file name.
|
||||
|
||||
docker compose -f docker-compose-with-volumes.yaml up
|
||||
|
||||
Please note that you can use a combination of using a configuration file and environment variables at the same time. However environment variables will always override the setting in your configuration file.
|
||||
27
deployments/docker/docker-compose-with-volumes.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
kerberos-agent1:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8081:80"
|
||||
environment:
|
||||
- AGENT_NAME=agent1
|
||||
# You can still override the configuration with environment variables, but might not makes sense if you are attaching a host config.
|
||||
# find full list of environment variables here: https://github.com/kerberos-io/agent#override-with-environment-variables
|
||||
volumes:
|
||||
- ./agent1/config:/home/agent/data/config
|
||||
- ./agent1/recordings:/home/agent/data/recordings
|
||||
kerberos-agent2:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- ./agent2/config:/home/agent/data/config
|
||||
- ./agent2/recordings:/home/agent/data/recordings
|
||||
kerberos-agent3:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8083:80"
|
||||
volumes:
|
||||
- ./agent3/config:/home/agent/data/config
|
||||
- ./agent3/recordings:/home/agent/data/recordings
|
||||
38
deployments/docker/docker-compose.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
version: "3.9"
|
||||
x-common-variables: &common-variables
|
||||
# Add variables here to add them to all agents
|
||||
AGENT_HUB_KEY: "xxxxx" # The access key linked to your account in Kerberos Hub.
|
||||
AGENT_HUB_PRIVATE_KEY: "xxxxx" # The secret access key linked to your account in Kerberos Hub.
|
||||
# find full list of environment variables here: https://github.com/kerberos-io/agent#override-with-environment-variables
|
||||
services:
|
||||
kerberos-agent1:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8081:80"
|
||||
environment:
|
||||
<<: *common-variables
|
||||
AGENT_NAME: agent1
|
||||
AGENT_CAPTURE_IPCAMERA_RTSP: rtsp://username:password@x.x.x.x/Streaming/Channels/101 # Hikvision camera RTSP url example
|
||||
AGENT_KEY: "1"
|
||||
kerberos-agent2:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8082:80"
|
||||
environment:
|
||||
<<: *common-variables
|
||||
AGENT_NAME: agent2
|
||||
AGENT_CAPTURE_IPCAMERA_RTSP: rtsp://username:password@x.x.x.x/channel1 # Linksys camera RTSP url example
|
||||
AGENT_KEY: "2"
|
||||
kerberos-agent3:
|
||||
image: "kerberos/agent:latest"
|
||||
ports:
|
||||
- "8083:80"
|
||||
environment:
|
||||
<<: *common-variables
|
||||
AGENT_NAME: agent3
|
||||
AGENT_CAPTURE_IPCAMERA_RTSP: rtsp://username:password@x.x.x.x/cam/realmonitor?channel=1&subtype=1 # Dahua camera RTSP url example
|
||||
AGENT_KEY: "3"
|
||||
networks:
|
||||
default:
|
||||
name: cluster-net
|
||||
external: true
|
||||
9
deployments/factory/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Deploy with Kerberos Factory
|
||||
|
||||
All of the previously deployments, `docker`, `kubernetes` and `openshift` are great for a technical audience. However for business users, it might be more convenient to have a clean web ui, that one can leverage to add one or more cameras (Kerberos Agents), without the hassle of the technical resources.
|
||||
|
||||
That's exactly why we have build [Kerberos Factory](https://github.com/kerberos-io/factory). It's a web ui on top of a Kubernetes cluster, which allows a non-technical users to administer and configure a video landscape.
|
||||
|
||||

|
||||
|
||||
The idea of [Kerberos Factory](https://github.com/kerberos-io/factory) is that one can configure and deploy a camera, by filling-in some basic input fields. Once done [Kerberos Factory](https://github.com/kerberos-io/factory), will create the relevant resources in your Kubernetes cluster.
|
||||
BIN
deployments/factory/factory-login.gif
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
111
deployments/kubernetes/README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Deployment with Kubernetes
|
||||
|
||||
As described in the [Deployment with Docker](https://github.com/kerberos-io/agent/tree/master/deployments/docker), `docker` is a great tool for smaller deployments, where you are just running on a single machine and want to ramp up quickly. As you might expect, this is a not an ideal situation for production deployments.
|
||||
|
||||
Kubernetes can help you to build a scalable, flexible and resilient deployment. By introducing the concept of multi-nodes and deployments, you can make sure your Kerberos Agents are evenly distributed across your different machines, and you can add more nodes when you need to scale out.
|
||||
|
||||
We've provided an example deployment `deployment-agent.yml` in this directory, which show case you have to create a deployment (and under the hood a pod), to run a Kerberos Agent workload.
|
||||
|
||||
## Create a Kerberos Agent deployment
|
||||
|
||||
It's always a best practices to isolate and structure your workloads in Kubernetes. To achieve this we are utilising the concept of namespaces. For this example we will create a new namespace `demo`.
|
||||
|
||||
kubectl create namespace demo
|
||||
|
||||
Now we have a namespace, have a look at `deployment-agent.yml` in this folder. This configuration file describes the Kubernetes resources we would like to create, and how the Kerberos Agent needs to behave: environment variables, container ports, etc. At the bottom of the file, we find a `service` part, this tells Kubernetes to expose the Kerberos Agent user interface on a publicly accessible IP address. **_Please note that you don't need to expose this, as you can configure the Kerberos Agent with a volume and/or environment variables._**
|
||||
|
||||
Let's move on, and apply the Kerberos Agent deployment and service.
|
||||
|
||||
kubectl apply -f deployment-agent.yml -n demo
|
||||
|
||||
Watch deployment and service to be ready.
|
||||
|
||||
watch kubectl get all -n demo
|
||||
|
||||
When the deployment and service is created successfully, you should see something like this.
|
||||
|
||||
Every 2.0s: kubectl get all -n demo Fri Dec 9 16:33:17 2022
|
||||
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
pod/agent-7c75c4dbcf-zxrb5 1/1 Running 0 19s
|
||||
|
||||
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
|
||||
service/agent-svc LoadBalancer 10.x.x.x 108.x.x.x 80:32664/TCP 20s
|
||||
|
||||
NAME READY UP-TO-DATE AVAILABLE AGE
|
||||
deployment.apps/agent 1/1 1 1 20s
|
||||
|
||||
NAME DESIRED CURRENT READY AGE
|
||||
replicaset.apps/agent-7c75c4dbcf 1 1 1 20s
|
||||
|
||||
When copying the `EXTERNAL-IP` and pasting it in your browser, you should see the Kerberos Agent user interface. You can use [the default username and password to sign-in](https://github.com/kerberos-io/agent#access-the-kerberos-agent), or if changed to your own (which is recommended).
|
||||
|
||||
## Configure with volumes
|
||||
|
||||
Just like with `docker`, you can also attach `volumes` to the Kerberos Agent deployment, by creating a `Persistent Volume` and mount it to a specific directory.
|
||||
|
||||
Depending on where and how you are hosting the Kubernetes cluster, you may need to create a new `storageClass` or use a predefined `storageClass` from your cloud provider (Azure, GCP, AWS, ..). Have a look at `deployment-agent-volume.yml` to review a complete example.
|
||||
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: agent
|
||||
spec:
|
||||
volumes:
|
||||
- name: kerberos-data
|
||||
persistentVolumeClaim:
|
||||
claimName: kerberos-data
|
||||
...
|
||||
containers:
|
||||
- name: agent
|
||||
image: kerberos/agent:latest
|
||||
volumeMounts:
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/config
|
||||
subPath: config
|
||||
...
|
||||
|
||||
## Expose with Ingress
|
||||
|
||||
In the first example `deployment-agent.yml` we are using a `LoadBalancer` to expose the Kerberos Agent user interface; as shown below. If you are a bit more experienced with Kubernetes, you will know there are other `service types` as well.
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
...
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- port: 80
|
||||
...
|
||||
|
||||
An alternative to `LoadBalancer` is `Ingress`. By leveraging an ingress such as `ingress-nginx` or `traefik` you setup a gateway (single point of contact), through which all communication to your apps (services) will flow.
|
||||
|
||||
A huge benefit (there are many others), is that you only allocate 1 public IP address for all your services. So instead of creating a `LoadBalancer` and thus a public IP address for every agent, you will create an `Ingress` service for each agent. Review the complete example at `deployment-agent-with-ingress.yml`.
|
||||
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: agent-ingress
|
||||
labels:
|
||||
name: agent-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
kubernetes.io/tls-acme: "true"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- "myagent.kerberos.io"
|
||||
secretName: agent-secret
|
||||
rules:
|
||||
- host: myagent.kerberos.io
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: "/"
|
||||
backend:
|
||||
service:
|
||||
name: agent-svc
|
||||
port:
|
||||
number: 80
|
||||
54
deployments/kubernetes/deployment-agent-volume.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: agent
|
||||
labels:
|
||||
name: agent
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: agent
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: agent
|
||||
spec:
|
||||
volumes:
|
||||
- name: kerberos-data
|
||||
persistentVolumeClaim:
|
||||
claimName: kerberos-data
|
||||
|
||||
initContainers:
|
||||
- name: download-config
|
||||
image: kerberos/agent:latest
|
||||
volumeMounts:
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/config
|
||||
subPath: config
|
||||
command:
|
||||
[
|
||||
"cp",
|
||||
"/home/agent/data/config.template.json",
|
||||
"/home/agent/data/config/config.json",
|
||||
]
|
||||
|
||||
containers:
|
||||
- name: agent
|
||||
image: kerberos/agent:latest
|
||||
volumeMounts:
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/config
|
||||
subPath: config
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/recordings
|
||||
subPath: recordings
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/snapshots
|
||||
subPath: snapshots
|
||||
- name: kerberos-data
|
||||
mountPath: /home/agent/data/cloud
|
||||
subPath: cloud
|
||||
ports:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
82
deployments/kubernetes/deployment-agent-with-ingress.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: agent
|
||||
labels:
|
||||
name: agent
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: agent
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: agent
|
||||
spec:
|
||||
containers:
|
||||
- name: agent
|
||||
image: kerberos/agent:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
env:
|
||||
- name: AGENT_NAME
|
||||
value: demo-agent
|
||||
- name: AGENT_CAPTURE_IPCAMERA_RTSP
|
||||
value: rtsp://fake.kerberos.io/stream
|
||||
- name: AGENT_HUB_KEY
|
||||
value: yyy
|
||||
- name: AGENT_HUB_PRIVATE_KEY
|
||||
value: yyy
|
||||
# find full list of environment variables here: https://github.com/kerberos-io/agent#override-with-environment-variables
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: agent-svc
|
||||
labels:
|
||||
name: agent-svc
|
||||
spec:
|
||||
#type: LoadBalancer
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
selector:
|
||||
app: agent
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: agent-ingress
|
||||
labels:
|
||||
name: agent-ingress
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
kubernetes.io/tls-acme: "true"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- "demo.kerberos.io"
|
||||
secretName: agent-secret
|
||||
rules:
|
||||
- host: demo.kerberos.io
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: "/"
|
||||
backend:
|
||||
service:
|
||||
name: agent-svc
|
||||
port:
|
||||
number: 80
|
||||
|
||||
53
deployments/kubernetes/deployment-agent.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: agent
|
||||
labels:
|
||||
name: agent
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: agent
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: agent
|
||||
spec:
|
||||
containers:
|
||||
- name: agent
|
||||
image: kerberos/agent:3.2.3
|
||||
ports:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 512Mi
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
env:
|
||||
- name: AGENT_NAME
|
||||
value: demo-agent
|
||||
- name: AGENT_CAPTURE_IPCAMERA_RTSP
|
||||
value: rtsp://fake.kerberos.io/stream
|
||||
- name: AGENT_HUB_KEY
|
||||
value: yyy
|
||||
- name: AGENT_HUB_PRIVATE_KEY
|
||||
value: yyy
|
||||
# find full list of environment variables here: https://github.com/kerberos-io/agent#override-with-environment-variables
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: agent-svc
|
||||
labels:
|
||||
name: agent-svc
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
selector:
|
||||
app: agent
|
||||
15
deployments/snap/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Deployment with Snap Store
|
||||
|
||||
By browsing to the Snap Store, you'll be able [to find our own snap `Kerberos Agent`](https://snapcraft.io/kerberosio). You can either install the `Kerberos Agent` through the command line.
|
||||
|
||||
snap install kerberosio
|
||||
|
||||
Or use the Desktop client to have a visual interface.
|
||||
|
||||

|
||||
|
||||
Once installed you can find your Kerberos Agent configration at `/var/snap/kerberosio/common`. Run the Kerberos Agent as following.
|
||||
|
||||
sudo kerberosio.agent -action=run -port=80
|
||||
|
||||
If successfull you'll be able to browse to port `80` or if you defined a different port. This will open the Kerberos Agent interface.
|
||||
BIN
deployments/snap/snapstore.png
Normal file
|
After Width: | Height: | Size: 616 KiB |
41
deployments/terraform/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Deployment with Terraform
|
||||
|
||||
If you are using Terraform as part of your DevOps stack, you might utilise it to deploy your Kerberos Agents. Within this deployment folder we have added an example Terraform file `docker.tf`, which installs the Kerberos Agent `docker` container on a remote system over `SSH`. We might create our own provider in the future, or add additional examples for example `snap`, `kubernetes`, etc.
|
||||
|
||||
For this example we will install Kerberos Agent using `docker` on a remote `linux` machine. Therefore we'll make sure we have the `TelkomIndonesia/linux` provider initialised.
|
||||
|
||||
terraform init
|
||||
|
||||
Once initialised you should see similar output:
|
||||
|
||||
Initializing the backend...
|
||||
|
||||
Initializing provider plugins...
|
||||
- Reusing previous version of telkomindonesia/linux from the dependency lock file
|
||||
- Using previously-installed telkomindonesia/linux v0.7.0
|
||||
|
||||
Go and open the `docker.tf` file and locate the `linux` provider, modify following credentials accordingly. Make sure they match for creating an `SSH` connection.
|
||||
|
||||
provider "linux" {
|
||||
host = "x.y.z.u"
|
||||
port = 22
|
||||
user = "root"
|
||||
password = "password"
|
||||
}
|
||||
|
||||
Apply the `docker.tf` file, to install `docker` and the `kerberos/agent` docker container.
|
||||
|
||||
terraform apply
|
||||
|
||||
Once done you should see following output, and you should be able to reach the remote machine on port `80` or if configured differently the specified port you've defined.
|
||||
|
||||
Do you want to perform these actions?
|
||||
Terraform will perform the actions described above.
|
||||
Only 'yes' will be accepted to approve.
|
||||
|
||||
Enter a value: yes
|
||||
|
||||
linux_script.install_docker_kerberos_agent: Modifying... [id=a56cf7b0-db66-4f9b-beec-8a4dcef2a0c7]
|
||||
linux_script.install_docker_kerberos_agent: Modifications complete after 3s [id=a56cf7b0-db66-4f9b-beec-8a4dcef2a0c7]
|
||||
|
||||
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
|
||||
47
deployments/terraform/docker.tf
Normal file
@@ -0,0 +1,47 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
linux = {
|
||||
source = "TelkomIndonesia/linux"
|
||||
version = "0.7.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "linux" {
|
||||
host = "x.y.z.u"
|
||||
port = 22
|
||||
user = "root"
|
||||
password = "password"
|
||||
}
|
||||
|
||||
locals {
|
||||
image = "kerberos/agent"
|
||||
version = "latest"
|
||||
port = 80
|
||||
}
|
||||
|
||||
resource "linux_script" "install_docker" {
|
||||
lifecycle_commands {
|
||||
create = "apt update && apt install -y $PACKAGE_NAME"
|
||||
read = "apt-cache policy $PACKAGE_NAME | grep 'Installed:' | grep -v '(none)' | awk '{ print $2 }' | xargs | tr -d '\n'"
|
||||
update = "apt update && apt install -y $PACKAGE_NAME"
|
||||
delete = "apt remove -y $PACKAGE_NAME"
|
||||
}
|
||||
environment = {
|
||||
PACKAGE_NAME = "docker"
|
||||
}
|
||||
}
|
||||
|
||||
resource "linux_script" "install_docker_kerberos_agent" {
|
||||
lifecycle_commands {
|
||||
create = "docker pull $IMAGE:$VERSION && docker run -d -p $PORT:80 --name agent $IMAGE:$VERSION"
|
||||
read = "docker inspect agent"
|
||||
update = "docker pull $IMAGE:$VERSION && docker rm agent --force && docker run -d -p $PORT:80 --name agent $IMAGE:$VERSION"
|
||||
delete = "docker rm agent --force"
|
||||
}
|
||||
environment = {
|
||||
IMAGE = local.image
|
||||
VERSION = local.version
|
||||
PORT = local.port
|
||||
}
|
||||
}
|
||||
BIN
machinery/.DS_Store
vendored
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).
|
||||
|
||||
636
machinery/cmd/mp4analyze/main.go
Normal file
@@ -0,0 +1,636 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/Eyevinn/mp4ff/avc"
|
||||
mp4ff "github.com/Eyevinn/mp4ff/mp4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fromFlag := flag.Int64("from", -1, "start of the detailed inspection window (track timescale units); default auto-detects the largest keyframe gap")
|
||||
toFlag := flag.Int64("to", -1, "end of the detailed inspection window (track timescale units); default auto-detected")
|
||||
flag.Parse()
|
||||
if flag.NArg() < 1 {
|
||||
fmt.Println("usage: mp4analyze [-from N] [-to N] <file.mp4>")
|
||||
os.Exit(1)
|
||||
}
|
||||
f, err := os.Open(flag.Arg(0))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer f.Close()
|
||||
parsed, err := mp4ff.DecodeFile(f)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Movie-level info
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
moov := parsed.Init.Moov
|
||||
fmt.Printf("ftyp/moov present. timescale(mvhd)=%d duration(mvhd)=%d\n",
|
||||
moov.Mvhd.Timescale, moov.Mvhd.Duration)
|
||||
for _, trak := range moov.Traks {
|
||||
ts := trak.Mdia.Mdhd.Timescale
|
||||
fmt.Printf(" trak id=%d handler=%s mdhd.timescale=%d mdhd.duration=%d\n",
|
||||
trak.Tkhd.TrackID, trak.Mdia.Hdlr.HandlerType, ts, trak.Mdia.Mdhd.Duration)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("no Init/Moov (pure fragmented stream?)")
|
||||
}
|
||||
|
||||
// sidx vs actual segment layout. MSE players use sidx to map presentation
|
||||
// time -> byte ranges; if sidx references disagree with the real segment
|
||||
// sizes/durations (e.g. after an early/short flush) the player fetches the
|
||||
// wrong bytes and fails to decode — a failure that "heals" on seek.
|
||||
fmt.Println("=== sidx references vs actual segments ===")
|
||||
var sidxRefs []mp4ff.SidxRef
|
||||
for _, c := range parsed.Children {
|
||||
if s, ok := c.(*mp4ff.SidxBox); ok {
|
||||
fmt.Printf(" sidx: timescale=%d earliestPresTime=%d firstOffset=%d refCount=%d anchor(after sidx)=%d\n",
|
||||
s.Timescale, s.EarliestPresentationTime, s.FirstOffset, len(s.SidxRefs), s.AnchorPoint)
|
||||
sidxRefs = s.SidxRefs
|
||||
}
|
||||
}
|
||||
// Actual segment sizes (styp+moof+mdat) and fragment durations.
|
||||
type segInfo struct {
|
||||
size uint64
|
||||
dur uint64
|
||||
}
|
||||
var actual []segInfo
|
||||
for _, seg := range parsed.Segments {
|
||||
var sz uint64
|
||||
if seg.Styp != nil {
|
||||
sz += seg.Styp.Size()
|
||||
}
|
||||
if seg.Sidx != nil {
|
||||
sz += seg.Sidx.Size()
|
||||
}
|
||||
var dur uint64
|
||||
for _, fr := range seg.Fragments {
|
||||
sz += fr.Moof.Size()
|
||||
if fr.Mdat != nil {
|
||||
sz += fr.Mdat.Size()
|
||||
}
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
dur += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
actual = append(actual, segInfo{size: sz, dur: dur})
|
||||
}
|
||||
for i := range actual {
|
||||
refStr := "(no sidx ref)"
|
||||
if i < len(sidxRefs) {
|
||||
r := sidxRefs[i]
|
||||
mark := ""
|
||||
if uint64(r.ReferencedSize) != actual[i].size {
|
||||
mark += fmt.Sprintf(" SIZE MISMATCH actual=%d", actual[i].size)
|
||||
}
|
||||
if uint64(r.SubSegmentDuration) != actual[i].dur {
|
||||
mark += fmt.Sprintf(" DUR MISMATCH actual=%d", actual[i].dur)
|
||||
}
|
||||
refStr = fmt.Sprintf("sidx.size=%d sidx.dur=%d type=%d sap=%d/%d%s",
|
||||
r.ReferencedSize, r.SubSegmentDuration, r.ReferenceType, r.StartsWithSAP, r.SAPType, mark)
|
||||
}
|
||||
fmt.Printf(" seg%02d actual.size=%d actual.dur=%d | %s\n", i, actual[i].size, actual[i].dur, refStr)
|
||||
}
|
||||
|
||||
fmt.Println("=== fragments ===")
|
||||
fragIdx := 0
|
||||
var allKeyGlobal []uint64 // global keyframe decode times (track timescale units)
|
||||
var prevTfdtEnd = map[uint32]uint64{}
|
||||
for si, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
tid := traf.Tfhd.TrackID
|
||||
tfdt := traf.Tfdt.BaseMediaDecodeTime()
|
||||
offset := uint64(0)
|
||||
var keys []uint64 // keyframe offset-from-tfdt
|
||||
var durs []uint64
|
||||
zeroDur := 0
|
||||
nSamples := 0
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
nSamples++
|
||||
if (s.Flags>>24)&0x03 == 0x02 { // sample_depends_on==2 => IDR/sync
|
||||
keys = append(keys, offset)
|
||||
if tid == 1 {
|
||||
allKeyGlobal = append(allKeyGlobal, tfdt+offset)
|
||||
}
|
||||
}
|
||||
if s.Dur == 0 {
|
||||
zeroDur++
|
||||
}
|
||||
durs = append(durs, uint64(s.Dur))
|
||||
offset += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
cont := ""
|
||||
if pe, ok := prevTfdtEnd[tid]; ok {
|
||||
if tfdt != pe {
|
||||
cont = fmt.Sprintf(" <-- tfdt GAP/JUMP prev_end=%d delta=%d", pe, int64(tfdt)-int64(pe))
|
||||
}
|
||||
}
|
||||
prevTfdtEnd[tid] = tfdt + offset
|
||||
if tid == 1 {
|
||||
// in-fragment keyframe gaps
|
||||
var gaps []int64
|
||||
for i := 1; i < len(keys); i++ {
|
||||
gaps = append(gaps, int64(keys[i])-int64(keys[i-1]))
|
||||
}
|
||||
fmt.Printf("seg%d frag%d trk%d tfdt=%d dur=%d nSamp=%d zeroDur=%d keys=%v inFragKeyGaps=%v%s\n",
|
||||
si, fragIdx, tid, tfdt, offset, nSamples, zeroDur, keys, gaps, cont)
|
||||
}
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== global video keyframe decode times & gaps ===")
|
||||
for i, k := range allKeyGlobal {
|
||||
gap := int64(0)
|
||||
if i > 0 {
|
||||
gap = int64(k) - int64(allKeyGlobal[i-1])
|
||||
}
|
||||
seam := ""
|
||||
if i > 1 {
|
||||
prevGap := int64(allKeyGlobal[i-1]) - int64(allKeyGlobal[i-2])
|
||||
if gap > 0 && prevGap > 0 && gap*2 < prevGap {
|
||||
seam = fmt.Sprintf(" <== SEAM? gap=%d < prevGap/2=%d", gap, prevGap/2)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" kf#%02d dt=%d gap=%d%s\n", i, k, gap, seam)
|
||||
}
|
||||
|
||||
// Choose the detailed-inspection window. By default centre it on the largest
|
||||
// keyframe gap (the most likely artifact location); -from/-to override.
|
||||
winLo, winHi := inspectWindow(allKeyGlobal, *fromFlag, *toFlag)
|
||||
fmt.Printf("=== detailed inspection window: dts %d..%d ===\n", winLo, winHi)
|
||||
|
||||
// Full sample timeline: DTS, CTS (=DTS+cto), composition offset, NAL types,
|
||||
// to detect PTS non-monotonicity / gaps / param-set changes at the seam.
|
||||
fmt.Println("=== per-sample timeline (full) — checking PTS monotonicity & nal types ===")
|
||||
var trex *mp4ff.TrexBox
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil && parsed.Init.Moov.Mvex != nil {
|
||||
for _, t := range parsed.Init.Moov.Mvex.Trexs {
|
||||
if t.TrackID == 1 {
|
||||
trex = t
|
||||
}
|
||||
}
|
||||
}
|
||||
var lastCTS int64 = -1
|
||||
var lastDTS int64 = -1
|
||||
sampIdx := 0
|
||||
fragIdx = 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fmt.Printf(" frag%d GetFullSamples err: %v\n", fragIdx, err)
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
dts := int64(s.DecodeTime)
|
||||
cts := dts + int64(s.CompositionTimeOffset)
|
||||
nals := nalTypes(s.Data)
|
||||
anomaly := ""
|
||||
if lastCTS >= 0 && cts < lastCTS {
|
||||
anomaly += fmt.Sprintf(" <== CTS BACKWARDS (prev=%d)", lastCTS)
|
||||
}
|
||||
if lastDTS >= 0 && dts < lastDTS {
|
||||
anomaly += fmt.Sprintf(" <== DTS BACKWARDS (prev=%d)", lastDTS)
|
||||
}
|
||||
// sample_is_non_sync_sample is bit 16 (0x00010000); a sync sample
|
||||
// has it clear and sample_depends_on==2 (i.e. an I-frame).
|
||||
isSync := s.Flags&0x00010000 == 0 && (s.Flags>>24)&0x03 == 0x02
|
||||
// Only print inside the inspection window and any anomalies, to keep output small.
|
||||
near := dts >= winLo && dts <= winHi
|
||||
if near || anomaly != "" {
|
||||
fmt.Printf(" s%04d frag%d dts=%d cts=%d cto=%d dur=%d size=%d sync=%v nal=%v%s\n",
|
||||
sampIdx, fragIdx, dts, cts, s.CompositionTimeOffset, s.Dur, len(s.Data), isSync, nals, anomaly)
|
||||
}
|
||||
lastCTS = cts
|
||||
lastDTS = dts
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
// Compare parameter sets: avcC (in moov) vs inline SPS/PPS at every IDR.
|
||||
// A looping source that restarts may re-emit SPS/PPS that differ from the
|
||||
// ones the player configured its decoder with from avcC — a classic cause
|
||||
// of a freeze that "heals" when you seek past the seam.
|
||||
fmt.Println("=== parameter set comparison (avcC vs inline IDR) ===")
|
||||
var avccSPS, avccPPS [][]byte
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
|
||||
continue
|
||||
}
|
||||
stsd := trak.Mdia.Minf.Stbl.Stsd
|
||||
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
|
||||
continue
|
||||
}
|
||||
avccSPS = stsd.AvcX.AvcC.SPSnalus
|
||||
avccPPS = stsd.AvcX.AvcC.PPSnalus
|
||||
}
|
||||
}
|
||||
for i, s := range avccSPS {
|
||||
fmt.Printf(" avcC SPS[%d] = %x\n", i, s)
|
||||
}
|
||||
for i, p := range avccPPS {
|
||||
fmt.Printf(" avcC PPS[%d] = %x\n", i, p)
|
||||
}
|
||||
fragIdx = 0
|
||||
sampIdx = 0
|
||||
var baseSPS, basePPS []byte
|
||||
if len(avccSPS) > 0 {
|
||||
baseSPS = avccSPS[0]
|
||||
}
|
||||
if len(avccPPS) > 0 {
|
||||
basePPS = avccPPS[0]
|
||||
}
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
spsList := nalsByType(s.Data, 7)
|
||||
ppsList := nalsByType(s.Data, 8)
|
||||
if len(spsList) > 0 || len(ppsList) > 0 {
|
||||
dts := int64(s.DecodeTime)
|
||||
note := ""
|
||||
if len(spsList) > 0 {
|
||||
if baseSPS == nil {
|
||||
baseSPS = spsList[0]
|
||||
} else if !bytesEqual(baseSPS, spsList[0]) {
|
||||
note += " <== SPS CHANGED vs base/avcC"
|
||||
}
|
||||
}
|
||||
if len(ppsList) > 0 {
|
||||
if basePPS == nil {
|
||||
basePPS = ppsList[0]
|
||||
} else if !bytesEqual(basePPS, ppsList[0]) {
|
||||
note += " <== PPS CHANGED vs base/avcC"
|
||||
}
|
||||
}
|
||||
var spsHex, ppsHex string
|
||||
if len(spsList) > 0 {
|
||||
spsHex = fmt.Sprintf("%x", spsList[0])
|
||||
}
|
||||
if len(ppsList) > 0 {
|
||||
ppsHex = fmt.Sprintf("%x", ppsList[0])
|
||||
}
|
||||
fmt.Printf(" IDR s%04d frag%d dts=%d SPS=%s PPS=%s%s\n",
|
||||
sampIdx, fragIdx, dts, spsHex, ppsHex, note)
|
||||
}
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
|
||||
sliceHeaders(parsed, trex, winLo, winHi)
|
||||
|
||||
summary(parsed, trex)
|
||||
}
|
||||
|
||||
func sliceHeaders(parsed *mp4ff.File, trex *mp4ff.TrexBox, winLo, winHi int64) {
|
||||
// Build SPS/PPS maps from avcC.
|
||||
spsMap := map[uint32]*avc.SPS{}
|
||||
ppsMap := map[uint32]*avc.PPS{}
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
if trak.Mdia == nil || trak.Mdia.Minf == nil || trak.Mdia.Minf.Stbl == nil {
|
||||
continue
|
||||
}
|
||||
stsd := trak.Mdia.Minf.Stbl.Stsd
|
||||
if stsd == nil || stsd.AvcX == nil || stsd.AvcX.AvcC == nil {
|
||||
continue
|
||||
}
|
||||
for _, s := range stsd.AvcX.AvcC.SPSnalus {
|
||||
if sps, err := avc.ParseSPSNALUnit(s, true); err == nil {
|
||||
spsMap[uint32(sps.ParameterID)] = sps
|
||||
}
|
||||
}
|
||||
for _, p := range stsd.AvcX.AvcC.PPSnalus {
|
||||
if pps, err := avc.ParsePPSNALUnit(p, spsMap); err == nil {
|
||||
ppsMap[pps.PicParameterSetID] = pps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== slice headers in inspection window (frame_num / poc / idr_pic_id) ===")
|
||||
fragIdx := 0
|
||||
sampIdx := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
fs, err := fr.GetFullSamples(trex)
|
||||
if err != nil {
|
||||
fragIdx++
|
||||
continue
|
||||
}
|
||||
for _, s := range fs {
|
||||
dts := int64(s.DecodeTime)
|
||||
if dts < winLo || dts > winHi {
|
||||
sampIdx++
|
||||
continue
|
||||
}
|
||||
for _, nal := range splitAVCC(s.Data) {
|
||||
t := nal[0] & 0x1f
|
||||
if t == 1 || t == 5 { // non-IDR or IDR slice
|
||||
sh, err := avc.ParseSliceHeader(nal, spsMap, ppsMap)
|
||||
if err != nil {
|
||||
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceHeader ERR: %v\n", sampIdx, fragIdx, dts, t, err)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" s%04d frag%d dts=%d nalType=%d sliceType=%v frameNum=%d idrPicId=%d pocLsb=%d\n",
|
||||
sampIdx, fragIdx, dts, t, sh.SliceType, sh.FrameNum, sh.IDRPicID, sh.PicOrderCntLsb)
|
||||
break
|
||||
}
|
||||
}
|
||||
sampIdx++
|
||||
}
|
||||
fragIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitAVCC splits a length-prefixed (4-byte) AVCC buffer into NAL units.
|
||||
func splitAVCC(b []byte) [][]byte {
|
||||
var out [][]byte
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
out = append(out, b[i:i+n])
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// nalTypes returns the list of H.264 NAL unit types present in an AVCC
|
||||
// (length-prefixed) sample buffer.
|
||||
func nalTypes(b []byte) []int {
|
||||
var out []int
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
out = append(out, int(b[i]&0x1f))
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nalsByType returns the raw NAL payloads (without length prefix) of the given
|
||||
// type from an AVCC (length-prefixed) sample buffer.
|
||||
func nalsByType(b []byte, want int) [][]byte {
|
||||
var out [][]byte
|
||||
i := 0
|
||||
for i+4 <= len(b) {
|
||||
n := int(uint32(b[i])<<24 | uint32(b[i+1])<<16 | uint32(b[i+2])<<8 | uint32(b[i+3]))
|
||||
i += 4
|
||||
if n <= 0 || i+n > len(b) {
|
||||
break
|
||||
}
|
||||
if int(b[i]&0x1f) == want {
|
||||
nal := make([]byte, n)
|
||||
copy(nal, b[i:i+n])
|
||||
out = append(out, nal)
|
||||
}
|
||||
i += n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inspectWindow returns the [lo,hi] decode-time range (track timescale units)
|
||||
// for which sample-level detail is printed. Explicit -from/-to win; otherwise
|
||||
// the window auto-centres on the largest gap between consecutive video
|
||||
// keyframes — the most likely location of a visible artifact — with a margin on
|
||||
// each side so the frames leading into and out of the gap are shown too.
|
||||
func inspectWindow(keyDecodeTimes []uint64, from, to int64) (int64, int64) {
|
||||
if from >= 0 || to >= 0 {
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if to < 0 {
|
||||
to = from + 2000
|
||||
}
|
||||
return from, to
|
||||
}
|
||||
if len(keyDecodeTimes) < 2 {
|
||||
return 0, 1 << 62
|
||||
}
|
||||
worstIdx, worstGap := 1, uint64(0)
|
||||
for i := 1; i < len(keyDecodeTimes); i++ {
|
||||
if g := keyDecodeTimes[i] - keyDecodeTimes[i-1]; g > worstGap {
|
||||
worstGap = g
|
||||
worstIdx = i
|
||||
}
|
||||
}
|
||||
const margin = 500
|
||||
lo := int64(keyDecodeTimes[worstIdx-1]) - margin
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
return lo, int64(keyDecodeTimes[worstIdx]) + margin
|
||||
}
|
||||
|
||||
// summary prints a compact, generic health report so a recording can be
|
||||
// validated at a glance without reading the full per-sample dump above.
|
||||
func summary(parsed *mp4ff.File, trex *mp4ff.TrexBox) {
|
||||
fmt.Println("=== SUMMARY (health checks) ===")
|
||||
|
||||
videoTracks, audioTracks := 0, 0
|
||||
var videoTimescale uint64 = 1
|
||||
if parsed.Init != nil && parsed.Init.Moov != nil {
|
||||
for _, trak := range parsed.Init.Moov.Traks {
|
||||
switch trak.Mdia.Hdlr.HandlerType {
|
||||
case "vide":
|
||||
videoTracks++
|
||||
if trak.Mdia.Mdhd.Timescale != 0 {
|
||||
videoTimescale = uint64(trak.Mdia.Mdhd.Timescale)
|
||||
}
|
||||
case "soun":
|
||||
audioTracks++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" tracks: %d video, %d audio\n", videoTracks, audioTracks)
|
||||
if audioTracks == 0 {
|
||||
fmt.Println(" note: no audio track is embedded in this file")
|
||||
}
|
||||
|
||||
type fragStat struct {
|
||||
idx int
|
||||
tfdt uint64
|
||||
dur uint64
|
||||
nSamp int
|
||||
nKeys int
|
||||
zeroDur int
|
||||
fps float64
|
||||
}
|
||||
var stats []fragStat
|
||||
var keyTimes []uint64
|
||||
var fpsArr []float64
|
||||
tfdtGaps := 0
|
||||
var prevEnd uint64
|
||||
havePrev := false
|
||||
fi := 0
|
||||
for _, seg := range parsed.Segments {
|
||||
for _, fr := range seg.Fragments {
|
||||
for _, traf := range fr.Moof.Trafs {
|
||||
if traf.Tfhd.TrackID != 1 {
|
||||
continue
|
||||
}
|
||||
st := fragStat{idx: fi, tfdt: traf.Tfdt.BaseMediaDecodeTime()}
|
||||
off := uint64(0)
|
||||
for _, trun := range traf.Truns {
|
||||
for _, s := range trun.Samples {
|
||||
st.nSamp++
|
||||
if (s.Flags>>24)&0x03 == 0x02 {
|
||||
st.nKeys++
|
||||
keyTimes = append(keyTimes, st.tfdt+off)
|
||||
}
|
||||
if s.Dur == 0 {
|
||||
st.zeroDur++
|
||||
}
|
||||
off += uint64(s.Dur)
|
||||
}
|
||||
}
|
||||
st.dur = off
|
||||
d := st.dur
|
||||
if d == 0 {
|
||||
d = 1
|
||||
}
|
||||
st.fps = float64(st.nSamp) * float64(videoTimescale) / float64(d)
|
||||
fpsArr = append(fpsArr, st.fps)
|
||||
if havePrev && st.tfdt != prevEnd {
|
||||
tfdtGaps++
|
||||
}
|
||||
prevEnd = st.tfdt + st.dur
|
||||
havePrev = true
|
||||
stats = append(stats, st)
|
||||
}
|
||||
fi++
|
||||
}
|
||||
}
|
||||
|
||||
medFps := medianFloat(fpsArr)
|
||||
fmt.Printf(" fragments: %d (video timescale=%d, median %.1f fps)\n", len(stats), videoTimescale, medFps)
|
||||
lowFps := 0
|
||||
totalZero := 0
|
||||
for _, st := range stats {
|
||||
totalZero += st.zeroDur
|
||||
flagStr := ""
|
||||
if medFps > 0 && st.fps < medFps*0.9 {
|
||||
lowFps++
|
||||
flagStr = " <== LOW FRAME RATE — likely dropped frames"
|
||||
}
|
||||
fmt.Printf(" frag%02d tfdt=%-6d dur=%-5d samples=%-3d keyframes=%d zeroDur=%d fps=%.1f%s\n",
|
||||
st.idx, st.tfdt, st.dur, st.nSamp, st.nKeys, st.zeroDur, st.fps, flagStr)
|
||||
}
|
||||
|
||||
var gaps []uint64
|
||||
for i := 1; i < len(keyTimes); i++ {
|
||||
gaps = append(gaps, keyTimes[i]-keyTimes[i-1])
|
||||
}
|
||||
irregular := 0
|
||||
if len(gaps) > 0 {
|
||||
med := medianUint(gaps)
|
||||
mn, mx := gaps[0], gaps[0]
|
||||
for _, g := range gaps {
|
||||
if g < mn {
|
||||
mn = g
|
||||
}
|
||||
if g > mx {
|
||||
mx = g
|
||||
}
|
||||
// Flag intervals that deviate by more than ~50% from the median GOP.
|
||||
if med > 0 && (g*2 > med*3 || g*2 < med) {
|
||||
irregular++
|
||||
}
|
||||
}
|
||||
fmt.Printf(" keyframe gaps: min=%d median=%d max=%d irregular=%d/%d\n", mn, med, mx, irregular, len(gaps))
|
||||
}
|
||||
fmt.Printf(" tfdt discontinuities: %d\n", tfdtGaps)
|
||||
fmt.Printf(" zero-duration samples: %d\n", totalZero)
|
||||
|
||||
fmt.Println(" verdict:")
|
||||
clean := true
|
||||
if audioTracks == 0 {
|
||||
fmt.Println(" - no audio track (expected if this recording is video-only)")
|
||||
}
|
||||
if lowFps > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d fragment(s) have a reduced frame rate (dropped frames) — likely source of the artifacts\n", lowFps)
|
||||
}
|
||||
if irregular > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d irregular keyframe interval(s)\n", irregular)
|
||||
}
|
||||
if tfdtGaps > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d timeline (tfdt) discontinuity(ies)\n", tfdtGaps)
|
||||
}
|
||||
if totalZero > 0 {
|
||||
clean = false
|
||||
fmt.Printf(" - %d zero-duration sample(s)\n", totalZero)
|
||||
}
|
||||
if clean {
|
||||
fmt.Println(" - container structure looks healthy")
|
||||
}
|
||||
}
|
||||
|
||||
func medianUint(v []uint64) uint64 {
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
c := append([]uint64(nil), v...)
|
||||
sort.Slice(c, func(i, j int) bool { return c[i] < c[j] })
|
||||
return c[len(c)/2]
|
||||
}
|
||||
|
||||
func medianFloat(v []float64) float64 {
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
c := append([]float64(nil), v...)
|
||||
sort.Float64s(c)
|
||||
return c[len(c)/2]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
{
|
||||
"type": "",
|
||||
"key": "",
|
||||
@@ -5,14 +6,17 @@
|
||||
"time": "false",
|
||||
"offline": "false",
|
||||
"auto_clean": "true",
|
||||
"max_directory_size": 100,
|
||||
"remove_after_upload": "true",
|
||||
"max_directory_size": 0,
|
||||
"timezone": "Africa/Ceuta",
|
||||
"capture": {
|
||||
"name": "",
|
||||
"ipcamera": {
|
||||
"rtsp": "",
|
||||
"sub_rtsp": "",
|
||||
"fps": ""
|
||||
"fps": "",
|
||||
"base_width": 640,
|
||||
"base_height": 0
|
||||
},
|
||||
"usbcamera": {
|
||||
"device": ""
|
||||
@@ -21,6 +25,11 @@
|
||||
"device": ""
|
||||
},
|
||||
"continuous": "false",
|
||||
"recording": "true",
|
||||
"snapshots": "true",
|
||||
"liveview": "true",
|
||||
"liveview_chunking": "false",
|
||||
"motion": "true",
|
||||
"postrecording": 20,
|
||||
"prerecording": 10,
|
||||
"maxlengthrecording": 30,
|
||||
@@ -89,20 +98,28 @@
|
||||
"s3": {
|
||||
"proxyuri": "http://proxy.kerberos.io",
|
||||
"bucket": "kerberosaccept",
|
||||
"region": "eu-west1"
|
||||
"region": "eu-west-1"
|
||||
},
|
||||
"kstorage": {},
|
||||
"kstorage_secondary": {},
|
||||
"dropbox": {},
|
||||
"mqtturi": "tcp://mqtt.kerberos.io:1883",
|
||||
"mqtt_username": "",
|
||||
"mqtt_password": "",
|
||||
"stunuri": "stun:turn.kerberos.io:8443",
|
||||
"turnuri": "turn:turn.kerberos.io:8443",
|
||||
"turn_force": "false",
|
||||
"stunuri": "stun:turn-fra1.kerberos.io:3478",
|
||||
"turnuri": "turn:turn-fra1.kerberos.io:3478",
|
||||
"turn_username": "username1",
|
||||
"turn_password": "password1",
|
||||
"heartbeaturi": "",
|
||||
"hub_encryption": "true",
|
||||
"hub_uri": "https://api.cloud.kerberos.io",
|
||||
"hub_key": "",
|
||||
"hub_private_key": "",
|
||||
"hub_site": "",
|
||||
"condition_uri": ""
|
||||
"condition_uri": "",
|
||||
"encryption": {},
|
||||
"signing": {},
|
||||
"realtimeprocessing": "false",
|
||||
"realtimeprocessing_topic": ""
|
||||
}
|
||||
0
machinery/data/snapshots/.empty
Normal file
BIN
machinery/data/test-480p.mp4
Normal file
@@ -0,0 +1 @@
|
||||
{"upload_url":"https://vault.kerberos.io/api/storage/tus/19e42fbc666a38064904caf8c46d182a","vault_uri":"https://vault.kerberos.io/api/storage/tus/","size":1591581}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package docs GENERATED BY SWAG; DO NOT EDIT
|
||||
// This file was generated by swaggo/swag
|
||||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import "github.com/swaggo/swag"
|
||||
@@ -25,6 +24,516 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/api/camera/onvif/capabilities": {
|
||||
"post": {
|
||||
"description": "Will return the ONVIF capabilities for the specific camera.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will return the ONVIF capabilities for the specific camera.",
|
||||
"operationId": "camera-onvif-capabilities",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/gotopreset": {
|
||||
"post": {
|
||||
"description": "Will activate the desired ONVIF preset.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will activate the desired ONVIF preset.",
|
||||
"operationId": "camera-onvif-gotopreset",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifPreset",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifPreset"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/inputs": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Will get the digital inputs from the ONVIF device.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will get the digital inputs from the ONVIF device.",
|
||||
"operationId": "get-digital-inputs",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/login": {
|
||||
"post": {
|
||||
"description": "Try to login into ONVIF supported camera.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Try to login into ONVIF supported camera.",
|
||||
"operationId": "camera-onvif-login",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/outputs": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Will get the relay outputs from the ONVIF device.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will get the relay outputs from the ONVIF device.",
|
||||
"operationId": "get-relay-outputs",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/outputs/{output}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Will trigger the relay output from the ONVIF device.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will trigger the relay output from the ONVIF device.",
|
||||
"operationId": "trigger-relay-output",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Output",
|
||||
"name": "output",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/pantilt": {
|
||||
"post": {
|
||||
"description": "Panning or/and tilting the camera using a direction (x,y).",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Panning or/and tilting the camera.",
|
||||
"operationId": "camera-onvif-pantilt",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifPanTilt",
|
||||
"name": "panTilt",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifPanTilt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/presets": {
|
||||
"post": {
|
||||
"description": "Will return the ONVIF presets for the specific camera.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will return the ONVIF presets for the specific camera.",
|
||||
"operationId": "camera-onvif-presets",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/verify": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Will verify the ONVIF connectivity.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will verify the ONVIF connectivity.",
|
||||
"operationId": "verify-onvif",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/zoom": {
|
||||
"post": {
|
||||
"description": "Zooming in or out the camera.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Zooming in or out the camera.",
|
||||
"operationId": "camera-onvif-zoom",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifZoom",
|
||||
"name": "zoom",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifZoom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/record": {
|
||||
"post": {
|
||||
"description": "Make a recording.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Make a recording.",
|
||||
"operationId": "camera-record",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/restart": {
|
||||
"post": {
|
||||
"description": "Restart the agent.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Restart the agent.",
|
||||
"operationId": "camera-restart",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/snapshot/base64": {
|
||||
"get": {
|
||||
"description": "Get a snapshot from the camera in base64.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Get a snapshot from the camera in base64.",
|
||||
"operationId": "snapshot-base64",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/snapshot/jpeg": {
|
||||
"get": {
|
||||
"description": "Get a snapshot from the camera in jpeg format.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Get a snapshot from the camera in jpeg format.",
|
||||
"operationId": "snapshot-jpeg",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/stop": {
|
||||
"post": {
|
||||
"description": "Stop the agent.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Stop the agent.",
|
||||
"operationId": "camera-stop",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/verify/{streamType}": {
|
||||
"post": {
|
||||
"description": "This method will validate a specific profile connection from an RTSP camera, and try to get the codec.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Validate a specific RTSP profile camera connection.",
|
||||
"operationId": "verify-camera",
|
||||
"parameters": [
|
||||
{
|
||||
"enum": [
|
||||
"primary",
|
||||
"secondary"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Stream Type",
|
||||
"name": "streamType",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Camera Streams",
|
||||
"name": "cameraStreams",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.CameraStreams"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/config": {
|
||||
"get": {
|
||||
"description": "Get the current configuration.",
|
||||
"tags": [
|
||||
"config"
|
||||
],
|
||||
"summary": "Get the current configuration.",
|
||||
"operationId": "config",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Update the current configuration.",
|
||||
"tags": [
|
||||
"config"
|
||||
],
|
||||
"summary": "Update the current configuration.",
|
||||
"operationId": "config",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Configuration",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Config"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/dashboard": {
|
||||
"get": {
|
||||
"description": "Get all information showed on the dashboard.",
|
||||
"tags": [
|
||||
"general"
|
||||
],
|
||||
"summary": "Get all information showed on the dashboard.",
|
||||
"operationId": "dashboard",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/days": {
|
||||
"get": {
|
||||
"description": "Get all days stored in the recordings directory.",
|
||||
"tags": [
|
||||
"general"
|
||||
],
|
||||
"summary": "Get all days stored in the recordings directory.",
|
||||
"operationId": "days",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/hub/verify": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -34,7 +543,7 @@ const docTemplate = `{
|
||||
],
|
||||
"description": "Will verify the hub connectivity.",
|
||||
"tags": [
|
||||
"config"
|
||||
"persistence"
|
||||
],
|
||||
"summary": "Will verify the hub connectivity.",
|
||||
"operationId": "verify-hub",
|
||||
@@ -59,6 +568,61 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/latest-events": {
|
||||
"post": {
|
||||
"description": "Get the latest recordings (events) from the recordings directory.",
|
||||
"tags": [
|
||||
"general"
|
||||
],
|
||||
"summary": "Get the latest recordings (events) from the recordings directory.",
|
||||
"operationId": "latest-events",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Event filter",
|
||||
"name": "eventFilter",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.EventFilter"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/login": {
|
||||
"post": {
|
||||
"description": "Get Authorization token.",
|
||||
"tags": [
|
||||
"authentication"
|
||||
],
|
||||
"summary": "Get Authorization token.",
|
||||
"operationId": "login",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Credentials",
|
||||
"name": "credentials",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Authentication"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Authorization"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/persistence/verify": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -68,7 +632,7 @@ const docTemplate = `{
|
||||
],
|
||||
"description": "Will verify the persistence.",
|
||||
"tags": [
|
||||
"config"
|
||||
"persistence"
|
||||
],
|
||||
"summary": "Will verify the persistence.",
|
||||
"operationId": "verify-persistence",
|
||||
@@ -98,7 +662,57 @@ const docTemplate = `{
|
||||
"models.APIResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {}
|
||||
"can_pan_tilt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"can_zoom": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"data": {},
|
||||
"message": {},
|
||||
"ptz_functions": {}
|
||||
}
|
||||
},
|
||||
"models.Authentication": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Authorization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expire": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.CameraStreams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rtsp": {
|
||||
"type": "string"
|
||||
},
|
||||
"sub_rtsp": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Capture": {
|
||||
@@ -119,9 +733,15 @@ const docTemplate = `{
|
||||
"ipcamera": {
|
||||
"$ref": "#/definitions/models.IPCamera"
|
||||
},
|
||||
"liveview": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxlengthrecording": {
|
||||
"type": "integer"
|
||||
},
|
||||
"motion": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -137,6 +757,12 @@ const docTemplate = `{
|
||||
"raspicamera": {
|
||||
"$ref": "#/definitions/models.RaspiCamera"
|
||||
},
|
||||
"recording": {
|
||||
"type": "string"
|
||||
},
|
||||
"snapshots": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcodingresolution": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -151,6 +777,9 @@ const docTemplate = `{
|
||||
"models.Config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auto_clean": {
|
||||
"type": "string"
|
||||
},
|
||||
"capture": {
|
||||
"$ref": "#/definitions/models.Capture"
|
||||
},
|
||||
@@ -160,10 +789,22 @@ const docTemplate = `{
|
||||
"condition_uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"dropbox": {
|
||||
"$ref": "#/definitions/models.Dropbox"
|
||||
},
|
||||
"encryption": {
|
||||
"$ref": "#/definitions/models.Encryption"
|
||||
},
|
||||
"friendly_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"heartbeaturi": {
|
||||
"description": "obsolete",
|
||||
"type": "string"
|
||||
},
|
||||
"hub_encryption": {
|
||||
"type": "string"
|
||||
},
|
||||
"hub_key": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -182,6 +823,9 @@ const docTemplate = `{
|
||||
"kstorage": {
|
||||
"$ref": "#/definitions/models.KStorage"
|
||||
},
|
||||
"max_directory_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mqtt_password": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -197,9 +841,18 @@ const docTemplate = `{
|
||||
"offline": {
|
||||
"type": "string"
|
||||
},
|
||||
"realtimeprocessing": {
|
||||
"type": "string"
|
||||
},
|
||||
"realtimeprocessing_topic": {
|
||||
"type": "string"
|
||||
},
|
||||
"region": {
|
||||
"$ref": "#/definitions/models.Region"
|
||||
},
|
||||
"remove_after_upload": {
|
||||
"type": "string"
|
||||
},
|
||||
"s3": {
|
||||
"$ref": "#/definitions/models.S3"
|
||||
},
|
||||
@@ -218,6 +871,9 @@ const docTemplate = `{
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_force": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_password": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -243,14 +899,62 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Dropbox": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Encryption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "string"
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"private_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"recordings": {
|
||||
"type": "string"
|
||||
},
|
||||
"symmetric_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.EventFilter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number_of_elements": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timestamp_offset_end": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timestamp_offset_start": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.IPCamera": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fps": {
|
||||
"type": "string"
|
||||
},
|
||||
"height": {
|
||||
"type": "integer"
|
||||
},
|
||||
"onvif": {
|
||||
"type": "boolean"
|
||||
"type": "string"
|
||||
},
|
||||
"onvif_password": {
|
||||
"type": "string"
|
||||
@@ -264,8 +968,20 @@ const docTemplate = `{
|
||||
"rtsp": {
|
||||
"type": "string"
|
||||
},
|
||||
"sub_fps": {
|
||||
"type": "string"
|
||||
},
|
||||
"sub_height": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sub_rtsp": {
|
||||
"type": "string"
|
||||
},
|
||||
"sub_width": {
|
||||
"type": "integer"
|
||||
},
|
||||
"width": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -276,6 +992,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"cloud_key": {
|
||||
"description": "old way, remove this",
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
@@ -292,6 +1009,56 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.OnvifCredentials": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onvif_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"onvif_username": {
|
||||
"type": "string"
|
||||
},
|
||||
"onvif_xaddr": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.OnvifPanTilt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onvif_credentials": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
},
|
||||
"pan": {
|
||||
"type": "number"
|
||||
},
|
||||
"tilt": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.OnvifPreset": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onvif_credentials": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
},
|
||||
"preset": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.OnvifZoom": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onvif_credentials": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
},
|
||||
"zoom": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Polygon": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -419,6 +1186,8 @@ var SwaggerInfo = &swag.Spec{
|
||||
Description: "This is the API for using and configure Kerberos Agent.",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -17,6 +17,516 @@
|
||||
},
|
||||
"basePath": "/",
|
||||
"paths": {
|
||||
"/api/camera/onvif/capabilities": {
|
||||
"post": {
|
||||
"description": "Will return the ONVIF capabilities for the specific camera.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will return the ONVIF capabilities for the specific camera.",
|
||||
"operationId": "camera-onvif-capabilities",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/gotopreset": {
|
||||
"post": {
|
||||
"description": "Will activate the desired ONVIF preset.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will activate the desired ONVIF preset.",
|
||||
"operationId": "camera-onvif-gotopreset",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifPreset",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifPreset"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/inputs": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Will get the digital inputs from the ONVIF device.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will get the digital inputs from the ONVIF device.",
|
||||
"operationId": "get-digital-inputs",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/login": {
|
||||
"post": {
|
||||
"description": "Try to login into ONVIF supported camera.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Try to login into ONVIF supported camera.",
|
||||
"operationId": "camera-onvif-login",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/outputs": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Will get the relay outputs from the ONVIF device.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will get the relay outputs from the ONVIF device.",
|
||||
"operationId": "get-relay-outputs",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/outputs/{output}": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Will trigger the relay output from the ONVIF device.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will trigger the relay output from the ONVIF device.",
|
||||
"operationId": "trigger-relay-output",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Output",
|
||||
"name": "output",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/pantilt": {
|
||||
"post": {
|
||||
"description": "Panning or/and tilting the camera using a direction (x,y).",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Panning or/and tilting the camera.",
|
||||
"operationId": "camera-onvif-pantilt",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifPanTilt",
|
||||
"name": "panTilt",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifPanTilt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/presets": {
|
||||
"post": {
|
||||
"description": "Will return the ONVIF presets for the specific camera.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will return the ONVIF presets for the specific camera.",
|
||||
"operationId": "camera-onvif-presets",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/verify": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Will verify the ONVIF connectivity.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Will verify the ONVIF connectivity.",
|
||||
"operationId": "verify-onvif",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifCredentials",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/onvif/zoom": {
|
||||
"post": {
|
||||
"description": "Zooming in or out the camera.",
|
||||
"tags": [
|
||||
"onvif"
|
||||
],
|
||||
"summary": "Zooming in or out the camera.",
|
||||
"operationId": "camera-onvif-zoom",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "OnvifZoom",
|
||||
"name": "zoom",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.OnvifZoom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/record": {
|
||||
"post": {
|
||||
"description": "Make a recording.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Make a recording.",
|
||||
"operationId": "camera-record",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/restart": {
|
||||
"post": {
|
||||
"description": "Restart the agent.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Restart the agent.",
|
||||
"operationId": "camera-restart",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/snapshot/base64": {
|
||||
"get": {
|
||||
"description": "Get a snapshot from the camera in base64.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Get a snapshot from the camera in base64.",
|
||||
"operationId": "snapshot-base64",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/snapshot/jpeg": {
|
||||
"get": {
|
||||
"description": "Get a snapshot from the camera in jpeg format.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Get a snapshot from the camera in jpeg format.",
|
||||
"operationId": "snapshot-jpeg",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/stop": {
|
||||
"post": {
|
||||
"description": "Stop the agent.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Stop the agent.",
|
||||
"operationId": "camera-stop",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/camera/verify/{streamType}": {
|
||||
"post": {
|
||||
"description": "This method will validate a specific profile connection from an RTSP camera, and try to get the codec.",
|
||||
"tags": [
|
||||
"camera"
|
||||
],
|
||||
"summary": "Validate a specific RTSP profile camera connection.",
|
||||
"operationId": "verify-camera",
|
||||
"parameters": [
|
||||
{
|
||||
"enum": [
|
||||
"primary",
|
||||
"secondary"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Stream Type",
|
||||
"name": "streamType",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Camera Streams",
|
||||
"name": "cameraStreams",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.CameraStreams"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.APIResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/config": {
|
||||
"get": {
|
||||
"description": "Get the current configuration.",
|
||||
"tags": [
|
||||
"config"
|
||||
],
|
||||
"summary": "Get the current configuration.",
|
||||
"operationId": "config",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Update the current configuration.",
|
||||
"tags": [
|
||||
"config"
|
||||
],
|
||||
"summary": "Update the current configuration.",
|
||||
"operationId": "config",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Configuration",
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Config"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/dashboard": {
|
||||
"get": {
|
||||
"description": "Get all information showed on the dashboard.",
|
||||
"tags": [
|
||||
"general"
|
||||
],
|
||||
"summary": "Get all information showed on the dashboard.",
|
||||
"operationId": "dashboard",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/days": {
|
||||
"get": {
|
||||
"description": "Get all days stored in the recordings directory.",
|
||||
"tags": [
|
||||
"general"
|
||||
],
|
||||
"summary": "Get all days stored in the recordings directory.",
|
||||
"operationId": "days",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/hub/verify": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -26,7 +536,7 @@
|
||||
],
|
||||
"description": "Will verify the hub connectivity.",
|
||||
"tags": [
|
||||
"config"
|
||||
"persistence"
|
||||
],
|
||||
"summary": "Will verify the hub connectivity.",
|
||||
"operationId": "verify-hub",
|
||||
@@ -51,6 +561,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/latest-events": {
|
||||
"post": {
|
||||
"description": "Get the latest recordings (events) from the recordings directory.",
|
||||
"tags": [
|
||||
"general"
|
||||
],
|
||||
"summary": "Get the latest recordings (events) from the recordings directory.",
|
||||
"operationId": "latest-events",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Event filter",
|
||||
"name": "eventFilter",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.EventFilter"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/login": {
|
||||
"post": {
|
||||
"description": "Get Authorization token.",
|
||||
"tags": [
|
||||
"authentication"
|
||||
],
|
||||
"summary": "Get Authorization token.",
|
||||
"operationId": "login",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Credentials",
|
||||
"name": "credentials",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Authentication"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.Authorization"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/persistence/verify": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -60,7 +625,7 @@
|
||||
],
|
||||
"description": "Will verify the persistence.",
|
||||
"tags": [
|
||||
"config"
|
||||
"persistence"
|
||||
],
|
||||
"summary": "Will verify the persistence.",
|
||||
"operationId": "verify-persistence",
|
||||
@@ -90,7 +655,57 @@
|
||||
"models.APIResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {}
|
||||
"can_pan_tilt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"can_zoom": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"data": {},
|
||||
"message": {},
|
||||
"ptz_functions": {}
|
||||
}
|
||||
},
|
||||
"models.Authentication": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Authorization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"expire": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.CameraStreams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rtsp": {
|
||||
"type": "string"
|
||||
},
|
||||
"sub_rtsp": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Capture": {
|
||||
@@ -111,9 +726,15 @@
|
||||
"ipcamera": {
|
||||
"$ref": "#/definitions/models.IPCamera"
|
||||
},
|
||||
"liveview": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxlengthrecording": {
|
||||
"type": "integer"
|
||||
},
|
||||
"motion": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -129,6 +750,12 @@
|
||||
"raspicamera": {
|
||||
"$ref": "#/definitions/models.RaspiCamera"
|
||||
},
|
||||
"recording": {
|
||||
"type": "string"
|
||||
},
|
||||
"snapshots": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcodingresolution": {
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -143,6 +770,9 @@
|
||||
"models.Config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auto_clean": {
|
||||
"type": "string"
|
||||
},
|
||||
"capture": {
|
||||
"$ref": "#/definitions/models.Capture"
|
||||
},
|
||||
@@ -152,10 +782,22 @@
|
||||
"condition_uri": {
|
||||
"type": "string"
|
||||
},
|
||||
"dropbox": {
|
||||
"$ref": "#/definitions/models.Dropbox"
|
||||
},
|
||||
"encryption": {
|
||||
"$ref": "#/definitions/models.Encryption"
|
||||
},
|
||||
"friendly_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"heartbeaturi": {
|
||||
"description": "obsolete",
|
||||
"type": "string"
|
||||
},
|
||||
"hub_encryption": {
|
||||
"type": "string"
|
||||
},
|
||||
"hub_key": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -174,6 +816,9 @@
|
||||
"kstorage": {
|
||||
"$ref": "#/definitions/models.KStorage"
|
||||
},
|
||||
"max_directory_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mqtt_password": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -189,9 +834,18 @@
|
||||
"offline": {
|
||||
"type": "string"
|
||||
},
|
||||
"realtimeprocessing": {
|
||||
"type": "string"
|
||||
},
|
||||
"realtimeprocessing_topic": {
|
||||
"type": "string"
|
||||
},
|
||||
"region": {
|
||||
"$ref": "#/definitions/models.Region"
|
||||
},
|
||||
"remove_after_upload": {
|
||||
"type": "string"
|
||||
},
|
||||
"s3": {
|
||||
"$ref": "#/definitions/models.S3"
|
||||
},
|
||||
@@ -210,6 +864,9 @@
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_force": {
|
||||
"type": "string"
|
||||
},
|
||||
"turn_password": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -235,14 +892,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Dropbox": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"access_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Encryption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "string"
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string"
|
||||
},
|
||||
"private_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"recordings": {
|
||||
"type": "string"
|
||||
},
|
||||
"symmetric_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.EventFilter": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number_of_elements": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timestamp_offset_end": {
|
||||
"type": "integer"
|
||||
},
|
||||
"timestamp_offset_start": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.IPCamera": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fps": {
|
||||
"type": "string"
|
||||
},
|
||||
"height": {
|
||||
"type": "integer"
|
||||
},
|
||||
"onvif": {
|
||||
"type": "boolean"
|
||||
"type": "string"
|
||||
},
|
||||
"onvif_password": {
|
||||
"type": "string"
|
||||
@@ -256,8 +961,20 @@
|
||||
"rtsp": {
|
||||
"type": "string"
|
||||
},
|
||||
"sub_fps": {
|
||||
"type": "string"
|
||||
},
|
||||
"sub_height": {
|
||||
"type": "integer"
|
||||
},
|
||||
"sub_rtsp": {
|
||||
"type": "string"
|
||||
},
|
||||
"sub_width": {
|
||||
"type": "integer"
|
||||
},
|
||||
"width": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -268,6 +985,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"cloud_key": {
|
||||
"description": "old way, remove this",
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
@@ -284,6 +1002,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.OnvifCredentials": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onvif_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"onvif_username": {
|
||||
"type": "string"
|
||||
},
|
||||
"onvif_xaddr": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.OnvifPanTilt": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onvif_credentials": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
},
|
||||
"pan": {
|
||||
"type": "number"
|
||||
},
|
||||
"tilt": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.OnvifPreset": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onvif_credentials": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
},
|
||||
"preset": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.OnvifZoom": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onvif_credentials": {
|
||||
"$ref": "#/definitions/models.OnvifCredentials"
|
||||
},
|
||||
"zoom": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Polygon": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -2,7 +2,40 @@ basePath: /
|
||||
definitions:
|
||||
models.APIResponse:
|
||||
properties:
|
||||
can_pan_tilt:
|
||||
type: boolean
|
||||
can_zoom:
|
||||
type: boolean
|
||||
data: {}
|
||||
message: {}
|
||||
ptz_functions: {}
|
||||
type: object
|
||||
models.Authentication:
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
models.Authorization:
|
||||
properties:
|
||||
code:
|
||||
type: integer
|
||||
expire:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
models.CameraStreams:
|
||||
properties:
|
||||
rtsp:
|
||||
type: string
|
||||
sub_rtsp:
|
||||
type: string
|
||||
type: object
|
||||
models.Capture:
|
||||
properties:
|
||||
@@ -16,8 +49,12 @@ definitions:
|
||||
type: integer
|
||||
ipcamera:
|
||||
$ref: '#/definitions/models.IPCamera'
|
||||
liveview:
|
||||
type: string
|
||||
maxlengthrecording:
|
||||
type: integer
|
||||
motion:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
pixelChangeThreshold:
|
||||
@@ -28,6 +65,10 @@ definitions:
|
||||
type: integer
|
||||
raspicamera:
|
||||
$ref: '#/definitions/models.RaspiCamera'
|
||||
recording:
|
||||
type: string
|
||||
snapshots:
|
||||
type: string
|
||||
transcodingresolution:
|
||||
type: integer
|
||||
transcodingwebrtc:
|
||||
@@ -37,15 +78,25 @@ definitions:
|
||||
type: object
|
||||
models.Config:
|
||||
properties:
|
||||
auto_clean:
|
||||
type: string
|
||||
capture:
|
||||
$ref: '#/definitions/models.Capture'
|
||||
cloud:
|
||||
type: string
|
||||
condition_uri:
|
||||
type: string
|
||||
dropbox:
|
||||
$ref: '#/definitions/models.Dropbox'
|
||||
encryption:
|
||||
$ref: '#/definitions/models.Encryption'
|
||||
friendly_name:
|
||||
type: string
|
||||
heartbeaturi:
|
||||
description: obsolete
|
||||
type: string
|
||||
hub_encryption:
|
||||
type: string
|
||||
hub_key:
|
||||
type: string
|
||||
hub_private_key:
|
||||
@@ -58,6 +109,8 @@ definitions:
|
||||
type: string
|
||||
kstorage:
|
||||
$ref: '#/definitions/models.KStorage'
|
||||
max_directory_size:
|
||||
type: integer
|
||||
mqtt_password:
|
||||
type: string
|
||||
mqtt_username:
|
||||
@@ -68,8 +121,14 @@ definitions:
|
||||
type: string
|
||||
offline:
|
||||
type: string
|
||||
realtimeprocessing:
|
||||
type: string
|
||||
realtimeprocessing_topic:
|
||||
type: string
|
||||
region:
|
||||
$ref: '#/definitions/models.Region'
|
||||
remove_after_upload:
|
||||
type: string
|
||||
s3:
|
||||
$ref: '#/definitions/models.S3'
|
||||
stunuri:
|
||||
@@ -82,6 +141,8 @@ definitions:
|
||||
type: array
|
||||
timezone:
|
||||
type: string
|
||||
turn_force:
|
||||
type: string
|
||||
turn_password:
|
||||
type: string
|
||||
turn_username:
|
||||
@@ -98,12 +159,43 @@ definitions:
|
||||
"y":
|
||||
type: number
|
||||
type: object
|
||||
models.Dropbox:
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
directory:
|
||||
type: string
|
||||
type: object
|
||||
models.Encryption:
|
||||
properties:
|
||||
enabled:
|
||||
type: string
|
||||
fingerprint:
|
||||
type: string
|
||||
private_key:
|
||||
type: string
|
||||
recordings:
|
||||
type: string
|
||||
symmetric_key:
|
||||
type: string
|
||||
type: object
|
||||
models.EventFilter:
|
||||
properties:
|
||||
number_of_elements:
|
||||
type: integer
|
||||
timestamp_offset_end:
|
||||
type: integer
|
||||
timestamp_offset_start:
|
||||
type: integer
|
||||
type: object
|
||||
models.IPCamera:
|
||||
properties:
|
||||
fps:
|
||||
type: string
|
||||
height:
|
||||
type: integer
|
||||
onvif:
|
||||
type: boolean
|
||||
type: string
|
||||
onvif_password:
|
||||
type: string
|
||||
onvif_username:
|
||||
@@ -112,14 +204,23 @@ definitions:
|
||||
type: string
|
||||
rtsp:
|
||||
type: string
|
||||
sub_fps:
|
||||
type: string
|
||||
sub_height:
|
||||
type: integer
|
||||
sub_rtsp:
|
||||
type: string
|
||||
sub_width:
|
||||
type: integer
|
||||
width:
|
||||
type: integer
|
||||
type: object
|
||||
models.KStorage:
|
||||
properties:
|
||||
access_key:
|
||||
type: string
|
||||
cloud_key:
|
||||
description: old way, remove this
|
||||
type: string
|
||||
directory:
|
||||
type: string
|
||||
@@ -130,6 +231,38 @@ definitions:
|
||||
uri:
|
||||
type: string
|
||||
type: object
|
||||
models.OnvifCredentials:
|
||||
properties:
|
||||
onvif_password:
|
||||
type: string
|
||||
onvif_username:
|
||||
type: string
|
||||
onvif_xaddr:
|
||||
type: string
|
||||
type: object
|
||||
models.OnvifPanTilt:
|
||||
properties:
|
||||
onvif_credentials:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
pan:
|
||||
type: number
|
||||
tilt:
|
||||
type: number
|
||||
type: object
|
||||
models.OnvifPreset:
|
||||
properties:
|
||||
onvif_credentials:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
preset:
|
||||
type: string
|
||||
type: object
|
||||
models.OnvifZoom:
|
||||
properties:
|
||||
onvif_credentials:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
zoom:
|
||||
type: number
|
||||
type: object
|
||||
models.Polygon:
|
||||
properties:
|
||||
coordinates:
|
||||
@@ -212,6 +345,339 @@ info:
|
||||
title: Swagger Kerberos Agent API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/api/camera/onvif/capabilities:
|
||||
post:
|
||||
description: Will return the ONVIF capabilities for the specific camera.
|
||||
operationId: camera-onvif-capabilities
|
||||
parameters:
|
||||
- description: OnvifCredentials
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Will return the ONVIF capabilities for the specific camera.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/gotopreset:
|
||||
post:
|
||||
description: Will activate the desired ONVIF preset.
|
||||
operationId: camera-onvif-gotopreset
|
||||
parameters:
|
||||
- description: OnvifPreset
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifPreset'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Will activate the desired ONVIF preset.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/inputs:
|
||||
post:
|
||||
description: Will get the digital inputs from the ONVIF device.
|
||||
operationId: get-digital-inputs
|
||||
parameters:
|
||||
- description: OnvifCredentials
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Will get the digital inputs from the ONVIF device.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/login:
|
||||
post:
|
||||
description: Try to login into ONVIF supported camera.
|
||||
operationId: camera-onvif-login
|
||||
parameters:
|
||||
- description: OnvifCredentials
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Try to login into ONVIF supported camera.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/outputs:
|
||||
post:
|
||||
description: Will get the relay outputs from the ONVIF device.
|
||||
operationId: get-relay-outputs
|
||||
parameters:
|
||||
- description: OnvifCredentials
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Will get the relay outputs from the ONVIF device.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/outputs/{output}:
|
||||
post:
|
||||
description: Will trigger the relay output from the ONVIF device.
|
||||
operationId: trigger-relay-output
|
||||
parameters:
|
||||
- description: OnvifCredentials
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
- description: Output
|
||||
in: path
|
||||
name: output
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Will trigger the relay output from the ONVIF device.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/pantilt:
|
||||
post:
|
||||
description: Panning or/and tilting the camera using a direction (x,y).
|
||||
operationId: camera-onvif-pantilt
|
||||
parameters:
|
||||
- description: OnvifPanTilt
|
||||
in: body
|
||||
name: panTilt
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifPanTilt'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Panning or/and tilting the camera.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/presets:
|
||||
post:
|
||||
description: Will return the ONVIF presets for the specific camera.
|
||||
operationId: camera-onvif-presets
|
||||
parameters:
|
||||
- description: OnvifCredentials
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Will return the ONVIF presets for the specific camera.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/verify:
|
||||
post:
|
||||
description: Will verify the ONVIF connectivity.
|
||||
operationId: verify-onvif
|
||||
parameters:
|
||||
- description: OnvifCredentials
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifCredentials'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Will verify the ONVIF connectivity.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/onvif/zoom:
|
||||
post:
|
||||
description: Zooming in or out the camera.
|
||||
operationId: camera-onvif-zoom
|
||||
parameters:
|
||||
- description: OnvifZoom
|
||||
in: body
|
||||
name: zoom
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.OnvifZoom'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Zooming in or out the camera.
|
||||
tags:
|
||||
- onvif
|
||||
/api/camera/record:
|
||||
post:
|
||||
description: Make a recording.
|
||||
operationId: camera-record
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Make a recording.
|
||||
tags:
|
||||
- camera
|
||||
/api/camera/restart:
|
||||
post:
|
||||
description: Restart the agent.
|
||||
operationId: camera-restart
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Restart the agent.
|
||||
tags:
|
||||
- camera
|
||||
/api/camera/snapshot/base64:
|
||||
get:
|
||||
description: Get a snapshot from the camera in base64.
|
||||
operationId: snapshot-base64
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Get a snapshot from the camera in base64.
|
||||
tags:
|
||||
- camera
|
||||
/api/camera/snapshot/jpeg:
|
||||
get:
|
||||
description: Get a snapshot from the camera in jpeg format.
|
||||
operationId: snapshot-jpeg
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Get a snapshot from the camera in jpeg format.
|
||||
tags:
|
||||
- camera
|
||||
/api/camera/stop:
|
||||
post:
|
||||
description: Stop the agent.
|
||||
operationId: camera-stop
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Stop the agent.
|
||||
tags:
|
||||
- camera
|
||||
/api/camera/verify/{streamType}:
|
||||
post:
|
||||
description: This method will validate a specific profile connection from an
|
||||
RTSP camera, and try to get the codec.
|
||||
operationId: verify-camera
|
||||
parameters:
|
||||
- description: Stream Type
|
||||
enum:
|
||||
- primary
|
||||
- secondary
|
||||
in: path
|
||||
name: streamType
|
||||
required: true
|
||||
type: string
|
||||
- description: Camera Streams
|
||||
in: body
|
||||
name: cameraStreams
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.CameraStreams'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.APIResponse'
|
||||
summary: Validate a specific RTSP profile camera connection.
|
||||
tags:
|
||||
- camera
|
||||
/api/config:
|
||||
get:
|
||||
description: Get the current configuration.
|
||||
operationId: config
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Get the current configuration.
|
||||
tags:
|
||||
- config
|
||||
post:
|
||||
description: Update the current configuration.
|
||||
operationId: config
|
||||
parameters:
|
||||
- description: Configuration
|
||||
in: body
|
||||
name: config
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Config'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Update the current configuration.
|
||||
tags:
|
||||
- config
|
||||
/api/dashboard:
|
||||
get:
|
||||
description: Get all information showed on the dashboard.
|
||||
operationId: dashboard
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Get all information showed on the dashboard.
|
||||
tags:
|
||||
- general
|
||||
/api/days:
|
||||
get:
|
||||
description: Get all days stored in the recordings directory.
|
||||
operationId: days
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Get all days stored in the recordings directory.
|
||||
tags:
|
||||
- general
|
||||
/api/hub/verify:
|
||||
post:
|
||||
description: Will verify the hub connectivity.
|
||||
@@ -232,7 +698,43 @@ paths:
|
||||
- Bearer: []
|
||||
summary: Will verify the hub connectivity.
|
||||
tags:
|
||||
- config
|
||||
- persistence
|
||||
/api/latest-events:
|
||||
post:
|
||||
description: Get the latest recordings (events) from the recordings directory.
|
||||
operationId: latest-events
|
||||
parameters:
|
||||
- description: Event filter
|
||||
in: body
|
||||
name: eventFilter
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.EventFilter'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
summary: Get the latest recordings (events) from the recordings directory.
|
||||
tags:
|
||||
- general
|
||||
/api/login:
|
||||
post:
|
||||
description: Get Authorization token.
|
||||
operationId: login
|
||||
parameters:
|
||||
- description: Credentials
|
||||
in: body
|
||||
name: credentials
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/models.Authentication'
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.Authorization'
|
||||
summary: Get Authorization token.
|
||||
tags:
|
||||
- authentication
|
||||
/api/persistence/verify:
|
||||
post:
|
||||
description: Will verify the persistence.
|
||||
@@ -253,7 +755,7 @@ paths:
|
||||
- Bearer: []
|
||||
summary: Will verify the persistence.
|
||||
tags:
|
||||
- config
|
||||
- persistence
|
||||
securityDefinitions:
|
||||
Bearer:
|
||||
in: header
|
||||
|
||||
187
machinery/go.mod
@@ -1,100 +1,145 @@
|
||||
module github.com/kerberos-io/agent/machinery
|
||||
|
||||
go 1.18
|
||||
go 1.25.0
|
||||
|
||||
//replace github.com/kerberos-io/joy4 v1.0.33 => ../../../../github.com/kerberos-io/joy4
|
||||
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.8.0
|
||||
github.com/appleboy/gin-jwt/v2 v2.10.3
|
||||
github.com/bluenviron/gortsplib/v5 v5.6.3
|
||||
github.com/bluenviron/mediacommon v1.14.0
|
||||
github.com/cedricve/go-onvif v0.0.0-20200222191200-567e8ce298f6
|
||||
github.com/deepch/vdk v0.0.19
|
||||
github.com/eclipse/paho.mqtt.golang v1.4.1
|
||||
github.com/gin-contrib/cors v1.4.0
|
||||
github.com/gin-contrib/pprof v1.4.0
|
||||
github.com/gin-gonic/contrib v0.0.0-20201101042839-6a891bf89f19
|
||||
github.com/gin-gonic/gin v1.8.1
|
||||
github.com/golang-jwt/jwt/v4 v4.4.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.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.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/kellydunn/golang-geo v0.7.0
|
||||
github.com/kerberos-io/joy4 v1.0.33
|
||||
github.com/kerberos-io/onvif v0.0.3
|
||||
github.com/kerberos-io/joy4 v1.0.64
|
||||
github.com/kerberos-io/onvif v1.2.2
|
||||
github.com/minio/minio-go/v6 v6.0.57
|
||||
github.com/nsmith5/mjpeg v0.0.0-20200913181537-54b8ada0e53e
|
||||
github.com/moq-dev/moq-go v0.5.7
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
|
||||
github.com/pion/webrtc/v3 v3.1.43
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/sirupsen/logrus v1.9.0
|
||||
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a
|
||||
github.com/swaggo/gin-swagger v1.5.2
|
||||
github.com/swaggo/swag v1.8.4
|
||||
github.com/pion/interceptor v0.1.47
|
||||
github.com/pion/rtp v1.10.5
|
||||
github.com/pion/webrtc/v4 v4.2.18
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.4
|
||||
github.com/tevino/abool v1.2.0
|
||||
gocv.io/x/gocv v0.31.0
|
||||
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||
github.com/zaf/g711 v1.4.0
|
||||
go.mongodb.org/mongo-driver v1.17.3
|
||||
go.opentelemetry.io/otel v1.36.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0
|
||||
go.opentelemetry.io/otel/sdk v1.36.0
|
||||
go.opentelemetry.io/otel/trace v1.36.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/beevik/etree v1.2.0 // indirect
|
||||
github.com/bluenviron/mediacommon/v2 v2.9.2 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/elgs/gostrgen v0.0.0-20220325073726-0c3e00d082f6 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.7.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elgs/gostrgen v0.0.0-20161222160715-9d61ae07eeae // indirect
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 // indirect
|
||||
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.20.0 // indirect
|
||||
github.com/go-openapi/spec v0.20.6 // indirect
|
||||
github.com/go-openapi/swag v0.22.0 // indirect
|
||||
github.com/go-playground/locales v0.14.0 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.11.0 // indirect
|
||||
github.com/goccy/go-json v0.9.10 // indirect
|
||||
github.com/gofrs/uuid v4.2.0+incompatible // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.1.0 // indirect
|
||||
github.com/juju/errors v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.16.7 // indirect
|
||||
github.com/klauspost/cpuid v1.2.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kylelemons/go-gypsy v1.0.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.1 // indirect
|
||||
github.com/lib/pq v1.10.6 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.2 // indirect
|
||||
github.com/pion/datachannel v1.5.2 // indirect
|
||||
github.com/pion/dtls/v2 v2.1.5 // indirect
|
||||
github.com/pion/ice/v2 v2.2.7 // indirect
|
||||
github.com/pion/interceptor v0.1.12 // indirect
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/mdns v0.0.5 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/moq-dev/moq-go-ffi v0.3.7 // indirect
|
||||
github.com/nxadm/tail v1.4.11 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pion/datachannel v1.6.2 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.5 // indirect
|
||||
github.com/pion/ice/v4 v4.4.0 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.10 // indirect
|
||||
github.com/pion/rtp v1.7.13 // indirect
|
||||
github.com/pion/sctp v1.8.2 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.6 // indirect
|
||||
github.com/pion/srtp/v2 v2.0.10 // indirect
|
||||
github.com/pion/stun v0.3.5 // indirect
|
||||
github.com/pion/transport v0.13.1 // indirect
|
||||
github.com/pion/turn/v2 v2.0.8 // indirect
|
||||
github.com/pion/udp v0.1.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.10 // indirect
|
||||
github.com/tklauser/numcpus v0.5.0 // indirect
|
||||
github.com/ugorji/go/codec v1.2.7 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.2 // indirect
|
||||
github.com/pion/rtcp v1.2.17 // indirect
|
||||
github.com/pion/sctp v1.11.1 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.19 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.12 // indirect
|
||||
github.com/pion/stun/v3 v3.1.6 // indirect
|
||||
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/pion/turn/v5 v5.0.12 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
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/ziutek/mymysql v1.5.4 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect
|
||||
golang.org/x/net v0.0.0-20220812174116-3211cb980234 // indirect
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
google.golang.org/protobuf v1.28.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.54.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect
|
||||
google.golang.org/grpc v1.72.1 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
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
|
||||
)
|
||||
|
||||
2192
machinery/go.sum
@@ -1,48 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
"github.com/kerberos-io/agent/machinery/src/components"
|
||||
"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"
|
||||
)
|
||||
|
||||
var VERSION = utils.VERSION
|
||||
|
||||
func startTracing(agentKey string, otelEndpoint string) (*trace.TracerProvider, error) {
|
||||
serviceName := "agent-" + agentKey
|
||||
headers := map[string]string{
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
||||
exporter, err := otlptrace.New(
|
||||
context.Background(),
|
||||
otlptracehttp.NewClient(
|
||||
otlptracehttp.WithEndpoint(otelEndpoint),
|
||||
otlptracehttp.WithHeaders(headers),
|
||||
otlptracehttp.WithInsecure(),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating new exporter: %w", err)
|
||||
}
|
||||
|
||||
tracerprovider := trace.NewTracerProvider(
|
||||
trace.WithBatcher(
|
||||
exporter,
|
||||
trace.WithMaxExportBatchSize(trace.DefaultMaxExportBatchSize),
|
||||
trace.WithBatchTimeout(trace.DefaultScheduleDelay*time.Millisecond),
|
||||
trace.WithMaxExportBatchSize(trace.DefaultMaxExportBatchSize),
|
||||
),
|
||||
trace.WithResource(
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceNameKey.String(serviceName),
|
||||
attribute.String("environment", "develop"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
otel.SetTracerProvider(tracerprovider)
|
||||
|
||||
return tracerprovider, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
const VERSION = "3.0"
|
||||
action := os.Args[1]
|
||||
// Start the show ;)
|
||||
// We'll parse the flags (named variables), and start the agent.
|
||||
|
||||
var action string
|
||||
var configDirectory string
|
||||
var name string
|
||||
var port string
|
||||
var timeout string
|
||||
var subnet string
|
||||
|
||||
flag.StringVar(&action, "action", "version", "Tell us what you want do 'run' or 'version'")
|
||||
flag.StringVar(&configDirectory, "config", ".", "Where is the configuration stored")
|
||||
flag.StringVar(&name, "name", "agent", "Provide a name for the agent")
|
||||
flag.StringVar(&port, "port", "80", "On which port should the agent run")
|
||||
flag.StringVar(&timeout, "timeout", "2000", "Number of milliseconds to wait for the ONVIF discovery to complete")
|
||||
flag.StringVar(&subnet, "subnet", "", "Optional subnet(s) to scan for discovery, e.g. '192.168.1.0/24' (comma-separated). Defaults to the local interfaces.")
|
||||
flag.Parse()
|
||||
|
||||
// Specify the level of loggin: "info", "warning", "debug", "error" or "fatal."
|
||||
logLevel := os.Getenv("LOG_LEVEL")
|
||||
if logLevel == "" {
|
||||
logLevel = "info"
|
||||
}
|
||||
// Specify the output formatter of the log: "text" or "json".
|
||||
logOutput := os.Getenv("LOG_OUTPUT")
|
||||
if logOutput == "" {
|
||||
logOutput = "text"
|
||||
}
|
||||
// Specify the timezone of the log: "UTC" or "Local".
|
||||
timezone, _ := time.LoadLocation("CET")
|
||||
log.Log.Init(timezone)
|
||||
log.Log.Init(logLevel, logOutput, configDirectory, timezone)
|
||||
|
||||
switch action {
|
||||
|
||||
case "version":
|
||||
log.Log.Info("You are currrently running Kerberos Agent " + VERSION)
|
||||
|
||||
case "pending-upload":
|
||||
name := os.Args[2]
|
||||
fmt.Println(name)
|
||||
|
||||
{
|
||||
log.Log.Info("main.Main(): You are currrently running Kerberos Agent " + VERSION)
|
||||
}
|
||||
case "discover":
|
||||
timeout := os.Args[2]
|
||||
fmt.Println(timeout)
|
||||
{
|
||||
// Convert duration to int
|
||||
timeout, err := time.ParseDuration(timeout + "ms")
|
||||
if err != nil {
|
||||
log.Log.Fatal("main.Main(): could not parse timeout: " + err.Error())
|
||||
return
|
||||
}
|
||||
var subnets []string
|
||||
for _, part := range strings.Split(subnet, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
subnets = append(subnets, trimmed)
|
||||
}
|
||||
}
|
||||
onvif.Discover(timeout, subnets...)
|
||||
}
|
||||
case "decrypt":
|
||||
{
|
||||
log.Log.Info("main.Main(): Decrypting: " + flag.Arg(0) + " with key: " + flag.Arg(1))
|
||||
symmetricKey := []byte(flag.Arg(1))
|
||||
|
||||
case "usbcamera-test":
|
||||
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
|
||||
}
|
||||
|
||||
deviceID := os.Args[2]
|
||||
capture.TestUSBCamera(deviceID)
|
||||
utils.Decrypt(flag.Arg(0), symmetricKey)
|
||||
}
|
||||
|
||||
case "run":
|
||||
{
|
||||
name := os.Args[2]
|
||||
port := os.Args[3]
|
||||
// Print Agent ASCII art
|
||||
utils.PrintASCIIArt()
|
||||
|
||||
// Print the environment variables which include "AGENT_" as prefix.
|
||||
utils.PrintEnvironmentVariables()
|
||||
|
||||
// Read the config on start, and pass it to the other
|
||||
// function and features. Please note that this might be changed
|
||||
@@ -51,35 +155,79 @@ func main() {
|
||||
configuration.Name = name
|
||||
configuration.Port = port
|
||||
|
||||
// Open this configuration either from Kerberos Agent or Kerberos Factory.
|
||||
components.OpenConfig(&configuration)
|
||||
// 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)
|
||||
|
||||
// Check the folder permissions, it might be that we do not have permissions to write
|
||||
// recordings, update the configuration or save snapshots.
|
||||
utils.CheckDataDirectoryPermissions(configDirectory)
|
||||
|
||||
// Set timezone
|
||||
timezone, _ := time.LoadLocation(configuration.Config.Timezone)
|
||||
log.Log.Init(timezone)
|
||||
log.Log.Init(logLevel, logOutput, configDirectory, timezone)
|
||||
|
||||
// Check if we have a device Key or not, if not
|
||||
// we will generate one.
|
||||
if configuration.Config.Key == "" {
|
||||
key := utils.RandStringBytesMaskImpr(30)
|
||||
configuration.Config.Key = key
|
||||
err := components.StoreConfig(configuration.Config)
|
||||
err := configService.StoreConfig(configDirectory, configuration.Config)
|
||||
if err == nil {
|
||||
log.Log.Info("Main: updated unique key for agent to: " + key)
|
||||
log.Log.Info("main.Main(): updated unique key for agent to: " + key)
|
||||
} else {
|
||||
log.Log.Info("Main: something went wrong while trying to store key: " + key)
|
||||
log.Log.Info("main.Main(): something went wrong while trying to store key: " + key)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a cancelable context, which will be used to cancel and restart.
|
||||
// This is used to restart the agent when the configuration is updated.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// We create a capture object, this will contain all the streaming clients.
|
||||
// And allow us to extract media from within difference places in the agent.
|
||||
capture := capture.Capture{
|
||||
RTSPClient: nil,
|
||||
RTSPSubClient: nil,
|
||||
}
|
||||
|
||||
// Bootstrapping the agent
|
||||
communication := models.Communication{
|
||||
Context: &ctx,
|
||||
CancelContext: &cancel,
|
||||
HandleBootstrap: make(chan string, 1),
|
||||
}
|
||||
go components.Bootstrap(&configuration, &communication)
|
||||
|
||||
go components.Bootstrap(ctx, configDirectory, &configuration, &communication, &capture)
|
||||
|
||||
// Start the REST API.
|
||||
routers.StartWebserver(&configuration, &communication)
|
||||
routers.StartWebserver(configDirectory, &configuration, &communication, &capture)
|
||||
}
|
||||
default:
|
||||
fmt.Println("Sorry I don't understand :(")
|
||||
{
|
||||
log.Log.Error("main.Main(): Sorry I don't understand :(")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/joy4/av/pubsub"
|
||||
|
||||
"github.com/kerberos-io/joy4/av"
|
||||
"github.com/kerberos-io/joy4/av/avutil"
|
||||
"github.com/kerberos-io/joy4/cgo/ffmpeg"
|
||||
"github.com/kerberos-io/joy4/format"
|
||||
)
|
||||
|
||||
func OpenRTSP(url string) (av.DemuxCloser, []av.CodecData, error) {
|
||||
format.RegisterAll()
|
||||
infile, err := avutil.Open(url)
|
||||
if err == nil {
|
||||
streams, errstreams := infile.Streams()
|
||||
return infile, streams, errstreams
|
||||
}
|
||||
return nil, []av.CodecData{}, err
|
||||
}
|
||||
|
||||
func GetVideoDecoder(streams []av.CodecData) *ffmpeg.VideoDecoder {
|
||||
// Load video codec
|
||||
var vstream av.VideoCodecData
|
||||
for _, stream := range streams {
|
||||
if stream.Type().IsAudio() {
|
||||
//astream := stream.(av.AudioCodecData)
|
||||
} else if stream.Type().IsVideo() {
|
||||
vstream = stream.(av.VideoCodecData)
|
||||
}
|
||||
}
|
||||
dec, _ := ffmpeg.NewVideoDecoder(vstream)
|
||||
return dec
|
||||
}
|
||||
|
||||
func DecodeImage(pkt av.Packet, decoder *ffmpeg.VideoDecoder, decoderMutex *sync.Mutex) (*ffmpeg.VideoFrame, error) {
|
||||
decoderMutex.Lock()
|
||||
img, err := decoder.Decode(pkt.Data)
|
||||
decoderMutex.Unlock()
|
||||
return img, err
|
||||
}
|
||||
|
||||
func HandleStream(infile av.DemuxCloser, queue *pubsub.Queue, communication *models.Communication) { //, wg *sync.WaitGroup) {
|
||||
|
||||
log.Log.Debug("HandleStream: started")
|
||||
var err error
|
||||
loop:
|
||||
for {
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
select {
|
||||
case <-communication.HandleStream:
|
||||
break loop
|
||||
default:
|
||||
}
|
||||
|
||||
var pkt av.Packet
|
||||
if pkt, err = infile.ReadPacket(); err != nil { // sometimes this throws an end of file..
|
||||
log.Log.Error("HandleStream: " + err.Error())
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
// Could be that a decode is throwing errors.
|
||||
if len(pkt.Data) > 0 {
|
||||
|
||||
queue.WritePacket(pkt)
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
select {
|
||||
case <-communication.HandleStream:
|
||||
break loop
|
||||
default:
|
||||
}
|
||||
|
||||
if pkt.IsKeyFrame {
|
||||
|
||||
// Increment packets, so we know the device
|
||||
// is not blocking.
|
||||
r := communication.PackageCounter.Load().(int64)
|
||||
log.Log.Info("HandleStream: packet size " + strconv.Itoa(len(pkt.Data)))
|
||||
communication.PackageCounter.Store((r + 1) % 1000)
|
||||
communication.LastPacketTimer.Store(time.Now().Unix())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
queue.Close()
|
||||
log.Log.Debug("HandleStream: finished")
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"gocv.io/x/gocv"
|
||||
)
|
||||
|
||||
func TestUSBCamera(deviceID string) {
|
||||
webcam, err := gocv.OpenVideoCapture(deviceID)
|
||||
if err != nil {
|
||||
log.Log.Error("Error opening video capture device: " + deviceID)
|
||||
return
|
||||
}
|
||||
defer webcam.Close()
|
||||
buf := gocv.NewMat()
|
||||
defer buf.Close()
|
||||
|
||||
ok := webcam.Read(&buf)
|
||||
|
||||
if ok {
|
||||
|
||||
now := time.Now().Unix()
|
||||
saveFile := "./data/capture-test/" + strconv.FormatInt(now, 10) + ".mp4"
|
||||
fps := 20.0 // webcam.Get(gocv.VideoCaptureFPS)
|
||||
writer, err := gocv.VideoWriterFile(saveFile, "X264", fps, buf.Cols(), buf.Rows(), true)
|
||||
if err != nil {
|
||||
log.Log.Error("error opening video writer device: " + saveFile)
|
||||
return
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
//window := gocv.NewWindow("Hello")
|
||||
log.Log.Info("Start reading device: " + deviceID)
|
||||
for i := 0; i < 100; i++ {
|
||||
if ok := webcam.Read(&buf); !ok {
|
||||
log.Log.Error("Device closed: " + deviceID)
|
||||
return
|
||||
}
|
||||
if buf.Empty() {
|
||||
continue
|
||||
}
|
||||
|
||||
writer.Write(buf)
|
||||
//window.IMShow(buf)
|
||||
//window.WaitKey(1)
|
||||
|
||||
log.Log.Info("Read frame")
|
||||
}
|
||||
|
||||
}
|
||||
log.Log.Info("Done. Close videocapture and recording file.")
|
||||
}
|
||||
238
machinery/src/capture/cleanup_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// writeRecording creates a file under recordingsDir and sets its modtime so the
|
||||
// tests can control the "oldest" ordering deterministically.
|
||||
func writeRecording(t *testing.T, recordingsDir, name string, ageMinutes int) {
|
||||
t.Helper()
|
||||
full := filepath.Join(recordingsDir, name)
|
||||
if err := os.WriteFile(full, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("write recording %s: %v", name, err)
|
||||
}
|
||||
mod := time.Now().Add(-time.Duration(ageMinutes) * time.Minute)
|
||||
if err := os.Chtimes(full, mod, mod); err != nil {
|
||||
t.Fatalf("chtimes %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// markPending creates the upload marker in cloudDir for the given recording,
|
||||
// marking it as still queued for upload.
|
||||
func markPending(t *testing.T, cloudDir, name string) {
|
||||
t.Helper()
|
||||
markerName := models.RecordingUploadMetadataFileName(name)
|
||||
if err := os.WriteFile(filepath.Join(cloudDir, markerName), nil, 0o644); err != nil {
|
||||
t.Fatalf("write marker %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCleanupDirs(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
base := t.TempDir()
|
||||
recordingsDir := filepath.Join(base, "data", "recordings")
|
||||
cloudDir := filepath.Join(base, "data", "cloud")
|
||||
if err := os.MkdirAll(recordingsDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir recordings: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(cloudDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud: %v", err)
|
||||
}
|
||||
return recordingsDir, cloudDir
|
||||
}
|
||||
|
||||
// The core regression: when the oldest recording is still pending upload but a
|
||||
// newer one has already been uploaded, cleanup must delete the uploaded (safe)
|
||||
// one and leave the pending recording on disk so it can still be uploaded.
|
||||
func TestPickRecordingToCleanup_PrefersUploaded(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
// oldest is still pending upload (marker present).
|
||||
writeRecording(t, recordingsDir, "oldest_pending.mp4", 30)
|
||||
markPending(t, cloudDir, "oldest_pending.mp4")
|
||||
// newer one has already been uploaded (no marker).
|
||||
writeRecording(t, recordingsDir, "newer_uploaded.mp4", 10)
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending {
|
||||
t.Fatalf("expected a safe (already-uploaded) deletion, got pending=true")
|
||||
}
|
||||
if name != "newer_uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want the uploaded recording newer_uploaded.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRecordingToCleanup_RecognizesLegacyMarkerName(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "legacy_pending.mp4", 30)
|
||||
if err := os.WriteFile(filepath.Join(cloudDir, "legacy_pending.mp4"), nil, 0o644); err != nil {
|
||||
t.Fatalf("write legacy marker: %v", err)
|
||||
}
|
||||
writeRecording(t, recordingsDir, "uploaded.mp4", 10)
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending || name != "uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked name=%q pending=%v, want uploaded.mp4 pending=false", name, pending)
|
||||
}
|
||||
}
|
||||
|
||||
// Among several already-uploaded recordings, the oldest uploaded one is chosen.
|
||||
func TestPickRecordingToCleanup_OldestUploadedFirst(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "old_uploaded.mp4", 40)
|
||||
writeRecording(t, recordingsDir, "mid_uploaded.mp4", 20)
|
||||
// pending one must be ignored even though it is not the oldest.
|
||||
writeRecording(t, recordingsDir, "pending.mp4", 30)
|
||||
markPending(t, cloudDir, "pending.mp4")
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pending {
|
||||
t.Fatalf("expected pending=false, got true")
|
||||
}
|
||||
if name != "old_uploaded.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want old_uploaded.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: when every recording is still pending upload, cleanup returns the
|
||||
// oldest one with pending=true so the caller can drop it (and its marker) to keep
|
||||
// the disk bounded.
|
||||
func TestPickRecordingToCleanup_AllPendingFallsBackToOldest(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
writeRecording(t, recordingsDir, "a_old.mp4", 50)
|
||||
markPending(t, cloudDir, "a_old.mp4")
|
||||
writeRecording(t, recordingsDir, "b_new.mp4", 5)
|
||||
markPending(t, cloudDir, "b_new.mp4")
|
||||
|
||||
name, pending, err := pickRecordingToCleanup(recordingsDir, cloudDir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !pending {
|
||||
t.Fatalf("expected pending=true when every recording is queued for upload")
|
||||
}
|
||||
if name != "a_old.mp4" {
|
||||
t.Fatalf("cleanup picked %q, want the oldest pending a_old.mp4", name)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty recordings directory yields os.ErrNotExist so the caller does nothing.
|
||||
func TestPickRecordingToCleanup_Empty(t *testing.T) {
|
||||
recordingsDir, cloudDir := newCleanupDirs(t)
|
||||
|
||||
if _, _, err := pickRecordingToCleanup(recordingsDir, cloudDir); err != os.ErrNotExist {
|
||||
t.Fatalf("expected os.ErrNotExist for an empty directory, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSizedRecording writes a recording of an exact byte size so tests can
|
||||
// exercise the megabyte-based directory-cap threshold.
|
||||
func writeSizedRecording(t *testing.T, dir, name string, size int) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o644); err != nil {
|
||||
t.Fatalf("write sized recording %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// When AGENT_AUTO_CLEAN_MAX_SIZE (MaxDirectorySize) is set, cleanup triggers once
|
||||
// the recordings directory grows past that many megabytes.
|
||||
func TestRecordingsNeedCleanup_FixedCap(t *testing.T) {
|
||||
recordingsDir, _ := newCleanupDirs(t)
|
||||
// ~2 MB of recordings on disk.
|
||||
writeSizedRecording(t, recordingsDir, "big.mp4", 2*1000*1000)
|
||||
|
||||
over := &models.Configuration{Config: models.Config{MaxDirectorySize: 1}}
|
||||
need, err := recordingsNeedCleanup(recordingsDir, over)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !need {
|
||||
t.Fatalf("expected cleanup when 2MB of recordings exceed the 1MB cap")
|
||||
}
|
||||
|
||||
under := &models.Configuration{Config: models.Config{MaxDirectorySize: 100}}
|
||||
need, err = recordingsNeedCleanup(recordingsDir, under)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if need {
|
||||
t.Fatalf("expected no cleanup when 2MB of recordings stay under the 100MB cap")
|
||||
}
|
||||
}
|
||||
|
||||
// With no fixed cap (the default), cleanup is driven by the free space left on
|
||||
// the recordings filesystem versus the reserve.
|
||||
func TestRecordingsNeedCleanup_DefaultDiskReserve(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("disk usage stats are only implemented on linux")
|
||||
}
|
||||
recordingsDir, _ := newCleanupDirs(t)
|
||||
|
||||
totalMB, availableMB, err := diskUsageMB(recordingsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("diskUsageMB: %v", err)
|
||||
}
|
||||
if totalMB <= 0 || availableMB <= 0 {
|
||||
t.Skipf("unexpected disk stats total=%dMB available=%dMB", totalMB, availableMB)
|
||||
}
|
||||
|
||||
// A reserve larger than the whole disk means free space is always below it.
|
||||
over := &models.Configuration{Config: models.Config{MinFreeSpace: totalMB + availableMB}}
|
||||
need, err := recordingsNeedCleanup(recordingsDir, over)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !need {
|
||||
t.Fatalf("expected cleanup when free space (%dMB) is below the reserve", availableMB)
|
||||
}
|
||||
|
||||
// A 1 MB reserve leaves plenty of free space, so nothing should be cleaned.
|
||||
under := &models.Configuration{Config: models.Config{MinFreeSpace: 1}}
|
||||
need, err = recordingsNeedCleanup(recordingsDir, under)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if need {
|
||||
t.Fatalf("expected no cleanup when free space (%dMB) exceeds the 1MB reserve", availableMB)
|
||||
}
|
||||
}
|
||||
|
||||
// The default 5% reserve must never truncate to 0MB on small disks, otherwise
|
||||
// cleanup would only trigger once the disk is completely full.
|
||||
func TestDefaultReserveMB(t *testing.T) {
|
||||
cases := []struct {
|
||||
totalMB int64
|
||||
want int64
|
||||
}{
|
||||
{totalMB: 0, want: 1}, // no/unknown disk size still reserves 1MB
|
||||
{totalMB: 10, want: 1}, // 5% = 0MB -> floored to 1MB
|
||||
{totalMB: 19, want: 1}, // 5% = 0MB -> floored to 1MB
|
||||
{totalMB: 20, want: 1}, // 5% = exactly 1MB
|
||||
{totalMB: 100, want: 5}, // 5% = 5MB
|
||||
{totalMB: 1000, want: 50},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := defaultReserveMB(c.totalMB); got != c.want {
|
||||
t.Errorf("defaultReserveMB(%d) = %d, want %d", c.totalMB, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
machinery/src/capture/disk_linux.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build linux
|
||||
|
||||
package capture
|
||||
|
||||
import "syscall"
|
||||
|
||||
// diskUsageMB returns the total capacity and the currently available space (both
|
||||
// in megabytes, decimal) of the filesystem that contains path. Auto-clean uses
|
||||
// it to default its cleanup threshold to the real disk capacity instead of a
|
||||
// fixed size, so recordings can grow to fill the disk while keeping a reserve
|
||||
// free. Linux is the agent's deployment target (amd64/arm64 containers).
|
||||
func diskUsageMB(path string) (totalMB int64, availableMB int64, err error) {
|
||||
var stat syscall.Statfs_t
|
||||
if err = syscall.Statfs(path, &stat); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
blockSize := int64(stat.Bsize)
|
||||
totalMB = int64(stat.Blocks) * blockSize / 1000 / 1000
|
||||
// Bavail is the free space available to unprivileged users, which is the
|
||||
// space we can actually keep writing recordings into.
|
||||
availableMB = int64(stat.Bavail) * blockSize / 1000 / 1000
|
||||
return totalMB, availableMB, nil
|
||||
}
|
||||
13
machinery/src/capture/disk_other.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package capture
|
||||
|
||||
import "errors"
|
||||
|
||||
// diskUsageMB is only implemented on Linux (the agent's deployment target). On
|
||||
// other platforms (e.g. local macOS/Windows dev builds) auto-clean falls back to
|
||||
// its historical fixed-size directory cap, so this reports the capability as
|
||||
// unavailable.
|
||||
func diskUsageMB(path string) (totalMB int64, availableMB int64, err error) {
|
||||
return 0, 0, errors.New("disk usage stats are not supported on this platform")
|
||||
}
|
||||
1735
machinery/src/capture/gortsplib.go
Normal file
79
machinery/src/capture/gortsplib_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/pem"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRTSPSTLSConfig(t *testing.T) {
|
||||
t.Run("verifies certificates by default", func(t *testing.T) {
|
||||
t.Setenv(rtspsCAFileEnv, "")
|
||||
t.Setenv(rtspsInsecureEnv, "")
|
||||
|
||||
got, err := rtspsTLSConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("rtspsTLSConfig() error = %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("rtspsTLSConfig() = %#v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows explicit insecure mode", func(t *testing.T) {
|
||||
t.Setenv(rtspsCAFileEnv, "/missing/ignored-in-insecure-mode.pem")
|
||||
t.Setenv(rtspsInsecureEnv, "true")
|
||||
|
||||
got, err := rtspsTLSConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("rtspsTLSConfig() error = %v", err)
|
||||
}
|
||||
if got == nil || !got.InsecureSkipVerify {
|
||||
t.Fatalf("rtspsTLSConfig() = %#v, want InsecureSkipVerify enabled", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adds a camera CA to system roots", func(t *testing.T) {
|
||||
t.Setenv(rtspsInsecureEnv, "")
|
||||
server := httptest.NewTLSServer(nil)
|
||||
defer server.Close()
|
||||
|
||||
certificate := server.Certificate()
|
||||
caFile := filepath.Join(t.TempDir(), "camera-ca.pem")
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw})
|
||||
if err := os.WriteFile(caFile, caPEM, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(rtspsCAFileEnv, caFile)
|
||||
|
||||
got, err := rtspsTLSConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("rtspsTLSConfig() error = %v", err)
|
||||
}
|
||||
if got == nil || got.RootCAs == nil {
|
||||
t.Fatalf("rtspsTLSConfig() = %#v, want custom RootCAs", got)
|
||||
}
|
||||
for _, subject := range got.RootCAs.Subjects() {
|
||||
if bytes.Equal(subject, certificate.RawSubject) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("camera CA was not added to RootCAs")
|
||||
})
|
||||
|
||||
t.Run("rejects an invalid camera CA file", func(t *testing.T) {
|
||||
t.Setenv(rtspsInsecureEnv, "")
|
||||
caFile := filepath.Join(t.TempDir(), "camera-ca.pem")
|
||||
if err := os.WriteFile(caFile, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(rtspsCAFileEnv, caFile)
|
||||
|
||||
if _, err := rtspsTLSConfig(); err == nil {
|
||||
t.Fatal("rtspsTLSConfig() error = nil, want invalid CA error")
|
||||
}
|
||||
})
|
||||
}
|
||||
88
machinery/src/capture/main_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
func TestPTSToDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pts int64
|
||||
clockRate int
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "one video second", pts: 90_000, clockRate: 90_000, want: time.Second},
|
||||
{name: "one audio frame", pts: 1_024, clockRate: 8_000, want: 128 * time.Millisecond},
|
||||
{name: "fractional millisecond", pts: 45_045, clockRate: 90_000, want: 500*time.Millisecond + 500*time.Microsecond},
|
||||
{name: "negative timestamp", pts: -45_045, clockRate: 90_000, want: -500*time.Millisecond - 500*time.Microsecond},
|
||||
{name: "large timestamp", pts: 90_000 * 60 * 60 * 24, clockRate: 90_000, want: 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := ptsToDuration(test.pts, test.clockRate); got != test.want {
|
||||
t.Fatalf("ptsToDuration(%d, %d) = %s, want %s", test.pts, test.clockRate, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueRecordingForUploadStoresFinalizedMetadata(t *testing.T) {
|
||||
configDirectory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(configDirectory, "data", "cloud"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
|
||||
mp4Video := &video.MP4{VideoTotalDuration: 20452, SampleCount: 613}
|
||||
metadata := recordingUploadMetadata("recording.mp4", "device-key", 1785934709414, mp4Video)
|
||||
queueRecordingForUpload(configDirectory, metadata)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upload marker: %v", err)
|
||||
}
|
||||
var stored models.RecordingUploadMetadata
|
||||
if err := json.Unmarshal(got, &stored); err != nil {
|
||||
t.Fatalf("decode upload marker: %v", err)
|
||||
}
|
||||
expectedFPS := mp4Video.AverageFPS()
|
||||
if stored.FileName != "recording.mp4" || stored.DeviceKey != "device-key" || stored.Timestamp != 1785934709414 || stored.Duration != 20452 || math.Abs(stored.FPS-expectedFPS) > 1e-9 {
|
||||
t.Fatalf("upload marker = %+v", stored)
|
||||
}
|
||||
if stored.FPS == math.Floor(stored.FPS) {
|
||||
t.Fatalf("upload marker FPS = %v, want fractional precision", stored.FPS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueueRecordingForUploadKeepsUnknownFPSCompatible(t *testing.T) {
|
||||
for _, fps := range []float64{0, 0.99, -1, math.NaN(), math.Inf(1), 241} {
|
||||
t.Run("invalid FPS", func(t *testing.T) {
|
||||
configDirectory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(configDirectory, "data", "cloud"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
|
||||
metadata := models.RecordingUploadMetadata{FileName: "recording.mp4"}
|
||||
if fps >= 1 && fps <= 240 && !math.IsNaN(fps) && !math.IsInf(fps, 0) {
|
||||
metadata.FPS = fps
|
||||
}
|
||||
queueRecordingForUpload(configDirectory, metadata)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(configDirectory, "data", "cloud", "recording.metadata"))
|
||||
if err != nil {
|
||||
t.Fatalf("read upload marker: %v", err)
|
||||
}
|
||||
if string(got) != `{"filename":"recording.mp4","device_key":"","timestamp":0,"duration":0}` {
|
||||
t.Fatalf("upload marker = %q, want metadata without FPS", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
72
machinery/src/capture/rtsp_client.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
)
|
||||
|
||||
type Capture struct {
|
||||
RTSPClient *Golibrtsp
|
||||
RTSPSubClient *Golibrtsp
|
||||
RTSPBackChannelClient *Golibrtsp
|
||||
}
|
||||
|
||||
func (c *Capture) SetMainClient(rtspUrl string) *Golibrtsp {
|
||||
c.RTSPClient = &Golibrtsp{
|
||||
Url: rtspUrl,
|
||||
}
|
||||
return c.RTSPClient
|
||||
}
|
||||
|
||||
func (c *Capture) SetSubClient(rtspUrl string) *Golibrtsp {
|
||||
c.RTSPSubClient = &Golibrtsp{
|
||||
Url: rtspUrl,
|
||||
}
|
||||
return c.RTSPSubClient
|
||||
}
|
||||
|
||||
func (c *Capture) SetBackChannelClient(rtspUrl string) *Golibrtsp {
|
||||
c.RTSPBackChannelClient = &Golibrtsp{
|
||||
Url: rtspUrl,
|
||||
}
|
||||
return c.RTSPBackChannelClient
|
||||
}
|
||||
|
||||
// RTSPClient is a interface that abstracts the RTSP client implementation.
|
||||
type RTSPClient interface {
|
||||
// Connect to the RTSP server.
|
||||
Connect(ctx context.Context, otelContext context.Context) error
|
||||
|
||||
// Connect to a backchannel RTSP server.
|
||||
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, otelContext context.Context) error
|
||||
|
||||
// Decode a packet into a image.
|
||||
DecodePacket(pkt packets.Packet) (image.YCbCr, error)
|
||||
|
||||
// Decode a packet into a image.
|
||||
DecodePacketRaw(pkt packets.Packet) (image.Gray, error)
|
||||
|
||||
// Write a packet to the RTSP server.
|
||||
WritePacket(pkt packets.Packet) error
|
||||
|
||||
// Close the connection to the RTSP server.
|
||||
Close(ctx context.Context) error
|
||||
|
||||
// Get a list of streams from the RTSP server.
|
||||
GetStreams() ([]packets.Stream, error)
|
||||
|
||||
// Get a list of video streams from the RTSP server.
|
||||
GetVideoStreams() ([]packets.Stream, error)
|
||||
|
||||
// Get a list of audio streams from the RTSP server.
|
||||
GetAudioStreams() ([]packets.Stream, error)
|
||||
}
|
||||
@@ -1,594 +0,0 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/joy4/av/pubsub"
|
||||
"github.com/minio/minio-go/v6"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
av "github.com/kerberos-io/joy4/av"
|
||||
"github.com/kerberos-io/joy4/cgo/ffmpeg"
|
||||
"gocv.io/x/gocv"
|
||||
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/computervision"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/utils"
|
||||
"github.com/kerberos-io/agent/machinery/src/webrtc"
|
||||
"github.com/shirou/gopsutil/disk"
|
||||
"github.com/shirou/gopsutil/host"
|
||||
)
|
||||
|
||||
func PendingUpload() {
|
||||
ff, err := utils.ReadDirectory("./data/cloud/")
|
||||
if err == nil {
|
||||
for _, f := range ff {
|
||||
log.Log.Info(f.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func HandleUpload(configuration *models.Configuration, communication *models.Communication) {
|
||||
|
||||
log.Log.Debug("HandleUpload: started")
|
||||
|
||||
config := configuration.Config
|
||||
watchDirectory := "./data/cloud/"
|
||||
|
||||
if config.Offline == "true" {
|
||||
log.Log.Debug("HandleUpload: stopping as Offline is enabled.")
|
||||
} else {
|
||||
|
||||
loop:
|
||||
for {
|
||||
ff, err := utils.ReadDirectory(watchDirectory)
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
select {
|
||||
case <-communication.HandleUpload:
|
||||
break loop
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
for _, f := range ff {
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
select {
|
||||
case <-communication.HandleUpload:
|
||||
break loop
|
||||
default:
|
||||
}
|
||||
|
||||
fileName := f.Name()
|
||||
if config.Cloud == "s3" {
|
||||
UploadS3(configuration, fileName, watchDirectory)
|
||||
} else if config.Cloud == "kstorage" {
|
||||
UploadKerberosVault(configuration, fileName, watchDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Log.Debug("HandleUpload: finished")
|
||||
}
|
||||
|
||||
func HandleHeartBeat(configuration *models.Configuration, communication *models.Communication) {
|
||||
|
||||
log.Log.Debug("HandleHeartBeat: started")
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
if config.Offline == "true" {
|
||||
log.Log.Debug("HandleHeartBeat: stopping as Offline is enabled.")
|
||||
} else {
|
||||
|
||||
url := config.HeartbeatURI
|
||||
key := ""
|
||||
username := ""
|
||||
vaultURI := ""
|
||||
|
||||
if config.Cloud == "s3" && config.S3 != nil && config.S3.Publickey != "" {
|
||||
username = config.S3.Username
|
||||
key = config.S3.Publickey
|
||||
} else if config.Cloud == "kstorage" && config.KStorage != nil && config.KStorage.CloudKey != "" {
|
||||
key = config.KStorage.CloudKey
|
||||
username = config.KStorage.Directory
|
||||
vaultURI = config.KStorage.URI
|
||||
}
|
||||
|
||||
// This is the new way ;)
|
||||
if config.HubURI != "" {
|
||||
url = config.HubURI + "/devices/heartbeat"
|
||||
}
|
||||
if config.HubKey != "" {
|
||||
key = config.HubKey
|
||||
}
|
||||
|
||||
loop:
|
||||
for {
|
||||
|
||||
uptime, _ := host.Uptime()
|
||||
days := strconv.Itoa(int(uptime / (60 * 60 * 24)))
|
||||
//12:11:48 up 11 days
|
||||
|
||||
//partitions, _ := disk.Partitions(false)
|
||||
usage, _ := disk.Usage("/")
|
||||
diskPercentUsed := strconv.Itoa(int(usage.UsedPercent))
|
||||
|
||||
onvifEnabled := "false"
|
||||
if config.Capture.IPCamera.ONVIFXAddr != "" {
|
||||
onvifEnabled = "true"
|
||||
}
|
||||
|
||||
// Check if the agent is running inside a cluster (Kerberos Factory) or as
|
||||
// an open source agent
|
||||
isEnterprise := false
|
||||
if os.Getenv("DEPLOYMENT") == "factory" || os.Getenv("MACHINERY_ENVIRONMENT") == "kubernetes" {
|
||||
isEnterprise = true
|
||||
}
|
||||
|
||||
var object = fmt.Sprintf(`{
|
||||
"key" : "%s",
|
||||
"hash" : "826133658",
|
||||
"version" : "3.0.0",
|
||||
"cpuid" : "Serial: xxx",
|
||||
"clouduser" : "%s",
|
||||
"cloudpublickey" : "%s",
|
||||
"cameraname" : "%s",
|
||||
"cameratype" : "IPCamera",
|
||||
"docker" : true,
|
||||
"kios" : false,
|
||||
"raspberrypi" : false,
|
||||
"enterprise" : %t,
|
||||
"board" : "",
|
||||
"disk1size" : "%s",
|
||||
"disk3size" : "%s",
|
||||
"diskvdasize" : "%s",
|
||||
"numberoffiles" : "33",
|
||||
"temperature" : "sh: 1: vcgencmd: not found",
|
||||
"wifissid" : "",
|
||||
"wifistrength" : "",
|
||||
"uptime" : "up %s days,",
|
||||
"timestamp" : 1564747908,
|
||||
"siteID" : "%s",
|
||||
"onvif" : "%s"
|
||||
}`, config.Key, username, key, config.Name, isEnterprise, "0", "0", diskPercentUsed, days, config.HubSite, onvifEnabled)
|
||||
|
||||
var jsonStr = []byte(object)
|
||||
buffy := bytes.NewBuffer(jsonStr)
|
||||
req, _ := http.NewRequest("POST", url, buffy)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
communication.CloudTimestamp.Store(time.Now().Unix())
|
||||
log.Log.Info("HandleHeartBeat: (200) Heartbeat received by Kerberos Hub.")
|
||||
} else {
|
||||
log.Log.Error("HandleHeartBeat: (400) Something went wrong while sending to Kerberos Hub.")
|
||||
}
|
||||
|
||||
// If we have a vault connect, we will also send some analytics
|
||||
// to that service.
|
||||
if vaultURI != "" {
|
||||
buffy = bytes.NewBuffer(jsonStr)
|
||||
req, _ = http.NewRequest("POST", vaultURI+"/devices/heartbeat", buffy)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client = &http.Client{}
|
||||
resp, err = client.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
log.Log.Info("HandleHeartBeat: (200) Heartbeat received by Kerberos Vault.")
|
||||
} else {
|
||||
log.Log.Error("HandleHeartBeat: (400) Something went wrong while sending to Kerberos Vault.")
|
||||
}
|
||||
}
|
||||
|
||||
// This will check if we need to stop the thread,
|
||||
// because of a reconfiguration.
|
||||
select {
|
||||
case <-communication.HandleHeartBeat:
|
||||
break loop
|
||||
case <-time.After(15 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Log.Debug("HandleHeartBeat: finished")
|
||||
}
|
||||
|
||||
func HandleLiveStreamSD(livestreamCursor *pubsub.QueueCursor, configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, decoder *ffmpeg.VideoDecoder, decoderMutex *sync.Mutex) {
|
||||
|
||||
log.Log.Debug("HandleLiveStreamSD: started")
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
if config.Offline == "true" {
|
||||
log.Log.Debug("HandleLiveStreamSD: stopping as Offline is enabled.")
|
||||
} else {
|
||||
|
||||
key := ""
|
||||
if config.Cloud == "s3" && config.S3 != nil && config.S3.Publickey != "" {
|
||||
key = config.S3.Publickey
|
||||
} else if config.Cloud == "kstorage" && config.KStorage != nil && config.KStorage.CloudKey != "" {
|
||||
key = config.KStorage.CloudKey
|
||||
}
|
||||
// This is the new way ;)
|
||||
if config.HubKey != "" {
|
||||
key = config.HubKey
|
||||
}
|
||||
|
||||
topic := "kerberos/" + key + "/device/" + config.Key + "/live"
|
||||
|
||||
lastLivestreamRequest := int64(0)
|
||||
|
||||
var cursorError error
|
||||
var pkt av.Packet
|
||||
|
||||
for cursorError == nil {
|
||||
pkt, cursorError = livestreamCursor.ReadPacket()
|
||||
if len(pkt.Data) == 0 || !pkt.IsKeyFrame {
|
||||
continue
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
select {
|
||||
case <-communication.HandleLiveSD:
|
||||
lastLivestreamRequest = now
|
||||
default:
|
||||
}
|
||||
if now-lastLivestreamRequest > 3 {
|
||||
continue
|
||||
}
|
||||
log.Log.Info("HandleLiveStreamSD: Sending base64 encoded images to MQTT.")
|
||||
sendImage(topic, mqttClient, pkt, decoder, decoderMutex)
|
||||
}
|
||||
}
|
||||
|
||||
log.Log.Debug("HandleLiveStreamSD: finished")
|
||||
}
|
||||
|
||||
func sendImage(topic string, mqttClient mqtt.Client, pkt av.Packet, decoder *ffmpeg.VideoDecoder, decoderMutex *sync.Mutex) {
|
||||
mat := computervision.GetRGBImage(pkt, decoder, decoderMutex)
|
||||
buffer, err := gocv.IMEncode(gocv.JPEGFileExt, mat)
|
||||
mat.Close()
|
||||
if err == nil {
|
||||
encoded := base64.StdEncoding.EncodeToString(buffer.GetBytes())
|
||||
mqttClient.Publish(topic, 0, false, encoded)
|
||||
}
|
||||
runtime.GC()
|
||||
debug.FreeOSMemory()
|
||||
}
|
||||
|
||||
func HandleLiveStreamHD(livestreamCursor *pubsub.QueueCursor, configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, codecs []av.CodecData, decoder *ffmpeg.VideoDecoder, decoderMutex *sync.Mutex) {
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
if config.Offline == "true" {
|
||||
log.Log.Debug("HandleLiveStreamHD: stopping as Offline is enabled.")
|
||||
} else {
|
||||
|
||||
// Should create a track here.
|
||||
track := webrtc.NewVideoTrack()
|
||||
go webrtc.WriteToTrack(livestreamCursor, configuration, communication, mqttClient, track, codecs, decoder, decoderMutex)
|
||||
|
||||
if config.Capture.ForwardWebRTC == "true" {
|
||||
// We get a request with an offer, but we'll forward it.
|
||||
for m := range communication.HandleLiveHDHandshake {
|
||||
// Forward SDP
|
||||
m.CloudKey = config.Key
|
||||
request, err := json.Marshal(m)
|
||||
if err == nil {
|
||||
mqttClient.Publish("kerberos/webrtc/request", 2, false, request)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("HandleLiveStreamHD: Waiting for peer connections.")
|
||||
for handshake := range communication.HandleLiveHDHandshake {
|
||||
log.Log.Info("HandleLiveStreamHD: setting up a peer connection.")
|
||||
key := config.Key + "/" + handshake.Cuuid
|
||||
webrtc.CandidatesMutex.Lock()
|
||||
_, ok := webrtc.CandidateArrays[key]
|
||||
if !ok {
|
||||
webrtc.CandidateArrays[key] = make(chan string, 30)
|
||||
}
|
||||
webrtc.CandidatesMutex.Unlock()
|
||||
webrtc.InitializeWebRTCConnection(configuration, communication, mqttClient, track, handshake, webrtc.CandidateArrays[key])
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyHub godoc
|
||||
// @Router /api/hub/verify [post]
|
||||
// @ID verify-hub
|
||||
// @Security Bearer
|
||||
// @securityDefinitions.apikey Bearer
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @Tags config
|
||||
// @Param config body models.Config true "Config"
|
||||
// @Summary Will verify the hub connectivity.
|
||||
// @Description Will verify the hub connectivity.
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func VerifyHub(c *gin.Context) {
|
||||
|
||||
var config models.Config
|
||||
err := c.BindJSON(&config)
|
||||
|
||||
if err == nil {
|
||||
hubKey := config.HubKey
|
||||
//hubPrivateKey := config.HubPrivateKey
|
||||
//hubSite := config.HubSite
|
||||
hubURI := config.HubURI
|
||||
|
||||
content := []byte(`{"message": "fake-message"}`)
|
||||
body := bytes.NewReader(content)
|
||||
req, err := http.NewRequest("POST", hubURI+"/queue/test", body)
|
||||
if err == nil {
|
||||
req.Header.Set("X-Kerberos-Cloud-Key", hubKey)
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
defer resp.Body.Close()
|
||||
if err == nil {
|
||||
if resp.StatusCode == 200 {
|
||||
c.JSON(200, body)
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while reaching the Kerberos Hub API: " + string(body),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while ready the response body: " + err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while reaching to the Kerberos Hub API: " + hubURI,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while creating the HTTP request: " + err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while receiving the config " + err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyPersistence godoc
|
||||
// @Router /api/persistence/verify [post]
|
||||
// @ID verify-persistence
|
||||
// @Security Bearer
|
||||
// @securityDefinitions.apikey Bearer
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @Tags config
|
||||
// @Param config body models.Config true "Config"
|
||||
// @Summary Will verify the persistence.
|
||||
// @Description Will verify the persistence.
|
||||
// @Success 200 {object} models.APIResponse
|
||||
func VerifyPersistence(c *gin.Context) {
|
||||
|
||||
var config models.Config
|
||||
err := c.BindJSON(&config)
|
||||
if err != nil || config.Cloud != "" {
|
||||
|
||||
if config.Cloud == "s3" {
|
||||
|
||||
//fmt.Println("Uploading...")
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
aws_access_key_id := config.S3.Publickey
|
||||
aws_secret_access_key := config.S3.Secretkey
|
||||
aws_region := config.S3.Region
|
||||
|
||||
// This is the new way ;)
|
||||
if config.HubKey != "" {
|
||||
aws_access_key_id = config.HubKey
|
||||
}
|
||||
if config.HubPrivateKey != "" {
|
||||
aws_secret_access_key = config.HubPrivateKey
|
||||
}
|
||||
|
||||
s3Client, err := minio.NewWithRegion("s3.amazonaws.com", aws_access_key_id, aws_secret_access_key, true, aws_region)
|
||||
if err != nil {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Creation of Kerberos Hub connection failed: " + err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
||||
// Check if we need to use the proxy.
|
||||
if config.S3.ProxyURI != "" {
|
||||
var transport http.RoundTripper = &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return url.Parse(config.S3.ProxyURI)
|
||||
},
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
s3Client.SetCustomTransport(transport)
|
||||
}
|
||||
|
||||
deviceKey := "fake-key"
|
||||
devicename := "justatest"
|
||||
coordinates := "200-200-400-400"
|
||||
eventToken := "769"
|
||||
|
||||
timestamp := time.Now().Unix()
|
||||
fileName := strconv.FormatInt(timestamp, 10) + "_6-967003_justatest_200-200-400-400_24_769.mp4"
|
||||
content := []byte("test-file")
|
||||
body := bytes.NewReader(content)
|
||||
|
||||
n, err := s3Client.PutObject(config.S3.Bucket,
|
||||
config.S3.Username+"/"+fileName,
|
||||
body,
|
||||
body.Size(),
|
||||
minio.PutObjectOptions{
|
||||
ContentType: "video/mp4",
|
||||
StorageClass: "ONEZONE_IA",
|
||||
UserMetadata: map[string]string{
|
||||
"event-timestamp": strconv.FormatInt(timestamp, 10),
|
||||
"event-microseconds": deviceKey,
|
||||
"event-instancename": devicename,
|
||||
"event-regioncoordinates": coordinates,
|
||||
"event-numberofchanges": deviceKey,
|
||||
"event-token": eventToken,
|
||||
"productid": deviceKey,
|
||||
"publickey": aws_access_key_id,
|
||||
"uploadtime": "now",
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Upload of fake recording failed: " + err.Error(),
|
||||
})
|
||||
} else {
|
||||
c.JSON(200, models.APIResponse{
|
||||
Data: "Upload Finished: file has been uploaded to bucket: " + strconv.FormatInt(n, 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.Cloud == "kstorage" {
|
||||
|
||||
uri := config.KStorage.URI
|
||||
accessKey := config.KStorage.AccessKey
|
||||
secretAccessKey := config.KStorage.SecretAccessKey
|
||||
directory := config.KStorage.Directory
|
||||
provider := config.KStorage.Provider
|
||||
|
||||
if err == nil && uri != "" && accessKey != "" && secretAccessKey != "" {
|
||||
var postData = []byte(`{"title":"Buy cheese and bread for breakfast."}`)
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", uri+"/ping", bytes.NewReader(postData))
|
||||
|
||||
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 := ioutil.ReadAll(resp.Body)
|
||||
defer resp.Body.Close()
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
|
||||
if provider != "" || directory != "" {
|
||||
|
||||
hubKey := config.KStorage.CloudKey
|
||||
// This is the new way ;)
|
||||
if config.HubKey != "" {
|
||||
hubKey = config.HubKey
|
||||
}
|
||||
|
||||
// Generate a random name.
|
||||
timestamp := time.Now().Unix()
|
||||
fileName := strconv.FormatInt(timestamp, 10) +
|
||||
"_6-967003_justatest_200-200-400-400_24_769.mp4"
|
||||
content := []byte("test-file")
|
||||
body := bytes.NewReader(content)
|
||||
//fileSize := int64(len(content))
|
||||
|
||||
req, err := http.NewRequest("POST", uri+"/storage", body)
|
||||
if err == nil {
|
||||
|
||||
req.Header.Set("Content-Type", "video/mp4")
|
||||
req.Header.Set("X-Kerberos-Storage-CloudKey", 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", "test")
|
||||
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
req.Header.Set("X-Kerberos-Storage-Directory", directory)
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if err == nil {
|
||||
if resp != nil {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
defer resp.Body.Close()
|
||||
if err == nil {
|
||||
if resp.StatusCode == 200 {
|
||||
c.JSON(200, body)
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "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.",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Upload of fake recording failed: " + err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while creating /storage POST request." + err.Error(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Provider and/or directory is missing from the request.",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while verifying storage credentials: " + string(body),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while verifying storage credentials:" + err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "No persistence was specified, so do not know what to verify:" + err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func UploadKerberosVault(configuration *models.Configuration, fileName string, directory string) bool {
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
if config.KStorage.AccessKey == "" ||
|
||||
config.KStorage.SecretAccessKey == "" ||
|
||||
config.KStorage.Provider == "" ||
|
||||
config.KStorage.Directory == "" ||
|
||||
config.KStorage.URI == "" {
|
||||
log.Log.Info("UploadKerberosVault: Kerberos Vault not properly configured.")
|
||||
}
|
||||
|
||||
//fmt.Println("Uploading...")
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
// 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 err != nil {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, file doesn't exists anymore.")
|
||||
os.Remove(directory + "/" + fileName)
|
||||
return false
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
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 {
|
||||
log.Log.Error("Error reading request. " + err.Error())
|
||||
}
|
||||
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)
|
||||
//client := &http.Client{Timeout: time.Second * 30}
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
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))
|
||||
// We will remove the file from disk as well
|
||||
os.Remove(fullname)
|
||||
os.Remove(directory + "/" + fileName)
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + resp.Status + ", " + string(body))
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + err.Error())
|
||||
}
|
||||
return true
|
||||
}
|
||||
1703
machinery/src/cloud/cloud.go
Normal file
51
machinery/src/cloud/cloud_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHeartbeatFailureLogIncludesHubResponse(t *testing.T) {
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Status: "400 Bad Request",
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"invalid heartbeat"}`)),
|
||||
}
|
||||
|
||||
responseBody, truncated, err := readHeartbeatResponseBody(response)
|
||||
if err != nil {
|
||||
t.Fatalf("readHeartbeatResponseBody() error = %v", err)
|
||||
}
|
||||
message := formatHeartbeatFailureLog(response, nil, responseBody, truncated, nil, 125*time.Millisecond)
|
||||
|
||||
for _, expected := range []string{
|
||||
"status_code=400",
|
||||
`status="400 Bad Request"`,
|
||||
"duration=125ms",
|
||||
`response_body="{\"error\":\"invalid heartbeat\"}"`,
|
||||
} {
|
||||
if !strings.Contains(message, expected) {
|
||||
t.Errorf("formatHeartbeatFailureLog() = %q, want it to contain %q", message, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeartbeatResponseBodyTruncatesLargeBody(t *testing.T) {
|
||||
response := &http.Response{
|
||||
Body: io.NopCloser(strings.NewReader(strings.Repeat("x", heartbeatResponseBodyLogLimit+1))),
|
||||
}
|
||||
|
||||
body, truncated, err := readHeartbeatResponseBody(response)
|
||||
if err != nil {
|
||||
t.Fatalf("readHeartbeatResponseBody() error = %v", err)
|
||||
}
|
||||
if !truncated {
|
||||
t.Fatal("readHeartbeatResponseBody() truncated = false, want true")
|
||||
}
|
||||
if len(body) != heartbeatResponseBodyLogLimit {
|
||||
t.Fatalf("len(body) = %d, want %d", len(body), heartbeatResponseBodyLogLimit)
|
||||
}
|
||||
}
|
||||
139
machinery/src/cloud/dropbox.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Package cloud contains the Dropbox implementation of the Cloud interface.
|
||||
// It uses the Dropbox SDK to upload files to Dropbox.
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
|
||||
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
|
||||
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/users"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// UploadDropbox uploads the file to your Dropbox account using the access token and directory.
|
||||
func UploadDropbox(configuration *models.Configuration, fileName string) (bool, bool, error) {
|
||||
|
||||
config := configuration.Config
|
||||
token := config.Dropbox.AccessToken
|
||||
directory := config.Dropbox.Directory
|
||||
if directory != "" {
|
||||
// Check if trailing slash if not we'll add one.
|
||||
if directory[len(directory)-1:] != "/" {
|
||||
directory = directory + "/"
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
err := "UploadDropbox: Dropbox not properly configured"
|
||||
log.Log.Info(err)
|
||||
return false, true, errors.New(err)
|
||||
}
|
||||
|
||||
// Upload to Dropbox
|
||||
log.Log.Info("UploadDropbox: Uploading to Dropbox")
|
||||
log.Log.Info("UploadDropbox: Upload started for " + fileName)
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
dConfig := dropbox.Config{
|
||||
Token: token,
|
||||
LogLevel: dropbox.LogInfo, // if needed, set the desired logging level. Default is off
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
if file != nil {
|
||||
defer func() {
|
||||
if cerr := file.Close(); cerr != nil {
|
||||
log.Log.Error("UploadDropbox: Error closing file: " + cerr.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// Upload the file
|
||||
dbf := files.New(dConfig)
|
||||
res, err := dbf.Upload(&files.UploadArg{
|
||||
CommitInfo: files.CommitInfo{
|
||||
Path: "/" + directory + fileName,
|
||||
Mode: &files.WriteMode{
|
||||
Tagged: dropbox.Tagged{
|
||||
Tag: "overwrite",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, file)
|
||||
|
||||
if err != nil {
|
||||
log.Log.Error("UploadDropbox: Error uploading file: " + err.Error())
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
log.Log.Info("UploadDropbox: File uploaded successfully, " + res.Name)
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
log.Log.Error("UploadDropbox: Error opening file: " + err.Error())
|
||||
return false, true, err
|
||||
}
|
||||
|
||||
// VerifyDropbox verifies if the Dropbox token is valid and it is able to upload a file.
|
||||
func VerifyDropbox(config models.Config, c *gin.Context) {
|
||||
|
||||
token := config.Dropbox.AccessToken
|
||||
directory := config.Dropbox.Directory
|
||||
if directory != "" {
|
||||
// Check if trailing slash if not we'll add one.
|
||||
if directory[len(directory)-1:] != "/" {
|
||||
directory = directory + "/"
|
||||
}
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
dConfig := dropbox.Config{
|
||||
Token: token,
|
||||
LogLevel: dropbox.LogInfo, // if needed, set the desired logging level. Default is off
|
||||
}
|
||||
dbx := users.New(dConfig)
|
||||
_, err := dbx.GetCurrentAccount()
|
||||
if err != nil {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while reaching the Dropbox API: " + err.Error(),
|
||||
})
|
||||
} else {
|
||||
|
||||
// Upload the file
|
||||
content := TestFile
|
||||
file := bytes.NewReader(content)
|
||||
|
||||
dbf := files.New(dConfig)
|
||||
_, err := dbf.Upload(&files.UploadArg{
|
||||
CommitInfo: files.CommitInfo{
|
||||
Path: "/" + directory + "kerbers-agent-test.mp4",
|
||||
Mode: &files.WriteMode{
|
||||
Tagged: dropbox.Tagged{
|
||||
Tag: "overwrite",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, file)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Something went wrong while reaching the Dropbox API: " + err.Error(),
|
||||
})
|
||||
} else {
|
||||
c.JSON(200, models.APIResponse{
|
||||
Data: "Dropbox is working fine.",
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.JSON(400, models.APIResponse{
|
||||
Data: "Dropbox token is not set.",
|
||||
})
|
||||
}
|
||||
}
|
||||
173
machinery/src/cloud/kerberos_hub.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func UploadKerberosHub(configuration *models.Configuration, fileName string) (bool, bool, error) {
|
||||
config := configuration.Config
|
||||
|
||||
if config.HubURI == "" ||
|
||||
config.HubKey == "" ||
|
||||
config.HubPrivateKey == "" ||
|
||||
config.S3.Region == "" {
|
||||
err := "UploadKerberosHub: Kerberos Hub not properly configured."
|
||||
log.Log.Info(err)
|
||||
return false, false, errors.New(err)
|
||||
}
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
|
||||
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.
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if err != nil {
|
||||
err := "UploadKerberosHub: Upload Failed, file doesn't exists anymore."
|
||||
log.Log.Info(err)
|
||||
return false, false, errors.New(err)
|
||||
}
|
||||
|
||||
// Check if we are allowed to upload to the hub with these credentials.
|
||||
// There might be different reasons like (muted, read-only..)
|
||||
req, err := http.NewRequest("HEAD", config.HubURI+"/storage/upload", nil)
|
||||
if err != nil {
|
||||
errorMessage := "UploadKerberosHub: error reading HEAD request, " + config.HubURI + "/storage: " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
req.Header.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
req.Header.Set("X-Kerberos-Storage-Device", config.Key)
|
||||
req.Header.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
req.Header.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
req.Header.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
|
||||
var client *http.Client
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
client = &http.Client{Transport: tr, CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
|
||||
} else {
|
||||
client = &http.Client{CheckRedirect: stripHubCredentialsOnCrossHostRedirect}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if resp != nil {
|
||||
if err == nil {
|
||||
if resp.StatusCode == 200 {
|
||||
log.Log.Info("UploadKerberosHub: Upload allowed using the credentials provided (" + config.HubKey + ", " + config.HubPrivateKey + ")")
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosHub: Upload NOT allowed using the credentials provided (" + config.HubKey + ", " + config.HubPrivateKey + ")")
|
||||
return false, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now we know we are allowed to upload to the hub, we can start uploading.
|
||||
req, err = http.NewRequest("POST", config.HubURI+"/storage/upload", file)
|
||||
if err != nil {
|
||||
errorMessage := "UploadKerberosHub: error reading POST request, " + config.KStorage.URI + "/storage/upload: " + 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-FileName", fileName)
|
||||
req.Header.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
req.Header.Set("X-Kerberos-Storage-Device", config.Key)
|
||||
req.Header.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
req.Header.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
req.Header.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
resp, err = client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if resp != nil {
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
if resp.StatusCode == 200 {
|
||||
log.Log.Info("UploadKerberosHub: Upload Finished, " + resp.Status + ".")
|
||||
return true, true, nil
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosHub: Upload Failed, " + resp.Status + ", " + string(body))
|
||||
return false, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage := "UploadKerberosHub: Upload Failed, " + err.Error()
|
||||
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
|
||||
}
|
||||
264
machinery/src/cloud/kerberos_vault.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"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
|
||||
|
||||
if config.KStorage.AccessKey == "" ||
|
||||
config.KStorage.SecretAccessKey == "" ||
|
||||
config.KStorage.Directory == "" ||
|
||||
config.KStorage.URI == "" {
|
||||
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.
|
||||
info, err := os.Stat("data/recordings/" + fileName)
|
||||
if err != nil {
|
||||
log.Log.Info("UploadKerberosVault: skipping " + fileName + ", file doesn't exist anymore")
|
||||
return false, false, nil
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
log.Log.Warning("UploadKerberosVault: skipping " + fileName + ", recording is empty")
|
||||
return false, false, nil
|
||||
}
|
||||
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
// - Size + - + microseconds
|
||||
// - device
|
||||
// - Region
|
||||
// - Number of changes
|
||||
// - Token
|
||||
// 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)
|
||||
|
||||
publicKey := config.KStorage.CloudKey
|
||||
if config.HubKey != "" {
|
||||
publicKey = config.HubKey
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + err.Error())
|
||||
} else {
|
||||
log.Log.Info("UploadKerberosVault: Upload Failed, " + body)
|
||||
}
|
||||
|
||||
// We only advance the retry policy when the vault gave a definitive
|
||||
// response (mirroring the original behaviour where transient network
|
||||
// errors did not consume retries). When the retry count reaches the
|
||||
// configured maximum we back off for the configured timeout.
|
||||
if responded {
|
||||
if kstorageRetryCount < config.KStorage.MaxRetries {
|
||||
kstorageRetryCount = (kstorageRetryCount + 1)
|
||||
}
|
||||
if kstorageRetryCount == config.KStorage.MaxRetries {
|
||||
kstorageRetryTimeout = time.Now().Add(time.Duration(config.KStorage.Timeout) * time.Second).Unix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
setQueuedRecordingMetadataHeaders(req.Header, fileName)
|
||||
|
||||
client := newVaultHTTPClient(0)
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
|
||||
body, rerr := io.ReadAll(resp.Body)
|
||||
if rerr != nil {
|
||||
return false, false, "", rerr
|
||||
}
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
return true, true, resp.Status + ", " + string(body), nil
|
||||
}
|
||||
return false, true, resp.Status + ", " + string(body), nil
|
||||
}
|
||||
|
||||
// setVaultHeaders sets the standard Kerberos Vault headers used by the legacy
|
||||
// single-POST upload.
|
||||
func setVaultHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
|
||||
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
|
||||
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
|
||||
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
|
||||
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
h.Set("X-Kerberos-Storage-Device", deviceKey)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
|
||||
}
|
||||
|
||||
// newVaultHTTPClient builds an HTTP client honouring the AGENT_TLS_INSECURE
|
||||
// escape hatch. A timeout of 0 disables the *overall* client timeout, which is
|
||||
// required for streaming large upload bodies without capping the total transfer
|
||||
// time. Transport-level timeouts are still applied so that a lost network
|
||||
// connection (for example the internet being disconnected) fails reasonably
|
||||
// fast and the upload is retried, instead of the request hanging until the OS
|
||||
// TCP timeout (which can be many minutes) and blocking the whole upload loop.
|
||||
func newVaultHTTPClient(timeout time.Duration) *http.Client {
|
||||
// Start from a clone of the default transport so we keep its sane dial and
|
||||
// TLS-handshake timeouts, connection pooling and HTTP/2 support even when the
|
||||
// AGENT_TLS_INSECURE escape hatch is enabled (a bare http.Transport would have
|
||||
// no dial/handshake timeouts at all).
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// ResponseHeaderTimeout bounds how long we wait for the vault's response
|
||||
// headers *after* the request body has been fully written. It does not limit
|
||||
// the time spent streaming the (potentially large) upload body, so big
|
||||
// recordings still upload fine, but a vault/network that disappears while we
|
||||
// wait for the acknowledgement is detected and the upload is retried instead
|
||||
// of hanging indefinitely.
|
||||
transport.ResponseHeaderTimeout = vaultResponseHeaderTimeout()
|
||||
|
||||
if os.Getenv("AGENT_TLS_INSECURE") == "true" {
|
||||
if transport.TLSClientConfig == nil {
|
||||
transport.TLSClientConfig = &tls.Config{}
|
||||
}
|
||||
transport.TLSClientConfig.InsecureSkipVerify = true
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
if timeout > 0 {
|
||||
client.Timeout = timeout
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// vaultResponseHeaderTimeout returns the maximum time to wait for a vault's
|
||||
// response headers after the request body has been written. It defaults to 5
|
||||
// minutes — generous enough for the vault to persist/finalize a chunk or a full
|
||||
// recording to its storage provider — and can be tuned with the
|
||||
// AGENT_VAULT_RESPONSE_HEADER_TIMEOUT_SECONDS environment variable. A value of 0
|
||||
// (or a negative/invalid value) disables the timeout.
|
||||
func vaultResponseHeaderTimeout() time.Duration {
|
||||
const def = 5 * time.Minute
|
||||
v := os.Getenv("AGENT_VAULT_RESPONSE_HEADER_TIMEOUT_SECONDS")
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
327
machinery/src/cloud/livehls.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/cloud/livehls"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
)
|
||||
|
||||
// hlsViewerTimeoutSeconds is how long the agent keeps shipping live HLS segments
|
||||
// after the last viewer keepalive. It is a few seconds longer than the segment
|
||||
// duration so a viewer whose keepalive is briefly delayed does not cause the
|
||||
// session to flap. When it lapses the session is torn down to stop wasting
|
||||
// upload bandwidth when nobody is watching.
|
||||
const hlsViewerTimeoutSeconds = 8
|
||||
|
||||
// hlsReadyReannounceSeconds throttles how often the agent re-announces an
|
||||
// already-ready session over MQTT in response to viewer keepalives. The initial
|
||||
// "receive-hls-ready" is a one-shot fired when the first segment lands; a viewer
|
||||
// that connects or hard-refreshes after that (while the session is still alive)
|
||||
// missed it, so we re-announce on subsequent keepalives. Viewers dedupe by
|
||||
// session id, so a re-announce for a session they already play is a no-op. ~2s
|
||||
// gets a refreshed viewer playing well within its connection timeout without
|
||||
// spamming the control plane.
|
||||
const hlsReadyReannounceSeconds = 2
|
||||
|
||||
// HandleLiveStreamHLS drives the live HLS producer. It mirrors HandleLiveStreamSD:
|
||||
// it reads the camera's packet stream from a Latest() cursor, and while a viewer
|
||||
// is active (kept alive via communication.HandleLiveHLS) it muxes the packets
|
||||
// into CMAF segments and ships them to hub-api, which stores each segment in an
|
||||
// ephemeral, short-TTL live window and serves the rolling playlist to viewers.
|
||||
//
|
||||
// A session is created lazily on the first keyframe seen while a viewer is active
|
||||
// and torn down once viewers go away, so an idle camera produces no live traffic.
|
||||
//
|
||||
// By default (AGENT_LIVE_HLS_PREWARM unset or != "false") the agent instead keeps
|
||||
// one long-lived session muxing continuously into a small in-memory ring buffer
|
||||
// while idle (uploading nothing) and, the moment a viewer arrives, flushes the
|
||||
// already-encoded init + most-recent segment(s) and starts uploading live. This
|
||||
// trades a little idle CPU for a near-instant "requesting stream", so viewers no
|
||||
// longer wait a full GOP for the first segment to be cut. Set
|
||||
// AGENT_LIVE_HLS_PREWARM=false to fall back to the lazy on-demand path above.
|
||||
func HandleLiveStreamHLS(configuration *models.Configuration, communication *models.Communication, mqttClient mqtt.Client, subStreamEnabled bool) {
|
||||
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): started")
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
if config.Offline == "true" {
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as Offline is enabled.")
|
||||
return
|
||||
}
|
||||
if config.Capture.Liveview == "false" {
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as Liveview is disabled.")
|
||||
return
|
||||
}
|
||||
if config.HubURI == "" || config.HubKey == "" {
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): stopping as the Hub is not configured (HubURI/HubKey).")
|
||||
return
|
||||
}
|
||||
|
||||
hubKey := config.HubKey
|
||||
deviceId := config.Key
|
||||
|
||||
region := ""
|
||||
if config.S3 != nil {
|
||||
region = config.S3.Region
|
||||
}
|
||||
|
||||
publisher := livehls.NewPublisher(livehls.PublisherConfig{
|
||||
HubURI: config.HubURI,
|
||||
HubKey: config.HubKey,
|
||||
HubPrivateKey: config.HubPrivateKey,
|
||||
Region: region,
|
||||
DeviceKey: deviceId,
|
||||
})
|
||||
|
||||
// The live session can be served from the main (high-resolution) or sub
|
||||
// (low-resolution) stream and switched on demand. requestedQuality tracks the
|
||||
// latest tier asked for over the keepalive; source holds the cursor plus the
|
||||
// encoded parameter sets/dimensions for the stream currently being muxed.
|
||||
// Encoded dimensions are only needed for the avcC fallback path (an SPS that
|
||||
// mp4ff's strict parser rejects).
|
||||
requestedQuality := models.StreamQualityAuto
|
||||
useSub := models.SelectSubStreamForQuality(config, requestedQuality, subStreamEnabled)
|
||||
source := buildHLSSource(config, communication, useSub)
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): serving live HLS from the " + source.label + " stream")
|
||||
|
||||
// prewarm keeps a single long-lived session muxing into an in-memory ring
|
||||
// buffer while idle and flushes it the instant a viewer arrives, eliminating
|
||||
// the per-request GOP wait. Enabled by default; set AGENT_LIVE_HLS_PREWARM=false
|
||||
// to fall back to the lazy on-demand path.
|
||||
prewarm := os.Getenv("AGENT_LIVE_HLS_PREWARM") != "false"
|
||||
if prewarm {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS prewarm ENABLED (set AGENT_LIVE_HLS_PREWARM=false to disable)")
|
||||
} else {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS prewarm DISABLED (AGENT_LIVE_HLS_PREWARM=false)")
|
||||
}
|
||||
|
||||
// lowLatency enables LL-HLS: each segment is sliced into CMAF parts shipped the
|
||||
// instant they close and advertised via #EXT-X-PART, taking glass-to-glass HLS
|
||||
// latency from ~4-6s down to ~1-2s. Enabled by default; set
|
||||
// AGENT_LIVE_HLS_LOW_LATENCY=false to fall back to whole-segment HLS.
|
||||
partTargetMs := uint64(0)
|
||||
if os.Getenv("AGENT_LIVE_HLS_LOW_LATENCY") != "false" {
|
||||
partTargetMs = livehls.DefaultPartTargetMs
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS low-latency (LL-HLS) ENABLED (set AGENT_LIVE_HLS_LOW_LATENCY=false to disable)")
|
||||
} else {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS low-latency (LL-HLS) DISABLED (AGENT_LIVE_HLS_LOW_LATENCY=false)")
|
||||
}
|
||||
|
||||
var session *livehls.Session
|
||||
lastViewerRequest := int64(0)
|
||||
lastReadyAnnounce := int64(0)
|
||||
|
||||
var cursorError error
|
||||
var pkt packets.Packet
|
||||
|
||||
for cursorError == nil {
|
||||
pkt, cursorError = source.cursor.ReadPacket()
|
||||
|
||||
now := time.Now().Unix()
|
||||
select {
|
||||
case q := <-communication.HandleLiveHLS:
|
||||
lastViewerRequest = now
|
||||
if q != "" {
|
||||
requestedQuality = q
|
||||
}
|
||||
// A keepalive may come from a viewer that just connected or hard-
|
||||
// refreshed and therefore missed the one-shot readiness announcement
|
||||
// fired when this session's first segment landed. Re-announce (throttled)
|
||||
// so late/refreshed viewers learn the active session id; the frontend
|
||||
// dedupes by session id, so this is a no-op for viewers already playing.
|
||||
// UploadsActive() is always true for the on-demand path; for prewarm it
|
||||
// suppresses a stale re-announce while idle (the flush-on-arrival path
|
||||
// below announces once the buffer has actually been shipped).
|
||||
if session != nil && session.IsReady() && session.UploadsActive() && now-lastReadyAnnounce >= hlsReadyReannounceSeconds {
|
||||
publishHLSReady(configuration, mqttClient, hubKey, deviceId, session.SessionID())
|
||||
lastReadyAnnounce = now
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
// Switch the source stream when the requested quality now maps to the other
|
||||
// stream. Tearing the current session down makes the producer rebuild the
|
||||
// init segment and announce a fresh session id from the new stream, which the
|
||||
// viewer re-attaches to.
|
||||
if wantSub := models.SelectSubStreamForQuality(config, requestedQuality, subStreamEnabled); wantSub != useSub {
|
||||
useSub = wantSub
|
||||
if session != nil {
|
||||
_ = session.Close()
|
||||
session = nil
|
||||
}
|
||||
source = buildHLSSource(config, communication, useSub)
|
||||
lastReadyAnnounce = 0
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): switched live HLS to the " + source.label + " stream (quality=" + requestedQuality + ")")
|
||||
continue
|
||||
}
|
||||
|
||||
viewerActive := now-lastViewerRequest <= hlsViewerTimeoutSeconds
|
||||
|
||||
if prewarm {
|
||||
// Keep one long-lived session muxing into the ring buffer. Create it on
|
||||
// the first keyframe (so the buffer opens on a random-access point) and
|
||||
// never tear it down for idleness; uploads, not muxing, are what we gate
|
||||
// on viewer presence.
|
||||
if session == nil {
|
||||
if len(pkt.Data) == 0 || !pkt.IsVideo || !pkt.IsKeyFrame {
|
||||
continue
|
||||
}
|
||||
session = livehls.NewSession(publisher, livehls.SessionOptions{
|
||||
Codec: pkt.Codec,
|
||||
SPSNALUs: source.sps,
|
||||
PPSNALUs: source.pps,
|
||||
VPSNALUs: source.vps,
|
||||
Width: source.width,
|
||||
Height: source.height,
|
||||
PartTargetMs: partTargetMs,
|
||||
StartBuffering: true,
|
||||
})
|
||||
session.SetOnReady(func(sessionID string) {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS session ready, announcing " + sessionID)
|
||||
publishHLSReady(configuration, mqttClient, hubKey, deviceId, sessionID)
|
||||
lastReadyAnnounce = time.Now().Unix()
|
||||
})
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): prewarming live HLS session " + session.SessionID())
|
||||
}
|
||||
|
||||
if viewerActive {
|
||||
// Activating flushes the cached init + buffered segment(s). onReady
|
||||
// announces the first-ever readiness; on a later re-activation it has
|
||||
// already fired, so announce here (throttled, so the first activation
|
||||
// does not double up) once the buffer has actually been shipped.
|
||||
if session.SetUploadsActive(true) && session.IsReady() && now-lastReadyAnnounce >= hlsReadyReannounceSeconds {
|
||||
publishHLSReady(configuration, mqttClient, hubKey, deviceId, session.SessionID())
|
||||
lastReadyAnnounce = now
|
||||
}
|
||||
} else {
|
||||
// No viewer: keep muxing into the buffer but stop uploading.
|
||||
session.SetUploadsActive(false)
|
||||
}
|
||||
|
||||
if len(pkt.Data) > 0 && pkt.IsVideo {
|
||||
if err := session.WritePacket(pkt); err != nil {
|
||||
log.Log.Error("cloud.HandleLiveStreamHLS(): " + err.Error())
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !viewerActive {
|
||||
// No viewer: stop and discard the session so we stop shipping segments.
|
||||
if session != nil {
|
||||
_ = session.Close()
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): no active viewers, stopped live HLS session " + session.SessionID())
|
||||
session = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(pkt.Data) == 0 || !pkt.IsVideo {
|
||||
continue
|
||||
}
|
||||
|
||||
// Start a session lazily, but only on a keyframe so the first segment opens
|
||||
// on a random-access point.
|
||||
if session == nil {
|
||||
if !pkt.IsKeyFrame {
|
||||
continue
|
||||
}
|
||||
session = livehls.NewSession(publisher, livehls.SessionOptions{
|
||||
Codec: pkt.Codec,
|
||||
SPSNALUs: source.sps,
|
||||
PPSNALUs: source.pps,
|
||||
VPSNALUs: source.vps,
|
||||
Width: source.width,
|
||||
Height: source.height,
|
||||
PartTargetMs: partTargetMs,
|
||||
})
|
||||
session.SetOnReady(func(sessionID string) {
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): live HLS session ready, announcing " + sessionID)
|
||||
publishHLSReady(configuration, mqttClient, hubKey, deviceId, sessionID)
|
||||
lastReadyAnnounce = time.Now().Unix()
|
||||
})
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): started live HLS session " + session.SessionID())
|
||||
}
|
||||
|
||||
if err := session.WritePacket(pkt); err != nil {
|
||||
log.Log.Error("cloud.HandleLiveStreamHLS(): " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if session != nil {
|
||||
_ = session.Close()
|
||||
}
|
||||
log.Log.Debug("cloud.HandleLiveStreamHLS(): finished")
|
||||
}
|
||||
|
||||
// publishHLSReady announces, over MQTT, that a live HLS session is available so
|
||||
// viewers can load the rolling playlist hub-api serves for {device}/{session}.
|
||||
func publishHLSReady(configuration *models.Configuration, mqttClient mqtt.Client, hubKey, deviceId, sessionID string) {
|
||||
valueMap := map[string]interface{}{
|
||||
"session": sessionID,
|
||||
"device": deviceId,
|
||||
}
|
||||
message := models.Message{
|
||||
Payload: models.Payload{
|
||||
Action: "receive-hls-ready",
|
||||
DeviceId: deviceId,
|
||||
Value: valueMap,
|
||||
},
|
||||
}
|
||||
payload, err := models.PackageMQTTMessage(configuration, message)
|
||||
if err == nil {
|
||||
mqttClient.Publish("kerberos/hub/"+hubKey, 0, false, payload)
|
||||
log.Log.Info("cloud.HandleLiveStreamHLS(): announced live HLS session " + sessionID)
|
||||
} else {
|
||||
log.Log.Error("cloud.HandleLiveStreamHLS(): failed to package receive-hls-ready message: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// hlsStreamSource bundles everything the live HLS producer needs to mux one of
|
||||
// the camera's streams: the packet cursor it reads from plus the encoded
|
||||
// parameter sets and dimensions used to build that stream's init segment.
|
||||
type hlsStreamSource struct {
|
||||
cursor *packets.QueueCursor
|
||||
sps [][]byte
|
||||
pps [][]byte
|
||||
vps [][]byte
|
||||
width uint16
|
||||
height uint16
|
||||
label string
|
||||
}
|
||||
|
||||
// buildHLSSource resolves the packet cursor and encoded parameter sets/dimensions
|
||||
// for the selected stream. useSub picks the sub (low-resolution) stream when one
|
||||
// is available; otherwise the main (high-resolution) stream is used. A fresh
|
||||
// Latest() cursor is created so muxing resumes from the live edge of the chosen
|
||||
// stream after a switch.
|
||||
func buildHLSSource(config models.Config, communication *models.Communication, useSub bool) hlsStreamSource {
|
||||
cam := config.Capture.IPCamera
|
||||
if useSub && communication.SubQueue != nil {
|
||||
return hlsStreamSource{
|
||||
cursor: communication.SubQueue.Latest(),
|
||||
sps: cam.SubSPSNALUs,
|
||||
pps: cam.SubPPSNALUs,
|
||||
vps: cam.SubVPSNALUs,
|
||||
width: uint16(cam.SubWidth),
|
||||
height: uint16(cam.SubHeight),
|
||||
label: "sub",
|
||||
}
|
||||
}
|
||||
return hlsStreamSource{
|
||||
cursor: communication.Queue.Latest(),
|
||||
sps: cam.SPSNALUs,
|
||||
pps: cam.PPSNALUs,
|
||||
vps: cam.VPSNALUs,
|
||||
width: uint16(cam.Width),
|
||||
height: uint16(cam.Height),
|
||||
label: "main",
|
||||
}
|
||||
}
|
||||
236
machinery/src/cloud/livehls/publisher.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// Package livehls implements the agent-side producer for live HLS streaming.
|
||||
//
|
||||
// It complements the recording pipeline: where recordings are muxed into one
|
||||
// fragmented MP4 and uploaded resumably (TUS) when complete, live HLS ships a
|
||||
// continuous series of small, independently-decodable CMAF segments to hub-api
|
||||
// the instant each is produced, so a browser can play a near-live HLS stream
|
||||
// without WebRTC/TURN (outbound HTTPS only).
|
||||
//
|
||||
// The wire contract (agent -> hub-api) intentionally mirrors the existing
|
||||
// header-based storage convention (X-Kerberos-Storage-Device / -FileName, plus
|
||||
// the Hub public/private key auth headers). hub-api authenticates the agent and
|
||||
// stores each segment in an ephemeral, short-TTL live window keyed by
|
||||
// {device}/{session}, which it serves straight back to the browser. The live
|
||||
// window is deliberately kept out of the vault and the recordings collection;
|
||||
// durable archival/DVR is a separate, later concern.
|
||||
//
|
||||
// Unlike recordings, live segments are NOT uploaded resumably: a 1-2s segment
|
||||
// that fails to upload is stale by the time a retry would land, so the publisher
|
||||
// is fire-and-forget and drops on failure (logged) rather than blocking the live
|
||||
// pipeline behind a retry/handshake.
|
||||
package livehls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
const (
|
||||
// liveIngestPath is the hub-api endpoint that accepts a single live segment
|
||||
// (or the init segment) and stores it in the ephemeral live window. hub-api
|
||||
// distinguishes init vs media segment and the object name via the
|
||||
// X-Kerberos-Live-* headers below, keeping a single route (mirrors the
|
||||
// existing /storage/upload convention).
|
||||
liveIngestPath = "/storage/live"
|
||||
|
||||
// Object names within a session. The init segment (ftyp+moov) is uploaded
|
||||
// once per session; media segments are seg-<sequence>.m4s.
|
||||
initObjectName = "init.mp4"
|
||||
|
||||
contentTypeInit = "video/mp4"
|
||||
contentTypeSegment = "video/iso.segment"
|
||||
|
||||
// Header names for the live ingest contract.
|
||||
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
|
||||
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
|
||||
headerHubRegion = "X-Kerberos-Hub-Region"
|
||||
headerStorageDevice = "X-Kerberos-Storage-Device"
|
||||
headerLiveSession = "X-Kerberos-Live-Session"
|
||||
headerLiveName = "X-Kerberos-Live-Name"
|
||||
headerLiveSequence = "X-Kerberos-Live-Sequence"
|
||||
headerLiveDuration = "X-Kerberos-Live-Duration"
|
||||
// Low-latency (LL-HLS) part headers. A part belongs to media segment
|
||||
// X-Kerberos-Live-Sequence and is the X-Kerberos-Live-Part-th chunk within it;
|
||||
// X-Kerberos-Live-Part-Independent flags a part that starts on a keyframe.
|
||||
headerLivePart = "X-Kerberos-Live-Part"
|
||||
headerLivePartIndependent = "X-Kerberos-Live-Part-Independent"
|
||||
|
||||
// defaultPublishTimeout bounds a single segment upload. A live segment that
|
||||
// cannot be delivered within roughly its own duration is stale, so the upload
|
||||
// is abandoned (dropped) rather than allowed to back up the pipeline.
|
||||
defaultPublishTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// PublisherConfig carries the hub endpoint and credentials needed to ship live
|
||||
// segments. It is populated from the agent's models.Config (HubURI/HubKey/...).
|
||||
type PublisherConfig struct {
|
||||
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
|
||||
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
|
||||
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
|
||||
Region string // storage region (X-Kerberos-Hub-Region), may be empty
|
||||
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
|
||||
|
||||
// Timeout optionally overrides defaultPublishTimeout (used by tests).
|
||||
Timeout time.Duration
|
||||
// HTTPClient optionally injects a client (used by tests). When nil a
|
||||
// redirect-credential-stripping client is created.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Publisher ships init and media segments to hub-api over plain HTTP POST.
|
||||
//
|
||||
// It is safe for sequential use from a single live-stream goroutine. Methods are
|
||||
// fire-and-forget: they return an error for the caller to log, but the caller is
|
||||
// expected to continue (drop-on-fail) rather than retry.
|
||||
type Publisher struct {
|
||||
cfg PublisherConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
|
||||
// headers on a cross-host redirect (net/http does this for standard auth headers
|
||||
// but not custom-named ones), matching the recording upload client.
|
||||
func NewPublisher(cfg PublisherConfig) *Publisher {
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultPublishTimeout
|
||||
}
|
||||
client = &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
|
||||
}
|
||||
}
|
||||
return &Publisher{cfg: cfg, client: client}
|
||||
}
|
||||
|
||||
// PublishInit uploads the session's init segment (ftyp+moov). It must be called
|
||||
// (and succeed) before the player can use any media segment, so the caller
|
||||
// should treat a failure here as "session not yet established" and retry on the
|
||||
// next init opportunity rather than shipping media segments blindly.
|
||||
func (p *Publisher) PublishInit(ctx context.Context, sessionID string, data []byte) error {
|
||||
return p.post(ctx, postParams{
|
||||
sessionID: sessionID,
|
||||
name: initObjectName,
|
||||
contentType: contentTypeInit,
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
// PublishSegment uploads one media segment (styp+moof+mdat). The segment's
|
||||
// sequence number and duration travel in headers so hub-api can update the
|
||||
// rolling playlist window without parsing the box structure.
|
||||
func (p *Publisher) PublishSegment(ctx context.Context, sessionID string, seg video.LiveSegment) error {
|
||||
return p.post(ctx, postParams{
|
||||
sessionID: sessionID,
|
||||
name: fmt.Sprintf("seg-%d.m4s", seg.SequenceNumber),
|
||||
sequence: seg.SequenceNumber,
|
||||
durationMs: seg.DurationMs,
|
||||
hasSegment: true,
|
||||
contentType: contentTypeSegment,
|
||||
body: seg.Data,
|
||||
})
|
||||
}
|
||||
|
||||
// PublishPart uploads one CMAF partial segment (LL-HLS). The part is named
|
||||
// seg-<segment>.<part>.m4s and carries its segment sequence, part index,
|
||||
// independence flag and duration in headers so hub-api can advertise it via
|
||||
// #EXT-X-PART and reconstruct the full segment by concatenating its parts.
|
||||
func (p *Publisher) PublishPart(ctx context.Context, sessionID string, part video.LivePart) error {
|
||||
return p.post(ctx, postParams{
|
||||
sessionID: sessionID,
|
||||
name: fmt.Sprintf("seg-%d.%d.m4s", part.SegmentSeq, part.PartIndex),
|
||||
sequence: part.SegmentSeq,
|
||||
durationMs: part.DurationMs,
|
||||
partIndex: part.PartIndex,
|
||||
independent: part.Independent,
|
||||
hasPart: true,
|
||||
contentType: contentTypeSegment,
|
||||
body: part.Data,
|
||||
})
|
||||
}
|
||||
|
||||
type postParams struct {
|
||||
sessionID string
|
||||
name string
|
||||
sequence uint32
|
||||
durationMs uint64
|
||||
hasSegment bool
|
||||
partIndex uint32
|
||||
independent bool
|
||||
hasPart bool
|
||||
contentType string
|
||||
body []byte
|
||||
}
|
||||
|
||||
// post performs a single fire-and-forget upload to the live ingest endpoint.
|
||||
func (p *Publisher) post(ctx context.Context, params postParams) error {
|
||||
if p.cfg.HubURI == "" {
|
||||
return fmt.Errorf("livehls: HubURI not configured")
|
||||
}
|
||||
if params.sessionID == "" {
|
||||
return fmt.Errorf("livehls: empty session id")
|
||||
}
|
||||
|
||||
url := strings.TrimRight(p.cfg.HubURI, "/") + liveIngestPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(params.body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("livehls: build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", params.contentType)
|
||||
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
|
||||
req.Header.Set(headerLiveSession, params.sessionID)
|
||||
req.Header.Set(headerLiveName, params.name)
|
||||
if params.hasSegment || params.hasPart {
|
||||
req.Header.Set(headerLiveSequence, strconv.FormatUint(uint64(params.sequence), 10))
|
||||
req.Header.Set(headerLiveDuration, strconv.FormatUint(params.durationMs, 10))
|
||||
}
|
||||
if params.hasPart {
|
||||
req.Header.Set(headerLivePart, strconv.FormatUint(uint64(params.partIndex), 10))
|
||||
independent := "0"
|
||||
if params.independent {
|
||||
independent = "1"
|
||||
}
|
||||
req.Header.Set(headerLivePartIndependent, independent)
|
||||
}
|
||||
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
|
||||
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
|
||||
req.Header.Set(headerHubRegion, p.cfg.Region)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("livehls: upload %s: %w", params.name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("livehls: upload %s rejected: %s", params.name, resp.Status)
|
||||
}
|
||||
log.Log.Debug("livehls.Publisher.post(): shipped " + params.name + " for session " + params.sessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
|
||||
// a redirect crosses to a different host. net/http strips standard sensitive
|
||||
// headers on a cross-host redirect but not custom-named ones, so without this the
|
||||
// Hub keys could leak to a redirect target.
|
||||
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del(headerHubPrivateKey)
|
||||
req.Header.Del(headerHubPublicKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
312
machinery/src/cloud/livehls/publisher_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package livehls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
// captured records one received upload for assertions.
|
||||
type captured struct {
|
||||
path string
|
||||
method string
|
||||
contentType string
|
||||
device string
|
||||
session string
|
||||
name string
|
||||
sequence string
|
||||
duration string
|
||||
hubPublic string
|
||||
hubPrivate string
|
||||
region string
|
||||
body []byte
|
||||
}
|
||||
|
||||
// newCapturingServer returns an httptest server that records every upload and
|
||||
// replies with the given status code.
|
||||
func newCapturingServer(t *testing.T, status int) (*httptest.Server, *[]captured, *sync.Mutex) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var got []captured
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
got = append(got, captured{
|
||||
path: r.URL.Path,
|
||||
method: r.Method,
|
||||
contentType: r.Header.Get("Content-Type"),
|
||||
device: r.Header.Get(headerStorageDevice),
|
||||
session: r.Header.Get(headerLiveSession),
|
||||
name: r.Header.Get(headerLiveName),
|
||||
sequence: r.Header.Get(headerLiveSequence),
|
||||
duration: r.Header.Get(headerLiveDuration),
|
||||
hubPublic: r.Header.Get(headerHubPublicKey),
|
||||
hubPrivate: r.Header.Get(headerHubPrivateKey),
|
||||
region: r.Header.Get(headerHubRegion),
|
||||
body: body,
|
||||
})
|
||||
mu.Unlock()
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &got, &mu
|
||||
}
|
||||
|
||||
func testPublisher(hubURI string) *Publisher {
|
||||
return NewPublisher(PublisherConfig{
|
||||
HubURI: hubURI,
|
||||
HubKey: "pub-key",
|
||||
HubPrivateKey: "priv-key",
|
||||
Region: "eu-west",
|
||||
DeviceKey: "cam-1",
|
||||
Timeout: 2 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func TestPublisherPublishInitSendsContractHeaders(t *testing.T) {
|
||||
srv, got, mu := newCapturingServer(t, http.StatusOK)
|
||||
p := testPublisher(srv.URL)
|
||||
|
||||
if err := p.PublishInit(context.Background(), "sess-1", []byte("INITBYTES")); err != nil {
|
||||
t.Fatalf("PublishInit: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(*got) != 1 {
|
||||
t.Fatalf("server received %d requests, want 1", len(*got))
|
||||
}
|
||||
c := (*got)[0]
|
||||
if c.method != http.MethodPost {
|
||||
t.Errorf("method=%s, want POST", c.method)
|
||||
}
|
||||
if c.path != liveIngestPath {
|
||||
t.Errorf("path=%s, want %s", c.path, liveIngestPath)
|
||||
}
|
||||
if c.contentType != contentTypeInit {
|
||||
t.Errorf("content-type=%s, want %s", c.contentType, contentTypeInit)
|
||||
}
|
||||
if c.device != "cam-1" {
|
||||
t.Errorf("device=%s, want cam-1", c.device)
|
||||
}
|
||||
if c.session != "sess-1" {
|
||||
t.Errorf("session=%s, want sess-1", c.session)
|
||||
}
|
||||
if c.name != initObjectName {
|
||||
t.Errorf("name=%s, want %s", c.name, initObjectName)
|
||||
}
|
||||
if c.hubPublic != "pub-key" || c.hubPrivate != "priv-key" || c.region != "eu-west" {
|
||||
t.Errorf("auth headers wrong: pub=%q priv=%q region=%q", c.hubPublic, c.hubPrivate, c.region)
|
||||
}
|
||||
if string(c.body) != "INITBYTES" {
|
||||
t.Errorf("body=%q, want INITBYTES", string(c.body))
|
||||
}
|
||||
// init must NOT carry segment-only headers.
|
||||
if c.sequence != "" || c.duration != "" {
|
||||
t.Errorf("init should not send sequence/duration, got seq=%q dur=%q", c.sequence, c.duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublisherPublishSegmentSendsSequenceAndDuration(t *testing.T) {
|
||||
srv, got, mu := newCapturingServer(t, http.StatusOK)
|
||||
p := testPublisher(srv.URL)
|
||||
|
||||
seg := video.LiveSegment{SequenceNumber: 7, DurationMs: 1960, Data: []byte("SEGMENT")}
|
||||
if err := p.PublishSegment(context.Background(), "sess-9", seg); err != nil {
|
||||
t.Fatalf("PublishSegment: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
c := (*got)[0]
|
||||
if c.contentType != contentTypeSegment {
|
||||
t.Errorf("content-type=%s, want %s", c.contentType, contentTypeSegment)
|
||||
}
|
||||
if c.name != "seg-7.m4s" {
|
||||
t.Errorf("name=%s, want seg-7.m4s", c.name)
|
||||
}
|
||||
if c.sequence != "7" {
|
||||
t.Errorf("sequence=%s, want 7", c.sequence)
|
||||
}
|
||||
if c.duration != "1960" {
|
||||
t.Errorf("duration=%s, want 1960", c.duration)
|
||||
}
|
||||
if string(c.body) != "SEGMENT" {
|
||||
t.Errorf("body=%q, want SEGMENT", string(c.body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublisherReturnsErrorOnNon2xx(t *testing.T) {
|
||||
srv, _, _ := newCapturingServer(t, http.StatusInternalServerError)
|
||||
p := testPublisher(srv.URL)
|
||||
|
||||
err := p.PublishSegment(context.Background(), "s", video.LiveSegment{SequenceNumber: 1, Data: []byte("x")})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error on 500 response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublisherErrorsWithoutHubURI(t *testing.T) {
|
||||
p := NewPublisher(PublisherConfig{DeviceKey: "cam"})
|
||||
if err := p.PublishInit(context.Background(), "s", []byte("x")); err == nil {
|
||||
t.Fatal("expected error when HubURI is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// makeAnnexBVideoPacket builds a synthetic capture packet carrying one Annex B
|
||||
// H.264 access unit at the given decode time (ms).
|
||||
func makeAnnexBVideoPacket(isKey bool, timeMs int64) packets.Packet {
|
||||
nalType := byte(0x01)
|
||||
if isKey {
|
||||
nalType = 0x65
|
||||
}
|
||||
data := []byte{0x00, 0x00, 0x00, 0x01, nalType}
|
||||
for i := 0; i < 80; i++ {
|
||||
data = append(data, byte(i))
|
||||
}
|
||||
return packets.Packet{
|
||||
IsVideo: true,
|
||||
IsKeyFrame: isKey,
|
||||
Codec: "H264",
|
||||
Data: data,
|
||||
TimeLegacy: time.Duration(timeMs) * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionShipsInitThenSegmentsAndFiresReady(t *testing.T) {
|
||||
srv, got, mu := newCapturingServer(t, http.StatusOK)
|
||||
p := testPublisher(srv.URL)
|
||||
|
||||
sess := NewSession(p, SessionOptions{
|
||||
Codec: "H264",
|
||||
SPSNALUs: [][]byte{liveTestSPSForSession()},
|
||||
PPSNALUs: [][]byte{{0x68, 0xce, 0x38, 0x80}},
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
TargetSegmentMs: 2000,
|
||||
})
|
||||
|
||||
var readyCalls int
|
||||
var readySession string
|
||||
sess.SetOnReady(func(id string) {
|
||||
readyCalls++
|
||||
readySession = id
|
||||
})
|
||||
|
||||
// 4 GOPs of 25 frames @ 40ms = 1s GOPs => with 2s target, 2 segments emitted
|
||||
// during streaming and a final one on Close.
|
||||
const gopFrames, gops = 25, 4
|
||||
for i := 0; i < gopFrames*gops; i++ {
|
||||
isKey := i%gopFrames == 0
|
||||
pkt := makeAnnexBVideoPacket(isKey, int64(i*40))
|
||||
if err := sess.WritePacket(pkt); err != nil {
|
||||
t.Fatalf("WritePacket(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
// A non-video packet must be ignored.
|
||||
if err := sess.WritePacket(packets.Packet{IsAudio: true, Data: []byte{1, 2, 3}}); err != nil {
|
||||
t.Fatalf("WritePacket(audio): %v", err)
|
||||
}
|
||||
if err := sess.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var initCount, segCount int
|
||||
for _, c := range *got {
|
||||
if c.name == initObjectName {
|
||||
initCount++
|
||||
if string(c.body[4:8]) != "ftyp" {
|
||||
t.Errorf("init body is not an ftyp box: % x", c.body[:12])
|
||||
}
|
||||
} else {
|
||||
segCount++
|
||||
if c.session != sess.SessionID() {
|
||||
t.Errorf("segment session=%s, want %s", c.session, sess.SessionID())
|
||||
}
|
||||
}
|
||||
}
|
||||
if initCount != 1 {
|
||||
t.Errorf("init uploaded %d times, want exactly 1", initCount)
|
||||
}
|
||||
if segCount < 2 {
|
||||
t.Errorf("got %d segment uploads, want >= 2", segCount)
|
||||
}
|
||||
if readyCalls != 1 {
|
||||
t.Errorf("OnReady fired %d times, want exactly 1", readyCalls)
|
||||
}
|
||||
if readySession != sess.SessionID() {
|
||||
t.Errorf("OnReady session=%s, want %s", readySession, sess.SessionID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRetriesInitWhenFirstAttemptFails(t *testing.T) {
|
||||
// Server fails the first N requests, then succeeds. This proves init is
|
||||
// re-attempted (not dropped) so the session can still establish.
|
||||
var mu sync.Mutex
|
||||
var inits, segs int
|
||||
failFirst := 1
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
name := r.Header.Get(headerLiveName)
|
||||
if name == initObjectName {
|
||||
inits++
|
||||
if inits <= failFirst {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
segs++
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
sess := NewSession(testPublisher(srv.URL), SessionOptions{
|
||||
Codec: "H264",
|
||||
SPSNALUs: [][]byte{liveTestSPSForSession()},
|
||||
PPSNALUs: [][]byte{{0x68, 0xce, 0x38, 0x80}},
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
})
|
||||
|
||||
var ready int
|
||||
sess.SetOnReady(func(string) { ready++ })
|
||||
|
||||
for i := 0; i < 60; i++ {
|
||||
isKey := i%25 == 0
|
||||
if err := sess.WritePacket(makeAnnexBVideoPacket(isKey, int64(i*40))); err != nil {
|
||||
t.Fatalf("WritePacket(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
if err := sess.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if inits < 2 {
|
||||
t.Errorf("init attempted %d times, want >= 2 (first failed then retried)", inits)
|
||||
}
|
||||
if segs < 1 {
|
||||
t.Errorf("no segments delivered after init recovered (segs=%d)", segs)
|
||||
}
|
||||
if ready != 1 {
|
||||
t.Errorf("OnReady fired %d times, want 1", ready)
|
||||
}
|
||||
}
|
||||
|
||||
// liveTestSPSForSession is the known-good baseline SPS reused across tests.
|
||||
func liveTestSPSForSession() []byte {
|
||||
return []byte{0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x00, 0xa0, 0x47, 0xfe, 0xc8}
|
||||
}
|
||||
457
machinery/src/cloud/livehls/session.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package livehls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/kerberos-io/agent/machinery/src/video"
|
||||
)
|
||||
|
||||
// DefaultTargetSegmentMs is the nominal live segment length. ~2s keeps standard
|
||||
// HLS latency reasonable (a player typically buffers ~3 segments) while staying
|
||||
// large enough that per-segment HTTP overhead is negligible.
|
||||
const DefaultTargetSegmentMs = 2000
|
||||
|
||||
// DefaultPartTargetMs is the nominal LL-HLS part length used when low latency is
|
||||
// enabled. ~300ms parts yield ~6-7 parts per 2s segment; with the playlist's
|
||||
// PART-HOLD-BACK at ~3x the part target this lands glass-to-glass latency around
|
||||
// 1-2s (versus ~4-6s for whole-segment HLS).
|
||||
const DefaultPartTargetMs = 300
|
||||
|
||||
// Session ties a video.LiveSegmenter to a Publisher: it converts capture packets
|
||||
// into CMAF segments and ships each one to hub-api. Exactly one init segment is
|
||||
// delivered per session (re-attempted until it lands), after which media
|
||||
// segments are published and the OnReady signal fires once so the control plane
|
||||
// (MQTT) can tell viewers the live playlist exists.
|
||||
//
|
||||
// A Session is driven from a single goroutine (the live-stream loop); its methods
|
||||
// are not safe for concurrent use except SessionID, which is immutable.
|
||||
type Session struct {
|
||||
id string
|
||||
publisher *Publisher
|
||||
segmenter *video.LiveSegmenter
|
||||
|
||||
// newContext produces the per-upload context (timeout). Overridable in tests.
|
||||
newContext func() (context.Context, context.CancelFunc)
|
||||
|
||||
mu sync.Mutex
|
||||
initBytes []byte
|
||||
initPublished bool
|
||||
// lastInitAt is when the init segment was last (re)uploaded. The init is
|
||||
// re-sent periodically so its short TTL in the hub live window never lapses
|
||||
// mid-session; see refreshInitIfStale.
|
||||
lastInitAt time.Time
|
||||
readyFired bool
|
||||
onReady func(sessionID string)
|
||||
|
||||
// uploadsActive gates whether the init and completed segments are shipped to
|
||||
// hub-api. It is true for the default on-demand path. The prewarm path starts
|
||||
// it false so the session keeps muxing into bufferedSegments without producing
|
||||
// any live traffic until a viewer actually arrives; see SetUploadsActive.
|
||||
uploadsActive bool
|
||||
// bufferedSegments is the in-memory ring buffer (the most recent
|
||||
// prewarmMaxBufferedSegments segments) kept while uploadsActive is false, so a
|
||||
// viewer that arrives can be served an already-encoded segment immediately
|
||||
// instead of waiting a full GOP for the next one to be cut.
|
||||
bufferedSegments []video.LiveSegment
|
||||
// bufferedParts is the LL-HLS counterpart of bufferedSegments: while idle it
|
||||
// retains the parts of the most recent (prewarmMaxBufferedSegments+1) segments,
|
||||
// pruned a WHOLE segment at a time so a flushed segment is never partial.
|
||||
bufferedParts []video.LivePart
|
||||
}
|
||||
|
||||
// SessionOptions configures a live HLS session.
|
||||
type SessionOptions struct {
|
||||
Codec string // "H264" or "H265"
|
||||
SPSNALUs [][]byte // parameter sets (raw or Annex B)
|
||||
PPSNALUs [][]byte //
|
||||
VPSNALUs [][]byte // H.265 only
|
||||
Width uint16 // encoded width (for the avcC fallback path)
|
||||
Height uint16 // encoded height
|
||||
TargetSegmentMs uint64 // 0 => DefaultTargetSegmentMs
|
||||
// PartTargetMs, when > 0, enables LL-HLS: each segment is additionally sliced
|
||||
// into ~PartTargetMs CMAF parts that are published (and advertised via
|
||||
// #EXT-X-PART) the instant they close, for ~1-2s glass-to-glass latency. 0
|
||||
// keeps the classic whole-segment path.
|
||||
PartTargetMs uint64
|
||||
// StartBuffering starts the session in prewarm (buffer-only) mode: it muxes
|
||||
// segments into an in-memory ring buffer but uploads nothing until
|
||||
// SetUploadsActive(true) is called. Default false => uploads are live
|
||||
// immediately (the on-demand path's behaviour).
|
||||
StartBuffering bool
|
||||
}
|
||||
|
||||
// NewSession builds a session with a fresh random id and wires the segmenter's
|
||||
// init/segment callbacks to the publisher.
|
||||
func NewSession(publisher *Publisher, opts SessionOptions) *Session {
|
||||
target := opts.TargetSegmentMs
|
||||
if target == 0 {
|
||||
target = DefaultTargetSegmentMs
|
||||
}
|
||||
seg := video.NewLiveSegmenter(opts.Codec, opts.SPSNALUs, opts.PPSNALUs, opts.VPSNALUs, target)
|
||||
seg.SetDimensions(opts.Width, opts.Height)
|
||||
if opts.PartTargetMs > 0 {
|
||||
seg.EnableLowLatency(opts.PartTargetMs)
|
||||
}
|
||||
|
||||
s := &Session{
|
||||
id: newSessionID(),
|
||||
publisher: publisher,
|
||||
segmenter: seg,
|
||||
// Uploads are live by default; the prewarm path opts into buffer-only mode.
|
||||
uploadsActive: !opts.StartBuffering,
|
||||
newContext: func() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), defaultPublishTimeout)
|
||||
},
|
||||
}
|
||||
|
||||
// The segmenter emits the init segment exactly once; capture it and try to
|
||||
// ship it. Failures here are non-fatal - publishInitIfNeeded re-attempts
|
||||
// before the next media segment so a transient hub hiccup at startup does not
|
||||
// permanently break the session.
|
||||
seg.OnInit = func(initBytes []byte) error {
|
||||
s.mu.Lock()
|
||||
s.initBytes = append([]byte(nil), initBytes...)
|
||||
active := s.uploadsActive
|
||||
s.mu.Unlock()
|
||||
// While prewarming we cache the init in memory but ship nothing; it is
|
||||
// uploaded on the first SetUploadsActive(true) flush.
|
||||
if active {
|
||||
s.publishInitIfNeeded()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Each completed media segment is shipped. We only publish a segment once the
|
||||
// init segment has landed (a media segment is useless without it), and we fire
|
||||
// OnReady after the first successfully shipped segment.
|
||||
seg.OnSegment = func(segment video.LiveSegment) error {
|
||||
s.mu.Lock()
|
||||
active := s.uploadsActive
|
||||
s.mu.Unlock()
|
||||
if !active {
|
||||
// Prewarm: retain the most recent segments in memory but upload nothing
|
||||
// until a viewer arrives (SetUploadsActive flushes them).
|
||||
s.bufferSegment(segment)
|
||||
return nil
|
||||
}
|
||||
if !s.publishInitIfNeeded() {
|
||||
log.Log.Warning("livehls.Session: dropping segment " +
|
||||
fmt.Sprintf("%d", segment.SequenceNumber) + " because init has not been delivered yet")
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := s.newContext()
|
||||
defer cancel()
|
||||
if err := s.publisher.PublishSegment(ctx, s.id, segment); err != nil {
|
||||
log.Log.Warning("livehls.Session: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
s.fireReadyOnce()
|
||||
// Keep the (write-once) init segment from ageing out of the live window
|
||||
// while the session is still producing media.
|
||||
s.refreshInitIfStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// In LL-HLS mode the segmenter emits parts (not whole segments); ship each one
|
||||
// the instant it closes. Mirrors OnSegment: buffer while prewarming, otherwise
|
||||
// publish after the init has landed and fire OnReady on the first part.
|
||||
if opts.PartTargetMs > 0 {
|
||||
seg.OnPart = func(part video.LivePart) error {
|
||||
s.mu.Lock()
|
||||
active := s.uploadsActive
|
||||
s.mu.Unlock()
|
||||
if !active {
|
||||
s.bufferPart(part)
|
||||
return nil
|
||||
}
|
||||
if !s.publishInitIfNeeded() {
|
||||
log.Log.Warning("livehls.Session: dropping part " +
|
||||
fmt.Sprintf("%d.%d", part.SegmentSeq, part.PartIndex) +
|
||||
" because init has not been delivered yet")
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := s.newContext()
|
||||
defer cancel()
|
||||
if err := s.publisher.PublishPart(ctx, s.id, part); err != nil {
|
||||
log.Log.Warning("livehls.Session: " + err.Error())
|
||||
return nil
|
||||
}
|
||||
s.fireReadyOnce()
|
||||
s.refreshInitIfStale()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// SessionID returns the immutable session identifier used in object keys and the
|
||||
// MQTT ready signal.
|
||||
func (s *Session) SessionID() string { return s.id }
|
||||
|
||||
// IsReady reports whether the session has delivered its init segment and at
|
||||
// least one media segment, i.e. the playlist hub-api serves is now playable. It
|
||||
// lets the live-stream loop re-announce "receive-hls-ready" to viewers that join
|
||||
// or hard-refresh after the initial one-shot signal (which they would otherwise
|
||||
// never receive, leaving the stream blank until the session is recreated).
|
||||
func (s *Session) IsReady() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readyFired
|
||||
}
|
||||
|
||||
// SetOnReady registers a callback fired exactly once, after the first media
|
||||
// segment has been successfully delivered. Used to publish the MQTT
|
||||
// "receive-hls-ready" signal so viewers can load the playlist.
|
||||
func (s *Session) SetOnReady(fn func(sessionID string)) {
|
||||
s.mu.Lock()
|
||||
s.onReady = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// prewarmMaxBufferedSegments is how many of the most recent completed segments
|
||||
// the prewarm path keeps in memory while idle and flushes to a viewer on arrival.
|
||||
// One segment keeps startup instant (the viewer immediately gets a playable
|
||||
// segment) while starting as close to the live edge as possible, so the HLS view
|
||||
// tracks the WebRTC/live edge instead of opening several seconds behind; hls.js
|
||||
// then converges to the edge via maxLiveSyncPlaybackRate. Raising it trades
|
||||
// latency-from-live for a little more startup cushion.
|
||||
const prewarmMaxBufferedSegments = 1
|
||||
|
||||
// SetUploadsActive toggles whether the session ships its init and segments to
|
||||
// hub-api, and reports whether this call flipped it from inactive to active.
|
||||
//
|
||||
// While uploads are inactive the session keeps muxing capture packets into an
|
||||
// in-memory ring buffer (the cached init plus the most recent
|
||||
// prewarmMaxBufferedSegments segments) but uploads nothing, so an idle camera
|
||||
// produces no live traffic. Switching from inactive to active immediately
|
||||
// flushes the cached init and buffered segments so a viewer can start almost
|
||||
// instantly instead of waiting a full GOP for the next segment to be cut.
|
||||
// Switching from active to inactive resets the init-published flag so the next
|
||||
// activation re-uploads the init (it may have aged out of the hub's short-TTL
|
||||
// live window while idle). All other transitions are no-ops. Driven from the
|
||||
// live-stream goroutine; not safe for concurrent use.
|
||||
func (s *Session) SetUploadsActive(active bool) bool {
|
||||
s.mu.Lock()
|
||||
if s.uploadsActive == active {
|
||||
s.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
s.uploadsActive = active
|
||||
if !active {
|
||||
// Going idle: force the next activation to re-deliver the init segment,
|
||||
// which may have expired from the hub live window while nobody was watching.
|
||||
s.initPublished = false
|
||||
s.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
// Inactive -> active: take the cached buffered segments/parts and flush them
|
||||
// outside the lock (the publish calls take their own time and re-acquire the
|
||||
// mutex).
|
||||
buffered := s.bufferedSegments
|
||||
bufferedParts := s.bufferedParts
|
||||
s.bufferedSegments = nil
|
||||
s.bufferedParts = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
// Deliver the init first; media segments are useless without it.
|
||||
for i := range buffered {
|
||||
if !s.publishInitIfNeeded() {
|
||||
break
|
||||
}
|
||||
ctx, cancel := s.newContext()
|
||||
if err := s.publisher.PublishSegment(ctx, s.id, buffered[i]); err != nil {
|
||||
log.Log.Warning("livehls.Session: prewarm flush: " + err.Error())
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
cancel()
|
||||
s.fireReadyOnce()
|
||||
s.refreshInitIfStale()
|
||||
}
|
||||
// LL-HLS: flush the buffered parts in order (oldest first) so the viewer gets a
|
||||
// playable, near-live window immediately.
|
||||
for i := range bufferedParts {
|
||||
if !s.publishInitIfNeeded() {
|
||||
break
|
||||
}
|
||||
ctx, cancel := s.newContext()
|
||||
if err := s.publisher.PublishPart(ctx, s.id, bufferedParts[i]); err != nil {
|
||||
log.Log.Warning("livehls.Session: prewarm flush (part): " + err.Error())
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
cancel()
|
||||
s.fireReadyOnce()
|
||||
s.refreshInitIfStale()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UploadsActive reports whether the session is currently shipping segments (as
|
||||
// opposed to buffering them while prewarming). Always true for the on-demand
|
||||
// path.
|
||||
func (s *Session) UploadsActive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.uploadsActive
|
||||
}
|
||||
|
||||
// bufferSegment appends a completed segment to the in-memory prewarm ring buffer,
|
||||
// discarding the oldest so at most prewarmMaxBufferedSegments are retained.
|
||||
func (s *Session) bufferSegment(seg video.LiveSegment) {
|
||||
s.mu.Lock()
|
||||
s.bufferedSegments = append(s.bufferedSegments, seg)
|
||||
if overflow := len(s.bufferedSegments) - prewarmMaxBufferedSegments; overflow > 0 {
|
||||
// Drop the oldest segment(s) and shrink the backing array so retained bytes
|
||||
// stay bounded.
|
||||
s.bufferedSegments = append([]video.LiveSegment(nil), s.bufferedSegments[overflow:]...)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// bufferPart appends a part to the LL-HLS prewarm ring buffer, pruning whole
|
||||
// older segments (never individual parts) so the retained window always consists
|
||||
// of complete segments plus the in-progress one. Pruning on a part-0 boundary
|
||||
// keeps at most prewarmMaxBufferedSegments fully-buffered segments behind the
|
||||
// current one, which guarantees a flushed segment can be reconstructed in full.
|
||||
func (s *Session) bufferPart(part video.LivePart) {
|
||||
s.mu.Lock()
|
||||
s.bufferedParts = append(s.bufferedParts, part)
|
||||
if part.PartIndex == 0 && part.SegmentSeq > uint32(prewarmMaxBufferedSegments) {
|
||||
minSeg := part.SegmentSeq - uint32(prewarmMaxBufferedSegments)
|
||||
kept := make([]video.LivePart, 0, len(s.bufferedParts))
|
||||
for _, p := range s.bufferedParts {
|
||||
if p.SegmentSeq >= minSeg {
|
||||
kept = append(kept, p)
|
||||
}
|
||||
}
|
||||
s.bufferedParts = kept
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// WritePacket feeds one capture packet into the segmenter. Non-video packets are
|
||||
// ignored (the spike is video-only). The decode timestamp is derived exactly as
|
||||
// the recording muxer does: DTS = PTS - compositionOffset, with the composition
|
||||
// offset forwarded for correct B-frame presentation order.
|
||||
func (s *Session) WritePacket(pkt packets.Packet) error {
|
||||
if !pkt.IsVideo {
|
||||
return nil
|
||||
}
|
||||
pts := uint64(pkt.TimeLegacy.Milliseconds())
|
||||
compositionOffset := pkt.CompositionTime
|
||||
dts := pts
|
||||
if compositionOffset > 0 && uint64(compositionOffset) <= pts {
|
||||
dts = pts - uint64(compositionOffset)
|
||||
} else if compositionOffset < 0 || uint64(compositionOffset) > pts {
|
||||
// Guard against invalid offsets to avoid producing a CTS (DTS+CTO) jump.
|
||||
compositionOffset = 0
|
||||
}
|
||||
return s.segmenter.WriteSample(pkt.IsKeyFrame, pkt.Data, dts, int32(compositionOffset))
|
||||
}
|
||||
|
||||
// Close flushes any buffered sample and ships the final segment.
|
||||
func (s *Session) Close() error {
|
||||
return s.segmenter.Close()
|
||||
}
|
||||
|
||||
// publishInitIfNeeded ensures the init segment has been delivered, attempting an
|
||||
// upload if it has not. Returns true once init is known to be published.
|
||||
func (s *Session) publishInitIfNeeded() bool {
|
||||
s.mu.Lock()
|
||||
if s.initPublished {
|
||||
s.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
initBytes := s.initBytes
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(initBytes) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := s.newContext()
|
||||
defer cancel()
|
||||
if err := s.publisher.PublishInit(ctx, s.id, initBytes); err != nil {
|
||||
log.Log.Warning("livehls.Session: init upload failed, will retry: " + err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.initPublished = true
|
||||
s.lastInitAt = time.Now()
|
||||
s.mu.Unlock()
|
||||
log.Log.Info("livehls.Session: init segment delivered for session " + s.id)
|
||||
return true
|
||||
}
|
||||
|
||||
// initRefreshInterval is how often the init segment is re-uploaded so its TTL in
|
||||
// the hub-api live window never lapses mid-session. The init segment is otherwise
|
||||
// written only once per session; because the live window expires objects after a
|
||||
// short TTL (LiveSegmentTTLSeconds, 45s on the hub) the init would age out after
|
||||
// ~1 minute and the playlist's #EXT-X-MAP would start 404ing, stalling playback.
|
||||
// Re-uploading well inside that TTL keeps the init alive for the life of the
|
||||
// session while still letting it expire naturally once the session ends.
|
||||
const initRefreshInterval = 15 * time.Second
|
||||
|
||||
// refreshInitIfStale re-uploads the init segment if it has not been refreshed
|
||||
// within initRefreshInterval, keeping its created_at (and thus its TTL) current
|
||||
// for as long as the session is producing segments. It is a no-op until the init
|
||||
// has first been published. Failures are non-fatal: the next segment retries.
|
||||
func (s *Session) refreshInitIfStale() {
|
||||
s.mu.Lock()
|
||||
if !s.initPublished || time.Since(s.lastInitAt) < initRefreshInterval {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
initBytes := s.initBytes
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(initBytes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := s.newContext()
|
||||
defer cancel()
|
||||
if err := s.publisher.PublishInit(ctx, s.id, initBytes); err != nil {
|
||||
log.Log.Warning("livehls.Session: init refresh failed, will retry: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.lastInitAt = time.Now()
|
||||
s.mu.Unlock()
|
||||
log.Log.Debug("livehls.Session: refreshed init segment TTL for session " + s.id)
|
||||
}
|
||||
|
||||
// fireReadyOnce invokes the OnReady callback the first time it is called.
|
||||
func (s *Session) fireReadyOnce() {
|
||||
s.mu.Lock()
|
||||
if s.readyFired || s.onReady == nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.readyFired = true
|
||||
fn := s.onReady
|
||||
s.mu.Unlock()
|
||||
fn(s.id)
|
||||
}
|
||||
|
||||
// newSessionID returns a short, unique, URL-safe session identifier of the form
|
||||
// <unix-seconds>-<random-hex>.
|
||||
func newSessionID() string {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// rand.Read essentially never fails; fall back to a time-only id.
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("%d-%s", time.Now().Unix(), hex.EncodeToString(b))
|
||||
}
|
||||
103
machinery/src/cloud/livemoq/annexb.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
var annexBStartCode = []byte{0x00, 0x00, 0x00, 0x01}
|
||||
|
||||
// H264NormalizationStats reports malformed duplication removed from an access unit.
|
||||
type H264NormalizationStats struct {
|
||||
DuplicateIDRNALUs int
|
||||
}
|
||||
|
||||
// EnsureAnnexB restores the start code stripped by the Agent capture queue.
|
||||
func EnsureAnnexB(payload []byte) []byte {
|
||||
if hasAnnexBStartCode(payload) {
|
||||
return payload
|
||||
}
|
||||
|
||||
framed := make([]byte, 0, len(annexBStartCode)+len(payload))
|
||||
framed = append(framed, annexBStartCode...)
|
||||
return append(framed, payload...)
|
||||
}
|
||||
|
||||
// NormalizeH264AccessUnit removes delimiters and exact duplicate parameter-set
|
||||
// or IDR NALUs that can confuse older MoQ splitters and decoders.
|
||||
func NormalizeH264AccessUnit(payload []byte) ([]byte, error) {
|
||||
normalized, _, err := NormalizeH264AccessUnitWithStats(payload)
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
// NormalizeH264AccessUnitWithStats also reports exact duplicate IDR NALUs.
|
||||
func NormalizeH264AccessUnitWithStats(payload []byte) ([]byte, H264NormalizationStats, error) {
|
||||
nalus, err := h264.AnnexBUnmarshal(EnsureAnnexB(payload))
|
||||
if err != nil {
|
||||
return nil, H264NormalizationStats{}, err
|
||||
}
|
||||
|
||||
stats := H264NormalizationStats{}
|
||||
normalized := make([][]byte, 0, len(nalus))
|
||||
for _, nalu := range nalus {
|
||||
if len(nalu) == 0 || nalu[0]&0x1f == 9 {
|
||||
continue
|
||||
}
|
||||
naluType := nalu[0] & 0x1f
|
||||
if naluType == 7 || naluType == 8 || naluType == 5 {
|
||||
duplicate := false
|
||||
for _, existing := range normalized {
|
||||
if bytes.Equal(existing, nalu) {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if duplicate {
|
||||
if naluType == 5 {
|
||||
stats.DuplicateIDRNALUs++
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
normalized = append(normalized, nalu)
|
||||
}
|
||||
|
||||
result, err := h264.AnnexBMarshal(normalized)
|
||||
return result, stats, err
|
||||
}
|
||||
|
||||
// BroadcastPath returns the relay path a quality tier is published on. Every
|
||||
// tier gets its own broadcast so a viewer switches between the camera's main and
|
||||
// sub stream by resubscribing to another path, without any control channel back
|
||||
// to the Agent. The high tier keeps the historical ".../live.hang" path so
|
||||
// existing viewers keep working; the low tier lives next to it on
|
||||
// ".../live-low.hang".
|
||||
func BroadcastPath(prefix string, deviceKey string, quality string) string {
|
||||
prefix = strings.Trim(prefix, "/")
|
||||
if prefix == "" {
|
||||
prefix = "devices"
|
||||
}
|
||||
name := "live.hang"
|
||||
if quality == models.StreamQualityLow {
|
||||
name = "live-low.hang"
|
||||
}
|
||||
return prefix + "/" + strings.Trim(deviceKey, "/") + "/" + name
|
||||
}
|
||||
|
||||
// TimestampUs converts the capture presentation timestamp from milliseconds.
|
||||
// CompositionTime must not be added: it is already represented in the PTS and
|
||||
// is only used by muxers to derive DTS for streams containing B-frames.
|
||||
func TimestampUs(presentationTimeMs int64) uint64 {
|
||||
if presentationTimeMs < 0 {
|
||||
return 0
|
||||
}
|
||||
return uint64(presentationTimeMs) * 1000
|
||||
}
|
||||
|
||||
func hasAnnexBStartCode(payload []byte) bool {
|
||||
return len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 &&
|
||||
((payload[2] == 0 && payload[3] == 1) || payload[2] == 1)
|
||||
}
|
||||
116
machinery/src/cloud/livemoq/annexb_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
func TestEnsureAnnexB(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
want []byte
|
||||
}{
|
||||
{
|
||||
name: "missing start code",
|
||||
payload: []byte{0x41, 0x01},
|
||||
want: []byte{0x00, 0x00, 0x00, 0x01, 0x41, 0x01},
|
||||
},
|
||||
{
|
||||
name: "four byte start code",
|
||||
payload: []byte{0x00, 0x00, 0x00, 0x01, 0x65},
|
||||
want: []byte{0x00, 0x00, 0x00, 0x01, 0x65},
|
||||
},
|
||||
{
|
||||
name: "three byte start code",
|
||||
payload: []byte{0x00, 0x00, 0x01, 0x41},
|
||||
want: []byte{0x00, 0x00, 0x01, 0x41},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := EnsureAnnexB(test.payload); !bytes.Equal(got, test.want) {
|
||||
t.Fatalf("EnsureAnnexB() = %x, want %x", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeH264AccessUnit(t *testing.T) {
|
||||
startCode := []byte{0x00, 0x00, 0x00, 0x01}
|
||||
sps := []byte{0x67, 0x42, 0x00, 0x1f}
|
||||
pps := []byte{0x68, 0xce, 0x06, 0xe2}
|
||||
aud := []byte{0x09, 0xf0}
|
||||
idr := []byte{0x65, 0x88, 0x84}
|
||||
|
||||
payload := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{sps, pps, aud, sps, pps, idr} {
|
||||
payload = append(payload, startCode...)
|
||||
payload = append(payload, nalu...)
|
||||
}
|
||||
|
||||
got, err := NormalizeH264AccessUnit(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{sps, pps, idr} {
|
||||
want = append(want, startCode...)
|
||||
want = append(want, nalu...)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("NormalizeH264AccessUnit() = %x, want %x", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeH264AccessUnitRemovesOnlyExactDuplicateIDRSlices(t *testing.T) {
|
||||
startCode := []byte{0x00, 0x00, 0x00, 0x01}
|
||||
idrSlice1 := []byte{0x65, 0x88, 0x84}
|
||||
idrSlice2 := []byte{0x65, 0x44, 0x22}
|
||||
|
||||
payload := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{idrSlice1, idrSlice1, idrSlice2} {
|
||||
payload = append(payload, startCode...)
|
||||
payload = append(payload, nalu...)
|
||||
}
|
||||
|
||||
got, stats, err := NormalizeH264AccessUnitWithStats(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := make([]byte, 0)
|
||||
for _, nalu := range [][]byte{idrSlice1, idrSlice2} {
|
||||
want = append(want, startCode...)
|
||||
want = append(want, nalu...)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("NormalizeH264AccessUnitWithStats() = %x, want %x", got, want)
|
||||
}
|
||||
if stats.DuplicateIDRNALUs != 1 {
|
||||
t.Fatalf("DuplicateIDRNALUs = %d, want 1", stats.DuplicateIDRNALUs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastPath(t *testing.T) {
|
||||
if got := BroadcastPath("/devices/", "/camera-1/", models.StreamQualityHigh); got != "devices/camera-1/live.hang" {
|
||||
t.Fatalf("BroadcastPath() high = %q", got)
|
||||
}
|
||||
if got := BroadcastPath("", "camera-1", models.StreamQualityHigh); got != "devices/camera-1/live.hang" {
|
||||
t.Fatalf("BroadcastPath() default = %q", got)
|
||||
}
|
||||
if got := BroadcastPath("", "camera-1", models.StreamQualityLow); got != "devices/camera-1/live-low.hang" {
|
||||
t.Fatalf("BroadcastPath() low = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampUs(t *testing.T) {
|
||||
if got := TimestampUs(1234); got != 1_234_000 {
|
||||
t.Fatalf("TimestampUs() = %d, want 1234000", got)
|
||||
}
|
||||
if got := TimestampUs(-1); got != 0 {
|
||||
t.Fatalf("TimestampUs() negative = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
41
machinery/src/cloud/livemoq/dedup.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KeyframeDeduplicator struct {
|
||||
hasPrevious bool
|
||||
timestampMs int64
|
||||
capturedAtMs int64
|
||||
observedAt time.Time
|
||||
digest [sha256.Size]byte
|
||||
}
|
||||
|
||||
func (d *KeyframeDeduplicator) Reset() {
|
||||
*d = KeyframeDeduplicator{}
|
||||
}
|
||||
|
||||
// IsDuplicate reports exact repeated keyframe access units observed close
|
||||
// together. Distinct IDR slices within one access unit remain untouched.
|
||||
func (d *KeyframeDeduplicator) IsDuplicate(timestampMs int64, capturedAtMs int64, payload []byte, observedAt time.Time, window time.Duration) bool {
|
||||
digest := sha256.Sum256(payload)
|
||||
duplicate := d.hasPrevious && d.timestampMs == timestampMs && d.digest == digest
|
||||
if duplicate {
|
||||
if capturedAtMs > 0 && d.capturedAtMs > 0 {
|
||||
gap := time.Duration(capturedAtMs-d.capturedAtMs) * time.Millisecond
|
||||
duplicate = gap >= 0 && gap <= window
|
||||
} else {
|
||||
gap := observedAt.Sub(d.observedAt)
|
||||
duplicate = gap >= 0 && gap <= window
|
||||
}
|
||||
}
|
||||
|
||||
d.hasPrevious = true
|
||||
d.timestampMs = timestampMs
|
||||
d.capturedAtMs = capturedAtMs
|
||||
d.observedAt = observedAt
|
||||
d.digest = digest
|
||||
return duplicate
|
||||
}
|
||||
60
machinery/src/cloud/livemoq/dedup_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKeyframeDeduplicator(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
window := 500 * time.Millisecond
|
||||
payload := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
if deduplicator.IsDuplicate(1_000, 10_000, payload, now, window) {
|
||||
t.Fatal("first keyframe reported as duplicate")
|
||||
}
|
||||
if !deduplicator.IsDuplicate(1_000, 10_020, payload, now.Add(20*time.Millisecond), window) {
|
||||
t.Fatal("exact repeated keyframe was not reported as duplicate")
|
||||
}
|
||||
if deduplicator.IsDuplicate(2_000, 11_000, payload, now.Add(time.Second), window) {
|
||||
t.Fatal("same payload with a new timestamp reported as duplicate")
|
||||
}
|
||||
if deduplicator.IsDuplicate(2_000, 11_020, append(payload, 0x01), now.Add(1020*time.Millisecond), window) {
|
||||
t.Fatal("different payload with the same timestamp reported as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorAllowsTimestampReuseOutsideWindow(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x00, 0x00, 0x00, 0x01, 0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 10_000, payload, now, 500*time.Millisecond)
|
||||
if deduplicator.IsDuplicate(1_000, 20_000, payload, now.Add(10*time.Second), 500*time.Millisecond) {
|
||||
t.Fatal("later keyframe after timestamp reset reported as duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorFallsBackToObservationTime(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 0, payload, now, 500*time.Millisecond)
|
||||
if !deduplicator.IsDuplicate(1_000, 0, payload, now.Add(20*time.Millisecond), 500*time.Millisecond) {
|
||||
t.Fatal("duplicate without capture time was not reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyframeDeduplicatorReset(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
payload := []byte{0x65, 0x88}
|
||||
deduplicator := KeyframeDeduplicator{}
|
||||
|
||||
deduplicator.IsDuplicate(1_000, 10_000, payload, now, 500*time.Millisecond)
|
||||
deduplicator.Reset()
|
||||
if deduplicator.IsDuplicate(1_000, 10_020, payload, now.Add(20*time.Millisecond), 500*time.Millisecond) {
|
||||
t.Fatal("first keyframe after reset reported as duplicate")
|
||||
}
|
||||
}
|
||||
55
machinery/src/cloud/livemoq/recovery.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package livemoq
|
||||
|
||||
import "time"
|
||||
|
||||
type FrameGateEvent uint8
|
||||
|
||||
const (
|
||||
FrameGateEventNone FrameGateEvent = iota
|
||||
FrameGateEventStarted
|
||||
FrameGateEventLagging
|
||||
FrameGateEventRecovered
|
||||
)
|
||||
|
||||
// FrameGate keeps publication on a decodable, recent GOP.
|
||||
type FrameGate struct {
|
||||
started bool
|
||||
recovering bool
|
||||
}
|
||||
|
||||
// Reset closes the gate so publication resumes on the next keyframe. It is used
|
||||
// when the publisher stopped writing for a reason unrelated to the stream health
|
||||
// (no subscribers), so the next viewer never receives a partial GOP.
|
||||
func (g *FrameGate) Reset() {
|
||||
g.started = false
|
||||
g.recovering = false
|
||||
}
|
||||
|
||||
// Allow rejects stale frames and waits for a fresh keyframe before reopening.
|
||||
func (g *FrameGate) Allow(isKeyFrame bool, capturedAtMs int64, now time.Time, maxAge time.Duration) (bool, FrameGateEvent) {
|
||||
if capturedAtMs > 0 && now.Sub(time.UnixMilli(capturedAtMs)) > maxAge {
|
||||
event := FrameGateEventNone
|
||||
if g.started {
|
||||
if !g.recovering {
|
||||
event = FrameGateEventLagging
|
||||
}
|
||||
g.started = false
|
||||
g.recovering = true
|
||||
}
|
||||
return false, event
|
||||
}
|
||||
|
||||
if !g.started {
|
||||
if !isKeyFrame {
|
||||
return false, FrameGateEventNone
|
||||
}
|
||||
g.started = true
|
||||
if g.recovering {
|
||||
g.recovering = false
|
||||
return true, FrameGateEventRecovered
|
||||
}
|
||||
return true, FrameGateEventStarted
|
||||
}
|
||||
|
||||
return true, FrameGateEventNone
|
||||
}
|
||||
49
machinery/src/cloud/livemoq/recovery_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package livemoq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFrameGateRecoversAtFreshKeyframe(t *testing.T) {
|
||||
now := time.UnixMilli(10_000)
|
||||
maxAge := 1500 * time.Millisecond
|
||||
gate := FrameGate{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isKeyFrame bool
|
||||
capturedAtMs int64
|
||||
wantAllowed bool
|
||||
wantEvent FrameGateEvent
|
||||
}{
|
||||
{name: "waits for initial keyframe", capturedAtMs: 10_000},
|
||||
{name: "starts at initial keyframe", isKeyFrame: true, capturedAtMs: 10_000, wantAllowed: true, wantEvent: FrameGateEventStarted},
|
||||
{name: "publishes fresh delta", capturedAtMs: 10_020, wantAllowed: true},
|
||||
{name: "detects stale packet", capturedAtMs: 8_000, wantEvent: FrameGateEventLagging},
|
||||
{name: "rejects fresh delta while recovering", capturedAtMs: 10_040},
|
||||
{name: "rejects stale keyframe without duplicate event", isKeyFrame: true, capturedAtMs: 8_000},
|
||||
{name: "recovers at fresh keyframe", isKeyFrame: true, capturedAtMs: 10_060, wantAllowed: true, wantEvent: FrameGateEventRecovered},
|
||||
{name: "publishes delta after recovery", capturedAtMs: 10_080, wantAllowed: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
allowed, event := gate.Allow(test.isKeyFrame, test.capturedAtMs, now, maxAge)
|
||||
if allowed != test.wantAllowed {
|
||||
t.Fatalf("Allow() allowed = %t, want %t", allowed, test.wantAllowed)
|
||||
}
|
||||
if event != test.wantEvent {
|
||||
t.Fatalf("Allow() event = %d, want %d", event, test.wantEvent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameGateAllowsMissingCaptureTime(t *testing.T) {
|
||||
gate := FrameGate{}
|
||||
allowed, event := gate.Allow(true, 0, time.Now(), time.Second)
|
||||
if !allowed || event != FrameGateEventStarted {
|
||||
t.Fatalf("Allow() = (%t, %d), want (true, %d)", allowed, event, FrameGateEventStarted)
|
||||
}
|
||||
}
|
||||
8
machinery/src/cloud/livemoq_disabled.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !moq
|
||||
|
||||
package cloud
|
||||
|
||||
import "github.com/kerberos-io/agent/machinery/src/models"
|
||||
|
||||
// StartLiveStreamMoQ is disabled in the standard Agent build.
|
||||
func StartLiveStreamMoQ(_ *models.Configuration, _ *models.Communication, _ bool) {}
|
||||
298
machinery/src/cloud/livemoq_enabled.go
Normal file
@@ -0,0 +1,298 @@
|
||||
//go:build moq
|
||||
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/cloud/livemoq"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/moq-dev/moq-go/moq"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMoQRelayURL = "https://relay.uug.ai/anon"
|
||||
minMoQRetryDelay = time.Second
|
||||
maxMoQRetryDelay = 30 * time.Second
|
||||
maxMoQLivePacketAge = 1500 * time.Millisecond
|
||||
slowMoQWriteThreshold = 100 * time.Millisecond
|
||||
moQWriteWarningInterval = 10 * time.Second
|
||||
duplicateKeyframeWindow = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
type liveMoQConfig struct {
|
||||
relayURL string
|
||||
broadcast string
|
||||
quality string
|
||||
sourceLabel string
|
||||
queue *packets.Queue
|
||||
}
|
||||
|
||||
// label identifies the tier in log lines, since one Agent runs a publisher per
|
||||
// quality tier.
|
||||
func (c liveMoQConfig) label() string {
|
||||
return c.quality + " (" + c.sourceLabel + " stream)"
|
||||
}
|
||||
|
||||
// StartLiveStreamMoQ starts the publisher only in the dedicated MoQ build and
|
||||
// only when explicitly enabled by the deployment.
|
||||
//
|
||||
// Unlike WebRTC and HLS — where a viewer negotiates a session with the Agent and
|
||||
// can therefore ask for another quality on the fly — MoQ viewers subscribe to a
|
||||
// relay and never talk to the Agent. The quality selector is honoured by
|
||||
// publishing each tier as its OWN broadcast (see livemoq.BroadcastPath): the
|
||||
// high tier from the camera's highest-resolution stream and the low tier from
|
||||
// its sub stream, so switching quality in the frontend is a resubscribe to the
|
||||
// other path. Each tier only uploads while it actually has subscribers, so the
|
||||
// second broadcast is close to free when nobody watches it.
|
||||
func StartLiveStreamMoQ(configuration *models.Configuration, communication *models.Communication, subStreamEnabled bool) {
|
||||
if os.Getenv("AGENT_LIVE_MOQ_ENABLED") != "true" {
|
||||
return
|
||||
}
|
||||
|
||||
config := configuration.Config
|
||||
if config.Offline == "true" || config.Capture.Liveview == "false" {
|
||||
log.Log.Info("cloud.StartLiveStreamMoQ(): disabled by Agent live-view configuration")
|
||||
return
|
||||
}
|
||||
if config.Key == "" {
|
||||
log.Log.Warning("cloud.StartLiveStreamMoQ(): AGENT_KEY is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Both tiers are published by default. AGENT_LIVE_MOQ_QUALITY pins the Agent
|
||||
// to a single tier for deployments that must never publish the other one
|
||||
// (viewers asking for the pinned-away tier then find no broadcast).
|
||||
qualities := []string{models.StreamQualityHigh, models.StreamQualityLow}
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("AGENT_LIVE_MOQ_QUALITY"))) {
|
||||
case models.StreamQualityHigh:
|
||||
qualities = []string{models.StreamQualityHigh}
|
||||
case models.StreamQualityLow:
|
||||
qualities = []string{models.StreamQualityLow}
|
||||
}
|
||||
|
||||
relayURL := os.Getenv("AGENT_LIVE_MOQ_URL")
|
||||
if relayURL == "" {
|
||||
relayURL = defaultMoQRelayURL
|
||||
}
|
||||
broadcastPrefix := os.Getenv("AGENT_LIVE_MOQ_BROADCAST_PREFIX")
|
||||
|
||||
ctx := context.Background()
|
||||
if communication.Context != nil {
|
||||
ctx = *communication.Context
|
||||
}
|
||||
|
||||
for _, quality := range qualities {
|
||||
queue := communication.Queue
|
||||
sourceLabel := "main"
|
||||
if models.SelectSubStreamForQuality(config, quality, subStreamEnabled) && communication.SubQueue != nil {
|
||||
queue = communication.SubQueue
|
||||
sourceLabel = "sub"
|
||||
}
|
||||
if queue == nil {
|
||||
log.Log.Warning("cloud.StartLiveStreamMoQ(): packet queue for the " + quality + " tier is unavailable")
|
||||
continue
|
||||
}
|
||||
go runLiveStreamMoQ(ctx, liveMoQConfig{
|
||||
relayURL: relayURL,
|
||||
broadcast: livemoq.BroadcastPath(broadcastPrefix, config.Key, quality),
|
||||
quality: quality,
|
||||
sourceLabel: sourceLabel,
|
||||
queue: queue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runLiveStreamMoQ(ctx context.Context, config liveMoQConfig) {
|
||||
log.Log.Info(fmt.Sprintf(
|
||||
"cloud.runLiveStreamMoQ(): publishing %s stream (quality=%s) to %s/%s",
|
||||
config.sourceLabel, config.quality, strings.TrimRight(config.relayURL, "/"), config.broadcast,
|
||||
))
|
||||
|
||||
retryDelay := minMoQRetryDelay
|
||||
for ctx.Err() == nil {
|
||||
connectedAt := time.Now()
|
||||
err := publishLiveStreamMoQ(ctx, config)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Log.Warning("cloud.runLiveStreamMoQ(): publisher stopped: " + err.Error())
|
||||
if time.Since(connectedAt) >= time.Minute {
|
||||
retryDelay = minMoQRetryDelay
|
||||
}
|
||||
|
||||
timer := time.NewTimer(retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if retryDelay < maxMoQRetryDelay {
|
||||
retryDelay *= 2
|
||||
if retryDelay > maxMoQRetryDelay {
|
||||
retryDelay = maxMoQRetryDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishLiveStreamMoQ(ctx context.Context, config liveMoQConfig) error {
|
||||
client, err := moq.Dial(ctx, config.relayURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to relay: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
sessionCtx, cancelSessionWatch := context.WithCancel(ctx)
|
||||
defer cancelSessionWatch()
|
||||
sessionClosed := make(chan error, 1)
|
||||
go func() {
|
||||
sessionClosed <- client.Session().Closed(sessionCtx)
|
||||
}()
|
||||
|
||||
broadcast, err := client.CreateBroadcast(config.broadcast)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create broadcast: %w", err)
|
||||
}
|
||||
defer broadcast.Finish()
|
||||
|
||||
stream, err := broadcast.PublishMedia("avc3", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create H.264 media stream: %w", err)
|
||||
}
|
||||
defer stream.Finish()
|
||||
|
||||
// Only upload while this tier is actually being watched. `publishing` starts
|
||||
// true so the track becomes discoverable on the relay even before the first
|
||||
// subscriber ever arrives; from the moment a viewer has attached once, the
|
||||
// subscriber watcher takes over and idles the tier again when everybody left.
|
||||
watchCtx, cancelWatch := context.WithCancel(ctx)
|
||||
defer cancelWatch()
|
||||
publishing := &atomic.Bool{}
|
||||
publishing.Store(true)
|
||||
go watchLiveStreamMoQSubscribers(watchCtx, stream, publishing, config)
|
||||
|
||||
cursor := config.queue.Latest()
|
||||
gate := livemoq.FrameGate{}
|
||||
deduplicator := livemoq.KeyframeDeduplicator{}
|
||||
var lastSlowWriteWarning time.Time
|
||||
var lastDuplicateKeyframeWarning time.Time
|
||||
idle := false
|
||||
for {
|
||||
select {
|
||||
case err := <-sessionClosed:
|
||||
return fmt.Errorf("relay session closed: %w", err)
|
||||
default:
|
||||
}
|
||||
|
||||
packet, err := cursor.ReadPacket()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read packet: %w", err)
|
||||
}
|
||||
if !publishing.Load() {
|
||||
// Keep draining the cursor so we stay at the live edge, but publish
|
||||
// nothing. The gate is closed so the next viewer resumes on a keyframe.
|
||||
if !idle {
|
||||
gate.Reset()
|
||||
deduplicator.Reset()
|
||||
idle = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
idle = false
|
||||
if !packet.IsVideo || len(packet.Data) == 0 || !strings.EqualFold(packet.Codec, "H264") {
|
||||
continue
|
||||
}
|
||||
allowed, event := gate.Allow(packet.IsKeyFrame, packet.CurrentTime, time.Now(), maxMoQLivePacketAge)
|
||||
switch event {
|
||||
case livemoq.FrameGateEventStarted:
|
||||
log.Log.Info("cloud.publishLiveStreamMoQ(): first H.264 keyframe received; " + config.label() + " broadcast is live")
|
||||
case livemoq.FrameGateEventLagging:
|
||||
log.Log.Warning("cloud.publishLiveStreamMoQ(): " + config.label() + " stream is lagging; dropping packets until a recent keyframe")
|
||||
case livemoq.FrameGateEventRecovered:
|
||||
log.Log.Info("cloud.publishLiveStreamMoQ(): caught up with the " + config.label() + " live stream at a recent keyframe")
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
payload, normalizationStats, err := livemoq.NormalizeH264AccessUnitWithStats(packet.Data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("normalize H.264 access unit: %w", err)
|
||||
}
|
||||
if normalizationStats.DuplicateIDRNALUs > 0 && time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s removed %d duplicate IDR NALU(s) from H.264 keyframe (timestamp_ms=%d)",
|
||||
config.label(), normalizationStats.DuplicateIDRNALUs, packet.Time,
|
||||
))
|
||||
lastDuplicateKeyframeWarning = time.Now()
|
||||
}
|
||||
if packet.IsKeyFrame && deduplicator.IsDuplicate(packet.Time, packet.CurrentTime, payload, time.Now(), duplicateKeyframeWindow) {
|
||||
if time.Since(lastDuplicateKeyframeWarning) >= moQWriteWarningInterval {
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s dropping duplicate H.264 keyframe (timestamp_ms=%d)",
|
||||
config.label(), packet.Time,
|
||||
))
|
||||
lastDuplicateKeyframeWarning = time.Now()
|
||||
}
|
||||
continue
|
||||
}
|
||||
frame := moq.Frame{
|
||||
Payload: payload,
|
||||
TimestampUs: livemoq.TimestampUs(packet.Time),
|
||||
}
|
||||
writeStartedAt := time.Now()
|
||||
if err := stream.WriteFrame(frame); err != nil {
|
||||
return fmt.Errorf("write H.264 access unit: %w", err)
|
||||
}
|
||||
writeDuration := time.Since(writeStartedAt)
|
||||
if writeDuration >= slowMoQWriteThreshold && time.Since(lastSlowWriteWarning) >= moQWriteWarningInterval {
|
||||
packetAge := time.Duration(0)
|
||||
if packet.CurrentTime > 0 {
|
||||
packetAge = time.Since(time.UnixMilli(packet.CurrentTime))
|
||||
if packetAge < 0 {
|
||||
packetAge = 0
|
||||
}
|
||||
}
|
||||
log.Log.Warning(fmt.Sprintf(
|
||||
"cloud.publishLiveStreamMoQ(): %s WriteFrame blocked for %s (packet_age=%s keyframe=%t)",
|
||||
config.label(), writeDuration.Round(time.Millisecond), packetAge.Round(time.Millisecond), packet.IsKeyFrame,
|
||||
))
|
||||
lastSlowWriteWarning = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchLiveStreamMoQSubscribers flips the publisher between uploading and idling
|
||||
// as viewers subscribe to and leave this tier's broadcast. Used and Unused both
|
||||
// block, so they are followed from their own goroutine.
|
||||
//
|
||||
// It deliberately never turns publishing off before the first subscriber has
|
||||
// been observed: the relay catalog is only complete once media has flowed, so
|
||||
// going idle up front could keep the tier undiscoverable. On any error it fails
|
||||
// open (keeps publishing) — a stalled watcher must never take the live view down.
|
||||
func watchLiveStreamMoQSubscribers(ctx context.Context, stream *moq.MediaProducer, publishing *atomic.Bool, config liveMoQConfig) {
|
||||
for ctx.Err() == nil {
|
||||
if err := stream.Used(ctx); err != nil {
|
||||
publishing.Store(true)
|
||||
return
|
||||
}
|
||||
if publishing.CompareAndSwap(false, true) {
|
||||
log.Log.Info("cloud.watchLiveStreamMoQSubscribers(): viewer subscribed, resuming the " + config.label() + " broadcast")
|
||||
}
|
||||
|
||||
if err := stream.Unused(ctx); err != nil {
|
||||
publishing.Store(true)
|
||||
return
|
||||
}
|
||||
publishing.Store(false)
|
||||
log.Log.Info("cloud.watchLiveStreamMoQSubscribers(): no viewers left, idling the " + config.label() + " broadcast")
|
||||
}
|
||||
}
|
||||
151
machinery/src/cloud/livesnapshot/publisher.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Package livesnapshot implements the agent-side producer for the live-view
|
||||
// "preview" (SD) mode over HTTP.
|
||||
//
|
||||
// Historically the preview pipeline shipped each resized keyframe (a base64
|
||||
// JPEG, often chunked) to viewers over the MQTT broker. MQTT is a control plane
|
||||
// for small messages, so pushing ~1 image/second of base64 image data per
|
||||
// watched camera congests the broker and delays genuine control traffic. This
|
||||
// package moves those frames off MQTT: the agent POSTs the latest resized JPEG
|
||||
// straight to hub-api over plain HTTPS (outbound only), and viewers fetch it
|
||||
// back with their session token. Only the tiny "a viewer is watching" keepalive
|
||||
// stays on MQTT.
|
||||
//
|
||||
// The wire contract (agent -> hub-api) deliberately mirrors the live HLS ingest
|
||||
// and the existing storage-upload convention (X-Kerberos-Storage-Device plus the
|
||||
// Hub public/private key auth headers). hub-api authenticates the agent and
|
||||
// stores the frame in an ephemeral, short-TTL per-device slot which it serves
|
||||
// straight back to authorized viewers; the frame never enters the vault or the
|
||||
// recordings collection.
|
||||
//
|
||||
// Like live HLS segments, a preview frame is worthless once stale: a frame that
|
||||
// fails to upload is superseded by the next one a second later, so the publisher
|
||||
// is fire-and-forget and drops on failure (logged) rather than retrying.
|
||||
package livesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// snapshotIngestPath is the hub-api endpoint that accepts the latest preview
|
||||
// frame and stores it in the device's ephemeral snapshot slot (mirrors the
|
||||
// /storage/live live-HLS ingest convention).
|
||||
snapshotIngestPath = "/storage/snapshot"
|
||||
|
||||
contentTypeJPEG = "image/jpeg"
|
||||
|
||||
// Header names for the snapshot ingest contract (shared with live HLS / storage).
|
||||
headerHubPublicKey = "X-Kerberos-Hub-PublicKey"
|
||||
headerHubPrivateKey = "X-Kerberos-Hub-PrivateKey"
|
||||
headerHubRegion = "X-Kerberos-Hub-Region"
|
||||
headerStorageDevice = "X-Kerberos-Storage-Device"
|
||||
|
||||
// defaultPublishTimeout bounds a single snapshot upload. Preview frames are
|
||||
// produced roughly once a second from a single goroutine, so an upload that
|
||||
// cannot land in a few seconds is abandoned rather than allowed to back up the
|
||||
// preview loop behind a slow request.
|
||||
defaultPublishTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// PublisherConfig carries the hub endpoint and credentials needed to ship
|
||||
// preview frames. It is populated from the agent's models.Config (the same
|
||||
// HubURI/HubKey/HubPrivateKey used by recordings and live HLS).
|
||||
type PublisherConfig struct {
|
||||
HubURI string // base hub-api URL, e.g. https://api.hub.example.com
|
||||
HubKey string // Hub public key (X-Kerberos-Hub-PublicKey)
|
||||
HubPrivateKey string // Hub private key (X-Kerberos-Hub-PrivateKey)
|
||||
Region string // storage region (X-Kerberos-Hub-Region), may be empty
|
||||
DeviceKey string // device/camera key (X-Kerberos-Storage-Device)
|
||||
|
||||
// Timeout optionally overrides defaultPublishTimeout (used by tests).
|
||||
Timeout time.Duration
|
||||
// HTTPClient optionally injects a client (used by tests). When nil a
|
||||
// redirect-credential-stripping client is created.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Publisher ships the latest preview frame to hub-api over plain HTTP POST.
|
||||
//
|
||||
// It is safe for sequential use from a single live-stream goroutine. PublishSnapshot
|
||||
// is fire-and-forget: it returns an error for the caller to log, but the caller is
|
||||
// expected to continue (drop-on-fail) rather than retry.
|
||||
type Publisher struct {
|
||||
cfg PublisherConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewPublisher builds a Publisher. The HTTP client strips the Hub credential
|
||||
// headers on a cross-host redirect (net/http does this for standard auth headers
|
||||
// but not custom-named ones), matching the recording/live-HLS upload clients.
|
||||
func NewPublisher(cfg PublisherConfig) *Publisher {
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultPublishTimeout
|
||||
}
|
||||
client = &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: stripHubCredentialsOnCrossHostRedirect,
|
||||
}
|
||||
}
|
||||
return &Publisher{cfg: cfg, client: client}
|
||||
}
|
||||
|
||||
// PublishSnapshot uploads a single resized preview frame (JPEG) as the device's
|
||||
// latest snapshot. It overwrites whatever frame was there before, so viewers
|
||||
// always fetch the most recent frame.
|
||||
func (p *Publisher) PublishSnapshot(ctx context.Context, jpeg []byte) error {
|
||||
if p.cfg.HubURI == "" {
|
||||
return fmt.Errorf("livesnapshot: HubURI not configured")
|
||||
}
|
||||
if len(jpeg) == 0 {
|
||||
return fmt.Errorf("livesnapshot: empty snapshot body")
|
||||
}
|
||||
|
||||
url := strings.TrimRight(p.cfg.HubURI, "/") + snapshotIngestPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jpeg))
|
||||
if err != nil {
|
||||
return fmt.Errorf("livesnapshot: build request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", contentTypeJPEG)
|
||||
req.Header.Set(headerStorageDevice, p.cfg.DeviceKey)
|
||||
req.Header.Set(headerHubPublicKey, p.cfg.HubKey)
|
||||
req.Header.Set(headerHubPrivateKey, p.cfg.HubPrivateKey)
|
||||
req.Header.Set(headerHubRegion, p.cfg.Region)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("livesnapshot: upload snapshot: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("livesnapshot: upload snapshot rejected: %s", resp.Status)
|
||||
}
|
||||
log.Log.Debug("livesnapshot.Publisher.PublishSnapshot(): shipped preview frame for device " + p.cfg.DeviceKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stripHubCredentialsOnCrossHostRedirect removes the Hub credential headers when
|
||||
// a redirect crosses to a different host. net/http strips standard sensitive
|
||||
// headers on a cross-host redirect but not custom-named ones, so without this the
|
||||
// Hub keys could leak to a redirect target.
|
||||
func stripHubCredentialsOnCrossHostRedirect(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del(headerHubPrivateKey)
|
||||
req.Header.Del(headerHubPublicKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
88
machinery/src/cloud/recording_metadata.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
const recordingFPSHeader = "X-Kerberos-Storage-Fps"
|
||||
const recordingDurationHeader = "X-Kerberos-Storage-Duration"
|
||||
const recordingTimestampHeader = "X-Kerberos-Storage-Timestamp"
|
||||
|
||||
// queuedRecordingFPS reads the FPS snapshot written into the upload marker
|
||||
// when the recording was finalized. Historical empty markers intentionally
|
||||
// return no value so receivers can retain their existing MP4-derived fallback.
|
||||
func queuedRecordingFPS(fileName string) string {
|
||||
value, ok := readRecordingUploadMetadata(fileName)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
marker := strings.TrimSpace(string(value))
|
||||
if strings.HasPrefix(marker, "{") {
|
||||
metadata, ok := decodeRecordingUploadMetadata(value)
|
||||
if !ok || metadata.FPS <= 0 || metadata.FPS > 240 || math.IsInf(metadata.FPS, 0) || math.IsNaN(metadata.FPS) {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(metadata.FPS, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// Compatibility with markers created before upload metadata used JSON.
|
||||
fps := marker
|
||||
parsed, err := strconv.ParseFloat(fps, 64)
|
||||
if err != nil || parsed <= 0 || parsed > 240 || math.IsInf(parsed, 0) || math.IsNaN(parsed) {
|
||||
return ""
|
||||
}
|
||||
return fps
|
||||
}
|
||||
|
||||
func queuedRecordingMetadata(fileName string) (models.RecordingUploadMetadata, bool) {
|
||||
value, ok := readRecordingUploadMetadata(fileName)
|
||||
if !ok || !strings.HasPrefix(strings.TrimSpace(string(value)), "{") {
|
||||
return models.RecordingUploadMetadata{}, false
|
||||
}
|
||||
return decodeRecordingUploadMetadata(value)
|
||||
}
|
||||
|
||||
func decodeRecordingUploadMetadata(value []byte) (models.RecordingUploadMetadata, bool) {
|
||||
var metadata models.RecordingUploadMetadata
|
||||
if err := json.Unmarshal(value, &metadata); err != nil {
|
||||
return models.RecordingUploadMetadata{}, false
|
||||
}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
func readRecordingUploadMetadata(fileName string) ([]byte, bool) {
|
||||
markerNames := []string{
|
||||
models.RecordingUploadMetadataFileName(fileName),
|
||||
filepath.Base(fileName),
|
||||
}
|
||||
for _, markerName := range markerNames {
|
||||
value, err := os.ReadFile(filepath.Join("data", "cloud", markerName))
|
||||
if err == nil {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func setQueuedRecordingMetadataHeaders(header http.Header, fileName string) {
|
||||
if fps := queuedRecordingFPS(fileName); fps != "" {
|
||||
header.Set(recordingFPSHeader, fps)
|
||||
}
|
||||
if metadata, ok := queuedRecordingMetadata(fileName); ok {
|
||||
if metadata.Duration > 0 {
|
||||
header.Set(recordingDurationHeader, strconv.FormatUint(metadata.Duration, 10))
|
||||
}
|
||||
if metadata.Timestamp > 0 {
|
||||
header.Set(recordingTimestampHeader, strconv.FormatInt(metadata.Timestamp, 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cloud
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -13,11 +14,10 @@ import (
|
||||
"github.com/minio/minio-go/v6"
|
||||
)
|
||||
|
||||
func UploadS3(configuration *models.Configuration, fileName string, directory string) bool {
|
||||
func UploadS3(configuration *models.Configuration, fileName string) (bool, bool, error) {
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
//fmt.Println("Uploading...")
|
||||
// timestamp_microseconds_instanceName_regionCoordinates_numberOfChanges_token
|
||||
// 1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4
|
||||
// - Timestamp
|
||||
@@ -28,10 +28,12 @@ func UploadS3(configuration *models.Configuration, fileName string, directory st
|
||||
// - Token
|
||||
|
||||
if config.S3 == nil {
|
||||
log.Log.Error("UploadS3: Uploading Failed, as no settings found")
|
||||
return false
|
||||
errorMessage := "UploadS3: Uploading Failed, as no settings found"
|
||||
log.Log.Error(errorMessage)
|
||||
return false, false, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
// Legacy support, should get rid of it!
|
||||
aws_access_key_id := config.S3.Publickey
|
||||
aws_secret_access_key := config.S3.Secretkey
|
||||
aws_region := config.S3.Region
|
||||
@@ -44,9 +46,18 @@ func UploadS3(configuration *models.Configuration, fileName string, directory st
|
||||
aws_secret_access_key = config.HubPrivateKey
|
||||
}
|
||||
|
||||
// Check if we have some credentials otherwise we abort the request.
|
||||
if aws_access_key_id == "" || aws_secret_access_key == "" {
|
||||
errorMessage := "UploadS3: Uploading Failed, as no credentials found"
|
||||
log.Log.Error(errorMessage)
|
||||
return false, false, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
s3Client, err := minio.NewWithRegion("s3.amazonaws.com", aws_access_key_id, aws_secret_access_key, true, aws_region)
|
||||
if err != nil {
|
||||
log.Log.Error(err.Error())
|
||||
errorMessage := "UploadS3: " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
// Check if we need to use the proxy.
|
||||
@@ -62,9 +73,9 @@ func UploadS3(configuration *models.Configuration, fileName string, directory st
|
||||
|
||||
fileParts := strings.Split(fileName, "_")
|
||||
if len(fileParts) == 1 {
|
||||
log.Log.Error("ERROR: " + fileName + " is not a valid name.")
|
||||
os.Remove(directory + "/" + fileName)
|
||||
return false
|
||||
errorMessage := "UploadS3: " + fileName + " is not a valid name."
|
||||
log.Log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
deviceKey := config.Key
|
||||
@@ -78,18 +89,21 @@ func UploadS3(configuration *models.Configuration, fileName string, directory st
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, err := os.OpenFile(fullname, os.O_RDWR, 0755)
|
||||
defer file.Close()
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Log.Error("UploadS3: " + err.Error())
|
||||
os.Remove(directory + "/" + fileName)
|
||||
return false
|
||||
errorMessage := "UploadS3: " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
log.Log.Error("UploadS3: " + err.Error())
|
||||
os.Remove(directory + "/" + fileName)
|
||||
return false
|
||||
errorMessage := "UploadS3: " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
}
|
||||
|
||||
n, err := s3Client.PutObject(config.S3.Bucket,
|
||||
@@ -113,11 +127,11 @@ func UploadS3(configuration *models.Configuration, fileName string, directory st
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Log.Error("UploadS3: Uploading Failed, " + err.Error())
|
||||
return false
|
||||
errorMessage := "UploadS3: Uploading Failed, " + err.Error()
|
||||
log.Log.Error(errorMessage)
|
||||
return false, true, errors.New(errorMessage)
|
||||
} else {
|
||||
log.Log.Info("UploadS3: Upload Finished, file has been uploaded to bucket: " + strconv.FormatInt(n, 10))
|
||||
os.Remove(directory + "/" + fileName)
|
||||
return true
|
||||
return true, true, nil
|
||||
}
|
||||
}
|
||||
4
machinery/src/cloud/test_file.go
Normal file
608
machinery/src/cloud/tus_client.go
Normal file
@@ -0,0 +1,608 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// tusResumableVersion is the tus protocol version implemented by this client.
|
||||
const tusResumableVersion = "1.0.0"
|
||||
|
||||
// tusUploadPath is appended to the configured Kerberos Vault URI to reach the
|
||||
// resumable upload endpoint. It mirrors how the legacy uploader appends
|
||||
// "/storage".
|
||||
const tusUploadPath = "/storage/tus/"
|
||||
|
||||
// tusResumeState is persisted in a sidecar file next to the agent data so an
|
||||
// interrupted upload can be resumed across retries and even agent restarts.
|
||||
type tusResumeState struct {
|
||||
UploadURL string `json:"upload_url"`
|
||||
VaultURI string `json:"vault_uri"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// resumableUploadsEnabled reports whether the resumable (tus) upload path should
|
||||
// be attempted. It is enabled by default and can be disabled (falling back to
|
||||
// the legacy single POST) by setting AGENT_DISABLE_RESUMABLE_UPLOAD=true.
|
||||
func resumableUploadsEnabled() bool {
|
||||
return os.Getenv("AGENT_DISABLE_RESUMABLE_UPLOAD") != "true"
|
||||
}
|
||||
|
||||
// tusDefaultChunkSize is the number of bytes uploaded per PATCH request when no
|
||||
// explicit size is configured. Splitting the upload into chunks keeps each HTTP
|
||||
// request small enough for intermediary proxies/load balancers and checkpoints
|
||||
// progress frequently, so an interruption resumes with minimal re-upload.
|
||||
const tusDefaultChunkSize int64 = 8 << 20 // 8 MiB (>= S3 multipart minimum part size)
|
||||
|
||||
const tusProgressBucketPercent int64 = 10
|
||||
|
||||
// tusChunkSize returns the number of bytes to send per PATCH request. It
|
||||
// defaults to tusDefaultChunkSize (8 MiB) and can be overridden with the
|
||||
// AGENT_TUS_CHUNK_SIZE_BYTES environment variable. A value of 0 (or negative)
|
||||
// disables chunking and sends the remaining bytes in a single PATCH.
|
||||
func tusChunkSize() int64 {
|
||||
v := os.Getenv("AGENT_TUS_CHUNK_SIZE_BYTES")
|
||||
if v == "" {
|
||||
return tusDefaultChunkSize
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return tusDefaultChunkSize
|
||||
}
|
||||
if n <= 0 {
|
||||
return 0 // chunking disabled: send everything in one PATCH
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func tusProgressBucket(offset, size int64) int64 {
|
||||
if size <= 0 {
|
||||
return 100
|
||||
}
|
||||
percent := (offset * 100) / size
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
return percent / tusProgressBucketPercent
|
||||
}
|
||||
|
||||
func logTusUploadProgress(label string, offset, size int64, loggedBucket *int64) {
|
||||
bucket := tusProgressBucket(offset, size)
|
||||
if bucket <= *loggedBucket {
|
||||
return
|
||||
}
|
||||
*loggedBucket = bucket
|
||||
percent := bucket * tusProgressBucketPercent
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
log.Log.Infof("%s: resumable upload progress %d%% (%d/%d bytes)", label, percent, offset, size)
|
||||
}
|
||||
|
||||
// tusHeaderFunc sets the authentication and routing headers required on every
|
||||
// tus request for a particular upload target (Kerberos Vault directly, or
|
||||
// Kerberos Hub which proxies to a vault). fileName is only meaningful on the
|
||||
// creation request; it is empty on HEAD/PATCH/DELETE.
|
||||
type tusHeaderFunc func(h http.Header, fileName string)
|
||||
|
||||
// runTusUpload performs a resumable (tus) upload of data/recordings/<fileName>
|
||||
// to baseURL, sending target-specific authentication/routing headers via
|
||||
// setHeaders on every request. It encapsulates the create/resume/chunk/finalize
|
||||
// state machine shared by the Kerberos Vault (direct) and Kerberos Hub (proxied)
|
||||
// upload paths.
|
||||
//
|
||||
// Return values:
|
||||
// - uploaded: the recording was fully received and persisted by the server.
|
||||
// - responded: the server returned a definitive HTTP response (used by the
|
||||
// caller to advance its retry/secondary-failover policy).
|
||||
// - supported: the server exposes a tus endpoint. When false, the caller
|
||||
// should fall back to the legacy single-POST upload (older deployments).
|
||||
// - body: a short message for logging.
|
||||
func runTusUpload(baseURL, metadata, fileName, label, slot string, setHeaders tusHeaderFunc) (uploaded bool, responded bool, supported bool, body string, err error) {
|
||||
fullname := "data/recordings/" + fileName
|
||||
|
||||
file, ferr := os.Open(fullname)
|
||||
if file != nil {
|
||||
defer file.Close()
|
||||
}
|
||||
if ferr != nil {
|
||||
msg := label + ": resumable upload failed, file doesn't exist anymore"
|
||||
log.Log.Info(msg)
|
||||
// The file is gone, so the legacy path cannot help either. Report it as
|
||||
// "supported" to avoid a pointless fallback attempt.
|
||||
return false, false, true, "", errors.New(msg)
|
||||
}
|
||||
|
||||
info, serr := file.Stat()
|
||||
if serr != nil {
|
||||
return false, false, true, "", serr
|
||||
}
|
||||
size := info.Size()
|
||||
|
||||
client := newVaultHTTPClient(0)
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
for k := range req.Header {
|
||||
if strings.HasPrefix(http.CanonicalHeaderKey(k), "X-Kerberos-") {
|
||||
req.Header.Del(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
sidecar := tusSidecarPath(fileName, slot)
|
||||
uploadURL := loadTusResumeState(sidecar, baseURL)
|
||||
|
||||
const maxAttempts = 4
|
||||
restartedAfterComplete := false
|
||||
|
||||
// lastStatus holds the HTTP status code of the most recent tus request. A
|
||||
// value of 0 means the request failed at the transport level (no HTTP
|
||||
// response at all, e.g. the vault was unreachable or the connection dropped
|
||||
// because the internet went down). It lets the final "gave up" return report
|
||||
// whether the vault actually answered, so the caller only advances its
|
||||
// retry/back-off policy on a definitive response and transient network errors
|
||||
// never consume the retry budget (matching the legacy single-POST behaviour).
|
||||
lastStatus := 0
|
||||
|
||||
// highWaterOffset is the furthest server-acknowledged offset observed across
|
||||
// all attempts (via HEAD or PATCH). It lets the retry budget be refreshed only
|
||||
// on GENUINE net forward progress. Without it, a server that keeps resetting the
|
||||
// offset — e.g. a persistent 409 ERR_MISMATCHED_OFFSET where HEAD reports 0 again
|
||||
// while the first chunk still "succeeds" — would refresh the budget every attempt
|
||||
// and loop forever, wedging the upload worker on one recording and saturating the
|
||||
// uplink.
|
||||
highWaterOffset := int64(0)
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// (1) Ensure we have an active upload URL, creating one if needed.
|
||||
if uploadURL == "" {
|
||||
created, status, cerr := tusCreate(client, baseURL, size, metadata, setHeaders, fileName)
|
||||
lastStatus = status
|
||||
if cerr != nil {
|
||||
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented {
|
||||
// The vault does not implement tus; let the caller fall back.
|
||||
return false, false, false, "", cerr
|
||||
}
|
||||
log.Log.Info(label + ": resumable create failed, " + cerr.Error())
|
||||
tusBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
uploadURL = created
|
||||
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
|
||||
}
|
||||
|
||||
// (2) Query the current server-side offset.
|
||||
offset, status, herr := tusHead(client, uploadURL, setHeaders)
|
||||
lastStatus = status
|
||||
if herr != nil {
|
||||
if status == http.StatusNotFound || status == http.StatusGone {
|
||||
// The upload expired/was removed server-side; start over.
|
||||
removeTusResumeState(sidecar)
|
||||
uploadURL = ""
|
||||
continue
|
||||
}
|
||||
log.Log.Info(label + ": resumable head failed, " + herr.Error())
|
||||
tusBackoff(attempt)
|
||||
continue
|
||||
}
|
||||
|
||||
// The furthest offset any previous attempt reached. If this attempt pushes
|
||||
// past it (via HEAD showing server-side progress or a successful PATCH) we made
|
||||
// genuine net progress and may refresh the retry budget; if not, a repeated
|
||||
// failure at the same spot must count against maxAttempts.
|
||||
startHighWater := highWaterOffset
|
||||
if offset > highWaterOffset {
|
||||
highWaterOffset = offset
|
||||
}
|
||||
|
||||
// (3) All bytes are present but the upload was not finalized (e.g. the
|
||||
// completion hook failed). A completed tus upload cannot be re-finalized
|
||||
// with another PATCH, so delete it and re-upload to force a clean finalize.
|
||||
if offset >= size {
|
||||
if restartedAfterComplete {
|
||||
return false, true, true, "resumable finalize did not complete", errors.New(label + ": resumable finalize did not complete")
|
||||
}
|
||||
tusTerminate(client, uploadURL, setHeaders)
|
||||
removeTusResumeState(sidecar)
|
||||
uploadURL = ""
|
||||
restartedAfterComplete = true
|
||||
continue
|
||||
}
|
||||
|
||||
// (4) Stream the remaining bytes to the vault via PATCH, reading directly
|
||||
// from disk so the recording is never fully buffered in memory. When a chunk
|
||||
// size is configured the data is sent across several PATCH requests,
|
||||
// checkpointing the offset after each one so an interruption resumes from the
|
||||
// last completed chunk instead of re-uploading everything.
|
||||
chunkSize := tusChunkSize()
|
||||
patchFailed := false
|
||||
var lastBody string
|
||||
loggedProgressBucket := tusProgressBucket(offset, size)
|
||||
for offset < size {
|
||||
// Re-seek every chunk so the on-disk position always matches the
|
||||
// server-acknowledged offset, even if a PATCH was partially accepted.
|
||||
if _, sErr := file.Seek(offset, io.SeekStart); sErr != nil {
|
||||
return false, false, true, "", sErr
|
||||
}
|
||||
patchLen := size - offset
|
||||
if chunkSize > 0 && chunkSize < patchLen {
|
||||
patchLen = chunkSize
|
||||
}
|
||||
newOffset, status, respBody, perr := tusPatch(client, uploadURL, offset, patchLen, file, setHeaders)
|
||||
lastStatus = status
|
||||
if perr != nil {
|
||||
if status >= 400 {
|
||||
// Definitive rejection (e.g. provider push failed during finalize).
|
||||
// Re-evaluate via HEAD on the next iteration to decide retry/restart.
|
||||
log.Log.Info(label + ": resumable patch rejected, " + perr.Error())
|
||||
} else {
|
||||
log.Log.Info(label + ": resumable patch failed, " + perr.Error())
|
||||
}
|
||||
tusBackoff(attempt)
|
||||
patchFailed = true
|
||||
break
|
||||
}
|
||||
offset = newOffset
|
||||
if offset > highWaterOffset {
|
||||
highWaterOffset = offset
|
||||
}
|
||||
lastBody = respBody
|
||||
logTusUploadProgress(label, offset, size, &loggedProgressBucket)
|
||||
if offset < size {
|
||||
// Partial progress: persist so a later retry resumes from here.
|
||||
saveTusResumeState(sidecar, tusResumeState{UploadURL: uploadURL, VaultURI: baseURL, Size: size})
|
||||
}
|
||||
}
|
||||
if patchFailed {
|
||||
if highWaterOffset > startHighWater {
|
||||
// Genuine net progress (we advanced past the furthest point any previous
|
||||
// attempt reached) refreshes the retry budget: maxAttempts bounds the
|
||||
// number of consecutive *non-progressing* failures, not the number of
|
||||
// chunks needed for a large recording. A server that keeps rejecting the
|
||||
// same offset (no net progress, e.g. a persistent ERR_MISMATCHED_OFFSET)
|
||||
// therefore gives up after maxAttempts instead of retrying forever.
|
||||
attempt = -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// All declared bytes have been sent and acknowledged: the upload is done.
|
||||
removeTusResumeState(sidecar)
|
||||
return true, true, true, lastBody, nil
|
||||
}
|
||||
|
||||
// Every attempt failed. Only report responded=true when the vault actually
|
||||
// answered on the last attempt (lastStatus > 0). If every attempt failed at
|
||||
// the transport level (lastStatus == 0, e.g. the internet was disconnected),
|
||||
// report responded=false so the caller keeps the recording queued and retries
|
||||
// later instead of consuming its retry budget and entering the long back-off
|
||||
// timeout.
|
||||
return false, lastStatus > 0, true, "resumable upload did not complete after retries", errors.New(label + ": resumable upload did not complete after retries")
|
||||
}
|
||||
|
||||
// uploadVaultResumable uploads a recording directly to a Kerberos Vault using
|
||||
// the tus resumable upload protocol. Credentials travel in the
|
||||
// X-Kerberos-Storage-* headers on every request and routing (directory/provider)
|
||||
// is additionally carried in the tus Upload-Metadata.
|
||||
func uploadVaultResumable(vault models.KStorage, publicKey, deviceKey, fileName, label, slot string) (bool, bool, bool, string, error) {
|
||||
baseURL := strings.TrimRight(vault.URI, "/") + tusUploadPath
|
||||
metadataValues := map[string]string{
|
||||
"filename": fileName,
|
||||
"device": deviceKey,
|
||||
"directory": vault.Directory,
|
||||
"provider": vault.Provider,
|
||||
"capture": "IPCamera",
|
||||
"cloudkey": publicKey,
|
||||
"fps": queuedRecordingFPS(fileName),
|
||||
}
|
||||
addRecordingTusMetadata(metadataValues, fileName)
|
||||
metadata := encodeTusMetadata(metadataValues)
|
||||
setHeaders := func(h http.Header, fn string) {
|
||||
setVaultTusHeaders(h, vault, publicKey, deviceKey, fn)
|
||||
}
|
||||
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
|
||||
}
|
||||
|
||||
// uploadHubResumable uploads a recording to Kerberos Hub's tus endpoint, which
|
||||
// authenticates the agent with its Hub public/private key and proxies the
|
||||
// resumable upload to the Kerberos Vault on the agent's behalf. The vault
|
||||
// directory and provider are resolved and injected by Kerberos Hub, so they are
|
||||
// intentionally omitted from the metadata here.
|
||||
func uploadHubResumable(config *models.Config, fileName, label, slot string) (bool, bool, bool, string, error) {
|
||||
baseURL := strings.TrimRight(config.HubURI, "/") + tusUploadPath
|
||||
metadataValues := map[string]string{
|
||||
"filename": fileName,
|
||||
"device": config.Key,
|
||||
"capture": "IPCamera",
|
||||
"fps": queuedRecordingFPS(fileName),
|
||||
}
|
||||
addRecordingTusMetadata(metadataValues, fileName)
|
||||
metadata := encodeTusMetadata(metadataValues)
|
||||
setHeaders := func(h http.Header, fn string) {
|
||||
setHubTusHeaders(h, config, fn)
|
||||
}
|
||||
return runTusUpload(baseURL, metadata, fileName, label, slot, setHeaders)
|
||||
}
|
||||
|
||||
func addRecordingTusMetadata(values map[string]string, fileName string) {
|
||||
metadata, ok := queuedRecordingMetadata(fileName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if metadata.Duration > 0 {
|
||||
values["duration"] = strconv.FormatUint(metadata.Duration, 10)
|
||||
}
|
||||
if metadata.Timestamp > 0 {
|
||||
values["timestamp"] = strconv.FormatInt(metadata.Timestamp, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// tusCreate performs the tus "creation" request (POST). On success it returns
|
||||
// the resolved upload URL the agent should use for subsequent HEAD/PATCH calls.
|
||||
func tusCreate(client *http.Client, baseURL string, size int64, metadata string, setHeaders tusHeaderFunc, fileName string) (string, int, error) {
|
||||
req, err := http.NewRequest("POST", baseURL, nil)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
req.Header.Set("Upload-Length", strconv.FormatInt(size, 10))
|
||||
if metadata != "" {
|
||||
req.Header.Set("Upload-Metadata", metadata)
|
||||
}
|
||||
setHeaders(req.Header, fileName)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", resp.StatusCode, fmt.Errorf("unexpected status creating upload: %s", resp.Status)
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return "", resp.StatusCode, errors.New("missing Location header in create response")
|
||||
}
|
||||
return resolveTusLocation(baseURL, location), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// tusHead performs the tus "offset" request (HEAD) and returns the current
|
||||
// server-side upload offset.
|
||||
func tusHead(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) (int64, int, error) {
|
||||
req, err := http.NewRequest("HEAD", uploadURL, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||
return 0, resp.StatusCode, fmt.Errorf("unexpected status on HEAD: %s", resp.Status)
|
||||
}
|
||||
offsetStr := resp.Header.Get("Upload-Offset")
|
||||
offset, perr := strconv.ParseInt(offsetStr, 10, 64)
|
||||
if perr != nil {
|
||||
return 0, resp.StatusCode, fmt.Errorf("invalid Upload-Offset header: %q", offsetStr)
|
||||
}
|
||||
return offset, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// tusPatch streams up to length bytes of the file (starting at offset) to the
|
||||
// upload URL using a single PATCH request. The body is read straight from the
|
||||
// *os.File, so the recording is never fully buffered in memory.
|
||||
func tusPatch(client *http.Client, uploadURL string, offset, length int64, file io.Reader, setHeaders tusHeaderFunc) (int64, int, string, error) {
|
||||
req, err := http.NewRequest("PATCH", uploadURL, io.LimitReader(file, length))
|
||||
if err != nil {
|
||||
return offset, 0, "", err
|
||||
}
|
||||
req.ContentLength = length
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
req.Header.Set("Content-Type", "application/offset+octet-stream")
|
||||
req.Header.Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
if err != nil {
|
||||
return offset, 0, "", err
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
respBody := string(bodyBytes)
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return offset, resp.StatusCode, respBody, fmt.Errorf("unexpected status on PATCH: %s, %s", resp.Status, respBody)
|
||||
}
|
||||
newOffsetStr := resp.Header.Get("Upload-Offset")
|
||||
newOffset, perr := strconv.ParseInt(newOffsetStr, 10, 64)
|
||||
if perr != nil {
|
||||
// A 204 without a parseable offset means this PATCH was fully accepted.
|
||||
return offset + length, resp.StatusCode, respBody, nil
|
||||
}
|
||||
return newOffset, resp.StatusCode, respBody, nil
|
||||
}
|
||||
|
||||
// tusTerminate best-effort deletes an upload server-side (DELETE).
|
||||
func tusTerminate(client *http.Client, uploadURL string, setHeaders tusHeaderFunc) {
|
||||
req, err := http.NewRequest("DELETE", uploadURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Tus-Resumable", tusResumableVersion)
|
||||
setHeaders(req.Header, "")
|
||||
|
||||
resp, derr := client.Do(req)
|
||||
if resp != nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
_ = derr
|
||||
}
|
||||
|
||||
// setVaultTusHeaders sets the Kerberos Vault authentication and routing headers
|
||||
// on every tus request. Credentials are sent on each request (and never stored
|
||||
// server-side in the upload metadata). When fileName is empty it is omitted, as
|
||||
// it is only useful on the creation request (routing also travels in the tus
|
||||
// Upload-Metadata).
|
||||
func setVaultTusHeaders(h http.Header, vault models.KStorage, publicKey, deviceKey, fileName string) {
|
||||
h.Set("X-Kerberos-Storage-CloudKey", publicKey)
|
||||
h.Set("X-Kerberos-Storage-AccessKey", vault.AccessKey)
|
||||
h.Set("X-Kerberos-Storage-SecretAccessKey", vault.SecretAccessKey)
|
||||
h.Set("X-Kerberos-Storage-Provider", vault.Provider)
|
||||
h.Set("X-Kerberos-Storage-Device", deviceKey)
|
||||
h.Set("X-Kerberos-Storage-Directory", vault.Directory)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// setHubTusHeaders sets the Kerberos Hub authentication headers on every tus
|
||||
// request of a hub-proxied resumable upload. The agent authenticates with its
|
||||
// Hub public/private key (exactly as the legacy single-POST hub upload does);
|
||||
// Kerberos Hub validates the subscription and injects the vault credentials and
|
||||
// directory/provider on the agent's behalf.
|
||||
func setHubTusHeaders(h http.Header, config *models.Config, fileName string) {
|
||||
h.Set("X-Kerberos-Hub-PublicKey", config.HubKey)
|
||||
h.Set("X-Kerberos-Hub-PrivateKey", config.HubPrivateKey)
|
||||
h.Set("X-Kerberos-Hub-Region", config.S3.Region)
|
||||
h.Set("X-Kerberos-Storage-Device", config.Key)
|
||||
h.Set("X-Kerberos-Storage-Capture", "IPCamera")
|
||||
if fileName != "" {
|
||||
h.Set("X-Kerberos-Storage-FileName", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// encodeTusMetadata serializes a map into the tus Upload-Metadata header format:
|
||||
// a comma separated list of "key base64(value)" pairs. Keys are sorted for a
|
||||
// deterministic header value. Empty values are skipped.
|
||||
func encodeTusMetadata(pairs map[string]string) string {
|
||||
parts := make([]string, 0, len(pairs))
|
||||
for k, v := range pairs {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, k+" "+base64.StdEncoding.EncodeToString([]byte(v)))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// resolveTusLocation turns the Location header returned by the create request
|
||||
// into an absolute URL. To keep talking to the agent's configured vault host
|
||||
// (and avoid issues when the vault sits behind a proxy that rewrites the host),
|
||||
// it keeps the configured base URL and only appends the server-assigned upload
|
||||
// id taken from the Location.
|
||||
func resolveTusLocation(baseURL, location string) string {
|
||||
if ref, err := url.Parse(location); err == nil {
|
||||
trimmed := strings.Trim(ref.Path, "/")
|
||||
if trimmed != "" {
|
||||
segments := strings.Split(trimmed, "/")
|
||||
id := segments[len(segments)-1]
|
||||
if id != "" {
|
||||
return strings.TrimRight(baseURL, "/") + "/" + id
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: resolve the reference against the base URL as-is.
|
||||
if base, err := url.Parse(baseURL); err == nil {
|
||||
if ref, err := url.Parse(location); err == nil {
|
||||
return base.ResolveReference(ref).String()
|
||||
}
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
// tusSidecarDir is the directory where resume state files are kept. It is
|
||||
// intentionally separate from data/cloud (which is scanned for recordings to
|
||||
// upload) so the sidecar files are never mistaken for recordings.
|
||||
func tusSidecarDir() string {
|
||||
return "data/tus"
|
||||
}
|
||||
|
||||
func tusSidecarPath(fileName, slot string) string {
|
||||
safe := strings.ReplaceAll(fileName, "/", "_")
|
||||
safe = strings.ReplaceAll(safe, string(os.PathSeparator), "_")
|
||||
return filepath.Join(tusSidecarDir(), safe+"."+slot+".json")
|
||||
}
|
||||
|
||||
// loadTusResumeState returns a previously stored upload URL for the given
|
||||
// sidecar, but only if it was created against the same vault base URL. Any
|
||||
// mismatch or read/parse error yields an empty string (start fresh).
|
||||
func loadTusResumeState(path, baseURL string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var state tusResumeState
|
||||
if err := json.Unmarshal(b, &state); err != nil {
|
||||
return ""
|
||||
}
|
||||
if state.UploadURL == "" || state.VaultURI != baseURL {
|
||||
return ""
|
||||
}
|
||||
return state.UploadURL
|
||||
}
|
||||
|
||||
func saveTusResumeState(path string, state tusResumeState) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
func removeTusResumeState(path string) {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
// tusBackoffBaseDelay is the base delay used by tusBackoff for the exponential
|
||||
// back-off between resume attempts. It is a package variable (rather than a
|
||||
// constant) so tests can shrink it to keep them fast.
|
||||
var tusBackoffBaseDelay = 500 * time.Millisecond
|
||||
|
||||
// tusBackoff sleeps for an exponentially increasing duration (capped) between
|
||||
// resume attempts to avoid hammering a temporarily unavailable vault.
|
||||
func tusBackoff(attempt int) {
|
||||
delay := tusBackoffBaseDelay * time.Duration(1<<uint(attempt))
|
||||
if delay > 3*time.Second {
|
||||
delay = 3 * time.Second
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
882
machinery/src/cloud/tus_client_test.go
Normal file
@@ -0,0 +1,882 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
)
|
||||
|
||||
// fakeUpload tracks the state of a single resumable upload on the fake server.
|
||||
type fakeUpload struct {
|
||||
size int64
|
||||
offset int64
|
||||
}
|
||||
|
||||
// recordedRequest captures the method and headers of a request received by the
|
||||
// fake tus server, so tests can assert the client's per-method auth headers.
|
||||
type recordedRequest struct {
|
||||
method string
|
||||
header http.Header
|
||||
}
|
||||
|
||||
// fakeTus is a tiny in-memory implementation of the tus 1.0.0 server protocol,
|
||||
// sufficient to exercise the agent's resumable client.
|
||||
type fakeTus struct {
|
||||
mu sync.Mutex
|
||||
uploads map[string]*fakeUpload
|
||||
counter int
|
||||
creates int
|
||||
lastPatchBytes int64
|
||||
patchSizes []int64
|
||||
|
||||
// unsupported makes the creation endpoint return 404, simulating an older
|
||||
// vault without a tus endpoint.
|
||||
unsupported bool
|
||||
// failFinalize causes the next N completing PATCH requests to return 502
|
||||
// after storing the bytes, simulating a failed completion hook.
|
||||
failFinalize int
|
||||
|
||||
// loseProgress simulates a vault that never durably retains the in-progress
|
||||
// upload: every PATCH is acknowledged (the response advertises the advanced
|
||||
// offset) but the stored offset is immediately reset to 0. HEAD therefore
|
||||
// keeps reporting 0 and the next chunk — sent at the advanced offset — is
|
||||
// rejected with 409, reproducing the cross-replica ERR_MISMATCHED_OFFSET
|
||||
// loop that previously wedged the agent's upload worker forever.
|
||||
loseProgress bool
|
||||
|
||||
// requests records the headers of every received request (in order) so
|
||||
// tests can assert which auth/routing headers the client sent per method.
|
||||
requests []recordedRequest
|
||||
}
|
||||
|
||||
func newFakeTus() *fakeTus {
|
||||
return &fakeTus{uploads: map[string]*fakeUpload{}}
|
||||
}
|
||||
|
||||
func (s *fakeTus) seed(size, offset int64) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.counter++
|
||||
id := fmt.Sprintf("seed-%d", s.counter)
|
||||
s.uploads[id] = &fakeUpload{size: size, offset: offset}
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *fakeTus) totalBytes() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var total int64
|
||||
for _, u := range s.uploads {
|
||||
total += u.offset
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *fakeTus) lastPatch() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastPatchBytes
|
||||
}
|
||||
|
||||
// patchCounts returns the number of PATCH requests received and the size of each.
|
||||
func (s *fakeTus) patchCounts() (int, []int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sizes := make([]int64, len(s.patchSizes))
|
||||
copy(sizes, s.patchSizes)
|
||||
return len(s.patchSizes), sizes
|
||||
}
|
||||
|
||||
func (s *fakeTus) createCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.creates
|
||||
}
|
||||
|
||||
// requestsForMethod returns the recorded requests for the given HTTP method.
|
||||
func (s *fakeTus) requestsForMethod(method string) []recordedRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var out []recordedRequest
|
||||
for _, req := range s.requests {
|
||||
if req.method == method {
|
||||
out = append(out, req)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *fakeTus) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, tusUploadPath)
|
||||
w.Header().Set("Tus-Resumable", tusResumableVersion)
|
||||
|
||||
s.mu.Lock()
|
||||
s.requests = append(s.requests, recordedRequest{method: r.Method, header: r.Header.Clone()})
|
||||
s.mu.Unlock()
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
if s.unsupported {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
length, _ := strconv.ParseInt(r.Header.Get("Upload-Length"), 10, 64)
|
||||
s.mu.Lock()
|
||||
s.counter++
|
||||
s.creates++
|
||||
newID := fmt.Sprintf("up-%d", s.counter)
|
||||
s.uploads[newID] = &fakeUpload{size: length}
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Location", tusUploadPath+newID)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
|
||||
case http.MethodHead:
|
||||
s.mu.Lock()
|
||||
u, ok := s.uploads[id]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(u.offset, 10))
|
||||
w.Header().Set("Upload-Length", strconv.FormatInt(u.size, 10))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
case http.MethodPatch:
|
||||
s.mu.Lock()
|
||||
u, ok := s.uploads[id]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if s.loseProgress {
|
||||
reqOffset, _ := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
|
||||
s.mu.Lock()
|
||||
cur := u.offset
|
||||
if reqOffset != cur {
|
||||
// The offset the client resumes from no longer matches what this
|
||||
// "replica" retained, so reject like a vault returning
|
||||
// ERR_MISMATCHED_OFFSET.
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(cur, 10))
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, r.Body)
|
||||
s.lastPatchBytes = n
|
||||
s.patchSizes = append(s.patchSizes, n)
|
||||
// Advertise progress to the client, then immediately forget it so the
|
||||
// next chunk (sent at the advanced offset) mismatches again.
|
||||
reported := cur + n
|
||||
u.offset = 0
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(reported, 10))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
n, _ := io.Copy(io.Discard, r.Body)
|
||||
s.mu.Lock()
|
||||
u.offset += n
|
||||
s.lastPatchBytes = n
|
||||
s.patchSizes = append(s.patchSizes, n)
|
||||
complete := u.offset >= u.size
|
||||
failNow := complete && s.failFinalize > 0
|
||||
if failNow {
|
||||
s.failFinalize--
|
||||
}
|
||||
offset := u.offset
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||||
if failNow {
|
||||
// Bytes are stored but the (simulated) completion hook failed.
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case http.MethodDelete:
|
||||
s.mu.Lock()
|
||||
delete(s.uploads, id)
|
||||
s.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// withRecording switches into a fresh temp working directory containing a
|
||||
// recording at data/recordings/<fileName>. The working directory is restored on
|
||||
// cleanup. Tests using this helper must not run in parallel.
|
||||
func withRecording(t *testing.T, fileName string, payload []byte) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
|
||||
if err := os.MkdirAll("data/recordings", 0o755); err != nil {
|
||||
t.Fatalf("mkdir recordings: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("data/recordings", fileName), payload, 0o644); err != nil {
|
||||
t.Fatalf("write recording: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withQueuedRecordingFPS(t *testing.T, fileName, fps string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll("data/cloud", 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
markerName := models.RecordingUploadMetadataFileName(fileName)
|
||||
if err := os.WriteFile(filepath.Join("data/cloud", markerName), []byte(fps), 0o644); err != nil {
|
||||
t.Fatalf("write cloud queue marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testVault(uri string) models.KStorage {
|
||||
return models.KStorage{
|
||||
URI: uri,
|
||||
AccessKey: "ak",
|
||||
SecretAccessKey: "sk",
|
||||
Provider: "gcp",
|
||||
Directory: "dir",
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadKerberosVaultSkipsEmptyRecording(t *testing.T) {
|
||||
fileName := "1787015373_3-654_office-camera17_0-0-0-0_-1_1960.mp4"
|
||||
withRecording(t, fileName, nil)
|
||||
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
vault := testVault(server.URL)
|
||||
configuration := &models.Configuration{Config: models.Config{
|
||||
Key: "device-key",
|
||||
KStorage: &vault,
|
||||
KStorageSecondary: &models.KStorage{},
|
||||
}}
|
||||
|
||||
uploaded, configured, err := UploadKerberosVault(configuration, fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadKerberosVault() error = %v", err)
|
||||
}
|
||||
if uploaded || configured {
|
||||
t.Fatalf("UploadKerberosVault() uploaded/configured = %v/%v, want false/false", uploaded, configured)
|
||||
}
|
||||
if requestCount != 0 {
|
||||
t.Fatalf("Vault received %d requests, want 0", requestCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_HappyPath(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("x"), 4096)
|
||||
withRecording(t, fileName, payload)
|
||||
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":29.97}`)
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !responded || !supported {
|
||||
t.Fatalf("uploaded/responded/supported = %v/%v/%v, want all true", uploaded, responded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected sidecar to be removed after success, stat err = %v", err)
|
||||
}
|
||||
posts := srv.requestsForMethod(http.MethodPost)
|
||||
metadata := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
|
||||
if got := metadata["fps"]; got != "29.97" {
|
||||
t.Fatalf("POST metadata fps = %q, want %q", got, "29.97")
|
||||
}
|
||||
if got := metadata["duration"]; got != "20452" {
|
||||
t.Fatalf("POST metadata duration = %q, want %q", got, "20452")
|
||||
}
|
||||
if got := metadata["timestamp"]; got != "1785934709414" {
|
||||
t.Fatalf("POST metadata timestamp = %q, want %q", got, "1785934709414")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
fps string
|
||||
want string
|
||||
}{
|
||||
{name: "json", fps: `{"fps":29}`, want: "29"},
|
||||
{name: "json fractional", fps: `{"fps":17.35}`, want: "17.35"},
|
||||
{name: "json with future field", fps: `{"fps":29,"codec":"h264"}`, want: "29"},
|
||||
{name: "json without fps", fps: `{}`},
|
||||
{name: "json invalid fps", fps: `{"fps":241}`},
|
||||
{name: "legacy fractional", fps: "29.97", want: "29.97"},
|
||||
{name: "legacy trimmed", fps: " 25 \n", want: "25"},
|
||||
{name: "empty"},
|
||||
{name: "invalid", fps: "invalid"},
|
||||
{name: "zero", fps: "0"},
|
||||
{name: "negative", fps: "-1"},
|
||||
{name: "nan", fps: "NaN"},
|
||||
{name: "infinite", fps: "+Inf"},
|
||||
{name: "unreasonable", fps: "241"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
withQueuedRecordingFPS(t, fileName, test.fps)
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != test.want {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want %q", got, test.want)
|
||||
}
|
||||
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != test.want {
|
||||
t.Fatalf("legacy FPS header = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSAllowsMissingHistoricalMarker(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != "" {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want empty for missing marker", got)
|
||||
}
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != "" {
|
||||
t.Fatalf("legacy FPS header = %q, want empty for missing marker", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingMetadataHeaders(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
withQueuedRecordingFPS(t, fileName, `{"filename":"recording.mp4","device_key":"device-key","timestamp":1785934709414,"duration":20452,"fps":25}`)
|
||||
|
||||
header := make(http.Header)
|
||||
setQueuedRecordingMetadataHeaders(header, fileName)
|
||||
if got := header.Get(recordingFPSHeader); got != "25" {
|
||||
t.Fatalf("FPS header = %q", got)
|
||||
}
|
||||
if got := header.Get(recordingDurationHeader); got != "20452" {
|
||||
t.Fatalf("duration header = %q", got)
|
||||
}
|
||||
if got := header.Get(recordingTimestampHeader); got != "1785934709414" {
|
||||
t.Fatalf("timestamp header = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueuedRecordingFPSAllowsLegacyMarkerFileName(t *testing.T) {
|
||||
fileName := "recording.mp4"
|
||||
withRecording(t, fileName, []byte("recording"))
|
||||
if err := os.MkdirAll("data/cloud", 0o755); err != nil {
|
||||
t.Fatalf("mkdir cloud queue: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("data/cloud", fileName), []byte("25"), 0o644); err != nil {
|
||||
t.Fatalf("write legacy cloud queue marker: %v", err)
|
||||
}
|
||||
|
||||
if got := queuedRecordingFPS(fileName); got != "25" {
|
||||
t.Fatalf("queuedRecordingFPS() = %q, want legacy marker FPS", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_Chunked(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
// 10 KiB payload uploaded in 4 KiB chunks => 3 PATCH requests (4096+4096+2048).
|
||||
payload := bytes.Repeat([]byte("c"), 10240)
|
||||
withRecording(t, fileName, payload)
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected chunked upload success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
count, sizes := srv.patchCounts()
|
||||
if count != 3 {
|
||||
t.Fatalf("expected 3 chunked PATCH requests, got %d (sizes=%v)", count, sizes)
|
||||
}
|
||||
want := []int64{4096, 4096, 2048}
|
||||
for i, w := range want {
|
||||
if sizes[i] != w {
|
||||
t.Fatalf("chunk %d size = %d, want %d (sizes=%v)", i, sizes[i], w, sizes)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(tusSidecarPath(fileName, "primary")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected sidecar removed after success, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_ChunkingDisabled(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("d"), 10240)
|
||||
withRecording(t, fileName, payload)
|
||||
// 0 disables chunking: the whole file should go out in a single PATCH.
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "0")
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
count, sizes := srv.patchCounts()
|
||||
if count != 1 {
|
||||
t.Fatalf("expected a single PATCH when chunking is disabled, got %d (sizes=%v)", count, sizes)
|
||||
}
|
||||
if sizes[0] != int64(len(payload)) {
|
||||
t.Fatalf("single PATCH size = %d, want %d", sizes[0], len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusChunkSize(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env string
|
||||
set bool
|
||||
want int64
|
||||
}{
|
||||
{name: "default when unset", set: false, want: tusDefaultChunkSize},
|
||||
{name: "default on invalid", env: "notanumber", set: true, want: tusDefaultChunkSize},
|
||||
{name: "explicit value", env: "65536", set: true, want: 65536},
|
||||
{name: "zero disables", env: "0", set: true, want: 0},
|
||||
{name: "negative disables", env: "-5", set: true, want: 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.set {
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", tc.env)
|
||||
} else {
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "")
|
||||
}
|
||||
if got := tusChunkSize(); got != tc.want {
|
||||
t.Fatalf("tusChunkSize() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_Unsupported(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.unsupported = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "f.mp4"
|
||||
withRecording(t, fileName, []byte("hello"))
|
||||
|
||||
uploaded, _, supported, _, _ := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false against a vault without a tus endpoint")
|
||||
}
|
||||
if supported {
|
||||
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUploadVaultResumable_NetworkErrorKeepsRetryBudget verifies that when the
|
||||
// vault is unreachable (mimicking the internet being disconnected) the resumable
|
||||
// upload reports responded=false. That is what stops the caller
|
||||
// (UploadKerberosVault) from consuming its retry budget and entering the long
|
||||
// back-off timeout on a transient network outage, so the recording keeps being
|
||||
// retried until connectivity returns.
|
||||
func TestUploadVaultResumable_NetworkErrorKeepsRetryBudget(t *testing.T) {
|
||||
// Bind then immediately release a loopback port so every connection to it is
|
||||
// refused, producing a transport-level error (no HTTP response).
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
if cerr := ln.Close(); cerr != nil {
|
||||
t.Fatalf("close listener: %v", cerr)
|
||||
}
|
||||
|
||||
// Keep the between-attempt back-off tiny so the test stays fast.
|
||||
oldDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = time.Millisecond
|
||||
defer func() { tusBackoffBaseDelay = oldDelay }()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
withRecording(t, fileName, bytes.Repeat([]byte("n"), 2048))
|
||||
|
||||
uploaded, responded, supported, _, err := uploadVaultResumable(testVault("http://"+addr), "pk", "dev", fileName, "test", "primary")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false when the vault is unreachable")
|
||||
}
|
||||
if !supported {
|
||||
t.Fatal("a transport error is not a missing tus endpoint; expected supported=true")
|
||||
}
|
||||
if responded {
|
||||
t.Fatal("expected responded=false for a pure network error so the retry budget is preserved")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the vault is unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_MismatchedOffsetGivesUp(t *testing.T) {
|
||||
// A vault that never durably retains the in-progress upload (offset resets to
|
||||
// 0 between chunks) makes every resume "progress" by one chunk and then fail
|
||||
// the next chunk with 409. Before the high-water gating fix this refreshed the
|
||||
// retry budget every attempt and looped forever, wedging the upload worker and
|
||||
// saturating the uplink (which starved heartbeats and reported the camera
|
||||
// offline). The loop must now be bounded: give up after a fixed number of
|
||||
// non-progressing attempts and report responded=true so the caller re-queues.
|
||||
srv := newFakeTus()
|
||||
srv.loseProgress = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
// Keep the between-attempt back-off tiny so the test stays fast.
|
||||
oldDelay := tusBackoffBaseDelay
|
||||
tusBackoffBaseDelay = time.Millisecond
|
||||
defer func() { tusBackoffBaseDelay = oldDelay }()
|
||||
|
||||
// Force multiple chunks so there is always a second chunk to be rejected.
|
||||
t.Setenv("AGENT_TUS_CHUNK_SIZE_BYTES", "4096")
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
withRecording(t, fileName, bytes.Repeat([]byte("m"), 12288))
|
||||
|
||||
done := make(chan struct{})
|
||||
var uploaded, responded bool
|
||||
var upErr error
|
||||
go func() {
|
||||
uploaded, responded, _, _, upErr = uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("resumable upload did not terminate: the retry loop is unbounded on a persistent mismatched offset")
|
||||
}
|
||||
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false when the vault never retains the offset")
|
||||
}
|
||||
if !responded {
|
||||
t.Fatal("expected responded=true (the vault answered) so the caller re-queues the recording")
|
||||
}
|
||||
if upErr == nil {
|
||||
t.Fatal("expected an error when the upload cannot complete")
|
||||
}
|
||||
|
||||
// The bounded retry budget must cap the number of PATCH requests. Two PATCHes
|
||||
// per attempt across a handful of attempts stays comfortably below this.
|
||||
if count, _ := srv.patchCounts(); count > 50 {
|
||||
t.Fatalf("expected a bounded number of PATCH requests, got %d (retry loop not bounded)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_FinalizeRetry(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.failFinalize = 1
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("y"), 2048)
|
||||
withRecording(t, fileName, payload)
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected success after a failed finalize + restart, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.createCount(); got < 2 {
|
||||
t.Fatalf("expected at least 2 create requests (restart after failed finalize), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadVaultResumable_ResumeFromSidecar(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
total := 8192
|
||||
half := 4096
|
||||
payload := bytes.Repeat([]byte("z"), total)
|
||||
withRecording(t, fileName, payload)
|
||||
|
||||
// Simulate a previous run that uploaded half the file before being interrupted.
|
||||
id := srv.seed(int64(total), int64(half))
|
||||
baseURL := strings.TrimRight(ts.URL, "/") + tusUploadPath
|
||||
saveTusResumeState(tusSidecarPath(fileName, "primary"), tusResumeState{
|
||||
UploadURL: strings.TrimRight(baseURL, "/") + "/" + id,
|
||||
VaultURI: baseURL,
|
||||
Size: int64(total),
|
||||
})
|
||||
|
||||
uploaded, _, supported, _, err := uploadVaultResumable(testVault(ts.URL), "pk", "dev", fileName, "test", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("expected resume success, got uploaded=%v supported=%v", uploaded, supported)
|
||||
}
|
||||
if got := srv.lastPatch(); got != int64(total-half) {
|
||||
t.Fatalf("resume should only send the remaining %d bytes, sent %d", total-half, got)
|
||||
}
|
||||
if srv.createCount() != 0 {
|
||||
t.Fatalf("resume should not create a new upload, got %d creates", srv.createCount())
|
||||
}
|
||||
}
|
||||
|
||||
func testHubConfig(hubURI string) *models.Config {
|
||||
return &models.Config{
|
||||
Key: "device-key",
|
||||
HubURI: hubURI,
|
||||
HubKey: "hubpub",
|
||||
HubPrivateKey: "hubpriv",
|
||||
S3: &models.S3{Region: "eu-west"},
|
||||
}
|
||||
}
|
||||
|
||||
// decodeTusMetadata parses a tus Upload-Metadata header value ("key b64,key b64")
|
||||
// back into a map of decoded key/value pairs.
|
||||
func decodeTusMetadata(meta string) map[string]string {
|
||||
out := map[string]string{}
|
||||
if meta == "" {
|
||||
return out
|
||||
}
|
||||
for _, pair := range strings.Split(meta, ",") {
|
||||
parts := strings.SplitN(strings.TrimSpace(pair), " ", 2)
|
||||
if parts[0] == "" {
|
||||
continue
|
||||
}
|
||||
val := ""
|
||||
if len(parts) == 2 {
|
||||
if b, err := base64.StdEncoding.DecodeString(parts[1]); err == nil {
|
||||
val = string(b)
|
||||
}
|
||||
}
|
||||
out[parts[0]] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestUploadHubResumable_HappyPath(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "1564859471_6-474162_oprit_577-283-727-375_1153_27.mp4"
|
||||
payload := bytes.Repeat([]byte("h"), 4096)
|
||||
withRecording(t, fileName, payload)
|
||||
withQueuedRecordingFPS(t, fileName, "29.97")
|
||||
|
||||
uploaded, _, supported, _, err := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !uploaded || !supported {
|
||||
t.Fatalf("uploaded/supported = %v/%v, want both true", uploaded, supported)
|
||||
}
|
||||
if got := srv.totalBytes(); got != int64(len(payload)) {
|
||||
t.Fatalf("server received %d bytes, want %d", got, len(payload))
|
||||
}
|
||||
|
||||
// The Hub auth headers must be present on every request type (POST/HEAD/PATCH),
|
||||
// because Kerberos Hub validates them on each proxied request. Conversely the
|
||||
// vault credentials/routing are injected by Kerberos Hub on the agent's behalf
|
||||
// and must never be sent by the agent on the hub path.
|
||||
for _, method := range []string{http.MethodPost, http.MethodHead, http.MethodPatch} {
|
||||
reqs := srv.requestsForMethod(method)
|
||||
if len(reqs) == 0 {
|
||||
t.Fatalf("expected at least one %s request", method)
|
||||
}
|
||||
for _, req := range reqs {
|
||||
if got := req.header.Get("X-Kerberos-Hub-PublicKey"); got != "hubpub" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-PublicKey = %q, want %q", method, got, "hubpub")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Hub-PrivateKey"); got != "hubpriv" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-PrivateKey = %q, want %q", method, got, "hubpriv")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Hub-Region"); got != "eu-west" {
|
||||
t.Errorf("%s: X-Kerberos-Hub-Region = %q, want %q", method, got, "eu-west")
|
||||
}
|
||||
if got := req.header.Get("X-Kerberos-Storage-Device"); got != "device-key" {
|
||||
t.Errorf("%s: X-Kerberos-Storage-Device = %q, want %q", method, got, "device-key")
|
||||
}
|
||||
for _, h := range []string{
|
||||
"X-Kerberos-Storage-AccessKey",
|
||||
"X-Kerberos-Storage-SecretAccessKey",
|
||||
"X-Kerberos-Storage-CloudKey",
|
||||
"X-Kerberos-Storage-Provider",
|
||||
"X-Kerberos-Storage-Directory",
|
||||
} {
|
||||
if got := req.header.Get(h); got != "" {
|
||||
t.Errorf("%s: %s should be empty on the hub path, got %q", method, h, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The creation request carries the upload metadata; on the hub path it must
|
||||
// omit directory/provider/cloudkey (Hub resolves those) but include
|
||||
// filename/device/capture. The filename header is also set on create.
|
||||
posts := srv.requestsForMethod(http.MethodPost)
|
||||
if got := posts[0].header.Get("X-Kerberos-Storage-FileName"); got != fileName {
|
||||
t.Errorf("POST X-Kerberos-Storage-FileName = %q, want %q", got, fileName)
|
||||
}
|
||||
meta := decodeTusMetadata(posts[0].header.Get("Upload-Metadata"))
|
||||
for _, omitted := range []string{"directory", "provider", "cloudkey"} {
|
||||
if _, ok := meta[omitted]; ok {
|
||||
t.Errorf("hub metadata must omit %q, got %v", omitted, meta)
|
||||
}
|
||||
}
|
||||
if meta["filename"] != fileName {
|
||||
t.Errorf("hub metadata filename = %q, want %q", meta["filename"], fileName)
|
||||
}
|
||||
if meta["device"] != "device-key" {
|
||||
t.Errorf("hub metadata device = %q, want %q", meta["device"], "device-key")
|
||||
}
|
||||
if meta["capture"] != "IPCamera" {
|
||||
t.Errorf("hub metadata capture = %q, want %q", meta["capture"], "IPCamera")
|
||||
}
|
||||
if meta["fps"] != "29.97" {
|
||||
t.Errorf("hub metadata fps = %q, want %q", meta["fps"], "29.97")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadHubResumable_Unsupported(t *testing.T) {
|
||||
srv := newFakeTus()
|
||||
srv.unsupported = true
|
||||
ts := httptest.NewServer(srv)
|
||||
defer ts.Close()
|
||||
|
||||
fileName := "f.mp4"
|
||||
withRecording(t, fileName, []byte("hello"))
|
||||
|
||||
uploaded, _, supported, _, _ := uploadHubResumable(testHubConfig(ts.URL), fileName, "test", "hub")
|
||||
if uploaded {
|
||||
t.Fatal("expected uploaded=false against a hub without a tus endpoint")
|
||||
}
|
||||
if supported {
|
||||
t.Fatal("expected supported=false so the caller falls back to the legacy upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeTusMetadata(t *testing.T) {
|
||||
got := encodeTusMetadata(map[string]string{
|
||||
"b": "2",
|
||||
"a": "1",
|
||||
"empty": "",
|
||||
})
|
||||
// keys sorted, empty values skipped, values base64-encoded.
|
||||
want := "a MQ==,b Mg=="
|
||||
if got != want {
|
||||
t.Fatalf("encodeTusMetadata = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTusLocation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
location string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "absolute path location",
|
||||
base: "http://host/storage/tus/",
|
||||
location: "/storage/tus/abc",
|
||||
want: "http://host/storage/tus/abc",
|
||||
},
|
||||
{
|
||||
name: "absolute url keeps configured host",
|
||||
base: "http://host/storage/tus/",
|
||||
location: "http://internal:8080/storage/tus/xyz",
|
||||
want: "http://host/storage/tus/xyz",
|
||||
},
|
||||
{
|
||||
name: "relative id",
|
||||
base: "http://host/api/storage/tus/",
|
||||
location: "abc",
|
||||
want: "http://host/api/storage/tus/abc",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := resolveTusLocation(tc.base, tc.location); got != tc.want {
|
||||
t.Fatalf("resolveTusLocation(%q, %q) = %q, want %q", tc.base, tc.location, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTusResumeStateRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(old)
|
||||
|
||||
path := tusSidecarPath("file.mp4", "primary")
|
||||
state := tusResumeState{UploadURL: "http://host/storage/tus/abc", VaultURI: "http://host/storage/tus/", Size: 123}
|
||||
saveTusResumeState(path, state)
|
||||
|
||||
if got := loadTusResumeState(path, state.VaultURI); got != state.UploadURL {
|
||||
t.Fatalf("loadTusResumeState = %q, want %q", got, state.UploadURL)
|
||||
}
|
||||
// A mismatched vault URI must not be reused.
|
||||
if got := loadTusResumeState(path, "http://other/storage/tus/"); got != "" {
|
||||
t.Fatalf("loadTusResumeState with mismatched vault = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/InVisionApp/conjungo"
|
||||
"github.com/kerberos-io/agent/machinery/src/database"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"gopkg.in/mgo.v2/bson"
|
||||
)
|
||||
|
||||
func GetImageFromFilePath() (image.Image, error) {
|
||||
snapshotDirectory := "./data/snapshots"
|
||||
files, err := ioutil.ReadDir(snapshotDirectory)
|
||||
if err == nil && len(files) > 1 {
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime().Before(files[j].ModTime())
|
||||
})
|
||||
filePath := "./data/snapshots/" + files[1].Name()
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
image, _, err := image.Decode(f)
|
||||
return image, err
|
||||
}
|
||||
return nil, errors.New("Could not find a snapshot in " + snapshotDirectory)
|
||||
}
|
||||
|
||||
func GetSnapshot() string {
|
||||
var snapshot string
|
||||
files, err := ioutil.ReadDir("./data/snapshots")
|
||||
if err == nil && len(files) > 1 {
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime().Before(files[j].ModTime())
|
||||
})
|
||||
f, _ := os.Open("./data/snapshots/" + files[1].Name())
|
||||
defer f.Close()
|
||||
// Read entire JPG into byte slice.
|
||||
reader := bufio.NewReader(f)
|
||||
content, _ := ioutil.ReadAll(reader)
|
||||
// Encode as base64.
|
||||
snapshot = base64.StdEncoding.EncodeToString(content)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// ReadUserConfig Reads the user configuration of the Kerberos Open Source instance.
|
||||
// This will return a models.User struct including the username, password,
|
||||
// selected language, and if the installation was completed or not.
|
||||
func ReadUserConfig() (userConfig models.User) {
|
||||
for {
|
||||
jsonFile, err := os.Open("./data/config/user.json")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Println("Config file is not found " + "./data/config/user.json" + ", trying again in 5s.")
|
||||
time.Sleep(5 * time.Second)
|
||||
} else {
|
||||
fmt.Println("Successfully Opened user.json")
|
||||
byteValue, _ := ioutil.ReadAll(jsonFile)
|
||||
err = json.Unmarshal(byteValue, &userConfig)
|
||||
if err != nil {
|
||||
fmt.Println("JSON file not valid: " + err.Error())
|
||||
} else {
|
||||
jsonFile.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
jsonFile.Close()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func OpenConfig(configuration *models.Configuration) {
|
||||
|
||||
// We are checking which deployment this is running, so we can load
|
||||
// into the configuration as expected.
|
||||
|
||||
if os.Getenv("DEPLOYMENT") == "factory" || os.Getenv("MACHINERY_ENVIRONMENT") == "kubernetes" {
|
||||
|
||||
// Factory deployment means that configuration is stored in MongoDB
|
||||
// Multiple agents have there configuration stored, and can benefit from
|
||||
// the concept of a global concept.
|
||||
|
||||
session := database.New().Copy()
|
||||
defer session.Close()
|
||||
db := session.DB(database.DatabaseName)
|
||||
collection := db.C("configuration")
|
||||
|
||||
collection.Find(bson.M{
|
||||
"type": "global",
|
||||
}).One(&configuration.GlobalConfig)
|
||||
|
||||
collection.Find(bson.M{
|
||||
"type": "config",
|
||||
"name": os.Getenv("DEPLOYMENT_NAME"),
|
||||
}).One(&configuration.CustomConfig)
|
||||
|
||||
// We will merge both configs in a single config file.
|
||||
// Read again from database but this store overwrite the same object.
|
||||
|
||||
opts := conjungo.NewOptions()
|
||||
opts.SetTypeMergeFunc(
|
||||
reflect.TypeOf(""),
|
||||
func(t, s reflect.Value, o *conjungo.Options) (reflect.Value, error) {
|
||||
targetStr, _ := t.Interface().(string)
|
||||
sourceStr, _ := s.Interface().(string)
|
||||
finalStr := targetStr
|
||||
if sourceStr != "" {
|
||||
finalStr = sourceStr
|
||||
}
|
||||
return reflect.ValueOf(finalStr), nil
|
||||
},
|
||||
)
|
||||
|
||||
// Merge Config toplevel
|
||||
conjungo.Merge(&configuration.Config, configuration.GlobalConfig, opts)
|
||||
conjungo.Merge(&configuration.Config, configuration.CustomConfig, opts)
|
||||
|
||||
// Merge Kerberos Vault settings
|
||||
var kerberosvault models.KStorage
|
||||
conjungo.Merge(&kerberosvault, configuration.GlobalConfig.KStorage, opts)
|
||||
conjungo.Merge(&kerberosvault, configuration.CustomConfig.KStorage, opts)
|
||||
configuration.Config.KStorage = &kerberosvault
|
||||
|
||||
// Merge Kerberos S3 settings
|
||||
var s3 models.S3
|
||||
conjungo.Merge(&s3, configuration.GlobalConfig.S3, opts)
|
||||
conjungo.Merge(&s3, configuration.CustomConfig.S3, opts)
|
||||
configuration.Config.S3 = &s3
|
||||
|
||||
} else if os.Getenv("DEPLOYMENT") == "" || os.Getenv("DEPLOYMENT") == "agent" {
|
||||
|
||||
// Local deployment means we do a stand-alone installation
|
||||
// Configuration is stored into a json file, and there is only 1 agent.
|
||||
|
||||
// Open device config
|
||||
for {
|
||||
jsonFile, err := os.Open("./data/config/config.json")
|
||||
if err != nil {
|
||||
log.Log.Error("Config file is not found " + "./data/config/config.json" + ", trying again in 5s.")
|
||||
time.Sleep(5 * time.Second)
|
||||
} else {
|
||||
log.Log.Info("Successfully Opened config.json from " + configuration.Name)
|
||||
byteValue, _ := ioutil.ReadAll(jsonFile)
|
||||
err = json.Unmarshal(byteValue, &configuration.Config)
|
||||
jsonFile.Close()
|
||||
if err != nil {
|
||||
fmt.Println("JSON file not valid: " + err.Error())
|
||||
} else {
|
||||
err = json.Unmarshal(byteValue, &configuration.CustomConfig)
|
||||
if err != nil {
|
||||
fmt.Println("JSON file not valid: " + err.Error())
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
jsonFile.Close()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func SaveConfig(config models.Config, configuration *models.Configuration, communication *models.Communication) error {
|
||||
if !communication.IsConfiguring.IsSet() {
|
||||
communication.IsConfiguring.Set()
|
||||
|
||||
err := StoreConfig(config)
|
||||
if err != nil {
|
||||
communication.IsConfiguring.UnSet()
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case communication.HandleBootstrap <- "restart":
|
||||
default:
|
||||
}
|
||||
|
||||
communication.IsConfiguring.UnSet()
|
||||
|
||||
return nil
|
||||
} else {
|
||||
return errors.New("☄ Already reconfiguring")
|
||||
}
|
||||
}
|
||||
|
||||
func StoreConfig(config models.Config) error {
|
||||
// Save into database
|
||||
if os.Getenv("DEPLOYMENT") == "factory" || os.Getenv("MACHINERY_ENVIRONMENT") == "kubernetes" {
|
||||
// Write to mongodb
|
||||
session := database.New().Copy()
|
||||
defer session.Close()
|
||||
db := session.DB(database.DatabaseName)
|
||||
collection := db.C("configuration")
|
||||
|
||||
err := collection.Update(bson.M{
|
||||
"type": "config",
|
||||
"name": os.Getenv("DEPLOYMENT_NAME"),
|
||||
}, &config)
|
||||
return err
|
||||
|
||||
// Save into file
|
||||
} else if os.Getenv("DEPLOYMENT") == "" || os.Getenv("DEPLOYMENT") == "agent" {
|
||||
res, _ := json.MarshalIndent(config, "", "\t")
|
||||
err := ioutil.WriteFile("./data/config/config.json", res, 0644)
|
||||
return err
|
||||
}
|
||||
|
||||
return errors.New("Not able to update config")
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
"github.com/kerberos-io/agent/machinery/src/cloud"
|
||||
"github.com/kerberos-io/agent/machinery/src/computervision"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/onvif"
|
||||
routers "github.com/kerberos-io/agent/machinery/src/routers/mqtt"
|
||||
"github.com/kerberos-io/joy4/av/pubsub"
|
||||
"github.com/tevino/abool"
|
||||
)
|
||||
|
||||
func Bootstrap(configuration *models.Configuration, communication *models.Communication) {
|
||||
log.Log.Debug("Bootstrap: started")
|
||||
|
||||
// Initiate the packet counter, this is being used to detect
|
||||
// if a camera is going blocky, or got disconnected.
|
||||
var packageCounter atomic.Value
|
||||
packageCounter.Store(int64(0))
|
||||
communication.PackageCounter = &packageCounter
|
||||
|
||||
// This is used when the last packet was received (timestamp),
|
||||
// this metric is used to determine if the camera is still online/connected.
|
||||
var lastPacketTimer atomic.Value
|
||||
packageCounter.Store(int64(0))
|
||||
communication.LastPacketTimer = &lastPacketTimer
|
||||
|
||||
// This is used to understand if we have a working Kerberos Hub connection
|
||||
// cloudTimestamp will be updated when successfully sending heartbeats.
|
||||
var cloudTimestamp atomic.Value
|
||||
cloudTimestamp.Store(int64(0))
|
||||
communication.CloudTimestamp = &cloudTimestamp
|
||||
|
||||
communication.HandleStream = make(chan string, 1)
|
||||
communication.HandleUpload = make(chan string, 1)
|
||||
communication.HandleHeartBeat = make(chan string, 1)
|
||||
communication.HandleLiveSD = make(chan int64, 1)
|
||||
communication.HandleLiveHDKeepalive = make(chan string, 1)
|
||||
communication.HandleLiveHDPeers = make(chan string, 1)
|
||||
communication.IsConfiguring = abool.New()
|
||||
|
||||
// Before starting the agent, we have a control goroutine, that might
|
||||
// do several checks to see if the agent is still operational.
|
||||
go ControlAgent(communication)
|
||||
|
||||
// Run the agent and fire up all the other
|
||||
// goroutines which do image capture, motion detection, onvif, etc.
|
||||
|
||||
for {
|
||||
// This will blocking until receiving a signal to be restarted, reconfigured, stopped, etc.
|
||||
status := RunAgent(configuration, communication)
|
||||
if status == "stop" {
|
||||
break
|
||||
}
|
||||
// We will re open the configuration, might have changed :O!
|
||||
OpenConfig(configuration)
|
||||
}
|
||||
log.Log.Debug("Bootstrap: finished")
|
||||
}
|
||||
|
||||
func RunAgent(configuration *models.Configuration, communication *models.Communication) string {
|
||||
log.Log.Debug("RunAgent: started")
|
||||
|
||||
config := configuration.Config
|
||||
|
||||
// Currently only support H264 encoded cameras, this will change.
|
||||
// Establishing the camera connection
|
||||
log.Log.Info("RunAgent: opening RTSP stream")
|
||||
rtspUrl := config.Capture.IPCamera.RTSP
|
||||
infile, streams, err := capture.OpenRTSP(rtspUrl)
|
||||
|
||||
//var decoder *ffmpeg.VideoDecoder
|
||||
var queue *pubsub.Queue
|
||||
status := "not started"
|
||||
|
||||
if err == nil {
|
||||
|
||||
// At some routines we will need to decode the image.
|
||||
// Make sure its properly locked as we only have a single decoder.
|
||||
var decoderMutex sync.Mutex
|
||||
decoder := capture.GetVideoDecoder(streams)
|
||||
|
||||
// Create a packet queue, which is filled by the HandleStream routing
|
||||
// and consumed by all other routines: motion, livestream, etc.
|
||||
if config.Capture.PreRecording <= 0 {
|
||||
config.Capture.PreRecording = 1
|
||||
log.Log.Warning("RunAgent: Prerecording value not found in config or invalid value! Found: " + strconv.FormatInt(config.Capture.PreRecording, 10))
|
||||
}
|
||||
queue = pubsub.NewQueue()
|
||||
queue.SetMaxGopCount(int(config.Capture.PreRecording)) // GOP time frame is set to prerecording.
|
||||
log.Log.Info("RunAgent: SetMaxGopCount was set with: " + strconv.Itoa(int(config.Capture.PreRecording)))
|
||||
queue.WriteHeader(streams)
|
||||
|
||||
// Configure a MQTT client which helps for a bi-directional communication
|
||||
communication.HandleONVIF = make(chan models.OnvifAction, 1)
|
||||
mqttClient := routers.ConfigureMQTT(configuration, communication)
|
||||
|
||||
// Handle heartbeats
|
||||
go cloud.HandleHeartBeat(configuration, communication)
|
||||
|
||||
// Handle the camera stream
|
||||
go capture.HandleStream(infile, queue, communication) //, &wg)
|
||||
|
||||
// Handle processing of motion
|
||||
motionCursor := queue.Oldest()
|
||||
communication.HandleMotion = make(chan models.MotionDataPartial, 1)
|
||||
go computervision.ProcessMotion(motionCursor, configuration, communication, mqttClient, decoder, &decoderMutex)
|
||||
|
||||
// Handle livestream SD (low resolution over MQTT)
|
||||
livestreamCursor := queue.Oldest()
|
||||
go cloud.HandleLiveStreamSD(livestreamCursor, configuration, communication, mqttClient, decoder, &decoderMutex)
|
||||
|
||||
// Handle livestream HD (high resolution over WEBRTC)
|
||||
livestreamHDCursor := queue.Oldest()
|
||||
communication.HandleLiveHDHandshake = make(chan models.SDPPayload, 1)
|
||||
go cloud.HandleLiveStreamHD(livestreamHDCursor, configuration, communication, mqttClient, streams, decoder, &decoderMutex)
|
||||
|
||||
// Handle recording, will write an mp4 to disk.
|
||||
recordingCursor := queue.Oldest()
|
||||
go capture.HandleRecordStream(recordingCursor, configuration, communication, streams)
|
||||
|
||||
// Handle Upload to cloud provider (Kerberos Hub, Kerberos Vault and others)
|
||||
go cloud.HandleUpload(configuration, communication)
|
||||
|
||||
// Handle ONVIF actions
|
||||
go onvif.HandleONVIFActions(configuration, communication)
|
||||
|
||||
// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
// This will go into a blocking state, once this channel is triggered
|
||||
// the agent will cleanup and restart.
|
||||
status = <-communication.HandleBootstrap
|
||||
|
||||
// Here we are cleaning up everything!
|
||||
if configuration.Config.Offline != "true" {
|
||||
communication.HandleHeartBeat <- "stop"
|
||||
communication.HandleUpload <- "stop"
|
||||
}
|
||||
infile.Close()
|
||||
queue.Close()
|
||||
close(communication.HandleONVIF)
|
||||
close(communication.HandleLiveHDHandshake)
|
||||
close(communication.HandleMotion)
|
||||
routers.DisconnectMQTT(mqttClient)
|
||||
decoder.Close()
|
||||
communication.HandleStream <- "stop"
|
||||
|
||||
// Waiting for some seconds to make sure everything is properly closed.
|
||||
log.Log.Info("RunAgent: waiting 3 seconds to make sure everything is properly closed.")
|
||||
time.Sleep(time.Second * 3)
|
||||
} else {
|
||||
log.Log.Error("Something went wrong while opening RTSP: " + err.Error())
|
||||
time.Sleep(time.Second * 3)
|
||||
}
|
||||
|
||||
log.Log.Debug("RunAgent: finished")
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func ControlAgent(communication *models.Communication) {
|
||||
log.Log.Debug("ControlAgent: started")
|
||||
packageCounter := communication.PackageCounter
|
||||
go func() {
|
||||
// A channel to check the camera activity
|
||||
var previousPacket int64 = 0
|
||||
var occurence = 0
|
||||
for {
|
||||
packetsR := packageCounter.Load().(int64)
|
||||
if packetsR == previousPacket {
|
||||
// If we are already reconfiguring,
|
||||
// we dont need to check if the stream is blocking.
|
||||
if !communication.IsConfiguring.IsSet() {
|
||||
occurence = occurence + 1
|
||||
}
|
||||
} else {
|
||||
|
||||
occurence = 0
|
||||
}
|
||||
|
||||
log.Log.Info("ControlAgent: Number of packets read " + strconv.FormatInt(packetsR, 10))
|
||||
|
||||
// After 15 seconds without activity this is thrown..
|
||||
if occurence == 3 {
|
||||
log.Log.Info("Main: Restarting machinery.")
|
||||
communication.HandleBootstrap <- "restart"
|
||||
time.Sleep(2 * time.Second)
|
||||
occurence = 0
|
||||
}
|
||||
previousPacket = packageCounter.Load().(int64)
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}()
|
||||
log.Log.Debug("ControlAgent: finished")
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/cedricve/go-onvif"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
)
|
||||
|
||||
func Discover(timeout time.Duration) {
|
||||
log.Log.Info("Discovering devices")
|
||||
log.Log.Info("Waiting for " + (timeout * time.Second).String())
|
||||
devices, err := onvif.StartDiscovery(timeout * time.Second)
|
||||
if err != nil {
|
||||
log.Log.Error(err.Error())
|
||||
} else {
|
||||
for _, device := range devices {
|
||||
hostname, _ := device.GetHostname()
|
||||
log.Log.Info(hostname.Name)
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
log.Log.Info("No devices descovered\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/deepch/vdk/av"
|
||||
"github.com/deepch/vdk/codec/h264parser"
|
||||
"github.com/deepch/vdk/format/rtsp"
|
||||
"github.com/nsmith5/mjpeg"
|
||||
)
|
||||
|
||||
type Stream struct {
|
||||
Name string
|
||||
Url string
|
||||
Debug bool
|
||||
Codecs string
|
||||
}
|
||||
|
||||
func CreateStream(name string, url string) *Stream {
|
||||
return &Stream{
|
||||
Name: name,
|
||||
Url: url,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Stream) Open() *rtsp.Client {
|
||||
|
||||
// Enable debugging
|
||||
if s.Debug {
|
||||
rtsp.DebugRtsp = true
|
||||
}
|
||||
|
||||
fmt.Println("Dialing in to " + s.Url)
|
||||
session, err := rtsp.Dial(s.Url)
|
||||
if err != nil {
|
||||
log.Println("Something went wrong dialing into stream: ", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
session.RtpKeepAliveTimeout = 10 * time.Second
|
||||
return session
|
||||
}
|
||||
|
||||
func (s Stream) Close(session *rtsp.Client) {
|
||||
fmt.Println("Closing RTSP session.")
|
||||
err := session.Close()
|
||||
if err != nil {
|
||||
log.Println("Something went wrong while closing your RTSP session: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s Stream) GetCodecs() []av.CodecData {
|
||||
session := s.Open()
|
||||
codec, err := session.Streams()
|
||||
log.Println("Reading codecs from stream: ", codec)
|
||||
if err != nil {
|
||||
log.Println("Something went wrong while reading codecs from stream: ", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
s.Close(session)
|
||||
return codec
|
||||
}
|
||||
|
||||
func (s Stream) ReadPackets(packetChannel chan av.Packet) {
|
||||
session := s.Open()
|
||||
fmt.Println("Start reading H264 packages from stream")
|
||||
for {
|
||||
packet, err := session.ReadPacket()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if len(packetChannel) < cap(packetChannel) {
|
||||
packetChannel <- packet
|
||||
}
|
||||
}
|
||||
s.Close(session)
|
||||
}
|
||||
|
||||
func GetSPSFromCodec(codecs []av.CodecData) ([]byte, []byte) {
|
||||
sps := codecs[0].(h264parser.CodecData).SPS()
|
||||
pps := codecs[0].(h264parser.CodecData).PPS()
|
||||
return sps, pps
|
||||
}
|
||||
|
||||
func StartMotionJPEG(imageFunction func() (image.Image, error), quality int) mjpeg.Handler {
|
||||
stream := mjpeg.Handler{
|
||||
Next: imageFunction,
|
||||
Options: &jpeg.Options{Quality: quality},
|
||||
}
|
||||
return stream
|
||||
}
|
||||
227
machinery/src/components/backchannel.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/kerberos-io/agent/machinery/src/capture"
|
||||
"github.com/kerberos-io/agent/machinery/src/log"
|
||||
"github.com/kerberos-io/agent/machinery/src/models"
|
||||
"github.com/kerberos-io/agent/machinery/src/packets"
|
||||
"github.com/kerberos-io/joy4/av"
|
||||
"github.com/pion/rtp"
|
||||
"github.com/zaf/g711"
|
||||
)
|
||||
|
||||
const (
|
||||
backchannelSampleRate = 8000
|
||||
backchannelTalkspurtGap = 500 * time.Millisecond
|
||||
backchannelReconnectInitial = time.Second
|
||||
backchannelReconnectMax = 30 * time.Second
|
||||
)
|
||||
|
||||
type backchannelClient interface {
|
||||
ConnectBackChannel(ctx context.Context, otelContext context.Context) error
|
||||
StartBackChannel(ctx context.Context, otelContext context.Context) error
|
||||
WritePacket(pkt packets.Packet) error
|
||||
Close(otelContext context.Context) error
|
||||
}
|
||||
|
||||
type backchannelPacketizer struct {
|
||||
sequenceNumber uint16
|
||||
timestamp uint32
|
||||
ssrc uint32
|
||||
lastPacketAt time.Time
|
||||
}
|
||||
|
||||
func newBackchannelPacketizer() backchannelPacketizer {
|
||||
return backchannelPacketizer{
|
||||
sequenceNumber: uint16(rand.Uint32()),
|
||||
timestamp: rand.Uint32(),
|
||||
ssrc: rand.Uint32(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *backchannelPacketizer) packet(audio models.AudioDataPartial, now time.Time) packets.Packet {
|
||||
bufferUlaw := make([]byte, len(audio.Data))
|
||||
for index, sample := range audio.Data {
|
||||
bufferUlaw[index] = g711.EncodeUlawFrame(sample)
|
||||
}
|
||||
|
||||
pkt := packets.Packet{
|
||||
Packet: &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
Marker: p.lastPacketAt.IsZero() || now.Sub(p.lastPacketAt) >= backchannelTalkspurtGap,
|
||||
PayloadType: 0,
|
||||
SequenceNumber: p.sequenceNumber,
|
||||
Timestamp: p.timestamp,
|
||||
SSRC: p.ssrc,
|
||||
},
|
||||
Payload: bufferUlaw,
|
||||
},
|
||||
}
|
||||
|
||||
p.timestamp += uint32(len(bufferUlaw))
|
||||
p.sequenceNumber++
|
||||
p.lastPacketAt = now
|
||||
|
||||
return pkt
|
||||
}
|
||||
|
||||
func GetBackChannelAudioCodec(streams []av.CodecData, communication *models.Communication) av.AudioCodecData {
|
||||
for _, stream := range streams {
|
||||
if stream.Type().IsAudio() {
|
||||
if stream.Type().String() == "PCM_MULAW" {
|
||||
pcmuCodec := stream.(av.AudioCodecData)
|
||||
if pcmuCodec.IsBackChannel() {
|
||||
communication.HasBackChannel = true
|
||||
return pcmuCodec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteAudioToBackchannel(communication *models.Communication, rtspClient capture.RTSPClient) {
|
||||
ctx := context.Background()
|
||||
if communication.Context != nil {
|
||||
ctx = *communication.Context
|
||||
}
|
||||
|
||||
writeAudioToBackchannel(ctx, ctx, communication.HandleAudio, rtspClient)
|
||||
}
|
||||
|
||||
func writeAudioToBackchannel(ctx context.Context, otelContext context.Context, audioChannel <-chan models.AudioDataPartial, rtspClient backchannelClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): writing to backchannel audio codec")
|
||||
|
||||
if err := rtspClient.StartBackChannel(ctx, otelContext); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error starting backchannel: " + err.Error())
|
||||
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
packetizer := newBackchannelPacketizer()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped")
|
||||
return
|
||||
case audio, ok := <-audioChannel:
|
||||
if !ok {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): finished")
|
||||
return
|
||||
}
|
||||
|
||||
audio = latestBackchannelAudio(audio, audioChannel)
|
||||
if len(audio.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
pkt := packetizer.packet(audio, time.Now())
|
||||
if err := rtspClient.WritePacket(pkt); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error writing packet to backchannel: " + err.Error())
|
||||
if !reconnectBackchannel(ctx, otelContext, rtspClient) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped while reconnecting")
|
||||
return
|
||||
}
|
||||
packetizer = newBackchannelPacketizer()
|
||||
continue
|
||||
}
|
||||
|
||||
if !waitForBackchannel(ctx, time.Duration(len(audio.Data))*time.Second/backchannelSampleRate) {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func latestBackchannelAudio(audio models.AudioDataPartial, audioChannel <-chan models.AudioDataPartial) models.AudioDataPartial {
|
||||
for {
|
||||
select {
|
||||
case next, ok := <-audioChannel:
|
||||
if !ok {
|
||||
return audio
|
||||
}
|
||||
audio = next
|
||||
default:
|
||||
return audio
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reconnectBackchannel(ctx context.Context, otelContext context.Context, rtspClient backchannelClient) bool {
|
||||
backoff := backchannelReconnectInitial
|
||||
for {
|
||||
if err := rtspClient.Close(otelContext); err != nil {
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error closing failed backchannel: " + err.Error())
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err := rtspClient.ConnectBackChannel(ctx, otelContext)
|
||||
if err == nil {
|
||||
err = rtspClient.StartBackChannel(ctx, otelContext)
|
||||
}
|
||||
if err == nil {
|
||||
log.Log.Info("Audio.WriteAudioToBackchannel(): reconnected backchannel")
|
||||
return true
|
||||
}
|
||||
|
||||
log.Log.Error("Audio.WriteAudioToBackchannel(): error reconnecting backchannel: " + err.Error())
|
||||
if !waitForBackchannel(ctx, backoff) {
|
||||
return false
|
||||
}
|
||||
backoff *= 2
|
||||
if backoff > backchannelReconnectMax {
|
||||
backoff = backchannelReconnectMax
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForBackchannel(ctx context.Context, duration time.Duration) bool {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func WriteFileToBackChannel(infile av.DemuxCloser) {
|
||||
// Do the warmup!
|
||||
file, err := os.Open("./audiofile.bye")
|
||||
if err != nil {
|
||||
fmt.Println("WriteFileToBackChannel: error opening audiofile.bye file")
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read file into buffer
|
||||
reader := bufio.NewReader(file)
|
||||
buffer := make([]byte, 1024)
|
||||
|
||||
count := 0
|
||||
for {
|
||||
_, err := reader.Read(buffer)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
// Send to backchannel
|
||||
infile.Write(buffer, 2, uint32(count))
|
||||
|
||||
count = count + 1024
|
||||
time.Sleep(128 * time.Millisecond)
|
||||
}
|
||||
}
|
||||