package runtime import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "regexp" "strings" "sync" "time" "fun.chick/msa-agent/internal/egress" ) // NodeAuth holds the agent's own session against the local SimAdmin. // // This exists because the phone must never hold a node password: the agent logs in itself and the // credential never traverses the relay in either direction. The login endpoints are therefore // reachable by this code and permanently unreachable through the policy (authPaths.proxied=false // plus the auth-pattern rejection in policy.Decision), so the only way to trigger a login attempt // against the node is for the agent itself to need one. // // The semantics mirror server/core.js ensureAuthenticated in the console, including its failure // modes: a stale password must not turn every command into a fresh login attempt, because some // devices punish repeated failures with lockouts. type NodeAuth struct { client *http.Client origin string password string cooldown time.Duration clock func() time.Time mu sync.Mutex state authState cookie string // retryAt is the wall time after which a failed login may be attempted again. retryAt time.Time } type authState int const ( // authUnknown is the startup state: nothing has been probed yet. authUnknown authState = iota // authOpen means the node answers without a session (protection disabled, or the auth status // endpoint does not exist on an old build). Commands forward without a cookie. authOpen // authReady means cookie holds a live session. authReady // authFailed means a login was attempted and rejected; no command may trigger another until // retryAt passes. This is the cooldown that keeps a stale password from becoming a lockout. authFailed // authNoPassword means the node demands authentication but the agent was configured without a // password. Retrying cannot help; the operator has to fix the config. authNoPassword ) var ( // ErrAuthNoPassword is returned when a protected node was given an agent with no password. ErrAuthNoPassword = errors.New("runtime: node requires authentication but the agent has no password configured") // ErrAuthRejected is returned while a failed login is inside its cooldown. ErrAuthRejected = errors.New("runtime: node rejected the agent credential (in login cooldown)") // ErrAuthUnavailable is returned when the local SimAdmin could not be reached during a probe. ErrAuthUnavailable = errors.New("runtime: local SimAdmin unreachable during authentication") sessionCookieRe = regexp.MustCompile(`simadmin_session=([^;\s,]+)`) ) // DefaultLoginCooldown matches the console's 60s auto-login cooldown. Two knobs in two languages // would drift; this one lives next to the code that enforces it. const DefaultLoginCooldown = 60 * time.Second // AuthConfig configures NodeAuth. Password may be empty: an agent pointed at an unprotected node // never logs in, and an agent pointed at a protected one fails with ErrAuthNoPassword rather than // looping. type AuthConfig struct { Password string Cooldown time.Duration Clock func() time.Time } // NewNodeAuth builds the auth holder over the same loopback client the executor forwards with, so // the login path cannot dial anywhere a command path could not. func NewNodeAuth(client *http.Client, target egress.Target, cfg AuthConfig) (*NodeAuth, error) { if client == nil { return nil, errors.New("runtime: auth client is nil") } clock := cfg.Clock if clock == nil { clock = time.Now } cooldown := cfg.Cooldown if cooldown <= 0 { cooldown = DefaultLoginCooldown } return &NodeAuth{ client: client, origin: target.Origin(), password: cfg.Password, cooldown: cooldown, clock: clock, }, nil } // Cookie returns the current session cookie value, or "" when the node needs none. Callers on the // forwarding path call Ensure first, which is what performs the probe and login. func (a *NodeAuth) Cookie() string { a.mu.Lock() defer a.mu.Unlock() return a.cookie } // Ensure brings the agent to a usable authentication state, at most one probe/login per call. // // It is called before forwarding. A ready or open state returns without touching the network, so // steady-state commands pay nothing; only an unknown or expired state triggers traffic. func (a *NodeAuth) Ensure(ctx context.Context) error { a.mu.Lock() switch a.state { case authOpen, authReady: a.mu.Unlock() return nil case authNoPassword: a.mu.Unlock() return ErrAuthNoPassword case authFailed: if a.clock().Before(a.retryAt) { a.mu.Unlock() return ErrAuthRejected } // Cooldown elapsed: fall through to a fresh probe. A node whose password was fixed, or // whose lockout expired, recovers without a restart. } a.mu.Unlock() authenticated, loginRequired, err := a.probeStatus(ctx) if err != nil { return err } if authenticated || !loginRequired { a.mu.Lock() a.state = authOpen a.cookie = "" a.mu.Unlock() return nil } return a.login(ctx) } // Invalidate marks the session unusable after a forwarded request came back unauthenticated, so // the next Ensure re-probes and (if needed) re-logs in. An agent that never invalidates turns every // SimAdmin restart into permanent 401s until the agent restarts too. func (a *NodeAuth) Invalidate() { a.mu.Lock() defer a.mu.Unlock() if a.state == authReady { a.state = authUnknown a.cookie = "" } } // probeStatus answers "does this request need a session, and does ours already work". // // The parse mirrors core.js: the payload nests under data on some builds and not others, the // protection flag is snake_case on some and camelCase on others. The default when the shape is not // recognised is *unprotected*, and that is deliberate in both directions. A legacy or odd build // that answers 200 with something this parser cannot read is better served without a cookie, since // the alternative is the agent demanding a password the node never asked for. Login is triggered // only when the node explicitly says it wants authentication. func (a *NodeAuth) probeStatus(ctx context.Context) (authenticated, loginRequired bool, err error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, a.origin+"/api/auth/status", nil) if err != nil { return false, false, err } if a.Cookie() != "" { request.Header.Set("cookie", a.Cookie()) } response, err := a.client.Do(request) if err != nil { return false, false, fmt.Errorf("%w: %v", ErrAuthUnavailable, err) } defer drain(response) // 401/403 means the node is protected and our cookie (if any) did not authenticate it. if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { return false, true, nil } if response.StatusCode < 200 || response.StatusCode >= 300 { // 404 and friends: an old SimAdmin with no auth endpoints at all is an open node. return false, false, nil } payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { return false, false, fmt.Errorf("%w: reading auth status: %v", ErrAuthUnavailable, err) } var doc map[string]any if err := json.Unmarshal(payload, &doc); err != nil { return false, false, nil } auth, _ := doc["data"].(map[string]any) if auth == nil { auth = doc } if v, ok := auth["authenticated"].(bool); ok { return v, !v, nil } if v, ok := auth["configured"].(bool); ok && !v { return false, false, nil } settings, _ := auth["settings"].(map[string]any) if settings != nil { if v, ok := settings["password_protection_enabled"].(bool); ok { return false, v, nil } if v, ok := settings["passwordProtectionEnabled"].(bool); ok { return false, v, nil } } return false, false, nil } // login performs POST /api/auth/login and stores the session cookie. Failures enter the cooldown; // the cooldown is the reason a wrong password can be fixed in place rather than requiring a // reinstall. func (a *NodeAuth) login(ctx context.Context) error { if a.password == "" { a.mu.Lock() a.state = authNoPassword a.mu.Unlock() return ErrAuthNoPassword } body := strings.NewReader(`{"password":` + jsonQuote(a.password) + `}`) request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.origin+"/api/auth/login", body) if err != nil { return err } request.Header.Set("content-type", "application/json") request.Header.Set("accept", "application/json") response, err := a.client.Do(request) if err != nil { return fmt.Errorf("%w: %v", ErrAuthUnavailable, err) } defer drain(response) if response.StatusCode < 200 || response.StatusCode >= 300 { a.mu.Lock() a.state = authFailed a.cookie = "" a.retryAt = a.clock().Add(a.cooldown) a.mu.Unlock() return fmt.Errorf("runtime: local SimAdmin rejected the agent password (status %d)", response.StatusCode) } match := sessionCookieRe.FindStringSubmatch(response.Header.Get("Set-Cookie")) if match == nil { // A 2xx without the session cookie is its own failure mode (a proxy that answered on the // node's behalf, say). Treat it as a rejection rather than forwarding cookie-less. a.mu.Lock() a.state = authFailed a.retryAt = a.clock().Add(a.cooldown) a.mu.Unlock() return errors.New("runtime: login succeeded but set no session cookie") } a.mu.Lock() a.state = authReady a.cookie = match[0] a.mu.Unlock() return nil } func drain(response *http.Response) { io.Copy(io.Discard, response.Body) response.Body.Close() } // jsonQuote encodes a string as a JSON string literal without pulling in an encoder for one field. func jsonQuote(s string) string { b, _ := json.Marshal(s) return string(b) }