feat(stream): optional per-agent HTTPS listener with hot-reloadable cert

Foundation for direct, valid-cert browser playback (agent-TLS feature) — the
cert broker + DNS are a later phase; this is inert until a certificate exists.

- StreamServer runs a second TLS listener on https_stream_port (default 11819)
  serving the SAME mux as HTTP (11818): same token + CORS gates, no new exposure.
- Certificate is read per-handshake from an atomic holder via tls.Config
  GetCertificate, so a cert issued/renewed asynchronously applies without a
  restart. SetTLSCertificate / LoadTLSCertificateFromFiles / HasTLSCertificate.
- Daemon arms HTTPS only when a cert pair exists at certs/agent.{crt,key} under
  the state dir; without it, no HTTPS port is opened and HTTP + funnel are
  unaffected. Shutdown drains the HTTPS server too.
- config: downloads.https_stream_port (default 11819, 0 = disabled).

Tests: real TLS handshake + hot-install (no-cert handshake fails, install →
200), disabled path, missing-cert load error.
This commit is contained in:
Deivid Soto 2026-06-01 13:03:35 +02:00
parent 132c88b3f0
commit 27bee8cdf4
4 changed files with 294 additions and 8 deletions

View file

@ -362,6 +362,21 @@ func runDaemonStart() error {
corsExtras := append([]string(nil), cfg.Download.CORSExtraOrigins...) corsExtras := append([]string(nil), cfg.Download.CORSExtraOrigins...)
corsExtras = append(corsExtras, mirrorCORSOrigins(ctx, cfg, userAgent)...) corsExtras = append(corsExtras, mirrorCORSOrigins(ctx, cfg, userAgent)...)
streamSrv.SetCORSAllowedOrigins(corsExtras) streamSrv.SetCORSAllowedOrigins(corsExtras)
// HTTPS stream listener (agent-TLS feature): only armed when a certificate is
// present on disk — without a valid cert there is nothing to serve over TLS,
// and the HTTP listener + funnel keep working. The future ACME broker writes
// the cert pair to certs/agent.{crt,key} under the agent state dir.
if cfg.Download.HTTPSStreamPort > 0 {
certPath := filepath.Join(config.DataDir(), "certs", "agent.crt")
keyPath := filepath.Join(config.DataDir(), "certs", "agent.key")
if err := streamSrv.LoadTLSCertificateFromFiles(certPath, keyPath); err != nil {
log.Printf("[stream] HTTPS disabled — no usable certificate at %s (%v)", certPath, err)
} else {
streamSrv.EnableTLS(cfg.Download.HTTPSStreamPort)
log.Printf("[stream] HTTPS armed on port %d with certificate %s", cfg.Download.HTTPSStreamPort, certPath)
}
}
// Reap HLS tmpdirs left over from a previous daemon run before we start // Reap HLS tmpdirs left over from a previous daemon run before we start
// accepting new sessions. The in-memory registry doesn't survive a // accepting new sessions. The in-memory registry doesn't survive a
// restart, so without this disk usage grows unbounded across restarts. // restart, so without this disk usage grows unbounded across restarts.

View file

@ -56,6 +56,7 @@ type DownloadConfig struct {
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random) ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818) StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
HTTPSStreamPort int `toml:"https_stream_port"` // HTTPS stream listener for direct valid-cert playback (default: 11819, 0 = disabled). Only serves once a certificate is present (agent-TLS feature).
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in) EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in)
// RequireStreamToken gates remote (non-loopback) /stream + /hls requests on a // RequireStreamToken gates remote (non-loopback) /stream + /hls requests on a
// signed, short-lived token embedded in the URLs the agent reports. Default // signed, short-lived token embedded in the URLs the agent reports. Default
@ -204,6 +205,7 @@ func Default() Config {
MaxConcurrent: 3, MaxConcurrent: 3,
MinFreeDiskMB: 2048, // 2 GiB reserve MinFreeDiskMB: 2048, // 2 GiB reserve
StreamPort: 11818, StreamPort: 11818,
HTTPSStreamPort: 11819,
RequireStreamToken: true, // secure by default; loopback exempt RequireStreamToken: true, // secure by default; loopback exempt
Transcode: TranscodeConfig{ Transcode: TranscodeConfig{
Enabled: true, Enabled: true,
@ -307,6 +309,9 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
if !meta.IsDefined("downloads", "stream_port") { if !meta.IsDefined("downloads", "stream_port") {
cfg.Download.StreamPort = 11818 cfg.Download.StreamPort = 11818
} }
if !meta.IsDefined("downloads", "https_stream_port") {
cfg.Download.HTTPSStreamPort = 11819
}
if !meta.IsDefined("general", "country") { if !meta.IsDefined("general", "country") {
cfg.General.Country = "US" cfg.General.Country = "US"
} }

View file

@ -2,6 +2,7 @@ package engine
import ( import (
"context" "context"
"crypto/tls"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -69,6 +70,15 @@ type StreamServer struct {
url string // best single URL (backward compat) url string // best single URL (backward compat)
urls StreamURLs // all available URLs by network type urls StreamURLs // all available URLs by network type
upnpMapping *UPnPMapping upnpMapping *UPnPMapping
// TLS — optional HTTPS listener for direct, valid-cert browser playback
// (agent-TLS feature). httpsPort 0 = disabled. tlsCert holds the current
// server certificate, swapped atomically on renewal; the TLS config reads it
// via GetCertificate so a renewed cert applies without dropping the listener.
// HTTP (port) keeps serving regardless — loopback players + the funnel use it.
httpsPort int
httpsServer *http.Server
tlsCert atomic.Pointer[tls.Certificate]
// enableUPnP gates whether Listen() asks the gateway to publish the // enableUPnP gates whether Listen() asks the gateway to publish the
// stream port to the WAN. UPnP is opt-in (false by default) because // stream port to the WAN. UPnP is opt-in (false by default) because
// /stream and /hls have no auth — exposing them on the public internet // /stream and /hls have no auth — exposing them on the public internet
@ -152,6 +162,41 @@ func (ss *StreamServer) SetUPnPEnabled(enabled bool) {
ss.enableUPnP = enabled ss.enableUPnP = enabled
} }
// EnableTLS arms the HTTPS listener on httpsPort. Call before Listen(). The
// listener starts even without a certificate installed yet — handshakes fail
// until one is set via SetTLSCertificate, so a cert issued asynchronously (the
// future ACME broker) applies live without a restart. httpsPort <= 0 is a no-op.
func (ss *StreamServer) EnableTLS(httpsPort int) {
if httpsPort > 0 {
ss.httpsPort = httpsPort
}
}
// SetTLSCertificate atomically installs or replaces the server certificate used
// by the HTTPS listener. Safe to call at any time (startup or on renewal); the
// new cert applies to the next TLS handshake without dropping the listener.
func (ss *StreamServer) SetTLSCertificate(cert *tls.Certificate) {
ss.tlsCert.Store(cert)
}
// LoadTLSCertificateFromFiles reads a PEM cert+key pair from disk and installs
// it. Returns an error if the pair is missing or invalid — the caller decides
// whether that's fatal (the daemon treats it as "TLS off, HTTP keeps serving").
func (ss *StreamServer) LoadTLSCertificateFromFiles(certPath, keyPath string) error {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return fmt.Errorf("load TLS keypair: %w", err)
}
ss.SetTLSCertificate(&cert)
return nil
}
// HasTLSCertificate reports whether a server certificate is currently installed.
func (ss *StreamServer) HasTLSCertificate() bool { return ss.tlsCert.Load() != nil }
// HTTPSPort returns the active HTTPS port, or 0 when TLS is disabled.
func (ss *StreamServer) HTTPSPort() int { return ss.httpsPort }
// SetFFmpegPath sets the ffmpeg binary used by /thumbnail to extract single // SetFFmpegPath sets the ffmpeg binary used by /thumbnail to extract single
// frames on demand. Call before Listen(); empty leaves thumbnails disabled // frames on demand. Call before Listen(); empty leaves thumbnails disabled
// (the handler returns 503). Read-only after Listen() — no locking in the handler. // (the handler returns 503). Read-only after Listen() — no locking in the handler.
@ -295,6 +340,71 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
}() }()
log.Printf("[stream] server listening on port %d", ss.port) log.Printf("[stream] server listening on port %d", ss.port)
// Optional HTTPS listener (agent-TLS feature). Non-fatal: if it can't bind,
// HTTP keeps serving so the funnel + LAN HTTP path are unaffected.
if ss.httpsPort > 0 {
if err := ss.listenTLS(ctx, mux); err != nil {
log.Printf("[stream] HTTPS listener disabled: %v", err)
ss.httpsPort = 0
}
}
return nil
}
// listenTLS starts the HTTPS listener on ss.httpsPort serving the same mux as
// the HTTP server. The certificate is read per-handshake from the atomic holder
// (tlsCert) so a renewed cert applies without restarting the listener; until a
// cert is installed, handshakes fail cleanly (the HTTP path is unaffected).
func (ss *StreamServer) listenTLS(ctx context.Context, mux http.Handler) error {
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) { _ = setReuseAddr(fd) })
},
}
var listener net.Listener
var err error
basePort := ss.httpsPort
for attempt := 0; attempt < 10; attempt++ {
listener, err = lc.Listen(ctx, "tcp", fmt.Sprintf("0.0.0.0:%d", ss.httpsPort))
if err == nil {
break
}
if !strings.Contains(err.Error(), "address already in use") {
return fmt.Errorf("https listen on %d: %w", ss.httpsPort, err)
}
ss.httpsPort++
}
if err != nil {
return fmt.Errorf("https: all ports busy (%d-%d): %w", basePort, ss.httpsPort, err)
}
ss.httpsPort = listener.Addr().(*net.TCPAddr).Port
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
NextProtos: []string{"h2", "http/1.1"},
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
if cert := ss.tlsCert.Load(); cert != nil {
return cert, nil
}
return nil, fmt.Errorf("no TLS certificate installed")
},
}
ss.httpsServer = &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
TLSConfig: tlsCfg,
}
go func() {
// Empty cert/key paths → ServeTLS uses TLSConfig.GetCertificate.
if err := ss.httpsServer.ServeTLS(listener, "", ""); err != nil && err != http.ErrServerClosed {
log.Printf("[stream] HTTPS server error: %v", err)
}
}()
log.Printf("[stream] HTTPS listening on port %d (certificate installed: %v)", ss.httpsPort, ss.HasTLSCertificate())
return nil return nil
} }
@ -447,6 +557,11 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
if ss.hls != nil { if ss.hls != nil {
ss.hls.CloseAll() ss.hls.CloseAll()
} }
if ss.httpsServer != nil {
if err := ss.httpsServer.Shutdown(ctx); err != nil {
log.Printf("[stream] HTTPS shutdown: %v", err)
}
}
if ss.server != nil { if ss.server != nil {
return ss.server.Shutdown(ctx) return ss.server.Shutdown(ctx)
} }

View file

@ -0,0 +1,151 @@
package engine
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"net"
"net/http"
"testing"
"time"
)
// genSelfSignedCert builds an in-memory self-signed cert valid for 127.0.0.1,
// used to exercise the agent's HTTPS listener without any CA/ACME plumbing.
func genSelfSignedCert(t *testing.T) (tls.Certificate, *x509.Certificate) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "unarr-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
leaf, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse cert: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
}
// freePort grabs an ephemeral TCP port and releases it, so the caller can hand
// a concrete port number to EnableTLS (which treats 0 as "disabled").
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
p := l.Addr().(*net.TCPAddr).Port
l.Close()
return p
}
// TestStreamServerTLS_HotInstall verifies the HTTPS listener: it starts even
// with no certificate (handshake fails), and a certificate installed *after*
// Listen applies live via the GetCertificate path — no restart, which is what
// the future ACME broker relies on.
func TestStreamServerTLS_HotInstall(t *testing.T) {
cert, leaf := genSelfSignedCert(t)
ss := NewStreamServer(0) // HTTP on a random free port
ss.EnableTLS(freePort(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := ss.Listen(ctx); err != nil {
t.Fatalf("Listen: %v", err)
}
defer ss.Shutdown(context.Background())
if ss.HTTPSPort() == 0 {
t.Fatal("HTTPSPort() = 0, want the armed HTTPS port")
}
if ss.HasTLSCertificate() {
t.Fatal("no certificate should be installed yet")
}
pool := x509.NewCertPool()
pool.AddCert(leaf)
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}},
}
url := fmt.Sprintf("https://127.0.0.1:%d/health", ss.HTTPSPort())
// Before a cert is installed, the handshake must fail.
if resp, err := client.Get(url); err == nil {
resp.Body.Close()
t.Fatal("GET succeeded before a certificate was installed; want handshake failure")
}
// Install the cert — the listener stays up and the next handshake succeeds.
ss.SetTLSCertificate(&cert)
if !ss.HasTLSCertificate() {
t.Fatal("HasTLSCertificate() = false after install")
}
var lastErr error
for attempt := 0; attempt < 20; attempt++ {
resp, err := client.Get(url)
if err != nil {
lastErr = err
time.Sleep(50 * time.Millisecond)
continue
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", url, resp.StatusCode)
}
return // success
}
t.Fatalf("GET %s never succeeded after cert install: %v", url, lastErr)
}
// TestStreamServerTLS_Disabled verifies that with TLS not armed, no HTTPS port
// is opened and the HTTP listener is unaffected.
func TestStreamServerTLS_Disabled(t *testing.T) {
ss := NewStreamServer(0)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := ss.Listen(ctx); err != nil {
t.Fatalf("Listen: %v", err)
}
defer ss.Shutdown(context.Background())
if ss.HTTPSPort() != 0 {
t.Errorf("HTTPSPort() = %d, want 0 (TLS disabled)", ss.HTTPSPort())
}
}
// TestLoadTLSCertificateFromFiles_Missing verifies the loader reports an error
// (not a panic) when the cert pair is absent — the daemon treats this as
// "TLS off, HTTP keeps serving".
func TestLoadTLSCertificateFromFiles_Missing(t *testing.T) {
ss := NewStreamServer(0)
err := ss.LoadTLSCertificateFromFiles(
t.TempDir()+"/nope.crt", t.TempDir()+"/nope.key")
if err == nil {
t.Fatal("expected error loading a missing cert pair")
}
if ss.HasTLSCertificate() {
t.Error("no certificate should be installed after a failed load")
}
}