Files

251 lines
6.1 KiB
Go
Raw Permalink Normal View History

2026-10-16 20:18:04 +08:00
package terminal
import (
"errors"
"io"
"log/slog"
"sync"
"time"
)
// ErrSessionClosed 表示会话已经关闭,不能再写入。
var ErrSessionClosed = errors.New("终端会话已关闭")
// Terminal 是终端后端的统一抽象:SSH PTY 与本地 PTY 都实现它。
// 会话只依赖这个接口,因此「服务端 SSH」和后续的「本地 Shell」可以共用同一套会话管理。
type Terminal interface {
io.ReadWriteCloser
Resize(cols, rows int) error
}
// backlogLimit 是每个会话保留的回放缓冲字节数:前端重连或刷新后能补回最近的输出。
const backlogLimit = 64 << 10
// Snapshot 是会话的只读快照,供 HTTP 层展示与清理判断使用。
type Snapshot struct {
ID string `json:"id"`
Label string `json:"label"`
ServerID int64 `json:"server_id"`
Closed bool `json:"closed"`
Reason string `json:"reason,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen"`
}
// Session 是一个已连接的终端会话:把后端输出分发给订阅者,并保留一小段回放缓冲。
type Session struct {
ID string
Label string
ServerID int64
term Terminal
// recorder 非空时把输出同步写一份到磁盘(默认关闭,见设置里的会话录制开关)
recorder OutputRecorder
// user / clientIP 只在建立会话时写入,供审计记录「谁、从哪来」
user string
clientIP string
// onClose 由管理器注入:会话自行结束(远端退出、超时)时也要走同一套清理与审计
onClose func(*Session)
mu sync.Mutex
backlog []byte
subs map[chan []byte]struct{}
closed bool
reason string
createdAt time.Time
lastSeen time.Time
endedAt time.Time
bytesIn int64
bytesOut int64
}
func newSession(id string, serverID int64, label, user, clientIP string, term Terminal) *Session {
now := time.Now()
return &Session{
ID: id, Label: label, ServerID: serverID, term: term, user: user, clientIP: clientIP,
subs: map[chan []byte]struct{}{}, createdAt: now, lastSeen: now,
}
}
// Subscribe 订阅输出:先回放已有缓冲,再接收后续数据。
// cancel 可重复调用;会话关闭时订阅通道会被关闭,读取方据此得知会话结束。
func (s *Session) Subscribe() (<-chan []byte, func()) {
ch := make(chan []byte, 64)
s.mu.Lock()
if s.closed {
s.mu.Unlock()
close(ch)
return ch, func() {}
}
s.subs[ch] = struct{}{}
backlog := append([]byte(nil), s.backlog...)
s.mu.Unlock()
if len(backlog) > 0 {
ch <- backlog
}
var once sync.Once
cancel := func() {
once.Do(func() {
s.mu.Lock()
if _, ok := s.subs[ch]; ok {
delete(s.subs, ch)
close(ch)
}
s.mu.Unlock()
})
}
return ch, cancel
}
// Input 把前端按键写入后端终端。
func (s *Session) Input(p []byte) error {
s.mu.Lock()
closed := s.closed
if !closed {
s.lastSeen = time.Now()
s.bytesIn += int64(len(p))
}
s.mu.Unlock()
if closed {
return ErrSessionClosed
}
_, err := s.term.Write(p)
return err
}
// Resize 同步终端窗口大小。
func (s *Session) Resize(cols, rows int) error {
if s.Snapshot().Closed {
return ErrSessionClosed
}
return s.term.Resize(cols, rows)
}
// Close 关闭会话:结束后端终端,并让所有订阅者收到关闭。
func (s *Session) Close(reason string) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
s.reason = reason
s.endedAt = time.Now()
subs := make([]chan []byte, 0, len(s.subs))
for ch := range s.subs {
delete(s.subs, ch)
subs = append(subs, ch)
}
hook := s.onClose
recorder := s.recorder
s.recorder = nil
s.mu.Unlock()
_ = s.term.Close()
for _, ch := range subs {
close(ch)
}
if recorder != nil {
recorder.Close()
}
// 只在「第一次关闭」时触发:管理器据此把会话移出列表并写审计
if hook != nil {
hook(s)
}
}
// Snapshot 返回会话的只读快照。
func (s *Session) Snapshot() Snapshot {
s.mu.Lock()
defer s.mu.Unlock()
return Snapshot{
ID: s.ID, Label: s.Label, ServerID: s.ServerID,
Closed: s.closed, Reason: s.reason,
CreatedAt: s.createdAt, LastSeen: s.lastSeen,
}
}
// Audit 汇总一条审计记录:会话结束时间、时长、断开原因与收发字节数。
func (s *Session) Audit() AuditEntry {
s.mu.Lock()
defer s.mu.Unlock()
ended := s.endedAt
if ended.IsZero() {
ended = time.Now()
}
reason := s.reason
if reason == "" {
reason = "会话已结束"
}
return AuditEntry{
SessionID: s.ID,
ServerID: s.ServerID,
Target: s.Label,
User: s.user,
ClientIP: s.clientIP,
StartedAt: s.createdAt.UTC().Format(time.RFC3339),
EndedAt: ended.UTC().Format(time.RFC3339),
DurationMS: ended.Sub(s.createdAt).Milliseconds(),
Reason: reason,
BytesIn: s.bytesIn,
BytesOut: s.bytesOut,
}
}
// write 把后端输出写入回放缓冲并分发给订阅者。
func (s *Session) write(p []byte) {
if len(p) == 0 {
return
}
s.mu.Lock()
s.lastSeen = time.Now()
s.bytesOut += int64(len(p))
s.backlog = appendBacklog(s.backlog, p)
recorder := s.recorder
subs := make([]chan []byte, 0, len(s.subs))
for ch := range s.subs {
subs = append(subs, ch)
}
s.mu.Unlock()
chunk := append([]byte(nil), p...)
if recorder != nil {
recorder.Write(chunk)
}
for _, ch := range subs {
select {
case ch <- chunk:
default:
// 订阅者读得慢就丢这一帧:终端是「最新优先」的流,卡住整个会话更糟
}
}
}
// startPump 持续读取后端输出;读到 EOF 说明远端结束,主动关闭会话。
func (s *Session) startPump(logger *slog.Logger) {
go func() {
buf := make([]byte, 32<<10)
for {
n, err := s.term.Read(buf)
if n > 0 {
s.write(buf[:n])
}
if err == nil {
continue
}
if !errors.Is(err, io.EOF) {
logger.Debug("终端读取结束", "session_id", s.ID, "error", err.Error())
}
s.Close("会话已结束")
return
}
}()
}
// appendBacklog 只保留末尾 backlogLimit 字节,避免长会话把内存吃满。
func appendBacklog(buf, p []byte) []byte {
buf = append(buf, p...)
if len(buf) <= backlogLimit {
return buf
}
return append([]byte(nil), buf[len(buf)-backlogLimit:]...)
}