Merge pull request #310 from kerberos-io/feature/integrate-moq-streaming-protocol

feature/integrate-moq-streaming-protocol
This commit is contained in:
Cédric Verstraeten
2026-08-05 22:40:46 +02:00
committed by GitHub
17 changed files with 420 additions and 167 deletions

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/devcontainers/go:1.24-bookworm FROM mcr.microsoft.com/devcontainers/go:1.24-trixie
# Install node environment # Install node environment
RUN apt-get update && \ RUN apt-get update && \

View File

@@ -1,7 +1,7 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the // 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 // README at: https://github.com/devcontainers/templates/tree/main/src/python
{ {
"name": "go:1.24-bookworm", "name": "go:1.24-trixie",
"runArgs": [ "runArgs": [
"--name=agent", "--name=agent",
"--network=host" "--network=host"
@@ -20,5 +20,5 @@
3000, 3000,
8080 8080
], ],
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download" "postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
} }

View File

@@ -16,10 +16,8 @@ jobs:
include: include:
- architecture: amd64 - architecture: amd64
runner: ubuntu-24.04 runner: ubuntu-24.04
dockerfile: Dockerfile
- architecture: arm64 - architecture: arm64
runner: ubuntu-24.04-arm runner: ubuntu-24.04-arm
dockerfile: Dockerfile.arm64
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
@@ -34,7 +32,7 @@ jobs:
length: 7 length: 7
- name: Run Build - name: Run Build
run: | run: |
docker build -t ${{ matrix.architecture }} -f ${{ matrix.dockerfile }} . docker build -t ${{ matrix.architecture }} .
CID=$(docker create ${{matrix.architecture}}) CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}} docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID} docker rm ${CID}

View File

@@ -108,7 +108,7 @@ jobs:
length: 7 length: 7
- name: Run Build - name: Run Build
run: | run: |
docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} -f Dockerfile.arm64 . docker build --provenance=false --build-arg VERSION=${{ needs.bump-release.outputs.tag }} -t ${{matrix.architecture}} .
CID=$(docker create ${{matrix.architecture}}) CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}} docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID} docker rm ${CID}

View File

@@ -71,7 +71,7 @@ jobs:
length: 7 length: 7
- name: Run Build - name: Run Build
run: | run: |
docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} -f Dockerfile.arm64 . docker build --provenance=false --build-arg VERSION=${{github.event.inputs.tag || github.ref_name}} -t ${{matrix.architecture}} .
CID=$(docker create ${{matrix.architecture}}) CID=$(docker create ${{matrix.architecture}})
docker cp ${CID}:/home/agent ./output-${{matrix.architecture}} docker cp ${CID}:/home/agent ./output-${{matrix.architecture}}
docker rm ${CID} docker rm ${CID}

14
.vscode/tasks.json vendored Normal file
View 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": []
}
]
}

View File

@@ -1,13 +1,15 @@
ARG BASE_IMAGE_VERSION=amd64-ddbe40e ARG GO_IMAGE=golang:1.24-trixie
ARG RUNTIME_IMAGE=debian:trixie-slim
ARG VERSION=0.0.0 ARG VERSION=0.0.0
FROM kerberos/base:${BASE_IMAGE_VERSION} AS build-machinery FROM ${GO_IMAGE} AS build-machinery
LABEL AUTHOR=uug.ai LABEL AUTHOR=uug.ai
# Re-declare VERSION inside this stage so the value passed via # Re-declare VERSION inside this stage so the value passed via
# `--build-arg VERSION=...` (e.g. the release tag) is available below. # `--build-arg VERSION=...` (e.g. the release tag) is available below.
# ARGs declared before the first FROM are not visible inside build stages. # ARGs declared before the first FROM are not visible inside build stages.
ARG VERSION ARG VERSION
ARG TARGETARCH
ENV GOROOT=/usr/local/go ENV GOROOT=/usr/local/go
ENV GOPATH=/go ENV GOPATH=/go
@@ -17,9 +19,10 @@ ENV GOSUMDB=off
########################################## ##########################################
# Installing some additional dependencies. # Installing some additional dependencies.
RUN apt-get upgrade -y && apt-get update && apt-get install -y --fix-missing --no-install-recommends \ RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
git build-essential cmake pkg-config unzip libgtk2.0-dev \ git build-essential cmake pkg-config unzip libgtk2.0-dev \
curl ca-certificates libcurl4-openssl-dev libssl-dev libjpeg62-turbo-dev && \ curl ca-certificates libavcodec-dev libavutil-dev libcurl4-openssl-dev \
libssl-dev libjpeg62-turbo-dev libswscale-dev && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
############################################################################## ##############################################################################
@@ -43,7 +46,9 @@ RUN cd /go/src/github.com/kerberos-io/agent/machinery && \
if [ -z "${VERSION}" ] || [ "${VERSION}" = "0.0.0" ]; then \ 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"); \ VERSION=$(cd /go/src/github.com/kerberos-io/agent && git describe --tags --always 2>/dev/null || echo "0.0.0"); \
fi && \ fi && \
go build -tags timetzdata,netgo,osusergo --ldflags "-s -w -X github.com/kerberos-io/agent/machinery/src/utils.VERSION=${VERSION} -extldflags '-static -latomic'" main.go && \ 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 && \ mkdir -p /agent && \
mv main /agent && \ mv main /agent && \
mv version /agent && \ mv version /agent && \
@@ -89,12 +94,16 @@ RUN mkdir -p ./agent && cp -r /go/src/github.com/kerberos-io/agent/machinery/www
############################################ ############################################
# Publish main binary to GitHub release # Publish main binary to GitHub release
FROM alpine:latest FROM ${RUNTIME_IMAGE}
############################ ############################
# Protect by non-root user. # 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
################################# #################################
# Copy files from previous images # Copy files from previous images
@@ -102,8 +111,6 @@ RUN addgroup -S kerberosio && adduser -S agent -G kerberosio && addgroup agent v
COPY --chown=0:0 --from=build-machinery /dist / COPY --chown=0:0 --from=build-machinery /dist /
COPY --chown=0:0 --from=build-ui /dist / COPY --chown=0:0 --from=build-ui /dist /
RUN apk update && apk add ca-certificates curl ffmpeg libstdc++ libc6-compat --no-cache && rm -rf /var/cache/apk/*
################## ##################
# Try running agent # Try running agent
@@ -123,7 +130,7 @@ RUN chown -R agent:kerberosio /home/agent/www
########################### ###########################
# Grant the necessary root capabilities to the process trying to bind to the privileged port # Grant the necessary root capabilities to the process trying to bind to the privileged port
RUN apk add libcap && setcap 'cap_net_bind_service=+ep' /home/agent/main RUN setcap 'cap_net_bind_service=+ep' /home/agent/main
################### ###################
# Run non-root user # Run non-root user

View File

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

View File

@@ -412,13 +412,43 @@ Remember the build step of the `web` part, during build time we move the build d
## Building for Docker ## Building for Docker
Inside the root of this `agent` repository, you will find a `Dockerfile`. This file contains the instructions for building and shipping a **Kerberos Agent**. Important to note is that you start from a prebuilt base image, `kerberos/base:xxx`. 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.
This base image already contains a couple of tools, such as Golang, FFmpeg and OpenCV. We do this for faster compilation times.
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. 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 . docker build -t kerberos/agent .
### Media over QUIC
The standard AMD64 and ARM64 images include the optional MoQ publisher. Its Rust
FFI archive requires CGO and glibc 2.38 or newer, which is why the standard image
uses Debian Trixie. The publisher is disabled unless explicitly enabled at runtime:
docker run --rm -p 80:80 \
-e AGENT_LIVE_MOQ_ENABLED=true \
-e AGENT_LIVE_MOQ_URL=https://relay.uug.ai/anon \
kerberos/agent
`AGENT_LIVE_MOQ_BROADCAST_PREFIX` defaults to `devices`, producing the broadcast
`devices/<agent-key>/live.hang`. `AGENT_LIVE_MOQ_QUALITY` accepts `auto` (the
default), `high`, or `low` and selects the main or sub camera stream when the
Agent starts. 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? ## 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). 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).

View File

@@ -26,6 +26,7 @@ require (
github.com/kerberos-io/joy4 v1.0.64 github.com/kerberos-io/joy4 v1.0.64
github.com/kerberos-io/onvif v1.2.2 github.com/kerberos-io/onvif v1.2.2
github.com/minio/minio-go/v6 v6.0.57 github.com/minio/minio-go/v6 v6.0.57
github.com/moq-dev/moq-go v0.5.7
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pion/interceptor v0.1.40 github.com/pion/interceptor v0.1.40
@@ -95,6 +96,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/montanaflynn/stats v0.7.1 // 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/nxadm/tail v1.4.11 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pion/datachannel v1.5.10 // indirect github.com/pion/datachannel v1.5.10 // indirect

View File

@@ -845,6 +845,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/moq-dev/moq-go v0.5.7 h1:LfFpgAU8FRMcnU85L5Lb03HzxhYbQ2+BhT/pJQdjy5U=
github.com/moq-dev/moq-go v0.5.7/go.mod h1:5K8zjKKjWe5lzfCtlfxoAfdCr3KX6EBuTOZsz0WBnzw=
github.com/moq-dev/moq-go-ffi v0.3.7 h1:+xwPOzTJHvB0tuTnW6znbvvOgT4yLnN7sMqXXZYh02M=
github.com/moq-dev/moq-go-ffi v0.3.7/go.mod h1:zxpOlUetvaoxWBnbXTdILtLLUytaoVuLvO36lftwWO0=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=

View File

@@ -0,0 +1,65 @@
package livemoq
import (
"bytes"
"strings"
"github.com/bluenviron/mediacommon/pkg/codecs/h264"
)
var annexBStartCode = []byte{0x00, 0x00, 0x00, 0x01}
// 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 duplicate parameter sets that
// can make older MoQ splitters emit a parameter-only frame before the IDR.
func NormalizeH264AccessUnit(payload []byte) ([]byte, error) {
nalus, err := h264.AnnexBUnmarshal(EnsureAnnexB(payload))
if err != nil {
return nil, err
}
normalized := make([][]byte, 0, len(nalus))
for _, nalu := range nalus {
if len(nalu) == 0 || nalu[0]&0x1f == 9 {
continue
}
if nalu[0]&0x1f == 7 || nalu[0]&0x1f == 8 {
duplicate := false
for _, existing := range normalized {
if bytes.Equal(existing, nalu) {
duplicate = true
break
}
}
if duplicate {
continue
}
}
normalized = append(normalized, nalu)
}
return h264.AnnexBMarshal(normalized)
}
func BroadcastPath(prefix string, deviceKey string) string {
prefix = strings.Trim(prefix, "/")
if prefix == "" {
prefix = "devices"
}
return prefix + "/" + strings.Trim(deviceKey, "/") + "/live.hang"
}
func hasAnnexBStartCode(payload []byte) bool {
return len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 &&
((payload[2] == 0 && payload[3] == 1) || payload[2] == 1)
}

View File

@@ -0,0 +1,74 @@
package livemoq
import (
"bytes"
"testing"
)
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 TestBroadcastPath(t *testing.T) {
if got := BroadcastPath("/devices/", "/camera-1/"); got != "devices/camera-1/live.hang" {
t.Fatalf("BroadcastPath() = %q", got)
}
if got := BroadcastPath("", "camera-1"); got != "devices/camera-1/live.hang" {
t.Fatalf("BroadcastPath() default = %q", got)
}
}

View 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) {}

View File

@@ -0,0 +1,171 @@
//go:build moq
package cloud
import (
"context"
"fmt"
"os"
"strings"
"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
)
type liveMoQConfig struct {
relayURL string
broadcast string
quality string
sourceLabel string
queue *packets.Queue
}
// StartLiveStreamMoQ starts the publisher only in the dedicated MoQ build and
// only when explicitly enabled by the deployment.
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
}
quality := os.Getenv("AGENT_LIVE_MOQ_QUALITY")
if quality == "" {
quality = models.StreamQualityAuto
}
useSub := models.SelectSubStreamForQuality(config, quality, subStreamEnabled)
queue := communication.Queue
sourceLabel := "main"
if useSub && communication.SubQueue != nil {
queue = communication.SubQueue
sourceLabel = "sub"
}
if queue == nil {
log.Log.Warning("cloud.StartLiveStreamMoQ(): selected packet queue is unavailable")
return
}
relayURL := os.Getenv("AGENT_LIVE_MOQ_URL")
if relayURL == "" {
relayURL = defaultMoQRelayURL
}
publisherConfig := liveMoQConfig{
relayURL: relayURL,
broadcast: livemoq.BroadcastPath(os.Getenv("AGENT_LIVE_MOQ_BROADCAST_PREFIX"), config.Key),
quality: quality,
sourceLabel: sourceLabel,
queue: queue,
}
ctx := context.Background()
if communication.Context != nil {
ctx = *communication.Context
}
go runLiveStreamMoQ(ctx, publisherConfig)
}
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()
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()
cursor := config.queue.Latest()
writing := false
for {
packet, err := cursor.ReadPacket()
if err != nil {
return fmt.Errorf("read packet: %w", err)
}
if !packet.IsVideo || len(packet.Data) == 0 || !strings.EqualFold(packet.Codec, "H264") {
continue
}
if !writing {
if !packet.IsKeyFrame {
continue
}
writing = true
log.Log.Info("cloud.publishLiveStreamMoQ(): first H.264 keyframe received; broadcast is live")
}
presentationTimeMs := packet.Time + packet.CompositionTime
if presentationTimeMs < 0 {
presentationTimeMs = 0
}
payload, err := livemoq.NormalizeH264AccessUnit(packet.Data)
if err != nil {
return fmt.Errorf("normalize H.264 access unit: %w", err)
}
frame := moq.Frame{
Payload: payload,
TimestampUs: uint64(presentationTimeMs) * 1000,
}
if err := stream.WriteFrame(frame); err != nil {
return fmt.Errorf("write H.264 access unit: %w", err)
}
}
}

View File

@@ -305,6 +305,10 @@ func RunAgent(configDirectory string, configuration *models.Configuration, commu
// watching. // watching.
go cloud.HandleLiveStreamHLS(configuration, communication, mqttClient, subStreamEnabled) go cloud.HandleLiveStreamHLS(configuration, communication, mqttClient, subStreamEnabled)
// MoQ is available only in the dedicated CGO/glibc build. The standard
// static Alpine build resolves this hook to a no-op.
cloud.StartLiveStreamMoQ(configuration, communication, subStreamEnabled)
// Handle livestream HD (high resolution over WEBRTC). Both the main and sub // Handle livestream HD (high resolution over WEBRTC). Both the main and sub
// stream are exposed as separate broadcasters so a viewer can request the // stream are exposed as separate broadcasters so a viewer can request the
// high (main) or low (sub) resolution per peer connection; "auto" prefers the // high (main) or low (sub) resolution per peer connection; "auto" prefers the

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
required_glibc="2.38"
current_glibc="$(getconf GNU_LIBC_VERSION | awk '{print $2}')"
if ! dpkg --compare-versions "$current_glibc" ge "$required_glibc"; then
echo "MoQ requires glibc ${required_glibc}+; this container has ${current_glibc}." >&2
echo "Rebuild the devcontainer with the Trixie base, then run this check again." >&2
exit 1
fi
echo "==> Testing MoQ packages (glibc ${current_glibc})"
GOWORK=off go test -tags moq ./src/cloud/livemoq ./src/cloud
binary="${TMPDIR:-/tmp}/agent-moq"
trap 'rm -f "$binary"' EXIT
echo "==> Linking the MoQ Agent"
GOWORK=off go build -tags moq -o "$binary" ./main.go
echo "==> Running the linked binary"
"$binary" -action version