chore(release): 发布 v2.0.0
CI / verify (push) Canceled after 0s

- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验
- 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令
- DDNS 服务商名称与自定义 IP,新增迁移 037/038/039
- CHANGELOG 与 VERSION 更新至 2.0.0
This commit is contained in:
2026-10-16 20:18:04 +08:00
parent a0cc553d2d
commit 302248a907
65 changed files with 7263 additions and 606 deletions
+311
View File
@@ -0,0 +1,311 @@
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
}