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:
parent
3a8c6ddd30
commit
2fcc0d397f
9 changed files with 423 additions and 19 deletions
136
internal/acme/acme.go
Normal file
136
internal/acme/acme.go
Normal 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
123
internal/acme/acme_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -172,6 +172,8 @@ func (sc *SyncClient) buildRequest() SyncRequest {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ type RegisterRequest struct {
|
|||
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
|
||||
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
|
||||
StreamPort int `json:"streamPort,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
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue