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

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