From 2fcc0d397f668705922dd991940c73b15156623e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 5 Jun 2026 12:09:46 +0200 Subject: [PATCH] feat(agent): per-agent direct-TLS cert client + HTTPS listener wiring The agent obtains a valid wildcard cert for *..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 --- internal/acme/acme.go | 136 +++++++++++++++++++++++++++++++ internal/acme/acme_test.go | 123 ++++++++++++++++++++++++++++ internal/agent/client.go | 20 +++++ internal/agent/daemon.go | 4 + internal/agent/sync.go | 24 +++--- internal/agent/types.go | 11 ++- internal/cmd/daemon.go | 109 +++++++++++++++++++++++-- internal/config/config.go | 5 ++ internal/engine/stream_server.go | 10 +++ 9 files changed, 423 insertions(+), 19 deletions(-) create mode 100644 internal/acme/acme.go create mode 100644 internal/acme/acme_test.go diff --git a/internal/acme/acme.go b/internal/acme/acme.go new file mode 100644 index 0000000..d8f77c7 --- /dev/null +++ b/internal/acme/acme.go @@ -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 *..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 *.. (plus the bare . 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) +} diff --git a/internal/acme/acme_test.go b/internal/acme/acme_test.go new file mode 100644 index 0000000..cd62ae3 --- /dev/null +++ b/internal/acme/acme_test.go @@ -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") + } +} diff --git a/internal/agent/client.go b/internal/agent/client.go index f1014c5..fcf21d2 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -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 { diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index 6d2658b..fa1e27a 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -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 *..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, diff --git a/internal/agent/sync.go b/internal/agent/sync.go index 8e88344..3ac61b9 100644 --- a/internal/agent/sync.go +++ b/internal/agent/sync.go @@ -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() diff --git a/internal/agent/types.go b/internal/agent/types.go index e1f2fe8..43f5a8c 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -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://..agent.unarr.app:/... 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"` diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 5818640..56f7fd2 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -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 *..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) +} diff --git a/internal/config/config.go b/internal/config/config.go index 2d6e664..260b31f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 *..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 { diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 6740246..01cfeb8 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -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) }