diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index e3f5e88..c3f870e 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -362,6 +362,21 @@ func runDaemonStart() error { corsExtras := append([]string(nil), cfg.Download.CORSExtraOrigins...) corsExtras = append(corsExtras, mirrorCORSOrigins(ctx, cfg, userAgent)...) 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 // accepting new sessions. The in-memory registry doesn't survive a // restart, so without this disk usage grows unbounded across restarts. diff --git a/internal/config/config.go b/internal/config/config.go index 126c819..c433846 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,14 +49,15 @@ type DownloadConfig struct { // Seeding lifecycle (BitTorrent only). Off by default — the daemon leeches // then drops the torrent. Enable to keep uploading after a download finishes; // seeding stops at whichever target is hit first, or never if both are unset. - SeedEnabled bool `toml:"seed_enabled"` // keep uploading after completion (default: false) - SeedRatio float64 `toml:"seed_ratio"` // stop once uploaded/size reaches this ratio (0 = no ratio target) - SeedTime string `toml:"seed_time"` // stop after this long since completion, e.g. "24h" (0/"" = no time target) - MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") - 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) - StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818) - EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in) + SeedEnabled bool `toml:"seed_enabled"` // keep uploading after completion (default: false) + SeedRatio float64 `toml:"seed_ratio"` // stop once uploaded/size reaches this ratio (0 = no ratio target) + SeedTime string `toml:"seed_time"` // stop after this long since completion, e.g. "24h" (0/"" = no time target) + MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") + 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) + 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) // RequireStreamToken gates remote (non-loopback) /stream + /hls requests on a // signed, short-lived token embedded in the URLs the agent reports. Default // true (secure by default); loopback callers (local mpv/vlc) are always exempt. @@ -204,6 +205,7 @@ func Default() Config { MaxConcurrent: 3, MinFreeDiskMB: 2048, // 2 GiB reserve StreamPort: 11818, + HTTPSStreamPort: 11819, RequireStreamToken: true, // secure by default; loopback exempt Transcode: TranscodeConfig{ Enabled: true, @@ -307,6 +309,9 @@ func applyDefaults(cfg *Config, meta toml.MetaData) { if !meta.IsDefined("downloads", "stream_port") { cfg.Download.StreamPort = 11818 } + if !meta.IsDefined("downloads", "https_stream_port") { + cfg.Download.HTTPSStreamPort = 11819 + } if !meta.IsDefined("general", "country") { cfg.General.Country = "US" } diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 3beb505..3271ec0 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -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) } diff --git a/internal/engine/stream_server_tls_test.go b/internal/engine/stream_server_tls_test.go new file mode 100644 index 0000000..d572b93 --- /dev/null +++ b/internal/engine/stream_server_tls_test.go @@ -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") + } +}