CI / verify (push) Canceled after 0s
- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验 - 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令 - DDNS 服务商名称与自定义 IP,新增迁移 037/038/039 - CHANGELOG 与 VERSION 更新至 2.0.0
312 lines
8.7 KiB
Go
312 lines
8.7 KiB
Go
package terminal
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"log/slog"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
// ErrDisabled 表示没有注入 Opener(终端功能未启用)。
|
|
ErrDisabled = errors.New("SSH 终端未启用")
|
|
// ErrTooManySessions 表示并发会话已达上限。
|
|
ErrTooManySessions = errors.New("并发终端会话数已达上限")
|
|
// ErrNotFound 表示会话不存在或已被回收。
|
|
ErrNotFound = errors.New("终端会话不存在或已关闭")
|
|
)
|
|
|
|
// Opener 由调用方注入:按服务端 ID 建立一个已认证的终端。
|
|
// 实现放在 frp 包,复用它的 SSH 凭据解密与 known_hosts 校验,避免重复实现一套认证。
|
|
type Opener func(ctx context.Context, serverID int64, cols, rows int) (Terminal, string, error)
|
|
|
|
// CreateOptions 是建立会话的入参:连哪台、初始尺寸,以及审计需要的调用方身份。
|
|
type CreateOptions struct {
|
|
ServerID int64
|
|
Cols int
|
|
Rows int
|
|
// User / ClientIP 只用于写审计记录,不参与连接
|
|
User string
|
|
ClientIP string
|
|
// Record 为真且注入了 RecorderFactory 时,本次会话输出会写入录制文件
|
|
Record bool
|
|
}
|
|
|
|
// RecorderFactory 按会话创建录制器;录制失败只记日志,不影响会话本身。
|
|
type RecorderFactory func(sessionID string, opts CreateOptions) (OutputRecorder, error)
|
|
|
|
// Options 是并发上限与超时配置。
|
|
type Options struct {
|
|
MaxSessions int
|
|
IdleTimeout time.Duration
|
|
MaxLifetime time.Duration
|
|
}
|
|
|
|
// Manager 管理所有终端会话:创建、查找、关闭与超时回收。
|
|
type Manager struct {
|
|
logger *slog.Logger
|
|
open Opener
|
|
|
|
mu sync.Mutex
|
|
sessions map[string]*Session
|
|
pending int
|
|
max int
|
|
idleAfter time.Duration
|
|
maxAge time.Duration
|
|
audit AuditSink
|
|
recorder RecorderFactory
|
|
}
|
|
|
|
// NewManager 创建会话管理器;open 为 nil 时视为功能未启用。
|
|
func NewManager(logger *slog.Logger, open Opener, opts Options) *Manager {
|
|
if opts.MaxSessions <= 0 {
|
|
opts.MaxSessions = 5
|
|
}
|
|
if opts.IdleTimeout <= 0 {
|
|
opts.IdleTimeout = 10 * time.Minute
|
|
}
|
|
if opts.MaxLifetime <= 0 {
|
|
opts.MaxLifetime = 12 * time.Hour
|
|
}
|
|
return &Manager{
|
|
logger: logger, open: open, sessions: map[string]*Session{},
|
|
max: opts.MaxSessions, idleAfter: opts.IdleTimeout, maxAge: opts.MaxLifetime,
|
|
}
|
|
}
|
|
|
|
// Enabled 返回当前是否具备建立会话的能力。
|
|
func (m *Manager) Enabled() bool { return m.open != nil }
|
|
|
|
// SetAuditSink 注入审计存储;未注入时会话审计只落日志(测试与未启用审计的场景)。
|
|
func (m *Manager) SetAuditSink(sink AuditSink) {
|
|
m.mu.Lock()
|
|
m.audit = sink
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// SetRecorderFactory 注入会话录制工厂;未注入时即使请求录制也不会写文件。
|
|
func (m *Manager) SetRecorderFactory(factory RecorderFactory) {
|
|
m.mu.Lock()
|
|
m.recorder = factory
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// Create 建立新会话:超过并发上限或后端连接失败时返回错误。
|
|
func (m *Manager) Create(ctx context.Context, opts CreateOptions) (*Session, error) {
|
|
if m.open == nil {
|
|
return nil, ErrDisabled
|
|
}
|
|
if err := m.reserve(); err != nil {
|
|
return nil, err
|
|
}
|
|
term, label, err := m.open(ctx, opts.ServerID, normCols(opts.Cols), normRows(opts.Rows))
|
|
if err != nil {
|
|
m.finishReserve(nil)
|
|
return nil, err
|
|
}
|
|
session := newSession(newSessionID(), opts.ServerID, label, opts.User, opts.ClientIP, term)
|
|
// 会话自行结束(远端退出、读写出错)时也要移出列表并写审计,因此挂在会话上
|
|
session.onClose = m.handleSessionClosed
|
|
if opts.Record {
|
|
m.attachRecorder(session, opts)
|
|
}
|
|
session.startPump(m.logger)
|
|
m.finishReserve(session)
|
|
m.logger.Info("终端会话已建立",
|
|
"session_id", session.ID, "server_id", opts.ServerID, "label", label,
|
|
"user", opts.User, "client_ip", opts.ClientIP)
|
|
return session, nil
|
|
}
|
|
|
|
// attachRecorder 按需挂上录制器;失败只记日志,绝不因此拒绝建立会话。
|
|
func (m *Manager) attachRecorder(session *Session, opts CreateOptions) {
|
|
m.mu.Lock()
|
|
factory := m.recorder
|
|
m.mu.Unlock()
|
|
if factory == nil {
|
|
return
|
|
}
|
|
recorder, err := factory(session.ID, opts)
|
|
if err != nil {
|
|
m.logger.Warn("终端录制未启用:无法创建录制文件",
|
|
"module", "TERMINAL", "session_id", session.ID, "error", err.Error())
|
|
return
|
|
}
|
|
session.recorder = recorder
|
|
m.logger.Info("终端会话开始录制", "module", "TERMINAL", "session_id", session.ID, "server_id", opts.ServerID)
|
|
}
|
|
|
|
// Get 返回会话;不存在时返回 false。会话可能已关闭,调用方需自行判断原因。
|
|
func (m *Manager) Get(id string) (*Session, bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
session, ok := m.sessions[id]
|
|
return session, ok
|
|
}
|
|
|
|
// Close 关闭并移除会话;会话不存在时幂等返回。
|
|
func (m *Manager) Close(id, reason string) {
|
|
m.mu.Lock()
|
|
session, ok := m.sessions[id]
|
|
if ok {
|
|
delete(m.sessions, id)
|
|
}
|
|
m.mu.Unlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
m.logger.Info("终端会话已关闭", "session_id", id, "server_id", session.ServerID, "reason", reason)
|
|
// 关闭动作会触发 handleSessionClosed:移出列表 + 写审计
|
|
session.Close(reason)
|
|
}
|
|
|
|
// CloseAll 关闭全部会话(服务停止或用户点「全部关闭」)。
|
|
func (m *Manager) CloseAll(reason string) {
|
|
for _, snap := range m.List() {
|
|
m.Close(snap.ID, reason)
|
|
}
|
|
}
|
|
|
|
// List 返回当前所有会话的只读快照。
|
|
func (m *Manager) List() []Snapshot {
|
|
m.mu.Lock()
|
|
sessions := make([]*Session, 0, len(m.sessions))
|
|
for _, session := range m.sessions {
|
|
sessions = append(sessions, session)
|
|
}
|
|
m.mu.Unlock()
|
|
out := make([]Snapshot, 0, len(sessions))
|
|
for _, session := range sessions {
|
|
out = append(out, session.Snapshot())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// StartSweeper 定期回收空闲超时、超长与已结束的会话;ctx 取消时关闭全部会话。
|
|
func (m *Manager) StartSweeper(ctx context.Context, interval time.Duration) {
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
m.CloseAll("服务停止")
|
|
return
|
|
case <-ticker.C:
|
|
m.sweep()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// reserve 预占一个会话名额:把「正在建立」的会话也计入上限,避免并发点击绕过限制。
|
|
func (m *Manager) reserve() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if len(m.sessions)+m.pending >= m.max {
|
|
return ErrTooManySessions
|
|
}
|
|
m.pending++
|
|
return nil
|
|
}
|
|
|
|
// finishReserve 结束预占:session 非空时登记为新会话。
|
|
func (m *Manager) finishReserve(session *Session) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.pending--
|
|
// 会话可能在登记前就被远端结束并自行回收,这种情况不再登记,避免列表里留下死会话
|
|
if session != nil && !session.Snapshot().Closed {
|
|
m.sessions[session.ID] = session
|
|
}
|
|
}
|
|
|
|
// handleSessionClosed 是会话的唯一收尾入口:会话无论被谁关闭都会走到这里。
|
|
func (m *Manager) handleSessionClosed(session *Session) {
|
|
m.mu.Lock()
|
|
delete(m.sessions, session.ID)
|
|
m.mu.Unlock()
|
|
m.recordAudit(session)
|
|
}
|
|
|
|
// recordAudit 写一条审计:注入了存储就落库,否则退化为结构化日志。
|
|
func (m *Manager) recordAudit(session *Session) {
|
|
entry := session.Audit()
|
|
m.mu.Lock()
|
|
sink := m.audit
|
|
m.mu.Unlock()
|
|
if sink == nil {
|
|
m.logger.Info("终端会话审计",
|
|
"module", "TERMINAL",
|
|
"session_id", entry.SessionID,
|
|
"server_id", entry.ServerID,
|
|
"target", entry.Target,
|
|
"user", entry.User,
|
|
"client_ip", entry.ClientIP,
|
|
"duration_ms", entry.DurationMS,
|
|
"reason", entry.Reason)
|
|
return
|
|
}
|
|
// 关闭动作可能来自请求 goroutine(其 ctx 随响应结束而取消),这里用独立超时写库
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := sink.WriteAudit(ctx, entry); err != nil {
|
|
m.logger.Warn("终端审计写入失败", "module", "TERMINAL", "session_id", entry.SessionID, "error", err.Error())
|
|
}
|
|
}
|
|
|
|
func (m *Manager) sweep() {
|
|
now := time.Now()
|
|
for _, snap := range m.List() {
|
|
if snap.Closed {
|
|
reason := snap.Reason
|
|
if reason == "" {
|
|
reason = "会话已结束"
|
|
}
|
|
m.Close(snap.ID, reason)
|
|
continue
|
|
}
|
|
if now.Sub(snap.LastSeen) > m.idleAfter {
|
|
m.Close(snap.ID, "空闲超时")
|
|
continue
|
|
}
|
|
if now.Sub(snap.CreatedAt) > m.maxAge {
|
|
m.Close(snap.ID, "超过最长会话时长")
|
|
}
|
|
}
|
|
}
|
|
|
|
func newSessionID() string {
|
|
buf := make([]byte, 8)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
// normCols 把前端传来的列数夹到合理范围,避免异常值让远端 PTY 申请失败。
|
|
func normCols(cols int) int {
|
|
if cols < 20 {
|
|
return 80
|
|
}
|
|
if cols > 500 {
|
|
return 500
|
|
}
|
|
return cols
|
|
}
|
|
|
|
// normRows 把前端传来的行数夹到合理范围。
|
|
func normRows(rows int) int {
|
|
if rows < 5 {
|
|
return 24
|
|
}
|
|
if rows > 300 {
|
|
return 300
|
|
}
|
|
return rows
|
|
}
|