Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,36 @@ its first tagged release.

## [Unreleased]

### Changed

- **Both ingest ports bind by default, and both are published by default.**
The RTMP listener used to bind only when some enabled source was configured
for it, while SRT bound unconditionally. That was not a policy — it was two
histories preserved side by side: `ffmpeg -listen 1` had held 1935 only while
an RTMP source existed, and the SRT listener had always been up. The asymmetry
showed on a fresh install that had chosen no ingest mode at all, which still
opened 6000 while refusing to open 1935 on the grounds that nothing there
spoke the protocol.

It also disagreed with this project's own instructions: `docs/HARDWARE.md` and
`docs/TROUBLESHOOTING.md` have always said to run
`-p 6000:6000/udp -p 1935:1935`, so we documented publishing a port that might
not be listening. datarhei Restreamer opens both, and so do we now.

`install.sh` matches: it asks for an RTMP port the way it asks for an SRT one
rather than a yes/no, defaults to publishing 1935, and takes `--rtmp-port 0`
to decline it. **The port is the switch on both sides** — the server treats 0
as off too — instead of a yes/no in the installer and a port in the settings
meaning the same thing two different ways.

What this adds is narrow: a host with **no firewall at all** now has 1935
reachable, where the source list used to close it by accident. Everywhere else
the ufw rule and the compose publish still decide, which is what they always
claimed to do. Both listeners refuse an unknown token or key in constant time,
both require the source to be ready before admitting anything, and a
connection that says nothing dies on the handshake timeout.


## [0.4.0] — 2026-08-07

A minor bump. Nothing here breaks a stored config, but two behaviours change in
Expand Down
58 changes: 25 additions & 33 deletions internal/engine/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,27 +228,33 @@ func (m *Manager) reconcileSharedIngest() {
s := srtserver.New(m.log, addr, m.lookupToken)
return s, s.Start()
})
// The RTMP port is bound only when a source actually uses RTMP.
// BOTH LISTENERS BIND, ALWAYS. The port is the switch, not the source list.
//
// SRT's listener is unconditional because it IS the SRT ingest and a source
// can be pointed at it at any moment. RTMP's was not: before this package,
// `ffmpeg -listen 1` bound 1935 only while an RTMP source was configured,
// so making it unconditional would newly expose a port on every install —
// including a fresh one that has not chosen an ingest mode yet — for a
// protocol nothing there speaks. Port 0 disables it explicitly, and no RTMP
// source has the same effect.
// RTMP used to bind only when some enabled source was configured for it,
// which preserved what `ffmpeg -listen 1` did before this package existed.
// SRT bound unconditionally, which preserved what IT did. Neither was a
// policy: they were two different histories, and the asymmetry showed. A
// fresh install that has not chosen an ingest mode still opened 6000 — the
// exact thing the old comment here justified NOT doing for 1935.
//
// Symmetry is also what the ecosystem does and what this project already
// documents: datarhei Restreamer publishes 1935 and 6000 together, and
// docs/HARDWARE.md and docs/TROUBLESHOOTING.md have always told operators to
// run `-p 6000:6000/udp -p 1935:1935`. Binding only one of them made our own
// install instructions describe a port that might not be listening.
//
// The exposure this adds is small and bounded. Both listeners refuse an
// unknown token or key in constant time, both require Target.Ready before
// admitting anything, and a connection that opens and says nothing dies on
// the handshake timeout. What changes is that a host with no firewall now
// has 1935 reachable where the source list used to close it by accident —
// so `install.sh`'s RTMP prompt, which controls the ufw rule and the
// compose publish, is now the only thing that decides reachability. It was
// always the thing that SAID it did.
//
// Port 0 remains the explicit off switch for either protocol.
rtmpPort := st.Listeners.RTMPPort
wantRTMP := true
if rows, err := m.store.ListSources(); err != nil {
// Read failure: leave the listener as it is rather than tearing it down
// on a transient error. An RTMP encoder that is mid-broadcast must not
// lose its socket because a settings read blipped.
m.log.Warn("cannot list sources for the rtmp listener gate; leaving it as-is", "err", err)
wantRTMP = rtmpAddr != ""
} else {
wantRTMP = anyRTMPSource(rows)
}
rtmp, rtmpAddr = reconcileListener(m.log, "rtmp", rtmpPort, wantRTMP, rtmp, rtmpAddr,
rtmp, rtmpAddr = reconcileListener(m.log, "rtmp", rtmpPort, true, rtmp, rtmpAddr,
(*rtmpserver.Server).Stop,
Comment on lines 256 to 258
func(addr string) (*rtmpserver.Server, error) {
s := rtmpserver.New(m.log, addr, m.lookupStreamKey)
Expand All @@ -261,20 +267,6 @@ func (m *Manager) reconcileSharedIngest() {
m.mu.Unlock()
}

// anyRTMPSource reports whether any enabled source expects the RTMP listener.
//
// Disabled sources do not count: a disabled source has no engine, so it has no
// subscriber, so a publisher reaching it would be admitted into a stream nobody
// reads — which is the failure Target.Ready exists to prevent.
func anyRTMPSource(rows []*db.Source) bool {
for _, s := range rows {
if s.Enabled && s.Ingest.Mode == db.IngestRTMP {
return true
}
}
return false
}

// reconcileListener brings one shared listener to match its configured port,
// returning what is bound afterwards and at which address.
//
Expand Down
27 changes: 16 additions & 11 deletions internal/engine/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,9 @@ func TestIngestLiveAndGPUBusyAggregateAcrossProgrammes(t *testing.T) {
}

// Neither listener is optional -- each IS the ingest for its protocol -- so the
// thing worth pinning is that both come up on their own, and that a port one of
// them cannot use leaves it down rather than bound to something arbitrary.
// thing worth pinning is that both come up on their own, whatever the sources
// happen to be configured for, and that a port one of them cannot use leaves it
// down rather than bound to something arbitrary.
func TestBothListenersBindWithoutBeingAskedTo(t *testing.T) {
m, store := managerFixture(t)
// Free ports rather than the 6000/1935 defaults: a unit test that binds a
Expand All @@ -252,18 +253,22 @@ func TestBothListenersBindWithoutBeingAskedTo(t *testing.T) {
if err := m.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
// SRT's listener IS the SRT ingest and binds unconditionally: a source can
// be pointed at it at any moment.
// BOTH bind, and this fixture has no RTMP source at all — that is the point.
//
// This test used to assert the opposite for RTMP, on the reasoning that
// binding it unconditionally would newly expose a port on an install where
// nothing spoke the protocol. The hole in that argument was visible in the
// line above it: SRT bound unconditionally on exactly the same install,
// including a fresh one that had chosen no ingest mode. The asymmetry was
// two histories preserved side by side rather than a decision, and it made
// the project's own install instructions — which publish both ports — describe
// something that might not be listening.
if !m.ListenerBound(db.IngestSRT) {
t.Error("the SRT listener did not bind; every SRT source is unreachable")
}
// RTMP's does NOT, and the asymmetry is deliberate. Before the shared
// listener existed, `ffmpeg -listen 1` bound 1935 only while an RTMP source
// was configured. Binding it unconditionally would newly expose a port on
// every install — including a fresh one that has not chosen an ingest mode —
// for a protocol nothing there speaks. This fixture has no RTMP source.
if m.ListenerBound(db.IngestRTMP) {
t.Error("the RTMP listener bound with no RTMP source configured; that is a port nobody asked for")
if !m.ListenerBound(db.IngestRTMP) {
t.Error("the RTMP listener did not bind. Both ports are open by default now; " +
"the port setting is the switch, and 0 is how an operator declines one")
}
// Pull dials out and has no listener to be gated by. Answering yes here
// would tell an operator a token protects an ingest no publisher reaches.
Expand Down
16 changes: 13 additions & 3 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ MODE="" # docker | binary
TLS_MODE="off" # off | selfsigned | acme
DOMAIN_NAME=""
ACME_EMAIL=""
ENABLE_RTMP="no"
# Both ingest ports are published by default, matching what the server now
# binds and what this project's own docs have always told people to run
# (`-p 6000:6000/udp -p 1935:1935`). Set --rtmp-port 0 to decline it.
ENABLE_RTMP="yes"
CONFIGURE_FIREWALL="no"
COMPOSE_CMD=""

Expand Down Expand Up @@ -571,7 +574,11 @@ gather_configuration() {
header "=== Ports ==="
[ "$HTTP_PORT_SET" = true ] || ask "Web UI port (tcp)" "$HTTP_PORT" HTTP_PORT
[ "$SRT_PORT_SET" = true ] || ask "SRT ingest port (UDP — this is the one people forget)" "$SRT_PORT" SRT_PORT
[ "$RTMP_SET" = true ] || ask_yn "Also expose RTMP on ${RTMP_PORT}/tcp? Only needed for encoders that cannot do SRT" "n" ENABLE_RTMP
[ "$RTMP_SET" = true ] || ask "RTMP ingest port (tcp — 0 to decline it)" "$RTMP_PORT" RTMP_PORT
# The port IS the switch, server-side too: internal/engine binds both
# listeners and treats 0 as off. Asking a yes/no here and a port there meant
# two different ways to say the same thing.
case "$RTMP_PORT" in 0|"") ENABLE_RTMP="no" ;; *) ENABLE_RTMP="yes" ;; esac

Comment on lines +578 to 582
warn_if_taken "$HTTP_PORT" tcp "web UI"
warn_if_taken "$SRT_PORT" udp "SRT ingest"
Expand Down Expand Up @@ -1079,7 +1086,8 @@ Options:
--mode docker|binary install mode (default: ask, then docker)
--http-port N web UI port (default 8080)
--srt-port N SRT ingest port, UDP (default 6000)
--rtmp also expose RTMP (default off)
--rtmp-port N RTMP ingest port, tcp (default 1935; 0 declines it)
--rtmp accepted for compatibility; RTMP is published by default
--tls off|selfsigned|acme
TLS mode. Not passing it takes the interactive
default, which is SELFSIGNED -- including under --yes.
Expand Down Expand Up @@ -1127,6 +1135,8 @@ parse_args() {
--srt-port) [ $# -ge 2 ] || die "missing value for --srt-port"; SRT_PORT="$2"; SRT_PORT_SET=true; shift 2 ;;
--srt-port=*) SRT_PORT="${1#*=}"; SRT_PORT_SET=true; shift ;;
--rtmp) ENABLE_RTMP="yes"; RTMP_SET=true; shift ;;
--rtmp-port) [ $# -ge 2 ] || die "missing value for --rtmp-port"; RTMP_PORT="$2"; RTMP_SET=true; shift 2 ;;
--rtmp-port=*) RTMP_PORT="${1#*=}"; RTMP_SET=true; shift ;;
--tls) [ $# -ge 2 ] || die "missing value for --tls"; TLS_MODE="$2"; TLS_SET=true; shift 2 ;;
--tls=*) TLS_MODE="${1#*=}"; TLS_SET=true; shift ;;
--hostname) [ $# -ge 2 ] || die "missing value for --hostname"; DOMAIN_NAME="$2"; shift 2 ;;
Expand Down
Loading