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

@ -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"`