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
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