feat(agent): per-agent direct-TLS cert client + HTTPS listener wiring

The agent obtains a valid wildcard cert for *.<hash>.agent.unarr.app from
the web broker (ACME DNS-01) so the https web player reaches it directly
over HTTPS instead of the CloudFlare funnel.

- internal/acme: generate EC P-256 key + CSR locally (private key never
  leaves the machine), fetch the signed chain from the broker, persist it
  atomically, NeedsIssue renewal check
- daemon: generate + persist a stable agent_hash in config.toml; register
  before requesting the cert (broker ownership check needs the row); arm
  the HTTPS listener with the cert; 6h renewal poll hot-swaps it (no restart)
- report httpsStreamPort + agentHash on register/sync
- stream_server: emit Access-Control-Allow-Private-Network on PNA preflight
  so an https page can reach the agent on loopback / LAN
This commit is contained in:
Deivid Soto 2026-06-05 12:09:46 +02:00
parent 3a8c6ddd30
commit 2fcc0d397f
9 changed files with 423 additions and 19 deletions

136
internal/acme/acme.go Normal file
View file

@ -0,0 +1,136 @@
// Package acme handles the agent side of the per-agent direct-TLS feature
// (plex.direct model). The agent generates and keeps its private key LOCALLY,
// builds a CSR for *.<hash>.agent.unarr.app, and sends only the CSR to the
// web-side broker (which runs the ACME order against Let's Encrypt via DNS-01
// and returns the signed chain). The key never leaves the machine.
//
// File layout under the agent state dir:
//
// certs/agent.key ECDSA P-256 private key (PEM, persisted across renewals)
// certs/agent.crt issued certificate chain (PEM, hot-reloaded by the stream server)
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
)
// GenerateHash returns a 32-hex-char (16-byte) high-entropy agent hash label.
func GenerateHash() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate agent hash: %w", err)
}
return hex.EncodeToString(b), nil
}
// Paths returns the key/cert file paths under the agent state dir.
func Paths(dataDir string) (keyPath, certPath string) {
dir := filepath.Join(dataDir, "certs")
return filepath.Join(dir, "agent.key"), filepath.Join(dir, "agent.crt")
}
// loadOrCreateKey returns the agent's persistent EC key, creating + persisting
// it on first use. Reused across renewals so the cert always matches the key.
func loadOrCreateKey(keyPath string) (*ecdsa.PrivateKey, error) {
if data, err := os.ReadFile(keyPath); err == nil {
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("agent.key is not valid PEM")
}
key, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse agent.key: %w", err)
}
return key, nil
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate EC key: %w", err)
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, fmt.Errorf("marshal EC key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
return nil, fmt.Errorf("mkdir certs: %w", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
return nil, fmt.Errorf("write agent.key: %w", err)
}
return key, nil
}
// BuildCSR ensures the persistent key exists and returns a PEM CSR requesting
// the wildcard *.<hash>.<baseDomain> (plus the bare <hash>.<baseDomain> so a
// future non-wildcard use still validates). baseDomain e.g. "agent.unarr.app".
func BuildCSR(dataDir, hash, baseDomain string) (csrPEM string, err error) {
keyPath, _ := Paths(dataDir)
key, err := loadOrCreateKey(keyPath)
if err != nil {
return "", err
}
wildcard := "*." + hash + "." + baseDomain
base := hash + "." + baseDomain
tmpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: wildcard},
DNSNames: []string{wildcard, base},
SignatureAlgorithm: x509.ECDSAWithSHA256,
}
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
if err != nil {
return "", fmt.Errorf("create CSR: %w", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})), nil
}
// WriteCert persists the issued certificate chain atomically (temp file + rename)
// so a concurrent reader (NeedsIssue, or the listener's GetCertificate reload)
// can never observe a half-written PEM during a renewal.
func WriteCert(dataDir, certPEM string) error {
_, certPath := Paths(dataDir)
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
return fmt.Errorf("mkdir certs: %w", err)
}
tmp := certPath + ".tmp"
if err := os.WriteFile(tmp, []byte(certPEM), 0o644); err != nil {
return fmt.Errorf("write agent.crt: %w", err)
}
if err := os.Rename(tmp, certPath); err != nil {
return fmt.Errorf("rename agent.crt: %w", err)
}
return nil
}
// renewBefore is how long ahead of expiry we proactively renew.
const renewBefore = 30 * 24 * time.Hour
// NeedsIssue reports whether we should (re)request a cert: true when the cert is
// missing, unparseable, expired, or within renewBefore of expiry.
func NeedsIssue(dataDir string) bool {
_, certPath := Paths(dataDir)
data, err := os.ReadFile(certPath)
if err != nil {
return true
}
block, _ := pem.Decode(data)
if block == nil {
return true
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return true
}
return time.Now().Add(renewBefore).After(cert.NotAfter)
}

123
internal/acme/acme_test.go Normal file
View file

@ -0,0 +1,123 @@
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
func TestGenerateHash(t *testing.T) {
h1, err := GenerateHash()
if err != nil {
t.Fatal(err)
}
if len(h1) != 32 {
t.Errorf("hash len = %d, want 32", len(h1))
}
h2, _ := GenerateHash()
if h1 == h2 {
t.Errorf("two hashes collided: %s", h1)
}
}
func TestBuildCSR(t *testing.T) {
dir := t.TempDir()
hash := "deadbeefdeadbeef"
csrPEM, err := BuildCSR(dir, hash, "agent.unarr.app")
if err != nil {
t.Fatal(err)
}
// Key persisted.
keyPath, _ := Paths(dir)
if _, err := os.Stat(keyPath); err != nil {
t.Errorf("key not persisted: %v", err)
}
// CSR parses + carries exactly the wildcard + base SANs.
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
t.Fatal("CSR is not valid PEM")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
t.Fatal(err)
}
want := map[string]bool{
"*.deadbeefdeadbeef.agent.unarr.app": false,
"deadbeefdeadbeef.agent.unarr.app": false,
}
for _, n := range csr.DNSNames {
if _, ok := want[n]; !ok {
t.Errorf("unexpected SAN: %s", n)
}
want[n] = true
}
for n, seen := range want {
if !seen {
t.Errorf("missing SAN: %s", n)
}
}
// A second BuildCSR reuses the same key (cert must match the persistent key).
before, _ := os.ReadFile(keyPath)
if _, err := BuildCSR(dir, hash, "agent.unarr.app"); err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(keyPath)
if string(before) != string(after) {
t.Errorf("key changed across BuildCSR calls — renewals would break")
}
}
func TestNeedsIssue(t *testing.T) {
dir := t.TempDir()
// Missing cert → needs issue.
if !NeedsIssue(dir) {
t.Error("missing cert should need issue")
}
_, certPath := Paths(dir)
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
t.Fatal(err)
}
writeSelfSigned := func(notAfter time.Time) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "*.x.agent.unarr.app"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: notAfter,
}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if err := os.WriteFile(certPath, pemBytes, 0o644); err != nil {
t.Fatal(err)
}
}
// Fresh cert (90d) → no issue needed.
writeSelfSigned(time.Now().Add(90 * 24 * time.Hour))
if NeedsIssue(dir) {
t.Error("fresh cert should not need issue")
}
// Within renew window (10d left) → needs issue.
writeSelfSigned(time.Now().Add(10 * 24 * time.Hour))
if !NeedsIssue(dir) {
t.Error("near-expiry cert should need issue")
}
// Garbage → needs issue.
_ = os.WriteFile(certPath, []byte("not a cert"), 0o644)
if !NeedsIssue(dir) {
t.Error("unparseable cert should need issue")
}
}

View file

@ -79,6 +79,26 @@ func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterRe
return &resp, nil
}
// IssueCert sends a CSR to the web-side ACME broker and returns the signed
// certificate chain (PEM). The agent's private key never leaves the machine —
// only the CSR is sent. Used by the per-agent direct-TLS feature.
func (c *Client) IssueCert(ctx context.Context, csrPEM string) (string, error) {
req := struct {
CSRPem string `json:"csrPem"`
}{CSRPem: csrPEM}
var resp struct {
Certificate string `json:"certificate"`
Error string `json:"error,omitempty"`
}
if err := c.doPost(ctx, "/api/internal/agent/issue-cert", req, &resp); err != nil {
return "", fmt.Errorf("issue cert: %w", err)
}
if resp.Certificate == "" {
return "", fmt.Errorf("issue cert: empty certificate (%s)", resp.Error)
}
return resp.Certificate, nil
}
// Deregister notifies the server that the agent is shutting down.
func (c *Client) Deregister(ctx context.Context, agentID string) error {
req := struct {

View file

@ -22,6 +22,8 @@ type DaemonConfig struct {
Version string
DownloadDir string
StreamPort int // port for the HTTP stream server
HTTPSStreamPort int // TLS stream listener port (per-agent direct-TLS); 0 when off
AgentHash string // stable high-entropy hash for *.<hash>.agent.unarr.app
StreamSecret string // hex HMAC key for stream tokens (reported so the web can mint HLS tokens)
LanIP string // LAN IP (reported in sync for stream URL resolution)
TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution)
@ -135,6 +137,8 @@ func (d *Daemon) Register(ctx context.Context) error {
Version: d.cfg.Version,
DownloadDir: d.cfg.DownloadDir,
StreamPort: d.cfg.StreamPort,
HTTPSStreamPort: d.cfg.HTTPSStreamPort,
AgentHash: d.cfg.AgentHash,
StreamSecret: d.cfg.StreamSecret,
LanIP: d.cfg.LanIP,
TailscaleIP: d.cfg.TailscaleIP,

View file

@ -165,17 +165,19 @@ func (sc *SyncClient) doSync(ctx context.Context) {
func (sc *SyncClient) buildRequest() SyncRequest {
req := SyncRequest{
AgentID: sc.cfg.AgentID,
Name: sc.cfg.AgentName,
Version: sc.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
DownloadDir: sc.cfg.DownloadDir,
StreamPort: sc.cfg.StreamPort,
LanIP: sc.cfg.LanIP,
TailscaleIP: sc.cfg.TailscaleIP,
CanDelete: sc.cfg.CanDelete,
IsDocker: RunningInDocker(),
AgentID: sc.cfg.AgentID,
Name: sc.cfg.AgentName,
Version: sc.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
DownloadDir: sc.cfg.DownloadDir,
StreamPort: sc.cfg.StreamPort,
HTTPSStreamPort: sc.cfg.HTTPSStreamPort,
AgentHash: sc.cfg.AgentHash,
LanIP: sc.cfg.LanIP,
TailscaleIP: sc.cfg.TailscaleIP,
CanDelete: sc.cfg.CanDelete,
IsDocker: RunningInDocker(),
}
if sc.GetTaskStates != nil {
req.Tasks = sc.GetTaskStates()

View file

@ -16,8 +16,13 @@ type RegisterRequest struct {
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
// HTTPSStreamPort + AgentHash drive the per-agent direct-TLS feature: the web
// builds https://<ip-dashed>.<hash>.agent.unarr.app:<httpsPort>/... once the
// agent has an issued cert. Zero/empty when the feature is off or pre-cert.
HTTPSStreamPort int `json:"httpsStreamPort,omitempty"`
AgentHash string `json:"agentHash,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
// StreamSecret is the daemon's per-run HMAC key (hex) for stream tokens. The
// web mints the HLS path token with it (the agent mints /stream tokens on its
// own URLs); the agent verifies both. In memory, regenerated each start, so a
@ -385,6 +390,8 @@ type SyncRequest struct {
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
HTTPSStreamPort int `json:"httpsStreamPort,omitempty"`
AgentHash string `json:"agentHash,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
FreeSlots int `json:"freeSlots"`

View file

@ -14,6 +14,7 @@ import (
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/acme"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
@ -127,6 +128,23 @@ func runDaemonStart() error {
return fmt.Errorf("create download dir: %w", err)
}
// Per-agent direct-TLS: ensure a stable high-entropy hash exists, generated
// + persisted once. Distinct from the (enumerable) agent UUID; the cert
// broker issues *.<hash>.agent.unarr.app for it.
if cfg.Download.HTTPSStreamPort > 0 && cfg.Agent.Hash == "" {
if h, err := acme.GenerateHash(); err != nil {
log.Printf("[acme] could not generate agent hash (%v) — direct-TLS disabled", err)
} else {
cfg.Agent.Hash = h
if err := config.Save(cfg, config.FilePath()); err != nil {
log.Printf("[acme] could not persist agent hash (%v) — direct-TLS disabled until persisted", err)
cfg.Agent.Hash = ""
} else {
log.Printf("[acme] generated agent hash %s", h)
}
}
}
// Clean up stale resume files (>7 days old)
resumeDir := filepath.Join(config.DataDir(), "resume")
if removed := download.CleanStaleFiles(resumeDir, 7*24*time.Hour); removed > 0 {
@ -188,6 +206,8 @@ func runDaemonStart() error {
Version: Version,
DownloadDir: cfg.Download.Dir,
StreamPort: cfg.Download.StreamPort,
HTTPSStreamPort: cfg.Download.HTTPSStreamPort,
AgentHash: cfg.Agent.Hash,
LanIP: engine.LanIP(),
TailscaleIP: engine.TailscaleIP(),
CanDelete: cfg.Library.AllowDelete,
@ -415,13 +435,24 @@ func runDaemonStart() error {
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.
// HTTPS stream listener (per-agent direct-TLS): obtain/renew the cert from the
// broker FIRST (broker runs ACME DNS-01 with our CSR; the private key never
// leaves us), then arm the listener if a usable cert is on disk. Without a
// valid cert there is nothing to serve over TLS, and the HTTP listener +
// funnel keep working regardless.
if cfg.Download.HTTPSStreamPort > 0 {
certPath := filepath.Join(config.DataDir(), "certs", "agent.crt")
keyPath := filepath.Join(config.DataDir(), "certs", "agent.key")
if cfg.Agent.Hash != "" {
// The broker's ownership check requires the agent to be registered
// first (the agent_hash must live on THIS user's agent_registration
// row). Register now — best-effort — so a fresh agent can get its cert
// on the first boot; d.Run() registers again later (idempotent upsert).
if err := d.Register(ctx); err != nil {
log.Printf("[acme] pre-cert registration failed (%v) — cert will arrive on a later renewal tick", err)
} else {
fetchAgentCert(ctx, agentClient, cfg.Agent.Hash)
}
}
keyPath, certPath := acme.Paths(config.DataDir())
if err := streamSrv.LoadTLSCertificateFromFiles(certPath, keyPath); err != nil {
log.Printf("[stream] HTTPS disabled — no usable certificate at %s (%v)", certPath, err)
} else {
@ -466,6 +497,34 @@ func runDaemonStart() error {
}
d.UpdateStreamPort(streamSrv.Port())
// Per-agent direct-TLS renewal: re-fetch the cert ahead of expiry and
// hot-swap it into the live listener (no restart). Only meaningful once the
// listener was armed at startup (a first-issuance that failed then needs a
// daemon restart to arm). Cheap 6 h poll; NeedsIssue gates the actual fetch.
if cfg.Download.HTTPSStreamPort > 0 && cfg.Agent.Hash != "" {
go func() {
t := time.NewTicker(6 * time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if !acme.NeedsIssue(config.DataDir()) {
continue
}
fetchAgentCert(ctx, agentClient, cfg.Agent.Hash)
keyPath, certPath := acme.Paths(config.DataDir())
if err := streamSrv.LoadTLSCertificateFromFiles(certPath, keyPath); err != nil {
log.Printf("[acme] hot-swap after renewal failed: %v", err)
} else {
log.Printf("[acme] renewed cert hot-swapped into live listener")
}
}
}
}()
}
// CloudFlare Quick Tunnel — needs the ACTUAL listening port (the
// configured port may have been busy and bumped). Spawning here ensures
// cloudflared --url points at the right socket. Failures degrade to
@ -1417,3 +1476,41 @@ func watchSessionReady(ctx context.Context, client *agent.Client, hsess *engine.
}
}
}
// agentTLSBaseDomain is the zone the cert broker issues per-agent wildcards
// under. Overridable for staging via UNARR_AGENT_TLS_BASE.
func agentTLSBaseDomain() string {
if v := os.Getenv("UNARR_AGENT_TLS_BASE"); v != "" {
return v
}
return "agent.unarr.app"
}
// fetchAgentCert obtains (or renews) the per-agent TLS cert from the web broker
// and writes it to the agent state dir. The agent's private key never leaves the
// machine — only a CSR is sent. Failure is non-fatal: HTTPS stays off and the
// HTTP listener + CloudFlare funnel keep serving.
func fetchAgentCert(ctx context.Context, client *agent.Client, hash string) {
dataDir := config.DataDir()
if !acme.NeedsIssue(dataDir) {
return
}
base := agentTLSBaseDomain()
csr, err := acme.BuildCSR(dataDir, hash, base)
if err != nil {
log.Printf("[acme] build CSR failed: %v", err)
return
}
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
cert, err := client.IssueCert(cctx, csr)
if err != nil {
log.Printf("[acme] cert issuance failed (HTTPS stays off, funnel still works): %v", err)
return
}
if err := acme.WriteCert(dataDir, cert); err != nil {
log.Printf("[acme] write cert failed: %v", err)
return
}
log.Printf("[acme] installed cert for *.%s.%s", hash, base)
}

View file

@ -37,6 +37,11 @@ type AuthConfig struct {
type AgentConfig struct {
ID string `toml:"id"`
Name string `toml:"name"`
// Hash is a stable high-entropy label (hex) for the per-agent direct-TLS
// feature. Distinct from ID (a UUID that could be guessed/enumerated): the
// cert broker issues *.<hash>.agent.unarr.app and the web encodes the agent's
// IP into a hostname under that wildcard. Generated + persisted on first run.
Hash string `toml:"agent_hash,omitempty"`
}
type DownloadConfig struct {

View file

@ -300,6 +300,16 @@ func (ss *StreamServer) writeCORSHeaders(w http.ResponseWriter, r *http.Request,
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
// Private Network Access: an https:// page (public) fetching this agent on a
// loopback/LAN address (private) triggers a PNA preflight carrying
// `Access-Control-Request-Private-Network: true`. Without echoing
// `Allow-Private-Network: true` Chrome blocks the request — so the
// loopback (127.0.0.1) + LAN-IP direct-play candidates would never connect
// from the production https player. Only emitted for already-allowlisted
// origins (above), so it widens nothing beyond the existing CORS trust.
if r.Header.Get("Access-Control-Request-Private-Network") == "true" {
w.Header().Set("Access-Control-Allow-Private-Network", "true")
}
if expose != "" {
w.Header().Set("Access-Control-Expose-Headers", expose)
}