// Package terminal 提供一个仅限管理员的网页端交互式终端:在 server 宿主进程 // (部署时即 runtime 容器)内拉起一个 shell,经 WebSocket 双向桥接 stdin/stdout, // 主要用途是运维(例如容器内 npm i -g 安装 ACP 客户端 CLI)。 // // 会话纯内存、不落库:开即用、断即清,server 重启即全部销毁(沿用 run profiles // 「运行态不持久化」的取舍)。进程树终止复用 internal/infra/proc,断连时整树清理, // 避免 shell 派生的子孙进程(npm→node…)残留为孤儿。 // // 跨平台:Unix 走真 PTY(github.com/creack/pty);Windows 走降级的 stdio 管道 // 实现(无 PTY、无 resize),仅供本地开发调试,不追求生产可用。 package terminal import ( "context" "log/slog" "sync" "time" "github.com/google/uuid" ) // SupervisorConfig 是从 config.TerminalConfig 派生的 supervisor 子集。 type SupervisorConfig struct { Shell string // 启动的 shell,如 /bin/bash MaxSessions int // 同时存在的会话上限 KillGrace time.Duration // 终止整组时 SIGTERM→SIGKILL 的宽限 // EnvWhitelist 是允许从宿主进程透传给 shell 的环境变量名白名单。 // APP_MASTER_KEY、DB DSN、git 凭据等敏感变量绝不应出现在此名单中。 EnvWhitelist []string } // ErrTooManySessions 在达到 MaxSessions 上限时返回,由 Handler 翻译为 errs code。 var ErrTooManySessions = errSentinel("terminal: too many active sessions") type errSentinel string func (e errSentinel) Error() string { return string(e) } // Session 是单个终端会话的运行时句柄。pty 持有平台相关的 shell 进程与 IO, // done 在进程退出(monitor)时关闭。 type Session struct { ID uuid.UUID pty *ptySession done chan struct{} // monitor 在 shell 退出后 close } // Read 从 shell 读输出(PTY master / 管道读端)。 func (s *Session) Read(p []byte) (int, error) { return s.pty.rw.Read(p) } // Write 向 shell 写输入(PTY master / stdin 管道)。 func (s *Session) Write(p []byte) (int, error) { return s.pty.rw.Write(p) } // Resize 调整终端窗口尺寸(Windows 降级实现为 no-op)。 func (s *Session) Resize(rows, cols uint16) error { return s.pty.resize(rows, cols) } // Done 在 shell 进程退出后被关闭,供调用方感知进程自行结束(如用户输入 exit)。 func (s *Session) Done() <-chan struct{} { return s.done } // Supervisor 是终端会话总管。被动结构:Open 时起 monitor goroutine,无主循环。 // 并发骨架照搬 run.RunSupervisor 已被测试验证的不变量(sessions map 写锁仅在 // Open 插入 / monitor 删除时持有;done 严格 close-once,monitor 唯一持有)。 type Supervisor struct { mu sync.RWMutex sessions map[uuid.UUID]*Session cfg SupervisorConfig log *slog.Logger } // NewSupervisor 构造空 Supervisor。 func NewSupervisor(cfg SupervisorConfig, log *slog.Logger) *Supervisor { if log == nil { log = slog.Default() } return &Supervisor{ sessions: map[uuid.UUID]*Session{}, cfg: cfg, log: log, } } // OpenParams 描述一次终端会话的初始参数。 type OpenParams struct { Rows uint16 Cols uint16 } // Open 启动一个 shell 会话并托管。达到 MaxSessions 上限时返回 ErrTooManySessions。 func (s *Supervisor) Open(p OpenParams) (*Session, error) { s.mu.RLock() full := len(s.sessions) >= s.cfg.MaxSessions s.mu.RUnlock() if full { return nil, ErrTooManySessions } rows, cols := p.Rows, p.Cols if rows == 0 { rows = defaultRows } if cols == 0 { cols = defaultCols } ps, err := startPTY(s.cfg.Shell, buildEnv(s.cfg.EnvWhitelist), rows, cols) if err != nil { return nil, err } id, err := uuid.NewRandom() if err != nil { ps.terminate() return nil, err } sess := &Session{ID: id, pty: ps, done: make(chan struct{})} s.mu.Lock() s.sessions[id] = sess s.mu.Unlock() go s.monitor(sess) return sess, nil } // monitor 阻塞等待 shell 退出后回收:关闭 done、移除映射、释放平台句柄。 func (s *Supervisor) monitor(sess *Session) { _ = sess.pty.cmd.Wait() // reap,避免僵尸进程 close(sess.done) s.mu.Lock() delete(s.sessions, sess.ID) s.mu.Unlock() sess.pty.group.Close() } // Close 终止指定会话(整树)。幂等:会话已不存在时直接返回。阻塞直到 done 关闭 // 或宽限耗尽。 func (s *Supervisor) Close(ctx context.Context, id uuid.UUID) { s.mu.RLock() sess, ok := s.sessions[id] s.mu.RUnlock() if !ok { return } sess.pty.group.TerminateGroup(sess.done, s.cfg.KillGrace) select { case <-sess.done: case <-ctx.Done(): } } // Count 返回当前活跃会话数。 func (s *Supervisor) Count() int { s.mu.RLock() defer s.mu.RUnlock() return len(s.sessions) } // CloseAll 在服务关停时终止全部会话。 func (s *Supervisor) CloseAll(ctx context.Context) { s.mu.RLock() ids := make([]uuid.UUID, 0, len(s.sessions)) for id := range s.sessions { ids = append(ids, id) } s.mu.RUnlock() var wg sync.WaitGroup for _, id := range ids { wg.Add(1) go func(sid uuid.UUID) { defer wg.Done() s.Close(ctx, sid) }(id) } done := make(chan struct{}) go func() { wg.Wait() close(done) }() select { case <-done: case <-ctx.Done(): } }