491 lines
12 KiB
Go
491 lines
12 KiB
Go
// Package loginpty runs an agent's interactive login command under a PTY on
|
|
// the kandev host. The UI surfaces this as a terminal so users can complete
|
|
// browser-based or token-prompt auth flows without shelling into the
|
|
// container.
|
|
//
|
|
// Sessions are keyed by agent ID — at most one login PTY per agent runs at a
|
|
// time, since concurrent logins for the same agent would race on the same
|
|
// HOME dotfile. The session terminates when the command exits, on idle
|
|
// timeout, or on explicit Stop.
|
|
package loginpty
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/creack/pty"
|
|
"github.com/google/uuid"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
// IdleTimeout terminates a session that hasn't produced output for this long.
|
|
// Picked to fit typical browser-OAuth flows (open browser → grant → callback).
|
|
IdleTimeout = 10 * time.Minute
|
|
|
|
// HardTimeout is the wall-clock upper bound; covers stuck sessions.
|
|
HardTimeout = 30 * time.Minute
|
|
|
|
// outputBufferSize is the rolling buffer of recent PTY output kept for
|
|
// late subscribers. Login flows are usually short so 16KB is plenty.
|
|
outputBufferSize = 16 * 1024
|
|
)
|
|
|
|
// Errors surfaced to callers.
|
|
var (
|
|
ErrSessionAlreadyRunning = errors.New("login session already running for this agent")
|
|
ErrSessionNotFound = errors.New("login session not found")
|
|
ErrSessionNotRunning = errors.New("login session is not running")
|
|
)
|
|
|
|
// Manager owns active login sessions and dispatches lifecycle callbacks.
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*Session // key: agentID
|
|
byID map[string]*Session // key: sessionID
|
|
log *logger.Logger
|
|
|
|
// onExit is invoked from a goroutine when a session terminates (with the
|
|
// agent ID and exit code). Used by callers to invalidate discovery cache
|
|
// and broadcast updated availability. Optional.
|
|
onExit func(agentID string, exitCode int, exitErr error)
|
|
}
|
|
|
|
// NewManager constructs a Manager. onExit may be nil.
|
|
func NewManager(log *logger.Logger, onExit func(agentID string, exitCode int, exitErr error)) *Manager {
|
|
return &Manager{
|
|
sessions: map[string]*Session{},
|
|
byID: map[string]*Session{},
|
|
log: log.WithFields(zap.String("component", "login-pty")),
|
|
onExit: onExit,
|
|
}
|
|
}
|
|
|
|
// Start spawns a PTY-backed process for the agent. Returns the session
|
|
// snapshot. Fails with ErrSessionAlreadyRunning if a session for this agent
|
|
// is already active.
|
|
func (m *Manager) Start(agentID string, cmd []string, cols, rows uint16) (*Session, error) {
|
|
if len(cmd) == 0 {
|
|
return nil, errors.New("empty command")
|
|
}
|
|
m.mu.Lock()
|
|
if existing, ok := m.sessions[agentID]; ok {
|
|
m.mu.Unlock()
|
|
_ = existing.Status() // touch
|
|
return existing, ErrSessionAlreadyRunning
|
|
}
|
|
|
|
if cols == 0 {
|
|
cols = 80
|
|
}
|
|
if rows == 0 {
|
|
rows = 24
|
|
}
|
|
|
|
sess := &Session{
|
|
ID: uuid.NewString(),
|
|
AgentID: agentID,
|
|
Cmd: append([]string(nil), cmd...),
|
|
subscribers: map[chan<- []byte]struct{}{},
|
|
log: m.log.WithFields(zap.String("agent", agentID)),
|
|
startedAt: time.Now(),
|
|
}
|
|
|
|
if err := sess.start(cmd, cols, rows); err != nil {
|
|
m.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
m.sessions[agentID] = sess
|
|
m.byID[sess.ID] = sess
|
|
m.mu.Unlock()
|
|
|
|
go m.supervise(sess)
|
|
return sess, nil
|
|
}
|
|
|
|
// Get returns a session by agent ID (or nil).
|
|
func (m *Manager) Get(agentID string) *Session {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.sessions[agentID]
|
|
}
|
|
|
|
// GetByID returns a session by session ID (or nil).
|
|
func (m *Manager) GetByID(sessionID string) *Session {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.byID[sessionID]
|
|
}
|
|
|
|
// Stop terminates the agent's session if one is running.
|
|
func (m *Manager) Stop(agentID string) error {
|
|
m.mu.Lock()
|
|
sess, ok := m.sessions[agentID]
|
|
m.mu.Unlock()
|
|
if !ok {
|
|
return ErrSessionNotFound
|
|
}
|
|
sess.stop()
|
|
return nil
|
|
}
|
|
|
|
// supervise watches the session's lifetime: waits on the process, enforces
|
|
// timeouts, then deletes the session from the manager and notifies onExit.
|
|
func (m *Manager) supervise(sess *Session) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), HardTimeout)
|
|
defer cancel()
|
|
|
|
exited := make(chan exitInfo, 1)
|
|
go func() {
|
|
err := sess.waitForCommand()
|
|
exited <- exitInfo{
|
|
code: exitCodeFromError(err),
|
|
err: err,
|
|
}
|
|
}()
|
|
|
|
idle := time.NewTimer(IdleTimeout)
|
|
defer idle.Stop()
|
|
|
|
var info exitInfo
|
|
loop:
|
|
for {
|
|
select {
|
|
case info = <-exited:
|
|
break loop
|
|
case <-idle.C:
|
|
sess.log.Info("login session idle timeout — terminating")
|
|
sess.stop()
|
|
case <-ctx.Done():
|
|
sess.log.Info("login session hard timeout — terminating")
|
|
sess.stop()
|
|
case <-sess.activityCh:
|
|
// Reset idle timer on output activity.
|
|
if !idle.Stop() {
|
|
select {
|
|
case <-idle.C:
|
|
default:
|
|
}
|
|
}
|
|
idle.Reset(IdleTimeout)
|
|
}
|
|
}
|
|
|
|
// Wait for readLoop to drain remaining PTY output before flipping running=false
|
|
// or closing subscribers. Otherwise late Subscribe callers (or BufferedOutput
|
|
// readers) could observe an empty session even though the process produced
|
|
// output. The pty is closed by stop() in timeout paths and by the child
|
|
// exit in the natural path; either way readLoop returns shortly after.
|
|
// Cap the wait so a stuck PTY can't deadlock cleanup.
|
|
select {
|
|
case <-sess.readDone:
|
|
case <-time.After(2 * time.Second):
|
|
sess.log.Warn("readLoop did not exit in time during supervise cleanup")
|
|
}
|
|
|
|
m.mu.Lock()
|
|
delete(m.sessions, sess.AgentID)
|
|
delete(m.byID, sess.ID)
|
|
m.mu.Unlock()
|
|
|
|
sess.mu.Lock()
|
|
now := time.Now()
|
|
sess.finishedAt = &now
|
|
sess.exitCode = &info.code
|
|
sess.running = false
|
|
durationMs := now.Sub(sess.startedAt).Milliseconds()
|
|
sess.mu.Unlock()
|
|
|
|
// Log the exit so we can tell apart "agent exited 0 silently" (e.g.
|
|
// already-authed) from "agent crashed". Suppress the noisy EOF error,
|
|
// which is just normal PTY closure.
|
|
exitErr := ""
|
|
if info.err != nil && info.err != io.EOF {
|
|
exitErr = info.err.Error()
|
|
}
|
|
sess.log.Info("login session ended",
|
|
zap.String("session_id", sess.ID),
|
|
zap.Int("exit_code", info.code),
|
|
zap.String("err", exitErr),
|
|
zap.Int64("duration_ms", durationMs),
|
|
)
|
|
|
|
sess.broadcastClose()
|
|
|
|
if m.onExit != nil {
|
|
m.onExit(sess.AgentID, info.code, info.err)
|
|
}
|
|
}
|
|
|
|
type exitInfo struct {
|
|
code int
|
|
err error
|
|
}
|
|
|
|
func exitCodeFromError(err error) int {
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return exitErr.ExitCode()
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Session is a single login PTY process plus its subscriber set.
|
|
// Exported fields are stable; everything else is internal.
|
|
type Session struct {
|
|
ID string
|
|
AgentID string
|
|
Cmd []string
|
|
|
|
mu sync.Mutex
|
|
cmd *exec.Cmd
|
|
pty *os.File
|
|
running bool
|
|
startedAt time.Time
|
|
finishedAt *time.Time
|
|
exitCode *int
|
|
|
|
subMu sync.Mutex
|
|
subscribers map[chan<- []byte]struct{}
|
|
subClosed bool // set under subMu by broadcastClose; once true, Subscribe closes the channel immediately
|
|
|
|
bufMu sync.Mutex
|
|
buffer []byte
|
|
|
|
activityCh chan struct{} // notifications to supervise loop on output
|
|
readDone chan struct{} // closed when readLoop exits (all PTY output drained)
|
|
|
|
log *logger.Logger
|
|
}
|
|
|
|
func (s *Session) start(cmd []string, cols, rows uint16) error {
|
|
s.activityCh = make(chan struct{}, 1)
|
|
s.readDone = make(chan struct{})
|
|
c := exec.Command(cmd[0], cmd[1:]...) // #nosec G204 — command is hard-coded per agent
|
|
c.Env = buildLoginEnv()
|
|
|
|
f, err := pty.StartWithSize(c, &pty.Winsize{Cols: cols, Rows: rows})
|
|
if err != nil {
|
|
return fmt.Errorf("start pty: %w", err)
|
|
}
|
|
|
|
s.cmd = c
|
|
s.pty = f
|
|
s.running = true
|
|
s.log.Info("login session started",
|
|
zap.String("session_id", s.ID),
|
|
zap.Strings("cmd", cmd),
|
|
zap.Int("pid", c.Process.Pid),
|
|
)
|
|
go s.readLoop()
|
|
return nil
|
|
}
|
|
|
|
func (s *Session) readLoop() {
|
|
defer close(s.readDone)
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := s.pty.Read(buf)
|
|
if n > 0 {
|
|
data := make([]byte, n)
|
|
copy(data, buf[:n])
|
|
s.appendBuffer(data)
|
|
s.broadcast(data)
|
|
select {
|
|
case s.activityCh <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
s.log.Debug("pty read ended", zap.Error(err))
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Session) appendBuffer(data []byte) {
|
|
s.bufMu.Lock()
|
|
defer s.bufMu.Unlock()
|
|
s.buffer = append(s.buffer, data...)
|
|
if len(s.buffer) > outputBufferSize {
|
|
s.buffer = s.buffer[len(s.buffer)-outputBufferSize:]
|
|
}
|
|
}
|
|
|
|
// BufferedOutput returns a copy of the rolling output buffer for catching up
|
|
// new WS subscribers.
|
|
func (s *Session) BufferedOutput() []byte {
|
|
s.bufMu.Lock()
|
|
defer s.bufMu.Unlock()
|
|
if len(s.buffer) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]byte, len(s.buffer))
|
|
copy(out, s.buffer)
|
|
return out
|
|
}
|
|
|
|
// Subscribe registers a channel for PTY output. The channel is non-blocking:
|
|
// if it fills up, chunks are dropped (the subscriber falls behind).
|
|
//
|
|
// If the session has already ended (broadcastClose ran), the channel is
|
|
// closed immediately so the caller's reader returns rather than blocking
|
|
// forever on a channel that nothing will ever send to or close.
|
|
//
|
|
// Both the close-check and the map insertion happen under subMu so a
|
|
// concurrent broadcastClose cannot race in between (which would otherwise
|
|
// leave the late subscriber registered in a fresh empty map with nothing
|
|
// to ever close it).
|
|
func (s *Session) Subscribe(ch chan<- []byte) {
|
|
s.subMu.Lock()
|
|
defer s.subMu.Unlock()
|
|
if s.subClosed {
|
|
close(ch)
|
|
return
|
|
}
|
|
s.subscribers[ch] = struct{}{}
|
|
}
|
|
|
|
// Unsubscribe removes a channel from the subscriber set.
|
|
func (s *Session) Unsubscribe(ch chan<- []byte) {
|
|
s.subMu.Lock()
|
|
defer s.subMu.Unlock()
|
|
delete(s.subscribers, ch)
|
|
}
|
|
|
|
func (s *Session) broadcast(data []byte) {
|
|
s.subMu.Lock()
|
|
defer s.subMu.Unlock()
|
|
for ch := range s.subscribers {
|
|
select {
|
|
case ch <- data:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// broadcastClose closes any remaining subscriber channels — used to wake WS
|
|
// loops on session exit. subClosed is set under subMu so any concurrent
|
|
// Subscribe taking subMu after this either observes subClosed and closes
|
|
// its own channel, or completed before us and is already in the snapshot.
|
|
func (s *Session) broadcastClose() {
|
|
s.subMu.Lock()
|
|
subs := s.subscribers
|
|
s.subscribers = map[chan<- []byte]struct{}{}
|
|
s.subClosed = true
|
|
s.subMu.Unlock()
|
|
for ch := range subs {
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
// Write sends user input to the underlying process via the PTY.
|
|
func (s *Session) Write(p []byte) (int, error) {
|
|
s.mu.Lock()
|
|
pf := s.pty
|
|
running := s.running
|
|
s.mu.Unlock()
|
|
if !running || pf == nil {
|
|
return 0, ErrSessionNotRunning
|
|
}
|
|
return pf.Write(p)
|
|
}
|
|
|
|
// Resize updates the PTY window size.
|
|
func (s *Session) Resize(cols, rows uint16) error {
|
|
s.mu.Lock()
|
|
pf := s.pty
|
|
running := s.running
|
|
s.mu.Unlock()
|
|
if !running || pf == nil {
|
|
return ErrSessionNotRunning
|
|
}
|
|
return pty.Setsize(pf, &pty.Winsize{Cols: cols, Rows: rows})
|
|
}
|
|
|
|
// Status snapshot for HTTP responses.
|
|
type Status struct {
|
|
ID string `json:"session_id"`
|
|
AgentID string `json:"agent_id"`
|
|
Cmd []string `json:"cmd"`
|
|
Running bool `json:"running"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
|
ExitCode *int `json:"exit_code,omitempty"`
|
|
}
|
|
|
|
// Status returns a value snapshot of the session.
|
|
func (s *Session) Status() Status {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
st := Status{
|
|
ID: s.ID,
|
|
AgentID: s.AgentID,
|
|
Cmd: append([]string(nil), s.Cmd...),
|
|
Running: s.running,
|
|
StartedAt: s.startedAt,
|
|
}
|
|
if s.finishedAt != nil {
|
|
f := *s.finishedAt
|
|
st.FinishedAt = &f
|
|
}
|
|
if s.exitCode != nil {
|
|
e := *s.exitCode
|
|
st.ExitCode = &e
|
|
}
|
|
return st
|
|
}
|
|
|
|
// stop tears the PTY down idempotently. The `!s.running` guard is flipped
|
|
// *under the same lock* that we use to read pty/cmd, so concurrent callers
|
|
// (timeout case in supervise + external Manager.Stop, etc.) don't double-
|
|
// close the file descriptor on the way out. A double Close on *os.File is
|
|
// safe in isolation, but if the OS recycles the fd between the two Close()
|
|
// calls, the second close hits a different open file.
|
|
func (s *Session) stop() {
|
|
s.mu.Lock()
|
|
if !s.running {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.running = false
|
|
pf := s.pty
|
|
cmd := s.cmd
|
|
s.mu.Unlock()
|
|
|
|
if pf != nil {
|
|
_ = pf.Close()
|
|
}
|
|
if cmd != nil && cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
}
|
|
|
|
func (s *Session) waitForCommand() error {
|
|
if s.cmd == nil {
|
|
return nil
|
|
}
|
|
return s.cmd.Wait()
|
|
}
|
|
|
|
// buildLoginEnv composes the environment for the login subprocess. The user's
|
|
// HOME (where agents write credentials) is inherited from the kandev process.
|
|
func buildLoginEnv() []string {
|
|
env := os.Environ()
|
|
env = append(env, "TERM=xterm-256color", "LANG=C.UTF-8", "LC_ALL=C.UTF-8")
|
|
return env
|
|
}
|