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:
parent
132c88b3f0
commit
27bee8cdf4
4 changed files with 294 additions and 8 deletions
|
|
@ -2,6 +2,7 @@ package engine
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
|
@ -69,6 +70,15 @@ type StreamServer struct {
|
|||
url string // best single URL (backward compat)
|
||||
urls StreamURLs // all available URLs by network type
|
||||
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
|
||||
// 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
|
||||
|
|
@ -152,6 +162,41 @@ func (ss *StreamServer) SetUPnPEnabled(enabled bool) {
|
|||
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
|
||||
// frames on demand. Call before Listen(); empty leaves thumbnails disabled
|
||||
// (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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
@ -447,6 +557,11 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
|
|||
if ss.hls != nil {
|
||||
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 {
|
||||
return ss.server.Shutdown(ctx)
|
||||
}
|
||||
|
|
|
|||
151
internal/engine/stream_server_tls_test.go
Normal file
151
internal/engine/stream_server_tls_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue