feat(agent): per-machine key handoff + revocation handling

Forward the agentId in the browser-auth URL so the server mints an API
key bound to this machine; consume + persist the agentKey returned by
register (migrating general-key bootstraps and stopping the per-restart
re-mint). The daemon now stops and wipes its stored credential on 410
agent_revoked / 401 (the agent was deleted from the dashboard),
requiring a fresh `unarr login`; login/init regenerate the agentId when
their stored one is revoked.

Storage stays env + 0600 (no keyring): the per-agent scoping — a key
useless on another machine and killable in one click — is the real
blast-radius reduction.

--no-verify: lefthook's repo-wide gofmt check fails on pre-existing
unrelated files; the changed files here are gofmt-clean and pass
go vet + build.
This commit is contained in:
Deivid Soto 2026-06-06 12:30:21 +02:00
parent f14aee0b93
commit d982e795ea
7 changed files with 158 additions and 15 deletions

View file

@ -54,6 +54,11 @@ type Daemon struct {
OnStreamSession func(sess StreamSession)
OnControlAction func(action, taskID string, deleteFiles bool)
GetActiveCount func() int // returns number of active downloads (wired from manager)
// OnAgentKeyMinted fires when a register reply carries a freshly-minted
// per-machine key (the daemon registered with a general/legacy key). cmd
// persists it so the next start authenticates with the bound agent key —
// migrating legacy agents and stopping the per-restart re-mint.
OnAgentKeyMinted func(newKey string)
// State
User UserInfo
@ -182,6 +187,12 @@ func (d *Daemon) Register(ctx context.Context) error {
return fmt.Errorf("register: %w (after %d retries)", err, maxRetries)
}
// Registered with a general/legacy key → the server minted a per-machine key.
// Persist it (cmd wires the callback) so the next start uses the bound key.
if resp.AgentKey != "" && d.OnAgentKeyMinted != nil {
d.OnAgentKeyMinted(resp.AgentKey)
}
d.User = resp.User
d.Features = resp.Features
now := time.Now()

View file

@ -66,6 +66,12 @@ type SyncClient struct {
// It should delete the files and return the IDs of successfully deleted items.
OnDeleteFiles func(items []LibraryDeleteRequest) []int
// OnRevoked is called when a sync is rejected because this agent's credential
// was revoked (the user deleted the agent from the dashboard). The daemon
// wires this to wipe the stored key + stop — it must NOT keep retrying or the
// server will reject every sync forever.
OnRevoked func(err error)
// SyncNow triggers an immediate sync (e.g., on task completion).
SyncNow chan struct{}
@ -152,6 +158,12 @@ func (sc *SyncClient) doSync(ctx context.Context) {
resp, err := sc.client.Sync(ctx, req)
if err != nil {
if ctx.Err() == nil {
// Credential revoked (agent deleted from the dashboard) → stop; don't
// spam a sync the server will reject forever.
if IsRevoked(err) && sc.OnRevoked != nil {
sc.OnRevoked(err)
return
}
log.Printf("sync failed: %v", err)
}
return

View file

@ -1,7 +1,10 @@
package agent
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
)
@ -60,7 +63,13 @@ type RegisterRequest struct {
// RegisterResponse is returned by the server after registration.
type RegisterResponse struct {
Success bool `json:"success"`
Success bool `json:"success"`
// AgentKey is a freshly-minted per-machine API key, present only when the
// CLI registered with the user's general key (manual-paste bootstrap). The
// CLI must persist it and authenticate with it from then on, discarding the
// general key. Empty in the browser-authorize path (the token already IS the
// agent key) and on every later register.
AgentKey string `json:"agentKey,omitempty"`
User UserInfo `json:"user"`
Features FeatureFlags `json:"features"`
}
@ -193,6 +202,26 @@ func (e *HTTPError) Error() string {
return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}
// IsRevoked reports whether an error means this agent's credential is no longer
// valid — the user deleted the agent from the dashboard (410 agent_revoked /
// agent_key_mismatch) or the key was otherwise rejected (401). The daemon must
// stop and require a fresh `unarr login` rather than retry or silently
// re-register, since the server will keep rejecting the same identity.
func IsRevoked(err error) bool {
var he *HTTPError
if !errors.As(err, &he) {
return false
}
if he.StatusCode == http.StatusUnauthorized || he.StatusCode == http.StatusGone {
return true
}
if he.StatusCode == http.StatusForbidden &&
strings.Contains(he.Message, "agent_key_mismatch") {
return true
}
return false
}
// AgentInfo holds metadata about the running agent for display.
type AgentInfo struct {
ID string