Merge branch 'feat/per-agent-api-keys'

This commit is contained in:
Deivid Soto 2026-06-07 19:43:23 +02:00
commit 9fdc099ea8
8 changed files with 175 additions and 15 deletions

View file

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

View file

@ -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.

View file

@ -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()

View file

@ -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()

View file

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