diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index 6d2658b..834b877 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -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() diff --git a/internal/agent/sync.go b/internal/agent/sync.go index 8e88344..6a13cf9 100644 --- a/internal/agent/sync.go +++ b/internal/agent/sync.go @@ -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 diff --git a/internal/agent/types.go b/internal/agent/types.go index e1f2fe8..345df4e 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -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,32 @@ func (e *HTTPError) Error() string { return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message) } +// IsRevoked reports whether an error is an EXPLICIT server revocation signal — +// the user deleted this agent from the dashboard. The server sends 410 +// agent_revoked (the registration is tombstoned OR the per-machine key was +// revoked — the auth layer maps a revoked agent key to 410, not 401) or 403 +// agent_key_mismatch (the key belongs to another machine). On these the daemon +// wipes its credential and requires a fresh `unarr login`. +// +// A BARE 401 is deliberately NOT treated as revoked: it's ambiguous (a deploy +// blip, a load-balancer hiccup, a transient auth error) and must never wipe a +// working agent's credential. The retry/log paths handle a transient 401; a +// genuine revocation always arrives as 410. +func IsRevoked(err error) bool { + var he *HTTPError + if !errors.As(err, &he) { + return false + } + if 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 diff --git a/internal/cmd/auth_browser.go b/internal/cmd/auth_browser.go index 186813a..68256df 100644 --- a/internal/cmd/auth_browser.go +++ b/internal/cmd/auth_browser.go @@ -24,7 +24,7 @@ const browserAuthTimeout = 60 * time.Second // 3. User logs in and clicks "Authorize" on the web page // 4. Web redirects to localhost:{port}/callback?token=tc_...&state={state} // 5. CLI validates state, extracts token, closes server -func browserAuth(apiURL string) (string, error) { +func browserAuth(apiURL, agentID string) (string, error) { // Validate apiURL is a well-formed HTTP(S) URL parsed, err := url.Parse(apiURL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { @@ -96,8 +96,12 @@ func browserAuth(apiURL string) (string, error) { } }() - // Open browser + // Open browser. Forward the agentId so the server mints a per-machine key + // bound to it (omitted → server falls back to the legacy general key). authURL := fmt.Sprintf("%s/unarr/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port) + if agentID != "" { + authURL += "&agentId=" + url.QueryEscape(agentID) + } openBrowser(authURL) // Listen for Enter key to skip to manual fallback diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 3ad69e3..3bbcc41 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -978,6 +978,26 @@ func runDaemonStart() error { // Start reporter only for stream task handling go reporter.Run(ctx) + // Credential revoked mid-run (agent deleted from the dashboard): wipe the + // stored key + agentId so a supervisor restart can't loop on a rejected + // identity, then stop the daemon. Reconnecting needs a fresh `unarr login`. + d.SyncClient().OnRevoked = func(err error) { + reportAgentRevoked(cfg, err) + cancel() + } + + // Legacy bootstrap: if register hands back a per-machine key, persist it so + // the next start authenticates with the bound agent key (one-time migration; + // also stops the server re-minting on every restart). + d.OnAgentKeyMinted = func(newKey string) { + cfg.Auth.APIKey = newKey + if serr := config.Save(cfg, resolvedConfigPath()); serr != nil { + log.Printf("[agent] could not persist per-machine key: %v", serr) + } else { + log.Printf("[agent] migrated to a per-machine agent key") + } + } + // Start daemon (blocks — runs sync loop) errCh := make(chan error, 1) go func() { @@ -1017,10 +1037,34 @@ func runDaemonStart() error { cancelAllPlayerSessions() streamSrv.Shutdown(context.Background()) cancel() + // Registration was rejected because this agent's credential is revoked + // (deleted from the dashboard). Wipe it and exit cleanly so the service + // supervisor doesn't restart-loop against a 410; user must re-login. + if agent.IsRevoked(err) { + reportAgentRevoked(cfg, err) + return nil + } return err } } +// reportAgentRevoked tells the user their agent was removed and wipes the +// stored credential (api key + agentId) so the next start requires a fresh +// `unarr login` (which mints a new per-machine key bound to a new agentId) +// instead of looping against a server that keeps rejecting the old identity. +func reportAgentRevoked(cfg config.Config, err error) { + log.Printf("[agent] credential revoked by server (%v) — this machine was removed from your account", err) + cfg.Auth.APIKey = "" + cfg.Agent.ID = "" + if serr := config.Save(cfg, resolvedConfigPath()); serr != nil { + log.Printf("[agent] could not clear stored credential: %v", serr) + } + fmt.Println() + fmt.Println(" This agent was removed from your account.") + fmt.Println(" Run `unarr login` on this machine to reconnect it.") + fmt.Println() +} + // isAllowedStreamPath checks that filePath is within one of the directories // the daemon is configured to manage. This defends against a compromised API // server sending a path traversal payload (e.g. /etc/passwd) in StreamRequest. diff --git a/internal/cmd/init.go b/internal/cmd/init.go index 9e7a8ca..113246b 100644 --- a/internal/cmd/init.go +++ b/internal/cmd/init.go @@ -75,12 +75,19 @@ func runInit(apiURLOverride string) error { apiKey := cfg.Auth.APIKey + // Resolve the agentId up front so browser-authorize can bind the minted + // per-machine key to it. + agentID := cfg.Agent.ID + if agentID == "" { + agentID = uuid.New().String() + } + if apiKey == "" { // Try browser-based auth first (like Claude Code / GitHub CLI) fmt.Println(" Opening browser to connect your account...") fmt.Println() - browserKey, browserErr := browserAuth(apiURL) + browserKey, browserErr := browserAuth(apiURL, agentID) if browserErr == nil && strings.HasPrefix(browserKey, "tc_") { apiKey = browserKey green.Println(" ✓ Connected via browser") @@ -127,11 +134,6 @@ func runInit(apiURLOverride string) error { // Validate API key by registering with the server fmt.Print(" Verifying API key... ") - agentID := cfg.Agent.ID - if agentID == "" { - agentID = uuid.New().String() - } - hostname, _ := os.Hostname() agentName := cfg.Agent.Name if agentName == "" { @@ -150,9 +152,21 @@ func runInit(apiURLOverride string) error { if err != nil { color.Red("FAILED") fmt.Println() + // Stored credential was revoked (machine deleted from the dashboard) — + // drop it so a re-run mints a fresh identity. + if agent.IsRevoked(err) { + clearRevokedIdentity(cfg, "init") + return nil + } return fmt.Errorf("API key validation failed: %w", err) } + // Manual-paste bootstrap: swap to the minted per-machine key, discard the + // general key the user pasted. + if resp.AgentKey != "" { + apiKey = resp.AgentKey + } + green.Println("OK") fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan)) fmt.Println() diff --git a/internal/cmd/login.go b/internal/cmd/login.go index 6ecfd0a..f6161c2 100644 --- a/internal/cmd/login.go +++ b/internal/cmd/login.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "os" "runtime" "strings" @@ -16,6 +17,20 @@ import ( "github.com/torrentclaw/unarr/internal/config" ) +// clearRevokedIdentity wipes the stored credential (api key + agentId) after the +// server reports this machine's registration was revoked, so a re-run of the +// given command mints a fresh identity instead of looping against a dead key. +func clearRevokedIdentity(cfg config.Config, retryCmd string) { + cfg.Auth.APIKey = "" + cfg.Agent.ID = "" + if err := config.Save(cfg, resolvedConfigPath()); err != nil { + log.Printf("could not clear revoked credential: %v", err) + } + fmt.Println(" This machine's previous registration was removed from your account.") + fmt.Printf(" Run `unarr %s` again to reconnect it as a new agent.\n", retryCmd) + fmt.Println() +} + func newLoginCmd() *cobra.Command { var apiURL string @@ -70,11 +85,18 @@ func runLogin(apiURLOverride string) error { var apiKey string + // Resolve the agentId up front so the browser-authorize flow can bind the + // minted per-machine key to it. + agentID := cfg.Agent.ID + if agentID == "" { + agentID = uuid.New().String() + } + // Try browser-based auth first fmt.Println(" Opening browser to connect your account...") fmt.Println() - browserKey, browserErr := browserAuth(apiURL) + browserKey, browserErr := browserAuth(apiURL, agentID) if browserErr == nil && strings.HasPrefix(browserKey, "tc_") { apiKey = browserKey green.Println(" ✓ Connected via browser") @@ -120,11 +142,6 @@ func runLogin(apiURLOverride string) error { fmt.Print(" Verifying API key... ") - agentID := cfg.Agent.ID - if agentID == "" { - agentID = uuid.New().String() - } - hostname, _ := os.Hostname() agentName := cfg.Agent.Name if agentName == "" { @@ -143,9 +160,21 @@ func runLogin(apiURLOverride string) error { if err != nil { color.Red("FAILED") fmt.Println() + // The stored credential was revoked (this machine was deleted from the + // dashboard). Drop it so the next run mints a fresh identity. + if agent.IsRevoked(err) { + clearRevokedIdentity(cfg, "login") + return nil + } return fmt.Errorf("API key validation failed: %w", err) } + // Manual-paste bootstrap: the server minted a per-machine key bound to this + // agentId. Swap to it and discard the general key the user pasted. + if resp.AgentKey != "" { + apiKey = resp.AgentKey + } + green.Println("OK") fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan)) fmt.Println() diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e8ad752..26db370 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -192,6 +192,17 @@ func Execute() { } // loadConfig loads config once (lazy initialization). +// resolvedConfigPath returns the config file the CLI actually reads/writes, +// honouring the global --config flag. Use this for every Save so a revocation +// wipe or key migration lands in the right file (e.g. the dev-local agent's +// ~/.config/unarr-dev/config.toml), not always the default path. +func resolvedConfigPath() string { + if cfgFile != "" { + return cfgFile + } + return config.FilePath() +} + func loadConfig() config.Config { if cfgLoaded { return appCfg