Standalone SRT bonding ingress relay. It accepts bonded/redundant SRT groups on one listener socket and forwards the deduplicated MPEG-TS payload to a downstream SRT target.
encoder / publisher
leg A: SRT caller + streamid
leg B: SRT caller + same streamid
|
v
+-----------------------------+
| srt-bonding-relay listener |
| SRTO_GROUPCONNECT=1 |
+-----------------------------+
|
| SRT group socket
| libsrt merges and deduplicates legs
v
+-----------------------------+
| session thread per streamid |
| reads one MPEG-TS payload |
+-----------------------------+
|
+----> srt:// output, same streamid
- Input listener. A single SRT socket is bound with
SRTO_GROUPCONNECT=1(mode=listener,transtype=live,latency=240ms). This lets an encoder connect multiple redundant network legs (e.g. two NICs/uplinks) as one SRT socket group; SRT itself merges and deduplicates packets across legs before the relay ever sees them, so the relay always reads one clean MPEG-TS stream per group, not per leg. - Accept loop. The main thread polls the listener with
srt_epoll_uwait. Each accepted connection (the first leg of a new group, or a follow-up leg joining an existing group in the background) spawns a session thread (session_main) tracked in a fixed-size session table (MAX_ACTIVE_SESSIONS= 256). - Streamid-based routing and dedup. The caller's SRT
streamidis read off the accepted socket and used as the key for a shared per-stream state slot. It's also copied verbatim onto the outgoing connection, so one listener can multiplex many logical streams to distinct downstream destinations/streamids. If a second publisher connects with a streamid that's already active, the relay waits up to 5s for the existing session to go quiet; if the existing session has had no input for 5s it's treated as stale and force-closed so the new publisher can take over, otherwise the new connection is rejected as a duplicate. - Forwarding. Each session thread reads deduplicated payload off the
group socket with
srt_recvmsg2and writes it downstream withsrt_sendmsg2over thesrt://output connection. If the downstream SRT target is unreachable or rejects the publish attempt, the session retries with backoff (1s, 2s, 4s, 8s, 16s) in the background without dropping the encoder's input connection. When libsrt exposes a downstream reject reason, it is included in the stream'slastError. - Stats collection. A per-session sampler (
update_stream_srt_counters, rate-limited to once/sec/session) pulls counters from libsrt (srt_bstats,srt_group_data) under a lock and writes them into the shared session state. This runs from the session thread itself — the status HTTP thread never touches libsrt directly, it only reads the already-collected state. - Status HTTP server. A separate thread serves the JSON status snapshot described below on its own port, bound to loopback only.
The relay accepts either:
srt-bonding-relay <config.json>srt-bonding-relay <srt-input-uri> <srt-output-uri>
Minimal JSON config:
{
"input_host": "0.0.0.0",
"input_port": 10081,
"output_host": "127.0.0.1",
"output_port": 10080,
"status_port": 8081,
"passphrase": "secret-value"
}Fields:
input_host: bonded SRT listener host/interfaceinput_port: bonded SRT listener portoutput_host: downstream SRT publish hostoutput_port: downstream SRT publish portstatus_port: local HTTP status endpoint portpassphrase: SRT passphrase applied to the input and to thesrt://output. Use""to disable encryption.
The relay is streamid-agnostic. It accepts any incoming streamid and copies it
to the downstream SRT publish socket automatically.
All config fields are required.
When passphrase is set, the relay configures SRTO_PASSPHRASE on the input
listener and output connection, then leaves passphrase validation to libsrt's
default encrypted-handshake handling.
Bad-passphrase callers are rejected by libsrt during the handshake, before
srt_accept() returns a connected socket to the relay. That means the relay
does not get a peer address for those failed attempts and does not emit a
relay-owned, fail2ban-ready bad-passphrase log line. Depending on libsrt's
runtime logging, journald may still capture internal SRT error messages for
failed handshakes, but they should be treated as diagnostic noise rather than
a stable security log format.
The status thread listens on 127.0.0.1:<status_port> (loopback only, not
reachable from other hosts) and returns a JSON snapshot of every active or
recently-active stream on any request, e.g.:
curl http://127.0.0.1:8081/statusstatus HTTP thread
|
v
copies in-memory session state
|
+--> never calls libsrt directly
|
v
JSON response
session threads
|
+--> sample srt_bstats() / srt_group_data() at most once per second
|
v
shared session state
The response body itself is compact (no pretty-printing) since nothing
requires this JSON to be human-formatted — pipe it through jq/python3 -m json.tool if you want to read it by eye.
Top-level fields:
pid,startedAtMs,updatedAtMs: process identity and snapshot timinglastError: the most recent process-level error, if anyactiveStreamIds: streamids with a currently connected inputstreamStates: one entry per known stream (see below); entries can briefly outliveactiveStreamIdswhile a session is tearing down
Each streamStates[] entry:
streamId,inputActive,outputConnected,retryFailuresforwardedPackets,forwardedBytes,lastPacketAt,lastInputPacketAt: relay-tracked forwarding progressinput: everything SRT reports about the (possibly bonded) input — see belowoutput: everything SRT reports about the single downstream connection (output is never bonded) — see belowlastErrorAt,lastError: most recent per-stream error, if any
input fields:
recvPacketsTotal,recvUniquePacketsTotal,recvLossTotal,recvDropTotal,retransTotal: group-level counters, i.e. SRT's own combined/deduplicated accounting across all bonded legs (srt_bstatson the group socket). The relay does not sum these itself.recvPacketsTotalspecifically can benull: for a bonded group socket SRT doesn't always populate that particular counter, so it's only reported when SRT marks it valid —recvUniquePacketsTotalis the reliable dedup'd total to use for group-level throughput.rttMs: RTT reported for the input group socket as a wholelatencyMs: negotiated SRT buffering latency for the input, i.e. the actual value SRT settled on after the handshake, not just what was requested. For a non-bonded input this comes straight fromsrt_bstats(msRcvTsbPdDelay, the same underlying valueSRTO_LATENCYreports). A bonded group socket's ownsrt_bstatsnever fills that field in, so for a real bonded input this is derived as the maxlatencyMsacrosslegs[]instead (see below) — no extra libsrt call needed either way.bandwidthMbps,recvRateMbps,belatedTotal,belatedAvgMs,undecryptTotal,reorderDistance,rcvBufMs: estimated capacity, actual receive rate, packets dropped for arriving past their delivery deadline (count + average lateness), decryption failures, reordering, and receive-buffer occupancy (ms) — all read straight from the samesrt_bstatscall as the fields above. Only populated for a non-bonded input (nullotherwise): likelatencyMs, a bonded group's ownsrt_bstatsnever fills these in, and there was no reasonable per-leg aggregation for most of them (unlike latency, where "max across legs" has a clear meaning) — checklegs[]for the real per-leg numbers on a bonded stream instead.legs: one entry per individual bonded connection currently in (or recently in) the input group, read directly fromsrt_group_data()+srt_bstats()on each member socket — this is real per-leg telemetry from libsrt, not something the relay derives or combines:ip,port: the leg's peer (encoder-side) address, as seen by the relaystate:pending|idle|running|brokenrttMs,recvPacketsTotal,recvUniquePacketsTotal,recvLossTotal,recvDropTotal,retransTotal: stats for that leg onlylatencyMs: the negotiated latency for that individual leg's own socket (msRcvTsbPdDelayfrom the member's ownsrt_bstats, not the group), so if an encoder ever negotiates a different value per leg it's visible herebandwidthMbps,recvRateMbps,belatedTotal,belatedAvgMs,undecryptTotal,reorderDistance,rcvBufMs: the same set of extra stats asinput.*above, but for that leg's own socket specifically — reliably populated per leg even though the group-levelinput.*versions are not
output fields:
sentPacketsTotal,sendLossTotal,sendDropTotal,retransTotal: send-side counters for the downstream connectionrttMs: RTT for the downstream connectionlatencyMs: negotiated latency on the downstreamsrt://connection the relay makes to the output (fromsrt_bstats'msRcvTsbPdDelay, same value asinput.latencyMs); the relay's defaultbuild_srt_uri()requestslatency=200here vs.latency=240on the bonded input listenerbandwidthMbps,sendRateMbps,undecryptTotal,sndBufMs: capacity, actual send rate, decryption failures, and send-buffer occupancy (ms) — the output socket is never a group, so these are populated after the first successful output stats sample while connected
Example response for a stream bonded over two legs (reformatted here for readability — the actual response is one compact line):
{
"pid": 12345,
"startedAtMs": 1783569708552,
"updatedAtMs": 1783569763437,
"lastError": null,
"activeStreamIds": ["camera1/main"],
"streamStates": [
{
"streamId": "camera1/main",
"inputActive": true,
"outputConnected": true,
"retryFailures": 0,
"forwardedPackets": 28,
"forwardedBytes": 36848,
"lastPacketAt": 1783569742530,
"lastInputPacketAt": 1783569742529,
"input": {
"recvPacketsTotal": null,
"recvUniquePacketsTotal": 22,
"recvLossTotal": 0,
"recvDropTotal": 0,
"retransTotal": 0,
"rttMs": null,
"latencyMs": 240,
"bandwidthMbps": null,
"recvRateMbps": null,
"belatedTotal": null,
"belatedAvgMs": null,
"undecryptTotal": null,
"reorderDistance": null,
"rcvBufMs": null,
"legs": [
{
"ip": "10.0.1.20",
"port": 51072,
"state": "running",
"rttMs": 24.6,
"latencyMs": 240,
"recvPacketsTotal": 26,
"recvUniquePacketsTotal": 26,
"recvLossTotal": 0,
"recvDropTotal": 0,
"retransTotal": 0,
"bandwidthMbps": 12.3,
"recvRateMbps": 0.47,
"belatedTotal": 0,
"belatedAvgMs": 0.0,
"undecryptTotal": 0,
"reorderDistance": 0,
"rcvBufMs": 221
},
{
"ip": "10.0.2.31",
"port": 39356,
"state": "running",
"rttMs": 31.2,
"latencyMs": 240,
"recvPacketsTotal": 25,
"recvUniquePacketsTotal": 25,
"recvLossTotal": 1,
"recvDropTotal": 0,
"retransTotal": 0,
"bandwidthMbps": 9.8,
"recvRateMbps": 0.46,
"belatedTotal": 2,
"belatedAvgMs": 6.4,
"undecryptTotal": 0,
"reorderDistance": 1,
"rcvBufMs": 235
}
]
},
"output": {
"sentPacketsTotal": 21,
"sendLossTotal": 0,
"sendDropTotal": 0,
"retransTotal": 0,
"rttMs": 0.043,
"latencyMs": 200,
"bandwidthMbps": 11.8,
"sendRateMbps": 0.42,
"undecryptTotal": 0,
"sndBufMs": 1
},
"lastErrorAt": 0,
"lastError": null
}
]
}The relay version is stored in the repo's VERSION file and embedded into the
binary at build time.
bash scripts/build-local.shCheck the embedded version:
./objs/srt-bonding-relay --versionRun it:
bash scripts/run.shSmoke test it locally:
bash scripts/smoke-test.shFormat C sources:
bash scripts/format.shBuild the release asset:
bash scripts/build-release.shThat produces:
build/srt-bonding-relay-linux-x86_64.tar.gz
Publish it to GitHub Releases, for example:
gh release create "$(cat VERSION)" build/srt-bonding-relay-linux-x86_64.tar.gz \
--title "srt-bonding-relay $(cat VERSION)" \
--notes "Initial standalone release"Before a release, update VERSION first. The binary version is embedded at
build time, so changing VERSION after the asset is built will not update the
already-built archive.
If the release already exists:
gh release upload "$(cat VERSION)" build/srt-bonding-relay-linux-x86_64.tar.gz --clobberThis repo includes a GitHub Actions workflow that:
- builds the relay with Docker
- checks C formatting with
clang-format - checks the usage path
- starts the relay with
srt-bonding-relay.json - verifies the local HTTP status endpoint responds