Merge branch 'feat/per-agent-api-keys'
This commit is contained in:
commit
9fdc099ea8
8 changed files with 175 additions and 15 deletions
|
|
@ -54,6 +54,11 @@ type Daemon struct {
|
||||||
OnStreamSession func(sess StreamSession)
|
OnStreamSession func(sess StreamSession)
|
||||||
OnControlAction func(action, taskID string, deleteFiles bool)
|
OnControlAction func(action, taskID string, deleteFiles bool)
|
||||||
GetActiveCount func() int // returns number of active downloads (wired from manager)
|
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
|
// State
|
||||||
User UserInfo
|
User UserInfo
|
||||||
|
|
@ -182,6 +187,12 @@ func (d *Daemon) Register(ctx context.Context) error {
|
||||||
return fmt.Errorf("register: %w (after %d retries)", err, maxRetries)
|
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.User = resp.User
|
||||||
d.Features = resp.Features
|
d.Features = resp.Features
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@ type SyncClient struct {
|
||||||
// It should delete the files and return the IDs of successfully deleted items.
|
// It should delete the files and return the IDs of successfully deleted items.
|
||||||
OnDeleteFiles func(items []LibraryDeleteRequest) []int
|
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 triggers an immediate sync (e.g., on task completion).
|
||||||
SyncNow chan struct{}
|
SyncNow chan struct{}
|
||||||
|
|
||||||
|
|
@ -152,6 +158,12 @@ func (sc *SyncClient) doSync(ctx context.Context) {
|
||||||
resp, err := sc.client.Sync(ctx, req)
|
resp, err := sc.client.Sync(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.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)
|
log.Printf("sync failed: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -61,6 +64,12 @@ type RegisterRequest struct {
|
||||||
// RegisterResponse is returned by the server after registration.
|
// RegisterResponse is returned by the server after registration.
|
||||||
type RegisterResponse struct {
|
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"`
|
User UserInfo `json:"user"`
|
||||||
Features FeatureFlags `json:"features"`
|
Features FeatureFlags `json:"features"`
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +202,32 @@ func (e *HTTPError) Error() string {
|
||||||
return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
|
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.
|
// AgentInfo holds metadata about the running agent for display.
|
||||||
type AgentInfo struct {
|
type AgentInfo struct {
|
||||||
ID string
|
ID string
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const browserAuthTimeout = 60 * time.Second
|
||||||
// 3. User logs in and clicks "Authorize" on the web page
|
// 3. User logs in and clicks "Authorize" on the web page
|
||||||
// 4. Web redirects to localhost:{port}/callback?token=tc_...&state={state}
|
// 4. Web redirects to localhost:{port}/callback?token=tc_...&state={state}
|
||||||
// 5. CLI validates state, extracts token, closes server
|
// 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
|
// Validate apiURL is a well-formed HTTP(S) URL
|
||||||
parsed, err := url.Parse(apiURL)
|
parsed, err := url.Parse(apiURL)
|
||||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
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)
|
authURL := fmt.Sprintf("%s/unarr/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port)
|
||||||
|
if agentID != "" {
|
||||||
|
authURL += "&agentId=" + url.QueryEscape(agentID)
|
||||||
|
}
|
||||||
openBrowser(authURL)
|
openBrowser(authURL)
|
||||||
|
|
||||||
// Listen for Enter key to skip to manual fallback
|
// Listen for Enter key to skip to manual fallback
|
||||||
|
|
|
||||||
|
|
@ -978,6 +978,26 @@ func runDaemonStart() error {
|
||||||
// Start reporter only for stream task handling
|
// Start reporter only for stream task handling
|
||||||
go reporter.Run(ctx)
|
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)
|
// Start daemon (blocks — runs sync loop)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -1017,10 +1037,34 @@ func runDaemonStart() error {
|
||||||
cancelAllPlayerSessions()
|
cancelAllPlayerSessions()
|
||||||
streamSrv.Shutdown(context.Background())
|
streamSrv.Shutdown(context.Background())
|
||||||
cancel()
|
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
|
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
|
// isAllowedStreamPath checks that filePath is within one of the directories
|
||||||
// the daemon is configured to manage. This defends against a compromised API
|
// the daemon is configured to manage. This defends against a compromised API
|
||||||
// server sending a path traversal payload (e.g. /etc/passwd) in StreamRequest.
|
// server sending a path traversal payload (e.g. /etc/passwd) in StreamRequest.
|
||||||
|
|
|
||||||
|
|
@ -75,12 +75,19 @@ func runInit(apiURLOverride string) error {
|
||||||
|
|
||||||
apiKey := cfg.Auth.APIKey
|
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 == "" {
|
if apiKey == "" {
|
||||||
// Try browser-based auth first (like Claude Code / GitHub CLI)
|
// Try browser-based auth first (like Claude Code / GitHub CLI)
|
||||||
fmt.Println(" Opening browser to connect your account...")
|
fmt.Println(" Opening browser to connect your account...")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
browserKey, browserErr := browserAuth(apiURL)
|
browserKey, browserErr := browserAuth(apiURL, agentID)
|
||||||
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
|
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
|
||||||
apiKey = browserKey
|
apiKey = browserKey
|
||||||
green.Println(" ✓ Connected via browser")
|
green.Println(" ✓ Connected via browser")
|
||||||
|
|
@ -127,11 +134,6 @@ func runInit(apiURLOverride string) error {
|
||||||
// Validate API key by registering with the server
|
// Validate API key by registering with the server
|
||||||
fmt.Print(" Verifying API key... ")
|
fmt.Print(" Verifying API key... ")
|
||||||
|
|
||||||
agentID := cfg.Agent.ID
|
|
||||||
if agentID == "" {
|
|
||||||
agentID = uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
agentName := cfg.Agent.Name
|
agentName := cfg.Agent.Name
|
||||||
if agentName == "" {
|
if agentName == "" {
|
||||||
|
|
@ -150,9 +152,21 @@ func runInit(apiURLOverride string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
color.Red("FAILED")
|
color.Red("FAILED")
|
||||||
fmt.Println()
|
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)
|
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")
|
green.Println("OK")
|
||||||
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
|
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -16,6 +17,20 @@ import (
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"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 {
|
func newLoginCmd() *cobra.Command {
|
||||||
var apiURL string
|
var apiURL string
|
||||||
|
|
||||||
|
|
@ -70,11 +85,18 @@ func runLogin(apiURLOverride string) error {
|
||||||
|
|
||||||
var apiKey string
|
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
|
// Try browser-based auth first
|
||||||
fmt.Println(" Opening browser to connect your account...")
|
fmt.Println(" Opening browser to connect your account...")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
browserKey, browserErr := browserAuth(apiURL)
|
browserKey, browserErr := browserAuth(apiURL, agentID)
|
||||||
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
|
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
|
||||||
apiKey = browserKey
|
apiKey = browserKey
|
||||||
green.Println(" ✓ Connected via browser")
|
green.Println(" ✓ Connected via browser")
|
||||||
|
|
@ -120,11 +142,6 @@ func runLogin(apiURLOverride string) error {
|
||||||
|
|
||||||
fmt.Print(" Verifying API key... ")
|
fmt.Print(" Verifying API key... ")
|
||||||
|
|
||||||
agentID := cfg.Agent.ID
|
|
||||||
if agentID == "" {
|
|
||||||
agentID = uuid.New().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
agentName := cfg.Agent.Name
|
agentName := cfg.Agent.Name
|
||||||
if agentName == "" {
|
if agentName == "" {
|
||||||
|
|
@ -143,9 +160,21 @@ func runLogin(apiURLOverride string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
color.Red("FAILED")
|
color.Red("FAILED")
|
||||||
fmt.Println()
|
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)
|
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")
|
green.Println("OK")
|
||||||
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
|
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,17 @@ func Execute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig loads config once (lazy initialization).
|
// 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 {
|
func loadConfig() config.Config {
|
||||||
if cfgLoaded {
|
if cfgLoaded {
|
||||||
return appCfg
|
return appCfg
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue