├── debian ├── compat ├── source │ ├── format │ └── options ├── srtrelay-docs.docs ├── rules ├── README ├── control ├── changelog └── copyright ├── mpegts ├── h264.ts ├── h265.ts ├── h264_long.ts ├── h265_long.ts ├── pes.go ├── h264.go ├── h265.go ├── psi.go ├── packet_test.go ├── packet.go ├── parser_test.go └── parser.go ├── internal └── metrics │ └── metrics.go ├── .gitignore ├── auth ├── authenticator.go ├── static.go ├── static_test.go ├── http_test.go └── http.go ├── Makefile ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── release.yml │ ├── golangci-lint.yml │ └── docker.yml ├── scripts ├── install-dependencies.sh └── stable-ffplay.sh ├── docs ├── Contributing.md └── API.md ├── proto └── srtrelay.proto ├── config ├── testfiles │ └── config_test.toml ├── config_test.go └── config.go ├── .golangci.yml ├── LICENSE ├── go.mod ├── Dockerfile ├── format └── demuxer.go ├── relay ├── channel_test.go ├── relay_test.go ├── relay.go └── channel.go ├── api ├── server.go └── metrics.go ├── config.toml.example ├── README.md ├── srt ├── server_test.go └── server.go ├── main.go ├── stream ├── streamid.go └── streamid_test.go ├── cmd └── srtbench │ └── bench.go └── go.sum /debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /debian/srtrelay-docs.docs: -------------------------------------------------------------------------------- 1 | README 2 | -------------------------------------------------------------------------------- /mpegts/h264.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voc/srtrelay/HEAD/mpegts/h264.ts -------------------------------------------------------------------------------- /mpegts/h265.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voc/srtrelay/HEAD/mpegts/h265.ts -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | %: 3 | dh $@ 4 | 5 | override_dh_usrlocal: 6 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | const Namespace = "srtrelay" 4 | -------------------------------------------------------------------------------- /mpegts/h264_long.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voc/srtrelay/HEAD/mpegts/h264_long.ts -------------------------------------------------------------------------------- /mpegts/h265_long.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voc/srtrelay/HEAD/mpegts/h265_long.ts -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | srtrelay 2 | config.toml 3 | # VSCode 4 | /.vscode 5 | # Jetbrains 6 | /.idea 7 | *.iml -------------------------------------------------------------------------------- /debian/source/options: -------------------------------------------------------------------------------- 1 | tar-ignore = ".git" 2 | tar-ignore = "CMakeFiles" 3 | tar-ignore = "rc.hex" 4 | tar-ignore = "obj-*" 5 | -------------------------------------------------------------------------------- /debian/README: -------------------------------------------------------------------------------- 1 | srtrelay 2 | -------- 3 | 4 | Streaming-Relay for the SRT-protocol 5 | 6 | -- derchris Thu, 08 Apr 2021 22:56:00 +0200 7 | -------------------------------------------------------------------------------- /auth/authenticator.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import "github.com/voc/srtrelay/stream" 4 | 5 | type Authenticator interface { 6 | Authenticate(stream.StreamID) bool 7 | } 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: srtrelay 2 | srtrelay: 3 | go build -o srtrelay 4 | 5 | install: srtrelay 6 | mkdir -p $$(pwd)/debian/srtrelay/usr/bin 7 | install -m 0755 srtrelay $$(pwd)/debian/srtrelay/usr/bin 8 | 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "monthly" 11 | -------------------------------------------------------------------------------- /scripts/install-dependencies.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | sudo apt update 4 | sudo apt install -y tclsh pkg-config cmake libssl-dev build-essential ffmpeg 5 | git clone --depth 1 --branch v1.5.3 https://github.com/Haivision/srt.git libsrt 6 | cd libsrt 7 | ./configure 8 | make -j 9 | sudo make install 10 | -------------------------------------------------------------------------------- /docs/Contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Please run tests before creating a pull request 3 | ``` 4 | go test ./... 5 | ``` 6 | 7 | ## General concepts 8 | - srtrelay hsould just be a 1:n multiplexer, one publisher (push) to multiple subscribers (pull) 9 | - Don't try to reimplement functionality already present elsewhere in the stack (e.g. remuxing/transcoding) 10 | - Allow any data to be relayed, not just MPEG-TS -------------------------------------------------------------------------------- /proto/srtrelay.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package voc.srtrelay.proto; 3 | // import "google/protobuf/empty.proto"; 4 | option go_package = "github.com/voc/srtrelay/proto"; 5 | 6 | message AddStream { 7 | string slug = 1; 8 | } 9 | 10 | message RemoveStream { 11 | string slug = 1; 12 | } 13 | 14 | message Notification { 15 | oneof payload { 16 | AddStream add_stream = 1; 17 | RemoveStream remove_stream = 2; 18 | } 19 | } -------------------------------------------------------------------------------- /mpegts/pes.go: -------------------------------------------------------------------------------- 1 | package mpegts 2 | 3 | // PES constants 4 | const ( 5 | PESStartCode = 0x000001 6 | MaxPayloadSize = PacketLen - 4 7 | PESHeaderSize = 6 8 | PESMaxLength = 200 * 1024 9 | 10 | PESStreamIDAudio = 0xc0 11 | PESStreamIDVideo = 0xe0 12 | ) 13 | 14 | type ElementaryStream struct { 15 | HasInit bool 16 | CodecParser CodecParser 17 | } 18 | 19 | type CodecParser interface { 20 | ContainsInit(pkt *Packet) (bool, error) 21 | } 22 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: srtrelay 2 | Section: video 3 | Priority: optional 4 | Maintainer: derchris 5 | Build-Depends: debhelper (>=10), pkgconf, ssl-cert, ca-certificates, tar, wget, curl, git, golang, libsrt-openssl-dev 6 | Standards-Version: 4.50 7 | Homepage: https://github.com/voc/srtrelay 8 | 9 | Package: srtrelay 10 | Priority: extra 11 | Architecture: any 12 | Depends: ${shlibs:Depends}, libsrt1-openssl 13 | Description: srtrelay 14 | Streaming-Relay for the SRT-protocol 15 | -------------------------------------------------------------------------------- /config/testfiles/config_test.toml: -------------------------------------------------------------------------------- 1 | [app] 2 | addresses = ["127.0.0.1:5432"] 3 | latency = 1337 4 | buffersize = 123000 5 | syncClients = true 6 | packetSize = 1456 7 | lossMaxTTL= 50 8 | publicAddress = "dontlookmeup:5432" 9 | listenBacklog = 30 10 | 11 | [api] 12 | enabled = false 13 | address = ":1234" 14 | 15 | [auth] 16 | type = "http" 17 | 18 | [auth.static] 19 | allow = ["play/*"] 20 | 21 | [auth.http] 22 | url = "http://localhost:1235/publish" 23 | timeout = "5s" 24 | application = "foo" 25 | passwordParam = "pass" -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - misspell 5 | exclusions: 6 | generated: lax 7 | presets: 8 | - comments 9 | - common-false-positives 10 | - legacy 11 | - std-error-handling 12 | rules: 13 | - linters: 14 | - errcheck 15 | path: _test.go 16 | paths: 17 | - third_party$ 18 | - builtin$ 19 | - examples$ 20 | formatters: 21 | enable: 22 | - goimports 23 | exclusions: 24 | generated: lax 25 | paths: 26 | - third_party$ 27 | - builtin$ 28 | - examples$ 29 | -------------------------------------------------------------------------------- /scripts/stable-ffplay.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # srt-live-transmit wrapper which actually manages to reconnect a failed srt stream 3 | url=$1 4 | pid=$$ 5 | fifo="/tmp/srt.${pid}" 6 | 7 | trap 'rm "${fifo}"' EXIT 8 | 9 | while true; do 10 | srt-live-transmit -a no "${url}" file://con > ${fifo} & 11 | p1=$! 12 | ffplay -v warning -hide_banner -nostats ${fifo} & 13 | p2=$! 14 | 15 | # check whether we still have 2 running jobs 16 | num_jobs="$(jobs -p | wc -l)" 17 | while [ $num_jobs -ge 2 ]; do 18 | sleep 1 19 | num_jobs="$(jobs -p | wc -l)" 20 | done 21 | 22 | kill ${p1} ${p2} 23 | sleep 1 24 | done 25 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request, pull_request_target] 4 | 5 | jobs: 6 | build: 7 | runs-on: "ubuntu-22.04" 8 | steps: 9 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 10 | 11 | - name: Set up Go 12 | uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 13 | with: 14 | go-version: 1.24 15 | 16 | - name: Install dependencies 17 | run: ./scripts/install-dependencies.sh 18 | 19 | - name: Build 20 | run: go build -v ./... 21 | 22 | - name: Test 23 | run: LD_LIBRARY_PATH="/usr/local/lib/" go test -v ./... 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Debian Package on Release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | build-deb: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout source 14 | uses: actions/checkout@v5 15 | 16 | - name: Build Debian package 17 | uses: jtdor/build-deb-action@v1 18 | with: 19 | docker-image: debian:bookworm-backports 20 | signed: false 21 | 22 | - name: Upload .deb to GitHub Release 23 | uses: softprops/action-gh-release@v2 24 | with: 25 | files: build/*.deb 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | -------------------------------------------------------------------------------- /auth/static.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/voc/srtrelay/stream" 5 | ) 6 | 7 | type StaticAuth struct { 8 | allow []string 9 | } 10 | 11 | type StaticAuthConfig struct { 12 | Allow []string 13 | } 14 | 15 | // NewStaticAuth creates an Authenticator with a static config backend 16 | func NewStaticAuth(config StaticAuthConfig) *StaticAuth { 17 | return &StaticAuth{ 18 | allow: config.Allow, 19 | } 20 | } 21 | 22 | // Implement Authenticator 23 | 24 | // Authenticate tries to match the stream id against the locally 25 | // configured matches in the allowlist. 26 | func (auth *StaticAuth) Authenticate(streamid stream.StreamID) bool { 27 | for _, allowed := range auth.allow { 28 | if streamid.Match(allowed) { 29 | return true 30 | } 31 | } 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /auth/static_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/voc/srtrelay/stream" 7 | ) 8 | 9 | func TestStaticAuth_Authenticate(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | allow []string 13 | streamid string 14 | want bool 15 | }{ 16 | {"MatchFirst", []string{"play/*", ""}, "play/foobar", true}, 17 | {"MatchLast", []string{"", "play/*"}, "play/foobar", true}, 18 | {"MatchNone", []string{}, "play/foobar", false}, 19 | } 20 | for _, tt := range tests { 21 | t.Run(tt.name, func(t *testing.T) { 22 | auth := NewStaticAuth(StaticAuthConfig{ 23 | Allow: tt.allow, 24 | }) 25 | streamid := stream.StreamID{} 26 | if err := streamid.FromString(tt.streamid); err != nil { 27 | t.Error(err) 28 | } 29 | if got := auth.Authenticate(streamid); got != tt.want { 30 | t.Errorf("StaticAuth.Authenticate() = %v, want %v", got, tt.want) 31 | } 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /mpegts/h264.go: -------------------------------------------------------------------------------- 1 | package mpegts 2 | 3 | // AVC NAL unit type constants 4 | const ( 5 | NALUnitCodedSliceIDR = 5 6 | NALUnitTypeSPS = 7 7 | NALUnitTypePPS = 8 8 | ) 9 | 10 | // H264Parser parser for h.264 init packets 11 | type H264Parser struct{} 12 | 13 | // ContainsInit checks whether the MPEG-TS packet contains a h.264 PPS or SPS 14 | func (p H264Parser) ContainsInit(pkt *Packet) (bool, error) { 15 | var state byte 16 | buf := pkt.Payload() 17 | for i := 0; i < len(buf); i++ { 18 | if state == 0x57 { 19 | nalType := buf[i] & 0x1F 20 | switch nalType { 21 | case NALUnitTypeSPS: 22 | fallthrough 23 | case NALUnitTypePPS: 24 | return true, nil 25 | } 26 | } 27 | 28 | cur := 0 29 | switch buf[i] { 30 | case 0x00: 31 | cur = 1 32 | case 0x01: 33 | cur = 3 34 | default: 35 | cur = 2 36 | } 37 | 38 | /* state of last four bytes packed into one byte; two bits for unseen/zero/over 39 | * one/one (0..3 respectively). 40 | */ 41 | state = (state << 2) | byte(cur) 42 | } 43 | return false, nil 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Anton Schubert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/voc/srtrelay 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/IGLOU-EU/go-wildcard/v2 v2.1.0 7 | github.com/Showmax/go-fqdn v1.0.0 8 | github.com/datarhei/gosrt v0.9.0 9 | github.com/haivision/srtgo v0.0.0-20230627061225-a70d53fcd618 10 | github.com/pelletier/go-toml/v2 v2.2.4 11 | github.com/prometheus/client_golang v1.23.2 12 | gotest.tools/v3 v3.5.2 13 | ) 14 | 15 | require ( 16 | github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c // indirect 17 | github.com/beorn7/perks v1.0.1 // indirect 18 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 19 | github.com/google/go-cmp v0.7.0 // indirect 20 | github.com/kr/text v0.2.0 // indirect 21 | github.com/mattn/go-pointer v0.0.1 // indirect 22 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 23 | github.com/prometheus/client_model v0.6.2 // indirect 24 | github.com/prometheus/common v0.66.1 // indirect 25 | github.com/prometheus/procfs v0.16.1 // indirect 26 | go.yaml.in/yaml/v2 v2.4.2 // indirect 27 | golang.org/x/crypto v0.33.0 // indirect 28 | golang.org/x/sys v0.35.0 // indirect 29 | google.golang.org/protobuf v1.36.8 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /mpegts/h265.go: -------------------------------------------------------------------------------- 1 | package mpegts 2 | 3 | // AVC NAL unit type constants 4 | const ( 5 | HEVCNALUnitTypeVPS = 32 6 | HEVCNALUnitTypeSPS = 33 7 | HEVCNALUnitTypePPS = 34 8 | ) 9 | 10 | // H265Parser parser for h.265 (HEVC) init packets 11 | type H265Parser struct{} 12 | 13 | // ContainsInit checks whether the MPEG-TS packet contains a H.265 VPS, SPS, or PPS 14 | func (p H265Parser) ContainsInit(pkt *Packet) (bool, error) { 15 | var state byte 16 | buf := pkt.Payload() 17 | for i := 0; i < len(buf); i++ { 18 | if state == 0x57 { 19 | // H.265 NAL unit type is in bits 1–6 of the first byte after start code 20 | nalType := (buf[i] >> 1) & 0x3F 21 | switch nalType { 22 | case HEVCNALUnitTypeVPS, 23 | HEVCNALUnitTypeSPS, 24 | HEVCNALUnitTypePPS: 25 | return true, nil 26 | } 27 | } 28 | 29 | cur := 0 30 | switch buf[i] { 31 | case 0x00: 32 | cur = 1 33 | case 0x01: 34 | cur = 3 35 | default: 36 | cur = 2 37 | } 38 | 39 | /* state of last four bytes packed into one byte; two bits for unseen/zero/over 40 | * one/one (0..3 respectively). 41 | */ 42 | state = (state << 2) | byte(cur) 43 | } 44 | return false, nil 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: golangci-lint 3 | on: 4 | push: 5 | paths: 6 | - "go.sum" 7 | - "go.mod" 8 | - "**.go" 9 | - ".github/workflows/golangci-lint.yml" 10 | - ".golangci.yml" 11 | pull_request: 12 | 13 | permissions: # added using https://github.com/step-security/secure-repo 14 | contents: read 15 | 16 | jobs: 17 | golangci: 18 | permissions: 19 | contents: read # for actions/checkout to fetch code 20 | pull-requests: read # for golangci/golangci-lint-action to fetch pull requests 21 | name: lint 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 26 | - name: Install Go 27 | uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 28 | with: 29 | go-version: 1.24.x 30 | - name: Install dependencies 31 | run: ./scripts/install-dependencies.sh 32 | - name: Lint 33 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 34 | with: 35 | args: --verbose 36 | version: v2.1.6 37 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # run every second morning 7 | - cron: '30 5 1/2 * *' 8 | 9 | jobs: 10 | docker: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 15 | 16 | - name: Login to Github Packages 17 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 18 | with: 19 | registry: ghcr.io 20 | username: ${{ github.actor }} 21 | password: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: Build and push 24 | id: docker_build 25 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 26 | with: 27 | push: true 28 | tags: ghcr.io/voc/srtrelay/srtrelay:latest 29 | 30 | - name: Delete old containers 31 | uses: actions/delete-package-versions@e5bc658cc4c965c472efe991f8beea3981499c55 # v5.0.0 32 | with: 33 | package-name: 'srtrelay/srtrelay' 34 | package-type: 'container' 35 | min-versions-to-keep: 10 36 | delete-only-untagged-versions: 'true' 37 | -------------------------------------------------------------------------------- /config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/voc/srtrelay/auth" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestConfig(t *testing.T) { 12 | conf, err := Parse([]string{"testfiles/config_test.toml"}) 13 | if err != nil { 14 | t.Error(err) 15 | } 16 | assert.Equal(t, conf.App.Addresses[0], "127.0.0.1:5432") 17 | assert.Equal(t, conf.App.Latency, uint(1337)) 18 | assert.Equal(t, conf.App.Buffersize, uint(123000)) 19 | assert.Equal(t, conf.App.SyncClients, true) 20 | assert.Equal(t, conf.App.PacketSize, uint(1456)) 21 | assert.Equal(t, conf.App.LossMaxTTL, uint(50)) 22 | assert.Equal(t, conf.App.PublicAddress, "dontlookmeup:5432") 23 | assert.Equal(t, conf.App.ListenBacklog, 30) 24 | 25 | assert.Equal(t, conf.API.Enabled, false) 26 | assert.Equal(t, conf.API.Address, ":1234") 27 | 28 | assert.Equal(t, conf.Auth.Type, "http") 29 | assert.Equal(t, conf.Auth.Static.Allow[0], "play/*") 30 | assert.Equal(t, conf.Auth.HTTP.URL, "http://localhost:1235/publish") 31 | assert.Equal(t, conf.Auth.HTTP.Timeout, auth.Duration(time.Second*5)) 32 | assert.Equal(t, conf.Auth.HTTP.Application, "foo") 33 | assert.Equal(t, conf.Auth.HTTP.PasswordParam, "pass") 34 | } 35 | -------------------------------------------------------------------------------- /auth/http_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/voc/srtrelay/stream" 9 | ) 10 | 11 | func serverMock() *httptest.Server { 12 | handler := http.NewServeMux() 13 | handler.HandleFunc("/ok", func(w http.ResponseWriter, r *http.Request) { 14 | w.Write([]byte("Ok")) 15 | }) 16 | handler.HandleFunc("/unauthorized", func(w http.ResponseWriter, r *http.Request) { 17 | http.Error(w, "Not authorized", http.StatusUnauthorized) 18 | }) 19 | 20 | srv := httptest.NewServer(handler) 21 | 22 | return srv 23 | } 24 | 25 | func Test_httpAuth_Authenticate(t *testing.T) { 26 | srv := serverMock() 27 | defer srv.Close() 28 | 29 | tests := []struct { 30 | name string 31 | url string 32 | want bool 33 | }{ 34 | {"AuthOk", "/ok", true}, 35 | {"AuthFail", "/unauthorized", false}, 36 | } 37 | 38 | for _, tt := range tests { 39 | t.Run(tt.name, func(t *testing.T) { 40 | auth := NewHTTPAuth(HTTPAuthConfig{ 41 | URL: srv.URL + tt.url, 42 | }) 43 | 44 | streamid := stream.StreamID{} 45 | 46 | if got := auth.Authenticate(streamid); got != tt.want { 47 | t.Errorf("httpAuth.Authenticate() = %v, want %v", got, tt.want) 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /mpegts/psi.go: -------------------------------------------------------------------------------- 1 | package mpegts 2 | 3 | import ( 4 | "encoding/binary" 5 | "io" 6 | ) 7 | 8 | // PSI constants 9 | const ( 10 | PSIHeaderLen = 8 11 | ) 12 | 13 | // PSI table type constants 14 | const ( 15 | TableTypePAT = 0x0 16 | TableTypePMT = 0x2 17 | ) 18 | 19 | // Elementary Stream StreamType constants 20 | const ( 21 | StreamTypeAudio = 0xf 22 | StreamTypeAVCVideo = 0x1b 23 | ) 24 | 25 | // PSIHeader struct 26 | type PSIHeader struct { 27 | tableID byte 28 | sectionLength uint16 29 | versionNumber byte 30 | currentNext bool // true means current table version is valid, false means current table version not yet valid 31 | sectionNumber byte // number of current section 32 | lastSectionNumber byte // number of last table section 33 | } 34 | 35 | // ParsePSIHeader func 36 | func ParsePSIHeader(data []byte) (*PSIHeader, error) { 37 | hdr := PSIHeader{} 38 | if len(data) < 3 { 39 | return nil, io.ErrUnexpectedEOF 40 | } 41 | hdr.tableID = data[0] 42 | hdr.sectionLength = binary.BigEndian.Uint16(data[1:3]) & 0xfff 43 | 44 | if len(data) < int(3+hdr.sectionLength) { 45 | return nil, io.ErrUnexpectedEOF 46 | } 47 | 48 | hdr.versionNumber = data[5] >> 1 & 0x1f 49 | currentNext := data[5] & 0x1 50 | if currentNext == 1 { 51 | hdr.currentNext = true 52 | } 53 | 54 | hdr.sectionNumber = data[6] 55 | hdr.lastSectionNumber = data[7] 56 | 57 | return &hdr, nil 58 | } 59 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM golang:1.24-bookworm AS build 3 | 4 | RUN apt-get update && \ 5 | apt-get upgrade -y && \ 6 | apt-get clean && \ 7 | rm -rf /var/lib/apt/lists/* 8 | 9 | RUN apt-get update && \ 10 | apt-get install --no-install-recommends -y git tclsh pkg-config cmake libssl-dev build-essential ninja-build && \ 11 | git clone --depth 1 --branch v1.5.3 https://github.com/Haivision/srt.git libsrt && \ 12 | cmake -S libsrt -B libsrt-build -G Ninja && \ 13 | ninja -C libsrt-build && \ 14 | ninja -C libsrt-build install && \ 15 | rm -rf libsrt libsrt-build && \ 16 | apt-get purge -y git tclsh pkg-config cmake libssl-dev build-essential ninja-build && \ 17 | apt-get autoremove -y && \ 18 | apt-get clean && \ 19 | rm -rf /var/lib/apt/lists/* 20 | 21 | COPY . /build 22 | WORKDIR /build 23 | ARG TARGETARCH 24 | RUN GOOS=linux GOARCH=$TARGETARCH go build -ldflags="-w -s" -v -o srtrelay . 25 | 26 | # clean start 27 | FROM debian:bookworm 28 | RUN apt-get update && \ 29 | apt-get upgrade -y && \ 30 | apt-get install --no-install-recommends -y libssl3 && \ 31 | apt-get clean && \ 32 | rm -rf /var/lib/apt/lists/* 33 | RUN useradd -l -m -r srtrelay 34 | USER srtrelay 35 | WORKDIR /home/srtrelay/ 36 | COPY --from=build /build/config.toml.example ./config.toml 37 | COPY --from=build /build/srtrelay ./ 38 | COPY --from=build /usr/local/lib/libsrt* /usr/lib/ 39 | CMD ["./srtrelay"] 40 | -------------------------------------------------------------------------------- /format/demuxer.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "github.com/voc/srtrelay/mpegts" 5 | ) 6 | 7 | // TransportType type 8 | type TransportType uint 9 | 10 | // TransportType constants 11 | const ( 12 | Unknown = 0 13 | MpegTs = 1 14 | ) 15 | 16 | // Demuxer used for finding synchronization point for onboarding a client 17 | type Demuxer struct { 18 | transport TransportType 19 | parser *mpegts.Parser 20 | } 21 | 22 | func NewDemuxer() *Demuxer { 23 | return &Demuxer{ 24 | parser: mpegts.NewParser(), 25 | } 26 | } 27 | 28 | // DetermineTransport tries to detect the type of transport from the stream 29 | // If the type is not clear it returns Unknown 30 | func DetermineTransport(data []byte) TransportType { 31 | if len(data) >= mpegts.HeaderLen && data[0] == mpegts.SyncByte { 32 | return MpegTs 33 | } 34 | 35 | return Unknown 36 | } 37 | 38 | // FindInit determines a synchronization point in the stream 39 | // Finally it returns the required stream packets up to that point 40 | func (d *Demuxer) FindInit(data []byte) ([][]byte, error) { 41 | if d.transport == Unknown { 42 | d.transport = DetermineTransport(data) 43 | } 44 | 45 | switch d.transport { 46 | case MpegTs: 47 | err := d.parser.Parse(data) 48 | if err != nil { 49 | return nil, err 50 | } 51 | res, err := d.parser.InitData() 52 | if err != nil { 53 | return nil, err 54 | } 55 | return res, nil 56 | default: 57 | return make([][]byte, 0), nil 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | srtrelay (1.4.0) stable; urgency=medium 2 | 3 | * fix srt-listener getting stuck with multiple clients re-/disconnecting 4 | * ignore SRT streamid keys sent by Blackmagic Atem Mini Pro 5 | * disconnect client early if stream is not found/is duplicated, thanks to @hanatyan128 6 | 7 | -- ischluff Tue, 17 Jun 2025 12:10:00 +0100 8 | 9 | srtrelay (1.3.0) stable; urgency=medium 10 | 11 | * make listenBacklog configurable 12 | * support recommended SRT streamid syntax https://github.com/Haivision/srt/blob/master/docs/features/access-control.md, thanks to @gpmidi 13 | * add prometheus-compatible /metrics endpoint, thanks to @SuperQ 14 | 15 | -- ischluff Mon, 26 Dec 2024 18:30:00 +0100 16 | 17 | srtrelay (1.2.0) stable; urgency=medium 18 | 19 | * configurable packet size 20 | * improved performance with buffer reuse 21 | * added pprof endpoint for debugging in production 22 | * updated srtgo to fix some race conditions 23 | 24 | -- ischluff Mon, 06 Nov 2023 18:30:00 +0100 25 | 26 | srtrelay (1.1.0) stable; urgency=medium 27 | 28 | * add publicAddress for configuring url in API responses 29 | * reduce log spam 30 | 31 | -- ischluff Mon, 20 Dec 2021 18:30:00 +0100 32 | 33 | srtrelay (1.0.0) stable; urgency=medium 34 | 35 | * Add early auth in SRT listen-callback 36 | * Fix graceful shutdown 37 | * Allow listening on multiple addresses 38 | 39 | -- ischluff Thu, 02 Dec 2021 21:38:00 +0100 40 | 41 | 42 | srtrelay (0.1.0) buster; urgency=medium 43 | 44 | * Initial release. 45 | 46 | -- derchris Thu, 08 Apr 2021 22:56:00 +0200 47 | -------------------------------------------------------------------------------- /mpegts/packet_test.go: -------------------------------------------------------------------------------- 1 | package mpegts 2 | 3 | import ( 4 | "encoding/hex" 5 | "testing" 6 | ) 7 | 8 | func TestPacket_ToBytes_FromBytes(t *testing.T) { 9 | buf := make([]byte, PacketLen) 10 | payload := []byte{1, 2, 3, 4, 6} 11 | 12 | // pad with adaptationField 13 | adaptationLen := MaxPayloadSize - len(payload) - 1 14 | adaptationField := make([]byte, adaptationLen) 15 | adaptationField[0] = 0x3 << 6 16 | for i := 1; i < adaptationLen; i++ { 17 | adaptationField[i] = 0xff 18 | } 19 | 20 | // encode packet 21 | pkt1 := CreatePacket(0x100). 22 | WithPayload(payload). 23 | WithAdaptationField(adaptationField). 24 | WithPUSI(true) 25 | err := pkt1.ToBytes(buf) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | // parse packet 31 | pkt2 := Packet{} 32 | err = pkt2.FromBytes(buf) 33 | if err != nil { 34 | t.Fatal(err, hex.Dump(buf)) 35 | } 36 | 37 | if pkt1.PID() != pkt2.PID() { 38 | t.Errorf("Failed to encode/parse PID, got: %d, expected: %d", pkt2.PID(), pkt1.PID()) 39 | } 40 | 41 | if pkt1.PUSI() != pkt2.PUSI() { 42 | t.Errorf("Failed to encode/parse PUSI, got: %v, expected: %v", pkt2.PUSI(), pkt1.PUSI()) 43 | } 44 | 45 | if hex.EncodeToString(pkt1.Payload()) != hex.EncodeToString(pkt2.Payload()) { 46 | t.Errorf("Failed to encode/parse Payload,\n got: %s,\n expected %s", 47 | hex.EncodeToString(pkt2.Payload()), 48 | hex.EncodeToString(pkt1.Payload())) 49 | } 50 | 51 | if hex.EncodeToString(pkt1.AdaptationField()) != hex.EncodeToString(pkt2.AdaptationField()) { 52 | t.Errorf("Failed to encode/parse AdaptationField,\n got: %s,\n expected %s", 53 | hex.EncodeToString(pkt2.AdaptationField()), 54 | hex.EncodeToString(pkt1.AdaptationField())) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: srtrelay 3 | Source: https://github.com/voc/srtrelay 4 | 5 | Files: * 6 | Copyright: 2021 iSchluff 7 | License: MIT 8 | 9 | Files: debian/* 10 | Copyright: 2021 derchris 11 | License: GPL-3.0+ 12 | 13 | License: MIT 14 | Permission is hereby granted, free of charge, to any person obtaining a copy 15 | of this software and associated documentation files (the "Software"), to deal 16 | in the Software without restriction, including without limitation the rights 17 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 18 | copies of the Software, and to permit persons to whom the Software is 19 | furnished to do so, subject to the following conditions: 20 | 21 | The above copyright notice and this permission notice shall be included in 22 | all copies or substantial portions of the Software. 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 25 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 26 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 27 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 28 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 29 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 30 | SOFTWARE. 31 | 32 | License: GPL-3.0+ 33 | This program is free software: you can redistribute it and/or modify 34 | it under the terms of the GNU General Public License as published by 35 | the Free Software Foundation, either version 3 of the License, or 36 | (at your option) any later version. 37 | . 38 | This package is distributed in the hope that it will be useful, 39 | but WITHOUT ANY WARRANTY; without even the implied warranty of 40 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 41 | GNU General Public License for more details. 42 | . 43 | You should have received a copy of the GNU General Public License 44 | along with this program. If not, see . 45 | . 46 | On Debian systems, the complete text of the GNU General 47 | Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". 48 | -------------------------------------------------------------------------------- /relay/channel_test.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestChannel_PubSub(t *testing.T) { 9 | ch := NewChannel("test", uint(1316*50)) 10 | 11 | // sub 12 | out, unsub := ch.Sub() 13 | data := []byte{1, 2, 3, 4} 14 | 15 | // pub 16 | ch.Pub(data) 17 | got := <-out 18 | 19 | if !reflect.DeepEqual(got, data) { 20 | t.Errorf("Sub ret = %x, want %x", got, data) 21 | } 22 | 23 | // unsub 24 | unsub() 25 | 26 | // pub2 27 | ch.Pub(data) 28 | got = <-out 29 | 30 | if got != nil { 31 | t.Errorf("Read after unsub ret %x, want nil", got) 32 | } 33 | } 34 | 35 | func TestChannel_DropOnOverflow(t *testing.T) { 36 | ch := NewChannel("test", uint(1316*50)) 37 | 38 | sub, _ := ch.Sub() 39 | capacity := cap(sub) + 1 40 | 41 | // Overflow subscriber on purpose 42 | for i := 0; i < capacity; i++ { 43 | ch.Pub([]byte{}) 44 | // Should drop and close subscriber on last iteration 45 | } 46 | 47 | // Check removal 48 | if remaining := len(ch.subs); remaining > 0 { 49 | t.Errorf("Got %d remaining channels, expected 0", remaining) 50 | } 51 | 52 | // Read elements from buffer 53 | for i := 0; i < capacity; i++ { 54 | expected := (i < capacity-1) 55 | _, got := <-sub 56 | if got != expected { 57 | t.Errorf("Channel read expects %t on pos %d, got %t", expected, i, got) 58 | } 59 | } 60 | } 61 | 62 | func TestChannel_Close(t *testing.T) { 63 | ch := NewChannel("test", 0) 64 | sub1, _ := ch.Sub() 65 | _, _ = ch.Sub() 66 | 67 | if got := len(ch.subs); got != 2 { 68 | t.Fatalf("Expected 2 subscribers on channel, got %d", got) 69 | } 70 | 71 | ch.Close() 72 | 73 | if got := len(ch.subs); got != 0 { 74 | t.Errorf("Expected 0 subscribers after close, got %d", got) 75 | } 76 | 77 | if _, ok := <-sub1; ok { 78 | t.Error("Subscriber channel should be closed after Close") 79 | } 80 | } 81 | 82 | func TestChannel_Stats(t *testing.T) { 83 | ch := NewChannel("test", 0) 84 | if num := ch.Stats().clients; num != 0 { 85 | t.Errorf("Expected 0 clients after create, got %d", num) 86 | } 87 | 88 | _, unsubscribe := ch.Sub() 89 | if num := ch.Stats().clients; num != 1 { 90 | t.Errorf("Expected 1 clients after subscribe, got %d", num) 91 | } 92 | 93 | unsubscribe() 94 | if num := ch.Stats().clients; num != 0 { 95 | t.Errorf("Expected 0 clients after unsubscribe, got %d", num) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "log" 8 | "net/http" 9 | "sync" 10 | "time" 11 | 12 | "github.com/voc/srtrelay/config" 13 | "github.com/voc/srtrelay/srt" 14 | 15 | "github.com/prometheus/client_golang/prometheus" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | ) 18 | 19 | // Server serves HTTP API requests 20 | type Server struct { 21 | conf config.APIConfig 22 | srtServer srt.Server 23 | done sync.WaitGroup 24 | } 25 | 26 | func NewServer(conf config.APIConfig, srtServer srt.Server) *Server { 27 | prometheus.MustRegister(NewExporter(srtServer)) 28 | log.Println("Registered server metrics") 29 | return &Server{ 30 | conf: conf, 31 | srtServer: srtServer, 32 | } 33 | } 34 | 35 | func (s *Server) Listen(ctx context.Context) error { 36 | mux := http.NewServeMux() 37 | mux.HandleFunc("/streams", s.HandleStreams) 38 | mux.HandleFunc("/sockets", s.HandleSockets) 39 | mux.Handle("/metrics", promhttp.Handler()) 40 | serv := &http.Server{ 41 | Addr: s.conf.Address, 42 | Handler: mux, 43 | ReadTimeout: 5 * time.Second, 44 | WriteTimeout: 5 * time.Second, 45 | MaxHeaderBytes: 1 << 14, 46 | } 47 | 48 | s.done.Add(2) 49 | // http listener 50 | go func() { 51 | defer s.done.Done() 52 | err := serv.ListenAndServe() 53 | if err != nil && !errors.Is(err, http.ErrServerClosed) { 54 | log.Println(err) 55 | } 56 | }() 57 | 58 | // shutdown goroutine 59 | go func() { 60 | defer s.done.Done() 61 | <-ctx.Done() 62 | ctx2, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 63 | defer cancel() 64 | if err := serv.Shutdown(ctx2); err != nil { 65 | log.Println(err) 66 | } 67 | }() 68 | 69 | return nil 70 | } 71 | 72 | // Wait blocks until listening sockets have been closed 73 | func (s *Server) Wait() { 74 | s.done.Wait() 75 | } 76 | 77 | func (s *Server) HandleStreams(w http.ResponseWriter, r *http.Request) { 78 | w.Header().Set("Content-Type", "application/json") 79 | stats := s.srtServer.GetStatistics() 80 | if err := json.NewEncoder(w).Encode(stats); err != nil { 81 | log.Println(err) 82 | } 83 | } 84 | 85 | func (s *Server) HandleSockets(w http.ResponseWriter, r *http.Request) { 86 | w.Header().Set("Content-Type", "application/json") 87 | stats := s.srtServer.GetSocketStatistics() 88 | if err := json.NewEncoder(w).Encode(stats); err != nil { 89 | log.Println(err) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /relay/relay_test.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestRelayImpl_SubscribeAndUnsubscribe(t *testing.T) { 10 | config := RelayConfig{BufferSize: 50, PacketSize: 1} 11 | relay := NewRelay(&config) 12 | data := []byte{1, 2, 3, 4} 13 | 14 | pub, err := relay.Publish("test") 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | 19 | sub, unsub, err := relay.Subscribe("test") 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | // send 25 | pub <- data 26 | 27 | // receive 28 | got, ok := <-sub 29 | if !ok { 30 | t.Fatal("Subscriber channel should not be closed") 31 | } 32 | if !reflect.DeepEqual(got, data) { 33 | t.Errorf("Sub ret = %x, want %x", got, data) 34 | } 35 | 36 | // unsubscribe 37 | unsub() 38 | 39 | // 2nd send 40 | pub <- data 41 | got, ok = <-sub 42 | 43 | if got != nil || ok { 44 | t.Errorf("Read after unsub ret %x, want nil", got) 45 | } 46 | } 47 | 48 | func TestRelayImpl_PublisherClose(t *testing.T) { 49 | config := RelayConfig{BufferSize: 1, PacketSize: 1} 50 | relay := NewRelay(&config) 51 | 52 | ch, _ := relay.Publish("test") 53 | sub, unsub, _ := relay.Subscribe("test") 54 | close(ch) 55 | 56 | // Wait for async teardown in goroutine 57 | time.Sleep(100 * time.Millisecond) 58 | 59 | if _, ok := <-sub; ok { 60 | t.Error("Subscriber channel should be closed") 61 | } 62 | 63 | // unsub after close shouldn't break 64 | unsub() 65 | 66 | _, err := relay.Publish("test") 67 | if err != nil { 68 | t.Error("Publish should be possible again after close") 69 | } 70 | } 71 | 72 | func TestRelayImpl_DoublePublish(t *testing.T) { 73 | config := RelayConfig{BufferSize: 1, PacketSize: 1} 74 | relay := NewRelay(&config) 75 | relay.Publish("foo") 76 | _, err := relay.Publish("foo") 77 | 78 | if err != ErrStreamAlreadyExists { 79 | t.Errorf("Publish to existing stream should return '%s', got '%s'", ErrStreamAlreadyExists, err) 80 | } 81 | } 82 | 83 | func TestRelayImpl_SubscribeNonExisting(t *testing.T) { 84 | config := RelayConfig{BufferSize: 1, PacketSize: 1} 85 | relay := NewRelay(&config) 86 | 87 | _, _, err := relay.Subscribe("foobar") 88 | if err != ErrStreamNotExisting { 89 | t.Errorf("Subscribe to non-existing stream should return '%s', got '%s'", ErrStreamNotExisting, err) 90 | } 91 | } 92 | 93 | func TestRelayImpl_ChannelExists(t *testing.T) { 94 | config := RelayConfig{BufferSize: 1, PacketSize: 1} 95 | relay := NewRelay(&config) 96 | 97 | ok := relay.ChannelExists("test") 98 | if ok { 99 | t.Fatal("Channel should not exist before publishing") 100 | } 101 | 102 | _, err := relay.Publish("test") 103 | if err != nil { 104 | t.Fatal(err) 105 | } 106 | ok = relay.ChannelExists("test") 107 | if !ok { 108 | t.Fatal("Channel should exist after publishing") 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /auth/http.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/voc/srtrelay/internal/metrics" 10 | "github.com/voc/srtrelay/stream" 11 | 12 | "github.com/prometheus/client_golang/prometheus" 13 | "github.com/prometheus/client_golang/prometheus/promauto" 14 | "github.com/prometheus/client_golang/prometheus/promhttp" 15 | ) 16 | 17 | var requestDurations = promauto.NewHistogramVec( 18 | prometheus.HistogramOpts{ 19 | Namespace: metrics.Namespace, 20 | Subsystem: "auth", 21 | Name: "request_duration_seconds", 22 | Help: "A histogram of auth http request latencies.", 23 | Buckets: prometheus.DefBuckets, 24 | NativeHistogramBucketFactor: 1.1, 25 | }, 26 | []string{"url", "application"}, 27 | ) 28 | 29 | type httpAuth struct { 30 | config HTTPAuthConfig 31 | client *http.Client 32 | } 33 | 34 | type Duration time.Duration 35 | 36 | func (d *Duration) UnmarshalText(b []byte) error { 37 | x, err := time.ParseDuration(string(b)) 38 | if err != nil { 39 | return err 40 | } 41 | *d = Duration(x) 42 | return nil 43 | } 44 | 45 | type HTTPAuthConfig struct { 46 | URL string 47 | Application string 48 | Timeout Duration // Timeout for Auth request 49 | PasswordParam string // POST Parameter containing stream passphrase 50 | } 51 | 52 | // NewHttpAuth creates an Authenticator with a HTTP backend 53 | func NewHTTPAuth(authConfig HTTPAuthConfig) Authenticator { 54 | m := requestDurations.MustCurryWith(prometheus.Labels{"url": authConfig.URL, "application": authConfig.Application}) 55 | return &httpAuth{ 56 | config: authConfig, 57 | client: &http.Client{ 58 | Timeout: time.Duration(authConfig.Timeout), 59 | Transport: promhttp.InstrumentRoundTripperDuration(m, http.DefaultTransport), 60 | }, 61 | } 62 | } 63 | 64 | // Implement Authenticator 65 | 66 | // Authenticate sends form-data in a POST-request to the configured url. 67 | // If the response code is 2xx the publish/play is allowed, otherwise it is denied. 68 | // This should be compatible with nginx-rtmps on_play/on_publish directives. 69 | // https://github.com/arut/nginx-rtmp-module/wiki/Directives#on_play 70 | func (h *httpAuth) Authenticate(streamid stream.StreamID) bool { 71 | response, err := h.client.PostForm(h.config.URL, url.Values{ 72 | "call": {streamid.Mode().String()}, 73 | "app": {h.config.Application}, 74 | "name": {streamid.Name()}, 75 | "username": {streamid.Username()}, 76 | h.config.PasswordParam: {streamid.Password()}, 77 | }) 78 | if err != nil { 79 | log.Println("http-auth:", err) 80 | return false 81 | } 82 | defer response.Body.Close() 83 | 84 | if response.StatusCode < 200 || response.StatusCode >= 300 { 85 | return false 86 | } 87 | 88 | return true 89 | } 90 | -------------------------------------------------------------------------------- /config.toml.example: -------------------------------------------------------------------------------- 1 | [app] 2 | # Relay listen address 3 | # You can add multiple addresses to listen on. If you use a domain name, it will be resolved to its IP-Addresses. 4 | # The first address listed here will be used to generate the listen-URL you can request by the API. 5 | # However, you can use whatever address you specified here to listen to a stream. Or just reorder the list. 6 | # listen on all addresses 7 | #addresses = [":1337"] 8 | # listen on localhost 9 | addresses = ["localhost:1337"] 10 | 11 | # Set public address for use in API responses 12 | # by default local FQDN + listen address port is used 13 | #publicAddress = "hostname:1337" 14 | 15 | # SRT protocol latency in ms 16 | # This should be a multiple of your expected RTT because SRT needs some time 17 | # to send and receive acknowledgements and retransmits to be effective. 18 | # The recommended latency is about ~2.5x RTT. 19 | #latency = 200 20 | 21 | # Relay buffer size in bytes, 384000 -> 1 second @ 3Mbits 22 | # This determines the maximum delay tolerance for connected clients. 23 | # Clients which are more than buffersize behind the live edge are disconnected. 24 | #buffersize = 384000 25 | 26 | # SRT protocol `lossmaxttl` value. Default 0 disables the mechanism. This is an advanced configuration, see 27 | # http://underpop.online.fr/f/ffmpeg/help/haivision-secure-reliable-transport-protocol.htm.gz 28 | # for more info 29 | #lossMaxTTL = 0 30 | 31 | # Experimental: synchronize MPEG-TS clients to a GOP start 32 | # This should not increase playback delay, just make sure the client always 33 | # starts with a clean packet stream. 34 | # Currently just implemented for H.264 35 | #syncClients = false 36 | 37 | # Set packet size in Bytes for SRT socket, 1316 Bytes is generally used for MPEG-TS the maximum is 1456 Bytes 38 | #packetSize = 1316 39 | 40 | # Max number of pending clients in the accept queue 41 | #listenBacklog = 10 42 | 43 | [api] 44 | # Set to false to disable the API endpoint 45 | #enabled = true 46 | 47 | # API listening address 48 | #address = ":8080" 49 | 50 | [auth] 51 | # Choose between available auth types (static and http) 52 | # for further config options see below 53 | #type = "static" 54 | 55 | [auth.static] 56 | # Streams are authenticated using a static list of allowed streamids 57 | # Each pattern is matched to the client streamid 58 | # in the form of // 59 | # Allows using * as wildcard (will match across slashes) 60 | #allow = ["*", "publish/foo/bar", "play/*"] 61 | 62 | [auth.http] 63 | # Streams are authenticated using HTTP POST calls against this URL 64 | # Should be compatible to nginx-rtmp on_publish/on_subscribe directives 65 | #url = "http://localhost:8080/publish" 66 | 67 | # auth timeout duration 68 | #timeout = "1s" 69 | 70 | # Value of the 'app' form-field to send in the POST request 71 | # Needed for compatibility with the RTMP-application field 72 | #application = "stream" 73 | 74 | # Key of the form-field to send the stream password in 75 | #passwordParam = "auth" 76 | -------------------------------------------------------------------------------- /relay/relay.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | var ( 11 | ErrStreamAlreadyExists = errors.New("stream already exists") 12 | ErrStreamNotExisting = errors.New("stream does not exist") 13 | ) 14 | 15 | type RelayConfig struct { 16 | BufferSize uint 17 | PacketSize uint 18 | } 19 | 20 | type Relay interface { 21 | Publish(string) (chan<- []byte, error) 22 | Subscribe(string) (<-chan []byte, UnsubscribeFunc, error) 23 | GetStatistics() []*StreamStatistics 24 | ChannelExists(name string) bool 25 | } 26 | 27 | type StreamStatistics struct { 28 | Name string `json:"name"` 29 | URL string `json:"url"` 30 | Clients int `json:"clients"` 31 | Created time.Time `json:"created"` 32 | } 33 | 34 | // RelayImpl represents a multi-channel stream relay 35 | type RelayImpl struct { 36 | mutex sync.Mutex 37 | channels map[string]*Channel 38 | config *RelayConfig 39 | } 40 | 41 | // NewRelay creates a relay 42 | func NewRelay(config *RelayConfig) Relay { 43 | return &RelayImpl{ 44 | channels: make(map[string]*Channel), 45 | config: config, 46 | } 47 | } 48 | 49 | // Publish claims a stream name for publishing 50 | func (s *RelayImpl) Publish(name string) (chan<- []byte, error) { 51 | s.mutex.Lock() 52 | defer s.mutex.Unlock() 53 | 54 | if _, exists := s.channels[name]; exists { 55 | return nil, ErrStreamAlreadyExists 56 | } 57 | 58 | channel := NewChannel(name, s.config.BufferSize/s.config.PacketSize) 59 | s.channels[name] = channel 60 | 61 | ch := make(chan []byte) 62 | 63 | // Setup publisher goroutine 64 | go func() { 65 | for { 66 | buf, ok := <-ch 67 | 68 | // Channel closed, Teardown pubsub 69 | if !ok { 70 | // Need a lock on the map first to stop new subscribers 71 | s.mutex.Lock() 72 | log.Println("Unpublished stream", name) 73 | delete(s.channels, name) 74 | channel.Close() 75 | s.mutex.Unlock() 76 | return 77 | } 78 | 79 | // Publish buf to subscribers 80 | channel.Pub(buf) 81 | } 82 | }() 83 | return ch, nil 84 | } 85 | 86 | // Subscribe subscribes to a stream by name 87 | func (s *RelayImpl) Subscribe(name string) (<-chan []byte, UnsubscribeFunc, error) { 88 | s.mutex.Lock() 89 | defer s.mutex.Unlock() 90 | channel, ok := s.channels[name] 91 | if !ok { 92 | return nil, nil, ErrStreamNotExisting 93 | } 94 | ch, unsub := channel.Sub() 95 | return ch, unsub, nil 96 | } 97 | 98 | func (s *RelayImpl) GetStatistics() []*StreamStatistics { 99 | statistics := make([]*StreamStatistics, 0) 100 | 101 | s.mutex.Lock() 102 | defer s.mutex.Unlock() 103 | 104 | for name, channel := range s.channels { 105 | stats := channel.Stats() 106 | statistics = append(statistics, &StreamStatistics{ 107 | Name: name, 108 | Clients: stats.clients, 109 | Created: stats.created, 110 | }) 111 | } 112 | return statistics 113 | } 114 | 115 | func (s *RelayImpl) ChannelExists(name string) bool { 116 | s.mutex.Lock() 117 | defer s.mutex.Unlock() 118 | _, exists := s.channels[name] 119 | return exists 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # srtrelay ![CI](https://github.com/voc/srtrelay/workflows/CI/badge.svg) 2 | Streaming-Relay for the SRT-protocol 3 | 4 | Use at your own risk. 5 | 6 | ## Quick start 7 | Run with docker (**Note:** nightly image not recommended for production) 8 | ```bash 9 | docker run ghcr.io/voc/srtrelay/srtrelay:latest 10 | 11 | # start publisher 12 | ffmpeg -i test.mp4 -c copy -f mpegts srt://localhost:1337?streamid=publish/test 13 | 14 | # start subscriber 15 | ffplay -fflags nobuffer srt://localhost:1337?streamid=play/test 16 | ``` 17 | 18 | Start docker with custom config. See [config.toml.example](config.toml.example) 19 | ```bash 20 | # provide your own config from the local directory 21 | docker run -v $(pwd)/config.toml:/home/srtrelay/config.toml ghcr.io/voc/srtrelay/srtrelay:latest 22 | ``` 23 | 24 | ## Run with docker-compose 25 | 26 | In your `docker-compose.yml`: 27 | 28 | ```yaml 29 | srtrelay: 30 | image: ghcr.io/voc/srtrelay/srtrelay:latest 31 | restart: always 32 | container_name: srtrelay 33 | volumes: 34 | - ./srtrelay-config.toml:/home/srtrelay/config.toml 35 | ports: 36 | - "44560:1337/udp" 37 | ``` 38 | 39 | This will forward port `44560` to internal port `1337` in the container. Importantly, forwarding UDP is required. 40 | It will also copy a `srtrelay-config.toml` file in the same directory into the container to use as config.toml 41 | 42 | Start the server with the usual 43 | 44 | ```bash 45 | docker-compose up -d 46 | ``` 47 | 48 | ## Build with docker 49 | You will need atleast docker-20.10 50 | 51 | ```bash 52 | docker build -t srtrelay . 53 | 54 | # run srtrelay 55 | docker run --rm -it srtrelay 56 | ``` 57 | 58 | ## Build without docker 59 | ### Install Dependencies 60 | Requires >=libsrt-1.4.2, golang and a C compiler 61 | 62 | **Ubuntu** 63 | - you will need to [build libsrt yourself](https://github.com/Haivision/srt#build-on-linux) 64 | 65 | **Debian 10**: 66 | - use libsrt-openssl-dev from the [voc repository](https://c3voc.de/wiki/projects:vocbian) 67 | - or [build it yourself](https://github.com/Haivision/srt#build-on-linux) 68 | 69 | **Gentoo**: 70 | - emerge net-libs/srt 71 | 72 | ### Build 73 | ```bash 74 | go build -o srtrelay 75 | 76 | # run srtrelay 77 | ./srtrelay 78 | ``` 79 | 80 | ## Usage 81 | ### Commandline Flags 82 | ```bash 83 | # List available flags 84 | ./srtrelay -h 85 | ``` 86 | 87 | ### Configuration 88 | Please take a look at [config.toml.example](config.toml.example) to learn more about configuring srtrelay. 89 | 90 | The configuration file can be placed under *config.toml* in the current working directory, at */etc/srtrelay/config.toml* or at a custom location specified via the *-config* flag. 91 | 92 | ### API 93 | See [docs/API.md](docs/API.md) for more information about the API. 94 | 95 | ## Contributing 96 | See [docs/Contributing.md](docs/Contributing.md) 97 | 98 | ## Credits 99 | Thanks go to 100 | - Haivision for [srt](https://github.com/Haivision/srt) and [srtgo](https://github.com/Haivision/srtgo) 101 | - Edward Wu for [srt-live-server](https://github.com/Edward-Wu/srt-live-server) 102 | - Quentin Renard for [go-astits](https://github.com/asticode/go-astits) 103 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # srtrelay API 2 | See [config.toml.example](../config.toml.example) for configuring the API endpoint. 3 | 4 | ## Stream status - /streams 5 | - Returns a list of active streams with additional statistics. 6 | - Content-Type: application/json 7 | - Example: 8 | ``` 9 | GET http://localhost:8080/streams 10 | 11 | [{"name":"abc","clients":0,"created":"2020-11-24T23:55:27.265206348+01:00"}] 12 | ``` 13 | 14 | ## Socket statistics - /sockets 15 | - Returns internal srt statistics for each SRT client 16 | - the exact statistics might change depending over time 17 | - this will show stats for both publishers and subscribers 18 | - Content-Type: application/json 19 | - Example: 20 | ```json 21 | [ 22 | { 23 | "address": "127.0.0.1:59565", 24 | "stream_id": "publish/q2", 25 | "stats": { 26 | "MsTimeStamp": 26686, 27 | "PktSentTotal": 0, 28 | "PktRecvTotal": 9484, 29 | "PktSndLossTotal": 0, 30 | "PktRcvLossTotal": 0, 31 | "PktRetransTotal": 0, 32 | "PktSentACKTotal": 1680, 33 | "PktRecvACKTotal": 0, 34 | "PktSentNAKTotal": 0, 35 | "PktRecvNAKTotal": 0, 36 | "UsSndDurationTotal": 0, 37 | "PktSndDropTotal": 0, 38 | "PktRcvDropTotal": 0, 39 | "PktRcvUndecryptTotal": 0, 40 | "ByteSentTotal": 0, 41 | "ByteRecvTotal": 11866496, 42 | "ByteRcvLossTotal": 0, 43 | "ByteRetransTotal": 0, 44 | "ByteSndDropTotal": 0, 45 | "ByteRcvDropTotal": 0, 46 | "ByteRcvUndecryptTotal": 0, 47 | "PktSent": 0, 48 | "PktRecv": 9484, 49 | "PktSndLoss": 0, 50 | "PktRcvLoss": 0, 51 | "PktRetrans": 0, 52 | "PktRcvRetrans": 0, 53 | "PktSentACK": 1680, 54 | "PktRecvACK": 0, 55 | "PktSentNAK": 0, 56 | "PktRecvNAK": 0, 57 | "MbpsSendRate": 0, 58 | "MbpsRecvRate": 3.557279995149639, 59 | "UsSndDuration": 0, 60 | "PktReorderDistance": 0, 61 | "PktRcvAvgBelatedTime": 0, 62 | "PktRcvBelated": 0, 63 | "PktSndDrop": 0, 64 | "PktRcvDrop": 0, 65 | "PktRcvUndecrypt": 0, 66 | "ByteSent": 0, 67 | "ByteRecv": 11866496, 68 | "ByteRcvLoss": 0, 69 | "ByteRetrans": 0, 70 | "ByteSndDrop": 0, 71 | "ByteRcvDrop": 0, 72 | "ByteRcvUndecrypt": 0, 73 | "UsPktSndPeriod": 10, 74 | "PktFlowWindow": 8192, 75 | "PktCongestionWindow": 8192, 76 | "PktFlightSize": 0, 77 | "MsRTT": 0.013, 78 | "MbpsBandwidth": 1843.584, 79 | "ByteAvailSndBuf": 12288000, 80 | "ByteAvailRcvBuf": 12160500, 81 | "MbpsMaxBW": 1000, 82 | "ByteMSS": 1500, 83 | "PktSndBuf": 0, 84 | "ByteSndBuf": 0, 85 | "MsSndBuf": 0, 86 | "MsSndTsbPdDelay": 200, 87 | "PktRcvBuf": 74, 88 | "ByteRcvBuf": 89851, 89 | "MsRcvBuf": 188, 90 | "MsRcvTsbPdDelay": 200, 91 | "PktSndFilterExtraTotal": 0, 92 | "PktRcvFilterExtraTotal": 0, 93 | "PktRcvFilterSupplyTotal": 0, 94 | "PktRcvFilterLossTotal": 0, 95 | "PktSndFilterExtra": 0, 96 | "PktRcvFilterExtra": 0, 97 | "PktRcvFilterSupply": 0, 98 | "PktRcvFilterLoss": 0, 99 | "PktReorderTolerance": 0 100 | } 101 | } 102 | ] 103 | ``` -------------------------------------------------------------------------------- /srt/server_test.go: -------------------------------------------------------------------------------- 1 | package srt 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "reflect" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/haivision/srtgo" 12 | "github.com/voc/srtrelay/relay" 13 | "github.com/voc/srtrelay/stream" 14 | ) 15 | 16 | func compareStats(got, expected []*relay.StreamStatistics) error { 17 | if len(got) != len(expected) { 18 | return fmt.Errorf("Wrong number of streams: got %d, expected %d", len(got), len(expected)) 19 | } 20 | for i, v := range expected { 21 | if !reflect.DeepEqual(*v, *got[i]) { 22 | return fmt.Errorf("Invalid stream statistics: got %v, expected %v", *got[i], *v) 23 | } 24 | } 25 | return nil 26 | } 27 | 28 | func TestServerImpl_GetStatistics(t *testing.T) { 29 | r := relay.NewRelay(&relay.RelayConfig{ 30 | BufferSize: 1, 31 | PacketSize: 1, 32 | }) 33 | s := &ServerImpl{ 34 | relay: r, 35 | config: &ServerConfig{Addresses: []string{"127.0.0.1:1337", "[::1]:1337"}, PublicAddress: "testserver.de:1337"}, 36 | } 37 | if _, err := r.Publish("s1"); err != nil { 38 | t.Fatal(err) 39 | } 40 | if _, _, err := r.Subscribe("s1"); err != nil { 41 | t.Fatal(err) 42 | } 43 | if _, _, err := r.Subscribe("s1"); err != nil { 44 | t.Fatal(err) 45 | } 46 | streams := s.GetStatistics() 47 | 48 | expected := []*relay.StreamStatistics{ 49 | {Name: "s1", URL: "srt://testserver.de:1337?streamid=#!::m=request,r=s1", Clients: 2, Created: streams[0].Created}, // New Format 50 | } 51 | if err := compareStats(streams, expected); err != nil { 52 | t.Error(err) 53 | } 54 | } 55 | 56 | type testSocket struct { 57 | N int 58 | ch chan []byte 59 | 60 | numWritten int 61 | } 62 | 63 | func (s *testSocket) Read(b []byte) (int, error) { 64 | buf, ok := <-s.ch 65 | if !ok { 66 | return 0, io.EOF 67 | } 68 | length := copy(b, buf) 69 | return length, nil 70 | } 71 | 72 | func (s *testSocket) Write(b []byte) (int, error) { 73 | s.numWritten++ 74 | return len(b), nil 75 | } 76 | 77 | func (s *testSocket) Close() {} 78 | 79 | func (s *testSocket) Stats() (*srtgo.SrtStats, error) { 80 | return &srtgo.SrtStats{}, nil 81 | } 82 | 83 | func TestPublish(t *testing.T) { 84 | s := NewServer(&Config{ 85 | Server: ServerConfig{}, 86 | Relay: relay.RelayConfig{BufferSize: 50, PacketSize: 1316}, 87 | }) 88 | 89 | rd := testSocket{ch: make(chan []byte)} 90 | wr := testSocket{} 91 | id, err := stream.NewStreamID("test", "", stream.ModePublish) 92 | if err != nil { 93 | t.Fatal(err) 94 | } 95 | 96 | var wg sync.WaitGroup 97 | wg.Add(2) 98 | 99 | // pub routine 100 | go func() { 101 | defer wg.Done() 102 | err = s.publish(&srtConn{ 103 | socket: &rd, 104 | streamid: id, 105 | address: "publisher:1234", 106 | }) 107 | if err != io.EOF { 108 | t.Error("publisher error", err) 109 | } 110 | }() 111 | 112 | // sub routine 113 | go func() { 114 | defer wg.Done() 115 | time.Sleep(50 * time.Millisecond) 116 | err := s.play(&srtConn{ 117 | socket: &wr, 118 | streamid: id, 119 | address: "player:1234", 120 | }) 121 | if err != nil { 122 | t.Error("player error", err) 123 | } 124 | }() 125 | 126 | time.Sleep(100 * time.Millisecond) 127 | for i := 0; i < 100; i++ { 128 | rd.ch <- []byte{1, 2, 3, 4} 129 | time.Sleep(1 * time.Millisecond) 130 | } 131 | close(rd.ch) 132 | wg.Wait() 133 | if wr.numWritten != 100 { 134 | t.Errorf("Wrong number of packets written: got %d, expected %d", wr.numWritten, 100) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/Showmax/go-fqdn" 11 | "github.com/pelletier/go-toml/v2" 12 | "github.com/voc/srtrelay/auth" 13 | ) 14 | 15 | const MetricsNamespace = "srtrelay" 16 | 17 | type Config struct { 18 | App AppConfig 19 | Auth AuthConfig 20 | API APIConfig 21 | } 22 | 23 | type AppConfig struct { 24 | Address string 25 | Addresses []string 26 | PublicAddress string 27 | Latency uint 28 | 29 | // total buffer size in bytes, determines maximum delay of a client 30 | Buffersize uint 31 | 32 | // Whether to sync clients to GOP start 33 | SyncClients bool 34 | 35 | // The value up to which the Reorder Tolerance may grow, 0 by default 36 | LossMaxTTL uint 37 | 38 | // max size of packets in bytes, default is 1316 39 | PacketSize uint 40 | 41 | // max number of pending connections, default is 10 42 | ListenBacklog int 43 | } 44 | 45 | type AuthConfig struct { 46 | Type string 47 | Static auth.StaticAuthConfig 48 | HTTP auth.HTTPAuthConfig 49 | } 50 | 51 | type APIConfig struct { 52 | Enabled bool 53 | Address string 54 | Port uint 55 | } 56 | 57 | // GetAuthenticator creates a new authenticator according to AuthConfig 58 | func GetAuthenticator(conf AuthConfig) (auth.Authenticator, error) { 59 | switch conf.Type { 60 | case "static": 61 | return auth.NewStaticAuth(conf.Static), nil 62 | case "http": 63 | return auth.NewHTTPAuth(conf.HTTP), nil 64 | default: 65 | return nil, fmt.Errorf("unknown auth type '%v'", conf.Type) 66 | } 67 | } 68 | 69 | func getHostname() string { 70 | name, err := fqdn.FqdnHostname() 71 | if err != nil { 72 | log.Println("fqdn:", err) 73 | if err != fqdn.ErrFqdnNotFound { 74 | return name 75 | } 76 | 77 | name, err = os.Hostname() 78 | if err != nil { 79 | log.Println("hostname:", err) 80 | } 81 | } 82 | return name 83 | } 84 | 85 | // Parse tries to find and parse config from paths in order 86 | func Parse(paths []string) (*Config, error) { 87 | // set defaults 88 | config := Config{ 89 | App: AppConfig{ 90 | Addresses: []string{"localhost:1337"}, 91 | Latency: 200, 92 | LossMaxTTL: 0, 93 | Buffersize: 384000, // 1s @ 3Mbits/s 94 | SyncClients: false, 95 | PacketSize: 1316, // max is 1456 96 | ListenBacklog: 10, 97 | }, 98 | Auth: AuthConfig{ 99 | Type: "static", 100 | Static: auth.StaticAuthConfig{ 101 | // Allow everything by default 102 | Allow: []string{"*"}, 103 | }, 104 | HTTP: auth.HTTPAuthConfig{ 105 | URL: "http://localhost:8080/publish", 106 | Timeout: auth.Duration(time.Second), 107 | Application: "stream", 108 | PasswordParam: "auth", 109 | }, 110 | }, 111 | API: APIConfig{ 112 | Enabled: true, 113 | Address: ":8080", 114 | }, 115 | } 116 | 117 | var data []byte 118 | var err error 119 | 120 | // try to read file from given paths 121 | for _, path := range paths { 122 | data, err = os.ReadFile(path) 123 | if err == nil { 124 | log.Println("Read config from", path) 125 | break 126 | } else { 127 | if os.IsNotExist(err) { 128 | continue 129 | } 130 | return nil, err 131 | } 132 | } 133 | 134 | // parse toml 135 | if data != nil { 136 | err = toml.Unmarshal(data, &config) 137 | if err != nil { 138 | return nil, err 139 | } 140 | } else { 141 | log.Println("Config file not found, using defaults") 142 | } 143 | 144 | // support old config files 145 | if config.App.Address != "" { 146 | log.Println("Note: config option address is deprecated, please use addresses") 147 | config.App.Addresses = []string{config.App.Address} 148 | } 149 | 150 | // guess public address if not set 151 | if config.App.PublicAddress == "" { 152 | split := strings.Split(config.App.Addresses[0], ":") 153 | if len(split) < 2 { 154 | log.Fatal("Invalid address: ", config.App.Addresses[0]) 155 | } 156 | config.App.PublicAddress = fmt.Sprintf("%s:%s", getHostname(), split[len(split)-1]) 157 | log.Println("Note: assuming public address", config.App.PublicAddress) 158 | } 159 | 160 | return &config, nil 161 | } 162 | -------------------------------------------------------------------------------- /relay/channel.go: -------------------------------------------------------------------------------- 1 | package relay 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | "sync/atomic" 7 | "time" 8 | 9 | "github.com/voc/srtrelay/internal/metrics" 10 | 11 | "github.com/prometheus/client_golang/prometheus" 12 | "github.com/prometheus/client_golang/prometheus/promauto" 13 | ) 14 | 15 | const relaySubsystem = "relay" 16 | 17 | var ( 18 | activeClients = promauto.NewGaugeVec( 19 | prometheus.GaugeOpts{ 20 | Name: prometheus.BuildFQName(metrics.Namespace, relaySubsystem, "active_clients"), 21 | Help: "The number of active clients per channel", 22 | }, 23 | []string{"channel_name"}, 24 | ) 25 | channelCreatedTimestamp = promauto.NewGaugeVec( 26 | prometheus.GaugeOpts{ 27 | Name: prometheus.BuildFQName(metrics.Namespace, relaySubsystem, "created_timestamp_seconds"), 28 | Help: "The UNIX timestamp when the channel was created", 29 | }, 30 | []string{"channel_name"}, 31 | ) 32 | ) 33 | 34 | type UnsubscribeFunc func() 35 | 36 | type Channel struct { 37 | name string 38 | mutex sync.Mutex 39 | subs Subs 40 | maxPackets uint 41 | 42 | // statistics 43 | clients atomic.Value 44 | created time.Time 45 | 46 | // Prometheus metrics. 47 | activeClients prometheus.Gauge 48 | createdTimestamp prometheus.Gauge 49 | } 50 | type Subs []chan []byte 51 | 52 | type Stats struct { 53 | clients int 54 | created time.Time 55 | } 56 | 57 | // Remove single subscriber 58 | func (subs Subs) Remove(sub chan []byte) Subs { 59 | idx := -1 60 | for i := range subs { 61 | if subs[i] == sub { 62 | idx = i 63 | break 64 | } 65 | } 66 | 67 | // subscriber was already removed 68 | if idx < 0 { 69 | return subs 70 | } 71 | 72 | defer close(sub) 73 | 74 | subs[idx] = subs[len(subs)-1] // Copy last element to index i. 75 | subs[len(subs)-1] = nil // Erase last element (write zero value). 76 | return subs[:len(subs)-1] // Truncate slice. 77 | } 78 | 79 | func NewChannel(name string, maxPackets uint) *Channel { 80 | channelActiveClients := activeClients.WithLabelValues(name) 81 | ch := &Channel{ 82 | name: name, 83 | subs: make([]chan []byte, 0, 10), 84 | maxPackets: maxPackets, 85 | created: time.Now(), 86 | activeClients: channelActiveClients, 87 | } 88 | ch.clients.Store(0) 89 | ch.createdTimestamp = channelCreatedTimestamp.WithLabelValues(name) 90 | ch.createdTimestamp.Set(float64(ch.created.UnixNano()) / 1000000.0) 91 | return ch 92 | } 93 | 94 | // Sub subscribes to a channel 95 | func (ch *Channel) Sub() (<-chan []byte, UnsubscribeFunc) { 96 | ch.mutex.Lock() 97 | defer ch.mutex.Unlock() 98 | sub := make(chan []byte, ch.maxPackets) 99 | ch.subs = append(ch.subs, sub) 100 | ch.clients.Store(len(ch.subs)) 101 | ch.activeClients.Inc() 102 | 103 | var unsub UnsubscribeFunc = func() { 104 | ch.mutex.Lock() 105 | defer ch.mutex.Unlock() 106 | 107 | // Channel already closed, just skip unsub 108 | if ch.subs == nil { 109 | return 110 | } 111 | 112 | ch.subs = ch.subs.Remove(sub) 113 | ch.clients.Store(len(ch.subs)) 114 | ch.activeClients.Dec() 115 | } 116 | return sub, unsub 117 | } 118 | 119 | // Pub publishes a packet to a channel 120 | func (ch *Channel) Pub(b []byte) { 121 | ch.mutex.Lock() 122 | defer ch.mutex.Unlock() 123 | 124 | toRemove := make(Subs, 0, 5) 125 | for i := range ch.subs { 126 | select { 127 | case ch.subs[i] <- b: 128 | continue 129 | 130 | // Remember overflowed chans for drop 131 | default: 132 | toRemove = append(toRemove, ch.subs[i]) 133 | log.Println("dropping overflowing client", i) 134 | } 135 | } 136 | for _, sub := range toRemove { 137 | ch.subs = ch.subs.Remove(sub) 138 | ch.activeClients.Dec() 139 | } 140 | ch.clients.Store(len(ch.subs)) 141 | } 142 | 143 | // Close closes a channel 144 | func (ch *Channel) Close() { 145 | ch.mutex.Lock() 146 | defer ch.mutex.Unlock() 147 | for i := range ch.subs { 148 | close(ch.subs[i]) 149 | } 150 | ch.subs = nil 151 | activeClients.DeleteLabelValues(ch.name) 152 | channelCreatedTimestamp.DeleteLabelValues(ch.name) 153 | } 154 | 155 | func (ch *Channel) Stats() Stats { 156 | return Stats{ 157 | clients: ch.clients.Load().(int), 158 | created: ch.created, 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /mpegts/packet.go: -------------------------------------------------------------------------------- 1 | package mpegts 2 | 3 | import ( 4 | "encoding/binary" 5 | "io" 6 | ) 7 | 8 | // Packet represents a MPEGTS packet 9 | type Packet struct { 10 | header uint32 11 | payload []byte 12 | adaptationField []byte 13 | } 14 | 15 | // MPEGTS Packet constants 16 | const ( 17 | SyncByte = 0x47 18 | 19 | HeaderLen = 4 20 | PacketLen = 188 21 | PIDOffset = 8 22 | 23 | PUSIHdrMask = 0x400000 24 | PIDHdrMask = 0x1fff00 25 | AdaptationHdrMask = 0x20 26 | PayloadHdrMask = 0x10 27 | ContinuityHdrMask = 0xf 28 | ) 29 | 30 | /** 31 | * Packet Parsing 32 | */ 33 | // FromBytes parses a MPEG-TS packet from a byte slice 34 | func (pkt *Packet) FromBytes(b []byte) error { 35 | if len(b) < PacketLen { 36 | return io.ErrUnexpectedEOF 37 | } 38 | 39 | if b[0] != SyncByte { 40 | return ErrInvalidPacket 41 | } 42 | 43 | pkt.header = binary.BigEndian.Uint32(b[0:HeaderLen]) 44 | offset := HeaderLen 45 | 46 | // has adaptation field 47 | if pkt.header&AdaptationHdrMask > 0 { 48 | afLength := int(b[offset]) 49 | if offset+afLength >= PacketLen { 50 | return ErrInvalidPacket 51 | } 52 | offset++ 53 | pkt.adaptationField = b[offset : offset+afLength] 54 | offset += afLength 55 | } else { 56 | pkt.adaptationField = nil 57 | } 58 | 59 | // has payload 60 | if pkt.header&PayloadHdrMask > 0 { 61 | pkt.payload = b[offset:PacketLen] 62 | } else { 63 | pkt.payload = nil 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // PID Payload ID 70 | func (pkt *Packet) PID() uint16 { 71 | return uint16(pkt.header & PIDHdrMask >> PIDOffset) 72 | } 73 | 74 | // Continuity sequence number of payload packets 75 | func (pkt *Packet) Continuity() byte { 76 | return byte(pkt.header & ContinuityHdrMask) 77 | } 78 | 79 | // PUSI the Payload Unit Start Indicator 80 | func (pkt *Packet) PUSI() bool { 81 | return pkt.header&PUSIHdrMask > 0 82 | } 83 | 84 | func (pkt *Packet) Payload() []byte { 85 | return pkt.payload 86 | } 87 | 88 | func (pkt *Packet) AdaptationField() []byte { 89 | return pkt.adaptationField 90 | } 91 | 92 | /** 93 | * Packet creation 94 | */ 95 | // CreatePacket returns a bare MPEG-TS Packet 96 | func CreatePacket(pid uint16) *Packet { 97 | var header uint32 98 | header |= uint32(SyncByte) << 24 99 | header |= uint32(pid&0x1fff) << 8 100 | return &Packet{ 101 | header: header, 102 | payload: nil, 103 | adaptationField: nil, 104 | } 105 | } 106 | 107 | func (pkt *Packet) WithPUSI(pusi bool) *Packet { 108 | if pusi { 109 | pkt.header |= 0x1 << 22 110 | } 111 | return pkt 112 | } 113 | 114 | func (pkt *Packet) WithPayload(payload []byte) *Packet { 115 | pkt.payload = payload 116 | return pkt 117 | } 118 | 119 | func (pkt *Packet) WithAdaptationField(adaptationField []byte) *Packet { 120 | pkt.adaptationField = adaptationField 121 | return pkt 122 | } 123 | 124 | // ToBytes encodes a valid MPEGTS packet into a byte slice 125 | // Expects a byte slice of atleast PacketLen 126 | // Encoded packet is only valid if error is nil 127 | func (pkt *Packet) ToBytes(data []byte) error { 128 | if len(data) < PacketLen { 129 | return io.ErrUnexpectedEOF 130 | } 131 | 132 | // Simplified MPEGTS packet header 133 | // TEI always 0 134 | // Transport priority always 0 135 | // TSC always 0 136 | // Continuity always 0 137 | if pkt.AdaptationField() != nil { 138 | pkt.header |= 0x1 << 5 139 | } 140 | if pkt.Payload() != nil { 141 | pkt.header |= 0x1 << 4 142 | } 143 | binary.BigEndian.PutUint32(data[0:4], pkt.header) 144 | offset := HeaderLen 145 | 146 | if pkt.AdaptationField() != nil { 147 | adaptationFieldLength := len(pkt.adaptationField) 148 | if adaptationFieldLength > PacketLen-offset-1 { 149 | return ErrDataTooLong 150 | } 151 | data[offset] = byte(adaptationFieldLength) 152 | offset++ 153 | copy(data[offset:offset+adaptationFieldLength], pkt.adaptationField) 154 | offset += adaptationFieldLength 155 | } 156 | 157 | payloadLength := len(pkt.payload) 158 | if payloadLength > PacketLen-offset { 159 | return ErrDataTooLong 160 | } 161 | copy(data[offset:offset+payloadLength], pkt.payload) 162 | 163 | return nil 164 | } 165 | 166 | // Size returns the MPEGTS packet size 167 | func (pkt Packet) Size() int { 168 | return PacketLen 169 | } 170 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "io" 7 | "log" 8 | "log/slog" 9 | "net" 10 | "net/http" 11 | "net/http/pprof" 12 | "os" 13 | "os/signal" 14 | "strings" 15 | "syscall" 16 | "time" 17 | 18 | "github.com/haivision/srtgo" 19 | "github.com/voc/srtrelay/api" 20 | "github.com/voc/srtrelay/config" 21 | "github.com/voc/srtrelay/relay" 22 | "github.com/voc/srtrelay/srt" 23 | ) 24 | 25 | func main() { 26 | // allow specifying config path 27 | configFlags := flag.NewFlagSet("config", flag.ContinueOnError) 28 | configFlags.SetOutput(io.Discard) 29 | configPath := configFlags.String("config", "config.toml", "") 30 | err := configFlags.Parse(os.Args[1:]) 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | 35 | // parse config 36 | conf, err := config.Parse([]string{*configPath, "/etc/srtrelay/config.toml"}) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | // flag just for usage 42 | flag.String("config", "config.toml", "path to config file") 43 | 44 | // actual flags, use config as default and storage 45 | var addresses string 46 | flag.StringVar(&addresses, "addresses", strings.Join(conf.App.Addresses, ","), "relay bind addresses, separated by commata") 47 | flag.UintVar(&conf.App.Latency, "latency", conf.App.Latency, "srt protocol latency in ms") 48 | flag.UintVar(&conf.App.Buffersize, "buffersize", conf.App.Buffersize, 49 | `relay buffer size in bytes, determines maximum delay of a client`) 50 | profile := flag.String("pprof", "", "enable profiling server on given address") 51 | flag.Parse() 52 | 53 | if *profile != "" { 54 | log.Println("Enabling profiling on", *profile) 55 | if err := enablePprof(*profile); err != nil { 56 | log.Println("failed to enable profiling:", err) 57 | } 58 | } 59 | 60 | conf.App.Addresses = strings.Split(addresses, ",") 61 | 62 | auth, err := config.GetAuthenticator(conf.Auth) 63 | if err != nil { 64 | log.Println(err) 65 | } 66 | 67 | serverConfig := srt.Config{ 68 | Server: srt.ServerConfig{ 69 | Addresses: conf.App.Addresses, 70 | PublicAddress: conf.App.PublicAddress, 71 | Latency: conf.App.Latency, 72 | LossMaxTTL: conf.App.LossMaxTTL, 73 | SyncClients: conf.App.SyncClients, 74 | Auth: auth, 75 | ListenBacklog: conf.App.ListenBacklog, 76 | }, 77 | Relay: relay.RelayConfig{ 78 | BufferSize: conf.App.Buffersize, 79 | PacketSize: conf.App.PacketSize, 80 | }, 81 | } 82 | 83 | // setup graceful shutdown 84 | ctx, cancel := context.WithCancel(context.Background()) 85 | handleSignal(ctx, cancel) 86 | 87 | // create server 88 | srtgo.InitSRT() 89 | srtServer := srt.NewServer(&serverConfig) 90 | err = srtServer.Listen(ctx) 91 | if err != nil { 92 | log.Fatal(err) 93 | } 94 | 95 | var apiServer *api.Server 96 | if conf.API.Enabled { 97 | apiServer = api.NewServer(conf.API, srtServer) 98 | err := apiServer.Listen(ctx) 99 | if err != nil { 100 | log.Fatal(err) 101 | } 102 | log.Printf("API listening on %s\n", conf.API.Address) 103 | } 104 | 105 | <-ctx.Done() 106 | 107 | // Wait for graceful shutdown 108 | shutdownDone := make(chan struct{}) 109 | go func() { 110 | srtServer.Wait() 111 | if apiServer != nil { 112 | apiServer.Wait() 113 | } 114 | close(shutdownDone) 115 | }() 116 | 117 | select { 118 | case <-shutdownDone: 119 | case <-time.After(time.Second * 2): 120 | slog.Warn("Graceful shutdown timed out, forcing exit") 121 | } 122 | srtgo.CleanupSRT() 123 | } 124 | 125 | func enablePprof(addr string) error { 126 | conn, err := net.Listen("tcp", addr) 127 | if err != nil { 128 | return err 129 | } 130 | mux := http.NewServeMux() 131 | mux.HandleFunc("/debug/pprof/", pprof.Index) 132 | srv := http.Server{ 133 | Handler: mux, 134 | } 135 | go func() { 136 | err := srv.Serve(conn) 137 | if err != nil && err != http.ErrServerClosed { 138 | log.Println(err) 139 | } 140 | }() 141 | return nil 142 | } 143 | 144 | func handleSignal(ctx context.Context, cancel context.CancelFunc) { 145 | // Set up channel on which to send signal notifications. 146 | // We must use a buffered channel or risk missing the signal 147 | // if we're not ready to receive when the signal is sent. 148 | c := make(chan os.Signal, 1) 149 | signal.Notify(c, 150 | syscall.SIGHUP, 151 | syscall.SIGINT, 152 | syscall.SIGTERM) 153 | 154 | go func() { 155 | for { 156 | select { 157 | case <-ctx.Done(): 158 | return 159 | case s := <-c: 160 | log.Println("caught signal", s) 161 | if s == syscall.SIGHUP { 162 | continue 163 | } 164 | cancel() 165 | } 166 | } 167 | }() 168 | } 169 | -------------------------------------------------------------------------------- /stream/streamid.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/IGLOU-EU/go-wildcard/v2" 9 | ) 10 | 11 | const IDPrefix = "#!::" 12 | 13 | var ( 14 | ErrInvalidSlashes = errors.New("invalid number of slashes, must be 1 or 2") 15 | ErrInvalidMode = errors.New("invalid mode") 16 | ErrMissingName = errors.New("missing name after slash") 17 | ErrInvalidNamePassword = errors.New("name/password is not allowed to contain slashes") 18 | ErrInvalidValue = fmt.Errorf("invalid value") 19 | ) 20 | 21 | // Mode - client mode 22 | type Mode uint8 23 | 24 | const ( 25 | _ Mode = iota 26 | ModePlay 27 | ModePublish 28 | ) 29 | 30 | func (m Mode) String() string { 31 | switch m { 32 | case ModePlay: 33 | return "play" 34 | case ModePublish: 35 | return "publish" 36 | default: 37 | return "unknown" 38 | } 39 | } 40 | 41 | // StreamID represents a connection tuple 42 | type StreamID struct { 43 | str string 44 | mode Mode 45 | name string 46 | password string 47 | username string 48 | } 49 | 50 | // NewStreamID creates new StreamID 51 | // returns error if mode is invalid. 52 | // id is nil on error 53 | func NewStreamID(name string, password string, mode Mode) (*StreamID, error) { 54 | id := &StreamID{ 55 | name: name, 56 | password: password, 57 | mode: mode, 58 | } 59 | var err error 60 | id.str, err = id.toString() 61 | if err != nil { 62 | return nil, err 63 | } 64 | return id, nil 65 | } 66 | 67 | // FromString reads a streamid from a string. 68 | // The accepted old stream id format is //. The second slash and password is 69 | // optional and defaults to empty. The new format is `#!::m=(request|publish),r=(stream-key),u=(username),s=(password)` 70 | // If error is not nil then StreamID will remain unchanged. 71 | func (s *StreamID) FromString(src string) error { 72 | 73 | if strings.HasPrefix(src, IDPrefix) { 74 | for _, kv := range strings.Split(src[len(IDPrefix):], ",") { 75 | kv2 := strings.SplitN(kv, "=", 2) 76 | if len(kv2) != 2 { 77 | return ErrInvalidValue 78 | } 79 | 80 | key, value := kv2[0], kv2[1] 81 | 82 | switch key { 83 | case "u": 84 | s.username = value 85 | 86 | case "r": 87 | s.name = value 88 | 89 | case "h": 90 | 91 | case "s": 92 | s.password = value 93 | 94 | case "t": 95 | 96 | case "m": 97 | switch value { 98 | case "request": 99 | s.mode = ModePlay 100 | 101 | case "publish": 102 | s.mode = ModePublish 103 | 104 | default: 105 | return ErrInvalidMode 106 | } 107 | 108 | // Ignore keys sent by Blackmagic Atem Mini Pro 109 | case "bmd_uuid": 110 | 111 | case "bmd_name": 112 | 113 | default: 114 | return fmt.Errorf("unsupported key '%s'", key) 115 | } 116 | } 117 | } else { 118 | split := strings.Split(src, "/") 119 | 120 | s.password = "" 121 | if len(split) == 3 { 122 | s.password = split[2] 123 | } else if len(split) != 2 { 124 | return ErrInvalidSlashes 125 | } 126 | modeStr := split[0] 127 | s.name = split[1] 128 | 129 | switch modeStr { 130 | case "play": 131 | s.mode = ModePlay 132 | case "publish": 133 | s.mode = ModePublish 134 | default: 135 | return ErrInvalidMode 136 | } 137 | } 138 | 139 | if len(s.name) == 0 { 140 | return ErrMissingName 141 | } 142 | 143 | s.str = src 144 | return nil 145 | } 146 | 147 | // toString returns a string representation of the streamid 148 | func (s *StreamID) toString() (string, error) { 149 | mode := "" 150 | switch s.mode { 151 | case ModePlay: 152 | mode = "play" 153 | case ModePublish: 154 | mode = "publish" 155 | default: 156 | return "", ErrInvalidMode 157 | } 158 | if strings.Contains(s.name, "/") { 159 | return "", ErrInvalidNamePassword 160 | } 161 | if strings.Contains(s.password, "/") { 162 | return "", ErrInvalidNamePassword 163 | } 164 | if len(s.password) == 0 { 165 | return fmt.Sprintf("%s/%s", mode, s.name), nil 166 | } 167 | return fmt.Sprintf("%s/%s/%s", mode, s.name, s.password), nil 168 | } 169 | 170 | // Match checks a streamid against a string with wildcards. 171 | // The string may contain * to match any number of characters. 172 | func (s StreamID) Match(pattern string) bool { 173 | return wildcard.Match(pattern, s.str) 174 | } 175 | 176 | func (s StreamID) String() string { 177 | return s.str 178 | } 179 | 180 | func (s StreamID) Mode() Mode { 181 | return s.mode 182 | } 183 | 184 | func (s StreamID) Name() string { 185 | return s.name 186 | } 187 | 188 | func (s StreamID) Password() string { 189 | return s.password 190 | } 191 | 192 | func (s StreamID) Username() string { 193 | return s.username 194 | } 195 | -------------------------------------------------------------------------------- /mpegts/parser_test.go: -------------------------------------------------------------------------------- 1 | package mpegts 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os" 7 | "os/exec" 8 | "testing" 9 | ) 10 | 11 | type ffprobeFrame struct { 12 | PictureNumber int `json:"coded_picture_number"` 13 | KeyFrame int `json:"key_frame"` 14 | PacketSize string `json:"pkt_size"` 15 | PictType string `json:"pict_type"` 16 | PTS int `json:"pkt_pts"` 17 | DTS int `json:"pkjt_dts"` 18 | } 19 | 20 | type ffprobeOutput struct { 21 | Frames []ffprobeFrame `json:"frames"` 22 | } 23 | 24 | func ffprobe(filename string) (ffprobeOutput, error) { 25 | cmd := exec.Command("ffprobe", "-v", "debug", "-hide_banner", "-of", "json", "-show_frames", filename) 26 | stdout, err := cmd.StdoutPipe() 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | stderr, err := cmd.StderrPipe() 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | if err := cmd.Start(); err != nil { 35 | log.Fatal(err) 36 | } 37 | go func() { 38 | buf := make([]byte, 1500) 39 | for { 40 | res, err := stderr.Read(buf) 41 | if err != nil { 42 | break 43 | } 44 | log.Printf("%s", buf[:res]) 45 | } 46 | }() 47 | var output ffprobeOutput 48 | if err := json.NewDecoder(stdout).Decode(&output); err != nil { 49 | log.Fatal(err) 50 | } 51 | if err := cmd.Wait(); err != nil { 52 | log.Fatal(err) 53 | } 54 | return output, err 55 | } 56 | 57 | func checkParser(t *testing.T, p *Parser, data []byte, name string, numFrames int) { 58 | numPackets := len(data) / PacketLen 59 | offset := 0 60 | for i := 0; i < numPackets; i++ { 61 | packet := data[i*PacketLen : (i+1)*PacketLen] 62 | offset = (i + 1) * PacketLen 63 | err := p.Parse(packet) 64 | if err != nil { 65 | t.Fatalf("%s - Parse failed: %v", name, err) 66 | } 67 | if p.hasInit() { 68 | break 69 | } 70 | } 71 | if !p.hasInit() { 72 | t.Errorf("%s - Should find init", name) 73 | } 74 | pkts, err := p.InitData() 75 | if err != nil { 76 | t.Fatalf("%s - Init data failed: %v", name, err) 77 | } 78 | if pkts == nil { 79 | t.Errorf("%s - Init should not be nil", name) 80 | } 81 | 82 | file, err := os.CreateTemp("", "srttest") 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | defer os.Remove(file.Name()) 87 | 88 | for i := range pkts { 89 | buf := pkts[i] 90 | if _, err := file.Write(buf); err != nil { 91 | t.Error(err) 92 | } 93 | } 94 | if _, err := file.Write(data[offset:]); err != nil { 95 | t.Fatal(err) 96 | } 97 | if err := file.Sync(); err != nil { 98 | t.Fatal(err) 99 | } 100 | 101 | // compare ffprobe results with original 102 | got, err := ffprobe(file.Name()) 103 | log.Println(got, err) 104 | if numFrames != len(got.Frames) { 105 | t.Errorf("%s - Failed ffprobe, got %d frames, expected %d", name, len(got.Frames), numFrames) 106 | } 107 | } 108 | 109 | func TestParser_ParseH264_basic(t *testing.T) { 110 | // Parse 1s complete MPEG-TS with NAL at start 111 | data, err := os.ReadFile("h264.ts") 112 | if err != nil { 113 | t.Fatalf("failed to open test file") 114 | } 115 | p := NewParser() 116 | checkParser(t, p, data, "simple", 25) 117 | } 118 | 119 | func TestParser_ParseH264_complex(t *testing.T) { 120 | // Parse 1s complete MPEG-TS with NAL at start 121 | data, err := os.ReadFile("h264_long.ts") 122 | if err != nil { 123 | t.Fatalf("failed to open test file") 124 | } 125 | log.Println("numpackets", len(data)/PacketLen) 126 | 127 | tests := []struct { 128 | name string 129 | offset int 130 | expectedFrames int 131 | }{ 132 | {"NoOffset", 0, 15}, 133 | {"GOPOffset", 50 * PacketLen, 10}, 134 | {"2GOPOffset", 100 * PacketLen, 5}, 135 | } 136 | for _, tt := range tests { 137 | p := NewParser() 138 | checkParser(t, p, data[tt.offset:], tt.name, tt.expectedFrames) 139 | } 140 | } 141 | 142 | func TestParser_ParseH265_basic(t *testing.T) { 143 | // Parse 1s complete MPEG-TS with NAL at start 144 | data, err := os.ReadFile("h265.ts") 145 | if err != nil { 146 | t.Fatalf("failed to open test file") 147 | } 148 | p := NewParser() 149 | checkParser(t, p, data, "simple", 25) 150 | } 151 | 152 | func TestParser_ParseH265_complex(t *testing.T) { 153 | // Parse 1s complete MPEG-TS with NAL at start 154 | data, err := os.ReadFile("h265_long.ts") 155 | if err != nil { 156 | t.Fatalf("failed to open test file") 157 | } 158 | log.Println("numpackets", len(data)/PacketLen) 159 | 160 | tests := []struct { 161 | name string 162 | offset int 163 | expectedFrames int 164 | }{ 165 | {"NoOffset", 0, 15}, 166 | {"GOPOffset", 50 * PacketLen, 10}, 167 | {"2GOPOffset", 100 * PacketLen, 5}, 168 | } 169 | for _, tt := range tests { 170 | p := NewParser() 171 | checkParser(t, p, data[tt.offset:], tt.name, tt.expectedFrames) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /cmd/srtbench/bench.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "sync" 11 | "sync/atomic" 12 | "syscall" 13 | "time" 14 | 15 | gosrt "github.com/datarhei/gosrt" 16 | ) 17 | 18 | func main() { 19 | addr := flag.String("addr", "localhost:1337", "address to connect to") 20 | streams := flag.Int("streams", 1, "number of streams") 21 | ratio := flag.Int("ratio", 5, "number of subscribers per publisher") 22 | flag.Parse() 23 | var pubs []*pub 24 | var subs []*sub 25 | 26 | ctx, cancel := context.WithCancel(context.Background()) 27 | defer cancel() 28 | ctx, _ = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) 29 | t := &test{ 30 | addr: *addr, 31 | errChan: make(chan error, 1), 32 | log: gosrt.NewLogger([]string{"connection:error"}), 33 | } 34 | for i := range *streams { 35 | pub, err := t.runPublisher(ctx, fmt.Sprintf("publish/%d", i)) 36 | if err != nil { 37 | log.Fatal("publisher:", err) 38 | } 39 | pubs = append(pubs, pub) 40 | for range *ratio { 41 | sub, err := t.runSubscriber(ctx, fmt.Sprintf("play/%d", i)) 42 | if err != nil { 43 | log.Fatal("subscriber:", err) 44 | } 45 | subs = append(subs, sub) 46 | } 47 | } 48 | lastWrite := int64(0) 49 | lastRead := int64(0) 50 | for { 51 | select { 52 | case <-ctx.Done(): 53 | case err := <-t.errChan: 54 | cancel() 55 | log.Println("error:", err) 56 | case log := <-t.log.Listen(): 57 | fmt.Printf("[%s] %s %s:%d\n", log.Topic, log.Message, log.File, log.Line) 58 | continue 59 | case <-time.After(time.Second): 60 | writeTotal := int64(0) 61 | // var stats gosrt.Statistics 62 | for _, pub := range pubs { 63 | writeTotal += pub.written.Load() 64 | // pub.conn.Stats(&stats) 65 | } 66 | readTotal := int64(0) 67 | for _, sub := range subs { 68 | readTotal += sub.read.Load() 69 | } 70 | log.Printf("write: %.3fMbit/s, read: %.3fMbit/s", float64(writeTotal-lastWrite)/1e6*8, float64(readTotal-lastRead)/1e6*8) 71 | lastWrite = writeTotal 72 | lastRead = readTotal 73 | continue 74 | } 75 | break 76 | } 77 | t.done.Wait() 78 | log.Println("done") 79 | } 80 | 81 | type test struct { 82 | addr string 83 | done sync.WaitGroup 84 | errChan chan error 85 | log gosrt.Logger 86 | } 87 | 88 | func (t *test) error(err error) { 89 | select { 90 | case t.errChan <- err: 91 | default: 92 | } 93 | } 94 | 95 | type pub struct { 96 | conn gosrt.Conn 97 | written atomic.Int64 98 | } 99 | 100 | type sub struct { 101 | conn gosrt.Conn 102 | read atomic.Int64 103 | } 104 | 105 | // run single publisher sending to relay 106 | func (t *test) runPublisher(ctx context.Context, streamid string) (*pub, error) { 107 | conf := gosrt.DefaultConfig() 108 | conf.StreamId = streamid 109 | conf.Latency = 50 * time.Millisecond 110 | conf.InputBW = 384000 // 3Mbit/s 111 | conf.MaxBW = 0 112 | conf.OverheadBW = 25 113 | conn, err := gosrt.Dial("srt", t.addr, conf) 114 | if err != nil { 115 | return nil, err 116 | // handle error 117 | } 118 | buffer := make([]byte, 1316) 119 | pub := &pub{ 120 | conn: conn, 121 | } 122 | t.done.Add(2) 123 | context.AfterFunc(ctx, func() { 124 | defer t.done.Done() 125 | conn.Close() 126 | }) 127 | 128 | go func() { 129 | defer t.done.Done() 130 | for { 131 | select { 132 | case <-ctx.Done(): 133 | return 134 | case <-time.After(3436 * time.Microsecond): 135 | // 1 packet every 3.436 milliseconds -> 3mbit/s 136 | } 137 | if err := conn.SetWriteDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { 138 | t.error(fmt.Errorf("set write deadline failed: %w", err)) 139 | break 140 | } 141 | n, err := conn.Write(buffer) 142 | if err != nil { 143 | if ctx.Err() != nil { 144 | return 145 | } 146 | // handle error 147 | t.error(fmt.Errorf("write failed: %w", err)) 148 | break 149 | } 150 | // handle received data 151 | pub.written.Add(int64(n)) 152 | } 153 | }() 154 | return pub, nil 155 | } 156 | 157 | func (t *test) runSubscriber(ctx context.Context, streamid string) (*sub, error) { 158 | conf := gosrt.DefaultConfig() 159 | conf.Latency = 50 * time.Millisecond 160 | conf.StreamId = streamid 161 | conf.PeerIdleTimeout = time.Second * 10 162 | conf.ConnectionTimeout = time.Second * 10 163 | conf.Logger = t.log 164 | conn, err := gosrt.Dial("srt", t.addr, conf) 165 | if err != nil { 166 | return nil, err 167 | // handle error 168 | } 169 | buffer := make([]byte, 2048) 170 | sub := &sub{ 171 | conn: conn, 172 | } 173 | t.done.Add(2) 174 | context.AfterFunc(ctx, func() { 175 | defer t.done.Done() 176 | conn.Close() 177 | }) 178 | 179 | go func() { 180 | defer t.done.Done() 181 | for { 182 | if ctx.Err() != nil { 183 | return 184 | } 185 | if err := conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)); err != nil { 186 | t.error(fmt.Errorf("set read deadline failed: %w", err)) 187 | break 188 | } 189 | n, err := conn.Read(buffer) 190 | if err != nil { 191 | if ctx.Err() != nil { 192 | return 193 | } 194 | // handle error 195 | t.error(fmt.Errorf("read failed: %w", err)) 196 | break 197 | } 198 | sub.read.Add(int64(n)) 199 | } 200 | }() 201 | return sub, nil 202 | } 203 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/IGLOU-EU/go-wildcard/v2 v2.1.0 h1:WFqyYAuIYLJ6mHZ4rp/bYXiR4E1IvXW4+zInYWdQBqI= 2 | github.com/IGLOU-EU/go-wildcard/v2 v2.1.0/go.mod h1:/sUMQ5dk2owR0ZcjRI/4AZ+bUFF5DxGCQrDMNBXUf5o= 3 | github.com/Showmax/go-fqdn v1.0.0 h1:0rG5IbmVliNT5O19Mfuvna9LL7zlHyRfsSvBPZmF9tM= 4 | github.com/Showmax/go-fqdn v1.0.0/go.mod h1:SfrFBzmDCtCGrnHhoDjuvFnKsWjEQX/Q9ARZvOrJAko= 5 | github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c h1:8XZeJrs4+ZYhJeJ2aZxADI2tGADS15AzIF8MQ8XAhT4= 6 | github.com/benburkert/openpgp v0.0.0-20160410205803-c2471f86866c/go.mod h1:x1vxHcL/9AVzuk5HOloOEPrtJY0MaalYr78afXZ+pWI= 7 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 8 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 9 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 10 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 11 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 12 | github.com/datarhei/gosrt v0.9.0 h1:FW8A+F8tBiv7eIa57EBHjtTJKFX+OjvLogF/tFXoOiA= 13 | github.com/datarhei/gosrt v0.9.0/go.mod h1:rqTRK8sDZdN2YBgp1EEICSV4297mQk0oglwvpXhaWdk= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 17 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 18 | github.com/haivision/srtgo v0.0.0-20230627061225-a70d53fcd618 h1:oGPTZa7I5wqmQs/UhWHj3ln6/CjQX2yQt784xx6H0wI= 19 | github.com/haivision/srtgo v0.0.0-20230627061225-a70d53fcd618/go.mod h1:aTd4vOr9wtzkCbbocUFh6atlJy7H/iV5jhqEWlTdCdA= 20 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 21 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 22 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 23 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 24 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 26 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 27 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 28 | github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0= 29 | github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= 30 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 31 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 32 | github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= 33 | github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 34 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 35 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 37 | github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 38 | github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 39 | github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 40 | github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= 41 | github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= 42 | github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 43 | github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 44 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 45 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 46 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 47 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 48 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 49 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 50 | go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= 51 | go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= 52 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 53 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 54 | golang.org/x/sys v0.0.0-20200926100807-9d91bd62050c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 56 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 57 | google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 58 | google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 61 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 62 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 63 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= 65 | gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= 66 | -------------------------------------------------------------------------------- /mpegts/parser.go: -------------------------------------------------------------------------------- 1 | package mpegts 2 | 3 | import ( 4 | "encoding/binary" 5 | "errors" 6 | "io" 7 | "log" 8 | ) 9 | 10 | // MPEGTS errors 11 | var ( 12 | ErrDataTooLong = errors.New("data too long") 13 | ErrInvalidPacket = errors.New("invalid MPEGTS packet") 14 | ) 15 | 16 | // PID constants 17 | const ( 18 | PIDPAT = 0x0 // Program Association Table (PAT) contains a directory listing of all Program Map Tables. 19 | PIDTSDT = 0x2 // Transport Stream Description Table (TSDT) contains descriptors related to the overall transport stream 20 | PIDNull = 0x1fff // Null Packet (used for fixed bandwidth padding) 21 | ) 22 | 23 | // StreamType constants 24 | const ( 25 | StreamTypeH264 = 0x1b 26 | StreamTypeH265 = 0x24 27 | ) 28 | 29 | // Parser object for finding the synchronization point in a MPEGTS stream 30 | // This works as follows: parse packets until all the following have been fulfilled 31 | // 1. Parse PAT to get PID->PMT mappings 32 | // 2. Parse PMTs to Find PID->PES mappings 33 | // 3. Parse PES to find H.264 SPS+PPS or equivalent 34 | // 35 | // Store the original packets or generate new ones to send to a client 36 | type Parser struct { 37 | init [][]byte // collected packets to initialize a decoder 38 | expectedPATSection byte // id of next expected PAT section 39 | expectedPMTSection byte // id of next expected PMT section 40 | PAT []byte 41 | PMT []byte 42 | hasPAT bool // MPEG-TS PAT packet stored 43 | hasPMT bool // MPEG-TS PMT packet stored 44 | pmtMap map[uint16]uint16 // map[pid]programNumber 45 | tspMap map[uint16]*ElementaryStream // transport stream program map 46 | } 47 | 48 | func NewParser() *Parser { 49 | return &Parser{ 50 | pmtMap: make(map[uint16]uint16), 51 | tspMap: make(map[uint16]*ElementaryStream), 52 | init: make([][]byte, 0, 3), 53 | } 54 | } 55 | 56 | func (p *Parser) hasInit() bool { 57 | if !p.hasPAT || !p.hasPMT { 58 | return false 59 | } 60 | 61 | for _, es := range p.tspMap { 62 | if !es.HasInit { 63 | return false 64 | } 65 | } 66 | 67 | return true 68 | } 69 | 70 | // InitData returns data needed for decoder init or nil if the parser is not ready yet 71 | func (p *Parser) InitData() ([][]byte, error) { 72 | if !p.hasInit() { 73 | return nil, nil 74 | } 75 | 76 | init := [][]byte{p.PAT, p.PMT} 77 | init = append(init, p.init...) 78 | 79 | return init, nil 80 | } 81 | 82 | // Parse processes all MPEGTS packets from a buffer 83 | func (p *Parser) Parse(data []byte) error { 84 | pkt := Packet{} 85 | for { 86 | if len(data) == 0 { 87 | return nil 88 | } 89 | err := pkt.FromBytes(data) 90 | if err != nil { 91 | // Incomplete packet, TODO: keep rest data? 92 | if err == io.ErrUnexpectedEOF { 93 | log.Fatalln("parsing failed, incomplete packet") 94 | return nil 95 | } 96 | return err 97 | } 98 | 99 | storePacket := false 100 | if pkt.PID() == PIDPAT { 101 | // parse PMT and store PAT packet 102 | store, err := p.ParsePSI(pkt.Payload()) 103 | if err != nil { 104 | return err 105 | } 106 | if store { 107 | p.PAT = data[:pkt.Size()] 108 | } 109 | 110 | } else if _, ok := p.pmtMap[pkt.PID()]; ok { 111 | // Parse PID->ES mapping and store PMT packet 112 | store, err := p.ParsePSI(pkt.Payload()) 113 | if err != nil { 114 | return err 115 | } 116 | if store { 117 | p.PMT = data[:pkt.Size()] 118 | } 119 | 120 | } else if stream, ok := p.tspMap[pkt.PID()]; ok { 121 | // Parse PES and store SPS+PPS packet 122 | // log.Println("payload", len(pkt.Payload), len(pkt.AdaptationField)) 123 | storePacket, err = stream.CodecParser.ContainsInit(&pkt) 124 | if err != nil { 125 | return err 126 | } 127 | if storePacket { 128 | stream.HasInit = true 129 | } 130 | } 131 | 132 | // store init packets and all remaining packets from the buffer after init 133 | if storePacket || p.hasInit() { 134 | p.init = append(p.init, data[:pkt.Size()]) 135 | } 136 | 137 | data = data[pkt.Size():] 138 | } 139 | } 140 | 141 | // ParsePSI selectively parses a Program Specific Information (PSI) table 142 | // We are only interested in PAT and PMT 143 | func (p *Parser) ParsePSI(data []byte) (bool, error) { 144 | // skip to section header 145 | ptr := int(data[0]) 146 | offset := 1 + ptr 147 | shouldStore := false 148 | 149 | hdr, err := ParsePSIHeader(data[offset:]) 150 | if err != nil { 151 | return false, err 152 | } 153 | offset += PSIHeaderLen 154 | 155 | // We are only interested in PAT and PMT 156 | switch hdr.tableID { 157 | case TableTypePAT: 158 | // expect program map in order 159 | if p.expectedPATSection != hdr.sectionNumber || !hdr.currentNext { 160 | return false, nil 161 | } 162 | 163 | end := offset + int(hdr.sectionLength-9)/4 164 | for { 165 | programNumber := binary.BigEndian.Uint16(data[offset : offset+2]) 166 | offset += 2 167 | pid := binary.BigEndian.Uint16(data[offset:offset+2]) & 0x1fff 168 | offset += 2 169 | if programNumber != 0 { 170 | p.pmtMap[pid] = programNumber 171 | } 172 | if offset >= end { 173 | break 174 | } 175 | } 176 | shouldStore = true 177 | p.expectedPATSection = hdr.sectionNumber + 1 178 | if hdr.sectionNumber == hdr.lastSectionNumber { 179 | p.hasPAT = true 180 | p.expectedPATSection = 0 181 | } 182 | 183 | case TableTypePMT: 184 | // expect program map in order 185 | if p.expectedPMTSection != hdr.sectionNumber || !hdr.currentNext { 186 | return false, nil 187 | } 188 | 189 | // skip PCR PID 190 | offset += 2 191 | 192 | programInfoLength := binary.BigEndian.Uint16(data[offset:offset+2]) & 0xfff 193 | offset += 2 + int(programInfoLength) 194 | 195 | end := offset + int(hdr.sectionLength-programInfoLength-13) 196 | for { 197 | streamType := data[offset] 198 | offset++ 199 | 200 | elementaryPID := binary.BigEndian.Uint16(data[offset:offset+2]) & 0x1fff 201 | offset += 2 202 | 203 | esInfoLength := binary.BigEndian.Uint16(data[offset:offset+2]) & 0xfff 204 | offset += 2 + int(esInfoLength) 205 | 206 | _, hasParser := p.tspMap[elementaryPID] 207 | if !hasParser { 208 | switch streamType { 209 | case StreamTypeH264: 210 | p.tspMap[elementaryPID] = &ElementaryStream{CodecParser: H264Parser{}} 211 | case StreamTypeH265: 212 | p.tspMap[elementaryPID] = &ElementaryStream{CodecParser: H265Parser{}} 213 | default: 214 | // log.Println("Unknown streamtype", elementaryPID) 215 | } 216 | } 217 | 218 | if offset >= end { 219 | break 220 | } 221 | } 222 | 223 | shouldStore = true 224 | p.expectedPMTSection = hdr.sectionNumber + 1 225 | if hdr.sectionNumber == hdr.lastSectionNumber { 226 | p.hasPMT = true 227 | p.expectedPMTSection = 0 228 | } 229 | } 230 | return shouldStore, nil 231 | } 232 | -------------------------------------------------------------------------------- /stream/streamid_test.go: -------------------------------------------------------------------------------- 1 | package stream 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestParseStreamID(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | streamID string 13 | wantMode Mode 14 | wantName string 15 | wantPass string 16 | wantUsername string 17 | wantErr error 18 | }{ 19 | // Old school 20 | {"MissingSlash", "s1", 0, "", "", "", ErrInvalidSlashes}, 21 | {"InvalidName", "play//s1", 0, "", "", "", ErrMissingName}, 22 | {"InvalidMode", "foobar/bla", 0, "", "", "", ErrInvalidMode}, 23 | {"InvalidSlash", "foobar/bla//", 0, "", "", "", ErrInvalidSlashes}, 24 | {"EmptyPass", "play/s1/", ModePlay, "s1", "", "", nil}, 25 | {"ValidPass", "play/s1/#![äöü", ModePlay, "s1", "#![äöü", "", nil}, 26 | {"ValidPlay", "play/s1", ModePlay, "s1", "", "", nil}, 27 | {"ValidPublish", "publish/abcdef", ModePublish, "abcdef", "", "", nil}, 28 | {"ValidPlaySpace", "play/bla fasel", ModePlay, "bla fasel", "", "", nil}, 29 | // New hotness - Bad 30 | {"NewInvalidPubEmptyName", "#!::m=publish", ModePublish, "", "", "", ErrMissingName}, 31 | {"NewInvalidPlayEmptyName", "#!::m=request", ModePlay, "", "", "", ErrMissingName}, 32 | {"NewInvalidPubBadKey", "#!::m=publish,y=bar", ModePublish, "", "", "", fmt.Errorf("unsupported key '%s'", "y")}, 33 | {"NewInvalidPlayBadKey", "#!::m=request,x=foo", ModePlay, "", "", "", fmt.Errorf("unsupported key '%s'", "x")}, 34 | {"NewInvalidPubNoEquals", "#!::m=publish,r", ModePublish, "abc", "", "", ErrInvalidValue}, 35 | {"NewInvalidPlayNoEquals", "#!::m=request,r", ModePlay, "abc", "", "", ErrInvalidValue}, 36 | {"NewInvalidPubNoValue", "#!::m=publish,r=", ModePublish, "abc", "", "", ErrMissingName}, 37 | {"NewInvalidPlayNoValue", "#!::m=request,s=", ModePlay, "abc", "", "", ErrMissingName}, 38 | {"NewInvalidPubBadKey", "#!::m=publish,x=", ModePublish, "abc", "", "", fmt.Errorf("unsupported key '%s'", "x")}, 39 | {"NewInvalidPlayBadKey", "#!::m=request,y=", ModePlay, "abc", "", "", fmt.Errorf("unsupported key '%s'", "y")}, 40 | // New hotness - Standard 41 | {"NewValidNameRequest", "#!::m=publish,r=abc", ModePublish, "abc", "", "", nil}, 42 | {"NewValidPlay", "#!::m=request,r=abc", ModePlay, "abc", "", "", nil}, 43 | {"NewValidNameRequestRev", "#!::r=abc,m=publish", ModePublish, "abc", "", "", nil}, 44 | {"NewValidPlayRev", "#!::r=abc,m=request", ModePlay, "abc", "", "", nil}, 45 | {"NewValidPassPub", "#!::m=publish,r=abc,s=bob", ModePublish, "abc", "bob", "", nil}, 46 | {"NewValidPassPlay", "#!::m=request,r=abc,s=alice", ModePlay, "abc", "alice", "", nil}, 47 | {"NewValidPassPubOrder", "#!::s=bob,m=publish,r=abc123", ModePublish, "abc123", "bob", "", nil}, 48 | {"NewValidPassPlayOrder", "#!::m=request,s=alice,r=def", ModePlay, "def", "alice", "", nil}, 49 | {"NewValidPubUsername", "#!::s=bob,m=publish,r=abc123,u=eve", ModePublish, "abc123", "bob", "eve", nil}, 50 | {"NewValidPlayUsername", "#!::m=request,s=alice,r=def,u=bar", ModePlay, "def", "alice", "bar", nil}, 51 | {"NewValidPubUsernameOrder", "#!::s=bob,m=publish,u=eve,r=abc123", ModePublish, "abc123", "bob", "eve", nil}, 52 | {"NewValidPlayUsernameOrder", "#!::m=request,u=bar,s=alice,r=def", ModePlay, "def", "alice", "bar", nil}, 53 | // New Hotness - Unicode 54 | {"NewValidUnicodePub", "#!::m=publish,r=#![äöü,s=bob", ModePublish, "#![äöü", "bob", "", nil}, 55 | {"NewValidUnicodePlay", "#!::m=request,r=#![äöü,s=alice", ModePlay, "#![äöü", "alice", "", nil}, 56 | {"NewValidUnicodePassPub", "#!::m=publish,s=#![äöü,r=bob", ModePublish, "bob", "#![äöü", "", nil}, 57 | {"NewValidUnicodePassPlay", "#!::m=request,s=#![äöü,r=alice", ModePlay, "alice", "#![äöü", "", nil}, 58 | {"NewValidUnicodeUserPub", "#!::s=bye,m=publish,u=#![äöü,r=art", ModePublish, "art", "bye", "#![äöü", nil}, 59 | {"NewValidUnicodeUserPlay", "#!::m=request,u=#![äöü,r=eve,s=hai", ModePlay, "eve", "hai", "#![äöü", nil}, 60 | } 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | var streamid StreamID 64 | err := streamid.FromString(tt.streamID) 65 | 66 | if err != nil { 67 | if err.Error() != tt.wantErr.Error() { // Only really care about str value for this, otherwise: if !errors.Is(err, tt.wantErr) { 68 | t.Errorf("ParseStreamID() error = %v, wantErr %v", err, tt.wantErr) 69 | } 70 | if streamid.String() != "" { 71 | t.Error("str should be empty on failed parse") 72 | } 73 | return 74 | } 75 | if name := streamid.Name(); name != tt.wantName { 76 | t.Errorf("ParseStreamID() got Name = %v, want %v", name, tt.wantName) 77 | } 78 | if mode := streamid.Mode(); mode != tt.wantMode { 79 | t.Errorf("ParseStreamID() got Mode = %v, want %v", mode, tt.wantMode) 80 | } 81 | if password := streamid.Password(); password != tt.wantPass { 82 | t.Errorf("ParseStreamID() got Password = %v, want %v", password, tt.wantMode) 83 | } 84 | if str := streamid.String(); str != tt.streamID { 85 | t.Errorf("String() got String = %v, want %v", str, tt.streamID) 86 | } 87 | if str := streamid.Username(); str != tt.wantUsername { 88 | t.Errorf("Username() got String = %v, want %v", str, tt.wantUsername) 89 | } 90 | }) 91 | } 92 | } 93 | 94 | func TestNewStreamID(t *testing.T) { 95 | tests := []struct { 96 | name string 97 | argName string 98 | argMode Mode 99 | argPassword string 100 | wantStreamID string 101 | wantErr error 102 | }{ 103 | {"InvalidMode", "s1", 0, "", "", ErrInvalidMode}, 104 | {"InvalidName", "s1/", ModePlay, "", "", ErrInvalidNamePassword}, 105 | {"InvalidPass", "s1", ModePlay, "foo/bar", "", ErrInvalidNamePassword}, 106 | {"ValidPlay", "s1", ModePlay, "", "play/s1", nil}, 107 | {"ValidPublish", "s1", ModePublish, "", "publish/s1", nil}, 108 | {"ValidPlayPass", "s1", ModePlay, "foo", "play/s1/foo", nil}, 109 | {"ValidPublishPass", "s1", ModePublish, "foo", "publish/s1/foo", nil}, 110 | } 111 | for _, tt := range tests { 112 | t.Run(tt.name, func(t *testing.T) { 113 | id, err := NewStreamID(tt.argName, tt.argPassword, tt.argMode) 114 | if !errors.Is(err, tt.wantErr) { 115 | t.Errorf("ParseStreamID() error = %v, wantErr %v", err, tt.wantErr) 116 | } 117 | if err != nil { 118 | if id != nil { 119 | t.Error("id should be nil on failed parse") 120 | } 121 | return 122 | } 123 | if err != nil { 124 | t.Error(err) 125 | } 126 | if str := id.String(); str != tt.wantStreamID { 127 | t.Errorf("NewStreamID() got String = %v, want %v", str, tt.wantStreamID) 128 | } 129 | }) 130 | } 131 | } 132 | 133 | func TestStreamID_Match(t *testing.T) { 134 | tests := []struct { 135 | name string 136 | id string 137 | pattern string 138 | want bool 139 | }{ 140 | {"MatchAll", "publish/foo/bar", "*", true}, 141 | {"FlatMatch", "publish/foo/bar", "pub*bar", true}, 142 | {"CompleteMatch", "play/one/two", "play/one/two", true}, 143 | } 144 | for _, tt := range tests { 145 | t.Run(tt.name, func(t *testing.T) { 146 | var s StreamID 147 | err := s.FromString(tt.id) 148 | if err != nil { 149 | t.Error(err) 150 | } 151 | if got := s.Match(tt.pattern); got != tt.want { 152 | t.Errorf("StreamID.Match() = %v, want %v", got, tt.want) 153 | } 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /api/metrics.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/voc/srtrelay/internal/metrics" 5 | "github.com/voc/srtrelay/srt" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | ) 9 | 10 | const srtSubsystem = "srt" 11 | 12 | var ( 13 | activeSocketsDesc = prometheus.NewDesc( 14 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "active_sockets"), 15 | "The number of active SRT sockets", 16 | nil, nil, 17 | ) 18 | 19 | // Metrics from: https://pkg.go.dev/github.com/haivision/srtgo#SrtStats 20 | pktSentTotalDesc = prometheus.NewDesc( 21 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "sent_packets_total"), 22 | "total number of sent data packets, including retransmissions", 23 | []string{"address", "stream_id"}, nil, 24 | ) 25 | 26 | pktRecvTotalDesc = prometheus.NewDesc( 27 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_packets_total"), 28 | "total number of received packets", 29 | []string{"address", "stream_id"}, nil, 30 | ) 31 | 32 | pktSndLossTotalDesc = prometheus.NewDesc( 33 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "sent_lost_packets_total"), 34 | "total number of lost packets (sender side)", 35 | []string{"address", "stream_id"}, nil, 36 | ) 37 | 38 | pktRcvLossTotalDesc = prometheus.NewDesc( 39 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_lost_packets_total"), 40 | "total number of lost packets (receive_side)", 41 | []string{"address", "stream_id"}, nil, 42 | ) 43 | 44 | pktRetransTotalDesc = prometheus.NewDesc( 45 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "retransmitted_packets_total"), 46 | "total number of retransmitted packets", 47 | []string{"address", "stream_id"}, nil, 48 | ) 49 | 50 | pktSentACKTotalDesc = prometheus.NewDesc( 51 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "sent_ack_packets_total"), 52 | "total number of sent ACK packets", 53 | []string{"address", "stream_id"}, nil, 54 | ) 55 | 56 | pktRecvACKTotalDesc = prometheus.NewDesc( 57 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_ack_packets_total"), 58 | "total number of received ACK packets", 59 | []string{"address", "stream_id"}, nil, 60 | ) 61 | 62 | pktSentNAKTotalDesc = prometheus.NewDesc( 63 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "sent_nak_packets_total"), 64 | "total number of received NAK packets", 65 | []string{"address", "stream_id"}, nil, 66 | ) 67 | 68 | pktRecvNAKTotalDesc = prometheus.NewDesc( 69 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_nak_packets_total"), 70 | "total number of received NAK packets", 71 | []string{"address", "stream_id"}, nil, 72 | ) 73 | 74 | sndDurationTotalDesc = prometheus.NewDesc( 75 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "udt_sent_duration_seconds_total"), 76 | "total time duration when UDT is sending data (idle time exclusive)", 77 | []string{"address", "stream_id"}, nil, 78 | ) 79 | 80 | pktSndDropTotalDesc = prometheus.NewDesc( 81 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "sent_dropped_packets_total"), 82 | "number of too-late-to-send dropped packets", 83 | []string{"address", "stream_id"}, nil, 84 | ) 85 | 86 | pktRcvDropTotalDesc = prometheus.NewDesc( 87 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_dropped_packets_total"), 88 | "number of too-late-to play missing packets", 89 | []string{"address", "stream_id"}, nil, 90 | ) 91 | 92 | pktRcvUndecryptTotalDesc = prometheus.NewDesc( 93 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_undecrypted_packets_total"), 94 | "number of undecrypted packets", 95 | []string{"address", "stream_id"}, nil, 96 | ) 97 | 98 | byteSentTotalDesc = prometheus.NewDesc( 99 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "sent_bytes_total"), 100 | "total number of sent data bytes, including retransmissions", 101 | []string{"address", "stream_id"}, nil, 102 | ) 103 | 104 | byteRecvTotalDesc = prometheus.NewDesc( 105 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_bytes_total"), 106 | "total number of received bytes", 107 | []string{"address", "stream_id"}, nil, 108 | ) 109 | 110 | byteRcvLossTotalDesc = prometheus.NewDesc( 111 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_lost_bytes_total"), 112 | "total number of lost bytes", 113 | []string{"address", "stream_id"}, nil, 114 | ) 115 | 116 | byteRetransTotalDesc = prometheus.NewDesc( 117 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "retransmitted_bytes_total"), 118 | "total number of retransmitted bytes", 119 | []string{"address", "stream_id"}, nil, 120 | ) 121 | 122 | byteSndDropTotalDesc = prometheus.NewDesc( 123 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "sent_dropped_bytes_total"), 124 | "number of too-late-to-send dropped bytes", 125 | []string{"address", "stream_id"}, nil, 126 | ) 127 | 128 | byteRcvDropTotalDesc = prometheus.NewDesc( 129 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_dropped_bytes_total"), 130 | "number of too-late-to play missing bytes (estimate based on average packet size)", 131 | []string{"address", "stream_id"}, nil, 132 | ) 133 | 134 | byteRcvUndecryptTotalDesc = prometheus.NewDesc( 135 | prometheus.BuildFQName(metrics.Namespace, srtSubsystem, "receive_undecrypted_bytes_total"), 136 | "number of undecrypted bytes", 137 | []string{"address", "stream_id"}, nil, 138 | ) 139 | ) 140 | 141 | // Exporter collects metrics. It implements prometheus.Collector. 142 | type Exporter struct { 143 | server srt.Server 144 | } 145 | 146 | func NewExporter(s srt.Server) *Exporter { 147 | e := Exporter{server: s} 148 | return &e 149 | } 150 | 151 | // Describe implements prometheus.Collector. 152 | func (e *Exporter) Describe(ch chan<- *prometheus.Desc) { 153 | ch <- activeSocketsDesc 154 | ch <- pktSentTotalDesc 155 | ch <- pktRecvTotalDesc 156 | ch <- pktSndLossTotalDesc 157 | ch <- pktRcvLossTotalDesc 158 | ch <- pktRetransTotalDesc 159 | ch <- pktSentACKTotalDesc 160 | ch <- pktRecvACKTotalDesc 161 | ch <- pktSentNAKTotalDesc 162 | ch <- pktRecvNAKTotalDesc 163 | ch <- sndDurationTotalDesc 164 | ch <- pktSndDropTotalDesc 165 | ch <- pktRcvDropTotalDesc 166 | ch <- pktRcvUndecryptTotalDesc 167 | ch <- byteSentTotalDesc 168 | ch <- byteRecvTotalDesc 169 | ch <- byteRcvLossTotalDesc 170 | ch <- byteRetransTotalDesc 171 | ch <- byteSndDropTotalDesc 172 | ch <- byteRcvDropTotalDesc 173 | ch <- byteRcvUndecryptTotalDesc 174 | } 175 | 176 | // Collect implements prometheus.Collector. 177 | func (e *Exporter) Collect(ch chan<- prometheus.Metric) { 178 | stats := e.server.GetSocketStatistics() 179 | ch <- prometheus.MustNewConstMetric(activeSocketsDesc, prometheus.GaugeValue, float64(len(stats))) 180 | for _, stat := range stats { 181 | ch <- prometheus.MustNewConstMetric(pktSentTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktSentTotal), stat.Address, stat.StreamID) 182 | ch <- prometheus.MustNewConstMetric(pktRecvTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktRecvTotal), stat.Address, stat.StreamID) 183 | ch <- prometheus.MustNewConstMetric(pktSndLossTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktSndLossTotal), stat.Address, stat.StreamID) 184 | ch <- prometheus.MustNewConstMetric(pktRcvLossTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktRcvLossTotal), stat.Address, stat.StreamID) 185 | ch <- prometheus.MustNewConstMetric(pktRetransTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktRetransTotal), stat.Address, stat.StreamID) 186 | ch <- prometheus.MustNewConstMetric(pktSentACKTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktSentACKTotal), stat.Address, stat.StreamID) 187 | ch <- prometheus.MustNewConstMetric(pktRecvACKTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktRecvACKTotal), stat.Address, stat.StreamID) 188 | ch <- prometheus.MustNewConstMetric(pktSentNAKTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktSentNAKTotal), stat.Address, stat.StreamID) 189 | ch <- prometheus.MustNewConstMetric(pktRecvNAKTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktRecvNAKTotal), stat.Address, stat.StreamID) 190 | ch <- prometheus.MustNewConstMetric(sndDurationTotalDesc, prometheus.CounterValue, float64(stat.Stats.UsSndDurationTotal)/1_000_000.0, stat.Address, stat.StreamID) 191 | ch <- prometheus.MustNewConstMetric(pktSndDropTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktSndDropTotal), stat.Address, stat.StreamID) 192 | ch <- prometheus.MustNewConstMetric(pktRcvDropTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktRcvDropTotal), stat.Address, stat.StreamID) 193 | ch <- prometheus.MustNewConstMetric(pktRcvUndecryptTotalDesc, prometheus.CounterValue, float64(stat.Stats.PktRcvUndecryptTotal), stat.Address, stat.StreamID) 194 | ch <- prometheus.MustNewConstMetric(byteSentTotalDesc, prometheus.CounterValue, float64(stat.Stats.ByteSentTotal), stat.Address, stat.StreamID) 195 | ch <- prometheus.MustNewConstMetric(byteRecvTotalDesc, prometheus.CounterValue, float64(stat.Stats.ByteRecvTotal), stat.Address, stat.StreamID) 196 | ch <- prometheus.MustNewConstMetric(byteRcvLossTotalDesc, prometheus.CounterValue, float64(stat.Stats.ByteRcvLossTotal), stat.Address, stat.StreamID) 197 | ch <- prometheus.MustNewConstMetric(byteRetransTotalDesc, prometheus.CounterValue, float64(stat.Stats.ByteRetransTotal), stat.Address, stat.StreamID) 198 | ch <- prometheus.MustNewConstMetric(byteSndDropTotalDesc, prometheus.CounterValue, float64(stat.Stats.ByteSndDropTotal), stat.Address, stat.StreamID) 199 | ch <- prometheus.MustNewConstMetric(byteRcvDropTotalDesc, prometheus.CounterValue, float64(stat.Stats.ByteRcvDropTotal), stat.Address, stat.StreamID) 200 | ch <- prometheus.MustNewConstMetric(byteRcvUndecryptTotalDesc, prometheus.CounterValue, float64(stat.Stats.ByteRcvUndecryptTotal), stat.Address, stat.StreamID) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /srt/server.go: -------------------------------------------------------------------------------- 1 | package srt 2 | 3 | // #cgo LDFLAGS: -lsrt 4 | // #include 5 | import "C" 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "log" 13 | "net" 14 | "strconv" 15 | "sync" 16 | 17 | "github.com/haivision/srtgo" 18 | "github.com/voc/srtrelay/auth" 19 | "github.com/voc/srtrelay/format" 20 | "github.com/voc/srtrelay/relay" 21 | "github.com/voc/srtrelay/stream" 22 | ) 23 | 24 | type Config struct { 25 | Server ServerConfig 26 | Relay relay.RelayConfig 27 | } 28 | 29 | type ServerConfig struct { 30 | Addresses []string 31 | PublicAddress string 32 | Latency uint 33 | LossMaxTTL uint 34 | Auth auth.Authenticator 35 | SyncClients bool 36 | ListenBacklog int 37 | } 38 | 39 | // Server is an interface for a srt relay server 40 | type Server interface { 41 | Listen(context.Context) error 42 | Wait() 43 | Handle(context.Context, *srtgo.SrtSocket, *net.UDPAddr) 44 | GetStatistics() []*relay.StreamStatistics 45 | GetSocketStatistics() []*SocketStatistics 46 | } 47 | 48 | // ServerImpl implements the Server interface 49 | type ServerImpl struct { 50 | config *ServerConfig 51 | relay relay.Relay 52 | 53 | mutex sync.Mutex 54 | conns map[*srtConn]bool 55 | done sync.WaitGroup 56 | } 57 | 58 | // NewServer creates a server 59 | func NewServer(config *Config) *ServerImpl { 60 | r := relay.NewRelay(&config.Relay) 61 | return &ServerImpl{ 62 | relay: r, 63 | config: &config.Server, 64 | conns: make(map[*srtConn]bool), 65 | } 66 | } 67 | 68 | // Listen sets up a SRT socket in listen mode 69 | func (s *ServerImpl) Listen(ctx context.Context) error { 70 | for _, address := range s.config.Addresses { 71 | host, portString, err := net.SplitHostPort(address) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | port, err := strconv.ParseUint(portString, 10, 16) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | var addresses []string 82 | if len(host) > 0 { 83 | addresses, err = net.LookupHost(host) 84 | if err != nil { 85 | return err 86 | } 87 | } else { 88 | addresses = []string{"::"} 89 | } 90 | 91 | for _, address := range addresses { 92 | err := s.listenAt(ctx, address, uint16(port)) 93 | if err != nil { 94 | return err 95 | } 96 | log.Printf("SRT Listening on %s:%d\n", address, port) 97 | } 98 | } 99 | 100 | return nil 101 | } 102 | 103 | // Wait blocks until listening sockets have been closed 104 | func (s *ServerImpl) Wait() { 105 | s.done.Wait() 106 | } 107 | 108 | func (s *ServerImpl) listenCallback(socket *srtgo.SrtSocket, version int, addr *net.UDPAddr, idstring string) bool { 109 | var streamid stream.StreamID 110 | 111 | // Parse stream id 112 | if err := streamid.FromString(idstring); err != nil { 113 | log.Println(err) 114 | return false 115 | } 116 | 117 | // Check authentication 118 | if !s.config.Auth.Authenticate(streamid) { 119 | log.Printf("%s - Stream '%s' access denied\n", addr, streamid) 120 | if err := socket.SetRejectReason(srtgo.RejectionReasonUnauthorized); err != nil { 121 | log.Printf("Error rejecting stream: %s", err) 122 | } 123 | return false 124 | } 125 | 126 | // Check channel existence before accept 127 | switch streamid.Mode() { 128 | case stream.ModePlay: 129 | if !s.relay.ChannelExists(streamid.Name()) { 130 | log.Printf("%s - Stream '%s' not found", addr, streamid) 131 | if err := socket.SetRejectReason(srtgo.RejectionReasonNotFound); err != nil { 132 | log.Printf("Error rejecting stream: %s", err) 133 | } 134 | return false 135 | } 136 | case stream.ModePublish: 137 | if s.relay.ChannelExists(streamid.Name()) { 138 | log.Printf("%s - Stream '%s' already exists", addr, streamid) 139 | if err := socket.SetRejectReason(srtgo.RejectionReasonForbidden); err != nil { 140 | log.Printf("Error rejecting stream: %s", err) 141 | } 142 | return false 143 | } 144 | } 145 | 146 | return true 147 | } 148 | 149 | func (s *ServerImpl) listenAt(ctx context.Context, host string, port uint16) error { 150 | options := make(map[string]string) 151 | options["blocking"] = "1" 152 | options["transtype"] = "live" 153 | options["latency"] = strconv.Itoa(int(s.config.Latency)) 154 | 155 | sck := srtgo.NewSrtSocket(host, port, options) 156 | if err := sck.SetSockOptInt(srtgo.SRTO_LOSSMAXTTL, int(s.config.LossMaxTTL)); err != nil { 157 | log.Printf("Error settings lossmaxttl: %s", err) 158 | } 159 | sck.SetListenCallback(s.listenCallback) 160 | err := sck.Listen(s.config.ListenBacklog) 161 | if err != nil { 162 | return fmt.Errorf("Listen failed for %v:%v : %v", host, port, err) 163 | } 164 | 165 | s.done.Add(2) 166 | // server socket closer 167 | go func() { 168 | defer s.done.Done() 169 | <-ctx.Done() 170 | sck.Close() 171 | }() 172 | 173 | // accept loop 174 | go func() { 175 | defer s.done.Done() 176 | for { 177 | sock, addr, err := sck.Accept() 178 | if err != nil { 179 | if errors.Is(err, &srtgo.SrtEpollTimeout{}) { 180 | continue 181 | } 182 | // exit silently if context closed 183 | select { 184 | case <-ctx.Done(): 185 | return 186 | default: 187 | } 188 | log.Println("accept failed", err) 189 | continue 190 | } 191 | go s.Handle(ctx, sock, addr) 192 | } 193 | }() 194 | return nil 195 | } 196 | 197 | // SRTConn wraps an srtsocket with additional state 198 | type srtConn struct { 199 | socket relaySocket 200 | address string 201 | streamid *stream.StreamID 202 | } 203 | 204 | type relaySocket interface { 205 | io.Reader 206 | io.Writer 207 | Close() 208 | Stats() (*srtgo.SrtStats, error) 209 | } 210 | 211 | // Handle srt client connection 212 | func (s *ServerImpl) Handle(ctx context.Context, sock *srtgo.SrtSocket, addr *net.UDPAddr) { 213 | var streamid stream.StreamID 214 | defer sock.Close() 215 | 216 | idstring, err := sock.GetSockOptString(C.SRTO_STREAMID) 217 | if err != nil { 218 | log.Println(err) 219 | return 220 | } 221 | 222 | // Parse stream id 223 | if err := streamid.FromString(idstring); err != nil { 224 | log.Println(err) 225 | return 226 | } 227 | 228 | conn := &srtConn{ 229 | socket: sock, 230 | address: addr.String(), 231 | streamid: &streamid, 232 | } 233 | 234 | subctx, cancel := context.WithCancel(ctx) 235 | defer cancel() 236 | s.registerForStats(subctx, conn) 237 | 238 | switch streamid.Mode() { 239 | case stream.ModePlay: 240 | err = s.play(conn) 241 | case stream.ModePublish: 242 | err = s.publish(conn) 243 | } 244 | if err != nil { 245 | log.Printf("%s - %s - %v", conn.address, conn.streamid.Name(), err) 246 | } 247 | } 248 | 249 | // play a stream from the server 250 | func (s *ServerImpl) play(conn *srtConn) error { 251 | sub, unsubscribe, err := s.relay.Subscribe(conn.streamid.Name()) 252 | if err != nil { 253 | return err 254 | } 255 | defer unsubscribe() 256 | log.Printf("%s - play %s\n", conn.address, conn.streamid.Name()) 257 | 258 | demux := format.NewDemuxer() 259 | playing := !s.config.SyncClients 260 | for { 261 | buf, ok := <-sub 262 | 263 | buffered := len(sub) 264 | if buffered > cap(sub)/2 { 265 | log.Printf("%s - %s - %d packets late in buffer\n", conn.address, conn.streamid.Name(), len(sub)) 266 | } 267 | 268 | // Upstream closed, drop connection 269 | if !ok { 270 | log.Printf("%s - %s dropped", conn.address, conn.streamid.Name()) 271 | return nil 272 | } 273 | 274 | // Find initial synchronization point 275 | // TODO: implement timeout for sync 276 | if !playing { 277 | init, err := demux.FindInit(buf) 278 | if err != nil { 279 | return err 280 | } else if init != nil { 281 | for i := range init { 282 | buf := init[i] 283 | _, err := conn.socket.Write(buf) 284 | if err != nil { 285 | return err 286 | } 287 | } 288 | playing = true 289 | } 290 | continue 291 | } 292 | 293 | // Write to socket 294 | _, err := conn.socket.Write(buf) 295 | if err != nil { 296 | return err 297 | } 298 | } 299 | } 300 | 301 | // publish a stream to the server 302 | func (s *ServerImpl) publish(conn *srtConn) error { 303 | pub, err := s.relay.Publish(conn.streamid.Name()) 304 | if err != nil { 305 | return err 306 | } 307 | defer close(pub) 308 | log.Printf("%s - publish %s\n", conn.address, conn.streamid.Name()) 309 | 310 | buf := make([]byte, 2048) 311 | for { 312 | n, err := conn.socket.Read(buf) 313 | 314 | // Push read buffers to all clients via the publish channel 315 | if n > 0 { 316 | tmp := make([]byte, n) 317 | copy(tmp, buf[:n]) 318 | pub <- tmp 319 | } 320 | 321 | if err != nil { 322 | return err 323 | } 324 | } 325 | } 326 | 327 | func (s *ServerImpl) registerForStats(ctx context.Context, conn *srtConn) { 328 | s.mutex.Lock() 329 | defer s.mutex.Unlock() 330 | 331 | s.conns[conn] = true 332 | 333 | go func() { 334 | <-ctx.Done() 335 | 336 | s.mutex.Lock() 337 | defer s.mutex.Unlock() 338 | 339 | delete(s.conns, conn) 340 | }() 341 | } 342 | 343 | func (s *ServerImpl) GetStatistics() []*relay.StreamStatistics { 344 | streams := s.relay.GetStatistics() 345 | for _, st := range streams { 346 | st.URL = fmt.Sprintf("srt://%s?streamid=#!::m=request,r=%s", s.config.PublicAddress, st.Name) // New format 347 | } 348 | return streams 349 | } 350 | 351 | type SocketStatistics struct { 352 | Address string `json:"address"` 353 | StreamID string `json:"stream_id"` 354 | Stats *srtgo.SrtStats `json:"stats"` 355 | } 356 | 357 | func (s *ServerImpl) GetSocketStatistics() []*SocketStatistics { 358 | statistics := make([]*SocketStatistics, 0) 359 | 360 | s.mutex.Lock() 361 | defer s.mutex.Unlock() 362 | 363 | for conn := range s.conns { 364 | srtStats, err := conn.socket.Stats() 365 | if err != nil { 366 | log.Printf("%s - error getting stats %s\n", conn.address, err) 367 | continue 368 | } 369 | statistics = append(statistics, &SocketStatistics{ 370 | Address: conn.address, 371 | StreamID: conn.streamid.String(), 372 | Stats: srtStats, 373 | }) 374 | } 375 | 376 | return statistics 377 | } 378 | --------------------------------------------------------------------------------