- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验 - 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令 - DDNS 服务商名称与自定义 IP,新增迁移 037/038/039 - CHANGELOG 与 VERSION 更新至 2.0.0
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditRetention 是审计记录的保留期:超过就由定时任务清理,避免表无限增长。
|
||||
const AuditRetention = 90 * 24 * time.Hour
|
||||
|
||||
// AuditEntry 是一条终端会话审计记录,回答「谁、何时、连了哪台、多久、为什么断开」。
|
||||
type AuditEntry struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
SessionID string `json:"session_id"`
|
||||
ServerID int64 `json:"server_id"`
|
||||
Target string `json:"target"`
|
||||
User string `json:"user"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
StartedAt string `json:"started_at"`
|
||||
EndedAt string `json:"ended_at"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BytesIn int64 `json:"bytes_in"`
|
||||
BytesOut int64 `json:"bytes_out"`
|
||||
}
|
||||
|
||||
// AuditSink 由会话管理器在会话结束时调用;未注入时审计只落日志。
|
||||
type AuditSink interface {
|
||||
WriteAudit(ctx context.Context, entry AuditEntry) error
|
||||
}
|
||||
|
||||
// AuditStore 把审计记录落到 SQLite,并提供查询与清理。
|
||||
type AuditStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewAuditStore(db *sql.DB) *AuditStore {
|
||||
return &AuditStore{db: db}
|
||||
}
|
||||
|
||||
// WriteAudit 写入一条会话审计。
|
||||
func (s *AuditStore) WriteAudit(ctx context.Context, entry AuditEntry) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO terminal_audit_logs(
|
||||
session_id, server_id, target, username, client_ip,
|
||||
started_at, ended_at, duration_ms, reason, bytes_in, bytes_out
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
entry.SessionID, entry.ServerID, entry.Target, entry.User, entry.ClientIP,
|
||||
entry.StartedAt, entry.EndedAt, entry.DurationMS, entry.Reason, entry.BytesIn, entry.BytesOut)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAudits 按结束时间倒序返回最近的审计记录;limit 缺省 100,上限 500。
|
||||
func (s *AuditStore) ListAudits(ctx context.Context, limit int) ([]AuditEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, session_id, server_id, target, username, client_ip,
|
||||
started_at, ended_at, duration_ms, COALESCE(reason, ''), bytes_in, bytes_out
|
||||
FROM terminal_audit_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]AuditEntry, 0, limit)
|
||||
for rows.Next() {
|
||||
var entry AuditEntry
|
||||
if err := rows.Scan(
|
||||
&entry.ID, &entry.SessionID, &entry.ServerID, &entry.Target,
|
||||
&entry.User, &entry.ClientIP, &entry.StartedAt, &entry.EndedAt,
|
||||
&entry.DurationMS, &entry.Reason, &entry.BytesIn, &entry.BytesOut,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// PruneAudits 清理超过保留期的审计记录,返回删除条数。
|
||||
func (s *AuditStore) PruneAudits(ctx context.Context) (int64, error) {
|
||||
cutoff := time.Now().UTC().Add(-AuditRetention).Format(time.RFC3339)
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM terminal_audit_logs WHERE ended_at < ?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
// TestAuditStoreRoundTrip:写入的审计能按时间倒序读回,并按保留期清理。
|
||||
func TestAuditStoreRoundTrip(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "audit.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("打开数据库失败:%v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("迁移失败:%v", err)
|
||||
}
|
||||
|
||||
store := NewAuditStore(conn)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
entries := []AuditEntry{
|
||||
{
|
||||
SessionID: "s1", ServerID: 1, Target: "东京 VPS · root@10.0.0.1",
|
||||
User: "admin", ClientIP: "192.168.1.2",
|
||||
StartedAt: now.Add(-2 * time.Minute).Format(time.RFC3339),
|
||||
EndedAt: now.Add(-time.Minute).Format(time.RFC3339),
|
||||
DurationMS: 60_000, Reason: "用户关闭", BytesIn: 12, BytesOut: 4096,
|
||||
},
|
||||
{
|
||||
SessionID: "s2", ServerID: 2, Target: "洛杉矶 VPS · root@10.0.0.2",
|
||||
User: "admin", ClientIP: "10.1.1.1",
|
||||
StartedAt: now.Format(time.RFC3339),
|
||||
EndedAt: now.Add(time.Second).Format(time.RFC3339),
|
||||
DurationMS: 1_000, Reason: "空闲超时",
|
||||
},
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := store.WriteAudit(ctx, entry); err != nil {
|
||||
t.Fatalf("写入审计失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := store.ListAudits(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("读取审计失败:%v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("期望 2 条审计,实际 %d 条", len(got))
|
||||
}
|
||||
// 按写入顺序倒序:最近结束的 s2 在前
|
||||
if got[0].SessionID != "s2" || got[1].SessionID != "s1" {
|
||||
t.Fatalf("排序不符:%+v", got)
|
||||
}
|
||||
if got[1].Reason != "用户关闭" || got[1].BytesOut != 4096 || got[1].ClientIP != "192.168.1.2" {
|
||||
t.Fatalf("字段回读不符:%+v", got[1])
|
||||
}
|
||||
|
||||
// 保留期外的记录会被清理
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`UPDATE terminal_audit_logs SET ended_at = ? WHERE session_id = 's1'`,
|
||||
now.Add(-AuditRetention-time.Hour).Format(time.RFC3339)); err != nil {
|
||||
t.Fatalf("准备过期数据失败:%v", err)
|
||||
}
|
||||
deleted, err := store.PruneAudits(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("清理失败:%v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("期望清理 1 条,实际 %d 条", deleted)
|
||||
}
|
||||
|
||||
remaining, err := store.ListAudits(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("读取审计失败:%v", err)
|
||||
}
|
||||
if len(remaining) != 1 || remaining[0].SessionID != "s2" {
|
||||
t.Fatalf("清理结果不符:%+v", remaining)
|
||||
}
|
||||
// limit 传 0 时回落到默认值,不应报错
|
||||
if _, err := store.ListAudits(ctx, 0); err != nil {
|
||||
t.Fatalf("默认 limit 读取失败:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxCommandNameLength 是快捷命令名称的长度上限(按字符算)
|
||||
maxCommandNameLength = 64
|
||||
// maxCommandLength 是命令正文的字节上限:够写复杂管道,但塞不进整个脚本文件
|
||||
maxCommandLength = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidCommand 表示名称或命令内容不合法。
|
||||
ErrInvalidCommand = errors.New("命令名称与内容都不能为空")
|
||||
// ErrCommandNotFound 表示快捷命令不存在。
|
||||
ErrCommandNotFound = errors.New("快捷命令不存在")
|
||||
)
|
||||
|
||||
// TerminalCommand 是保存的一条快捷命令。
|
||||
type TerminalCommand struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CommandStore 把快捷命令落 SQLite:换浏览器也在,与终端会话无关。
|
||||
type CommandStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewCommandStore(db *sql.DB) *CommandStore {
|
||||
return &CommandStore{db: db}
|
||||
}
|
||||
|
||||
// ListCommands 按新增时间倒序返回全部快捷命令。
|
||||
func (s *CommandStore) ListCommands(ctx context.Context) ([]TerminalCommand, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, name, command, created_at FROM terminal_commands ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]TerminalCommand, 0, 16)
|
||||
for rows.Next() {
|
||||
var item TerminalCommand
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Command, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateCommand 保存一条快捷命令:名称与内容必填,尾部的换行会被去掉(发送时再补回车)。
|
||||
func (s *CommandStore) CreateCommand(ctx context.Context, name, command string) (TerminalCommand, error) {
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
trimmedCommand := strings.TrimRight(strings.ReplaceAll(command, "\r\n", "\n"), "\n")
|
||||
if trimmedName == "" || strings.TrimSpace(trimmedCommand) == "" {
|
||||
return TerminalCommand{}, ErrInvalidCommand
|
||||
}
|
||||
if len([]rune(trimmedName)) > maxCommandNameLength {
|
||||
return TerminalCommand{}, errors.New("命令名称过长")
|
||||
}
|
||||
if len(trimmedCommand) > maxCommandLength {
|
||||
return TerminalCommand{}, errors.New("命令内容过长")
|
||||
}
|
||||
|
||||
createdAt := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO terminal_commands(name, command, created_at) VALUES (?, ?, ?)`,
|
||||
trimmedName, trimmedCommand, createdAt)
|
||||
if err != nil {
|
||||
return TerminalCommand{}, err
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return TerminalCommand{}, err
|
||||
}
|
||||
return TerminalCommand{ID: id, Name: trimmedName, Command: trimmedCommand, CreatedAt: createdAt}, nil
|
||||
}
|
||||
|
||||
// DeleteCommand 删除一条快捷命令;不存在时返回 ErrCommandNotFound。
|
||||
func (s *CommandStore) DeleteCommand(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM terminal_commands WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return ErrCommandNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/havline/havline/internal/db"
|
||||
)
|
||||
|
||||
// TestCommandStoreRoundTrip:保存、列表、删除快捷命令,并校验输入约束。
|
||||
func TestCommandStoreRoundTrip(t *testing.T) {
|
||||
conn, err := db.Open(filepath.Join(t.TempDir(), "commands.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("打开数据库失败:%v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
|
||||
t.Fatalf("迁移失败:%v", err)
|
||||
}
|
||||
|
||||
store := NewCommandStore(conn)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := store.CreateCommand(ctx, " 磁盘占用 ", "df -h\n")
|
||||
if err != nil {
|
||||
t.Fatalf("保存命令失败:%v", err)
|
||||
}
|
||||
if first.Name != "磁盘占用" {
|
||||
t.Fatalf("名称应当去掉首尾空格:%q", first.Name)
|
||||
}
|
||||
if first.Command != "df -h" {
|
||||
t.Fatalf("命令应当去掉尾部换行:%q", first.Command)
|
||||
}
|
||||
if first.ID == 0 || first.CreatedAt == "" {
|
||||
t.Fatalf("保存结果缺少 ID 或时间:%+v", first)
|
||||
}
|
||||
if _, err := store.CreateCommand(ctx, "systemctl", "systemctl status nginx"); err != nil {
|
||||
t.Fatalf("保存第二条失败:%v", err)
|
||||
}
|
||||
|
||||
list, err := store.ListCommands(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("列表失败:%v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("期望 2 条,实际 %d 条", len(list))
|
||||
}
|
||||
// 倒序:后加的在前
|
||||
if list[0].Name != "systemctl" {
|
||||
t.Fatalf("排序不符:%+v", list)
|
||||
}
|
||||
|
||||
if err := store.DeleteCommand(ctx, first.ID); err != nil {
|
||||
t.Fatalf("删除失败:%v", err)
|
||||
}
|
||||
if err := store.DeleteCommand(ctx, first.ID); !errors.Is(err, ErrCommandNotFound) {
|
||||
t.Fatalf("重复删除应当 ErrCommandNotFound,实际 %v", err)
|
||||
}
|
||||
rest, err := store.ListCommands(ctx)
|
||||
if err != nil || len(rest) != 1 {
|
||||
t.Fatalf("删除后应当剩 1 条:%v %d", err, len(rest))
|
||||
}
|
||||
|
||||
// 输入约束
|
||||
if _, err := store.CreateCommand(ctx, " ", "ls"); !errors.Is(err, ErrInvalidCommand) {
|
||||
t.Fatalf("空名称应当报错,实际 %v", err)
|
||||
}
|
||||
if _, err := store.CreateCommand(ctx, "空白命令", " "); !errors.Is(err, ErrInvalidCommand) {
|
||||
t.Fatalf("空命令应当报错,实际 %v", err)
|
||||
}
|
||||
if _, err := store.CreateCommand(ctx, strings.Repeat("长", maxCommandNameLength+1), "ls"); err == nil {
|
||||
t.Fatal("超长名称应当报错")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxRecordingBytes 单个录制文件的大小上限:超过就停写,
|
||||
// 避免 `yes`、无限日志之类的输出把 NAS 磁盘写满。
|
||||
MaxRecordingBytes = 32 << 20
|
||||
// backingExt 是录制文件扩展名;内容为 asciinema v2 文本格式。
|
||||
backingExt = ".cast"
|
||||
)
|
||||
|
||||
// sessionIDPattern 限定会话 ID 形态:录制文件按会话 ID 命名,必须挡住路径穿越。
|
||||
var sessionIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{4,64}$`)
|
||||
|
||||
// OutputRecorder 记录一次会话的输出;实现需自行保证并发安全。
|
||||
type OutputRecorder interface {
|
||||
Write(p []byte)
|
||||
Close()
|
||||
}
|
||||
|
||||
// Recording 是一条录制文件的元信息。
|
||||
type Recording struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Size int64 `json:"size"`
|
||||
StartedAt string `json:"started_at"`
|
||||
EndedAt string `json:"ended_at"`
|
||||
}
|
||||
|
||||
// RecordingStore 管理 logs/terminal 下的录制文件:列表、读取与清理。
|
||||
type RecordingStore struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func NewRecordingStore(dir string) *RecordingStore {
|
||||
return &RecordingStore{dir: dir}
|
||||
}
|
||||
|
||||
// Dir 返回录制目录(启动时需要先建好)。
|
||||
func (s *RecordingStore) Dir() string { return s.dir }
|
||||
|
||||
// EnsureDir 建好目录:录制开关打开前也应该能列出空目录。
|
||||
func (s *RecordingStore) EnsureDir() error {
|
||||
return os.MkdirAll(s.dir, 0o755)
|
||||
}
|
||||
|
||||
// Path 返回某个会话的录制文件路径;会话 ID 非法时返回 false,避免路径穿越。
|
||||
func (s *RecordingStore) Path(sessionID string) (string, bool) {
|
||||
if !sessionIDPattern.MatchString(sessionID) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Join(s.dir, sessionID+backingExt), true
|
||||
}
|
||||
|
||||
// List 按录制结束时间倒序返回全部录制;started_at 取自 cast 文件头。
|
||||
func (s *RecordingStore) List() ([]Recording, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Recording{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Recording, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backingExt) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sessionID := strings.TrimSuffix(entry.Name(), backingExt)
|
||||
out = append(out, Recording{
|
||||
SessionID: sessionID,
|
||||
Size: info.Size(),
|
||||
StartedAt: s.readStartedAt(filepath.Join(s.dir, entry.Name())),
|
||||
EndedAt: info.ModTime().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].EndedAt > out[j].EndedAt })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Prune 删除修改时间早于 before 的录制,返回删除条数。
|
||||
func (s *RecordingStore) Prune(before time.Time) (int, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
deleted := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backingExt) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil || !info.ModTime().Before(before) {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(s.dir, entry.Name())); err == nil {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// readStartedAt 读 cast 头里的 timestamp;读不到就用空串。
|
||||
func (s *RecordingStore) readStartedAt(path string) string {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer file.Close()
|
||||
line, err := bufio.NewReader(file).ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
return ""
|
||||
}
|
||||
var header struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(line)), &header); err != nil || header.Timestamp <= 0 {
|
||||
return ""
|
||||
}
|
||||
return time.Unix(header.Timestamp, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// castRecorder 以 asciinema v2 文本格式记录一段会话:
|
||||
// 第一行是头(版本/宽高/起始时间),之后每行是一条 [相对秒数, "o", 输出]。
|
||||
type castRecorder struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
started time.Time
|
||||
written int64
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// NewCastRecorder 建好录制文件并写下 cast 头。
|
||||
func NewCastRecorder(store *RecordingStore, sessionID, title string, cols, rows int, startedAt time.Time) (OutputRecorder, error) {
|
||||
path, ok := store.Path(sessionID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("会话 ID 不合法")
|
||||
}
|
||||
if err := store.EnsureDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recorder := &castRecorder{file: file, writer: bufio.NewWriter(file), started: startedAt}
|
||||
header := map[string]any{
|
||||
"version": 2,
|
||||
"width": cols,
|
||||
"height": rows,
|
||||
"timestamp": startedAt.Unix(),
|
||||
"title": title,
|
||||
"env": map[string]string{"TERM": "xterm-256color", "SHELL": "/bin/sh"},
|
||||
}
|
||||
encoded, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := recorder.writer.Write(append(encoded, '\n')); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, err
|
||||
}
|
||||
return recorder, nil
|
||||
}
|
||||
|
||||
// Write 追加一帧输出;超过大小上限后停止写入,避免把磁盘写满。
|
||||
func (r *castRecorder) Write(p []byte) {
|
||||
if len(p) == 0 {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.stopped || r.file == nil || r.written >= MaxRecordingBytes {
|
||||
return
|
||||
}
|
||||
elapsed := time.Since(r.started).Seconds()
|
||||
chunk := strings.ToValidUTF8(string(p), "\uFFFD")
|
||||
payload, err := json.Marshal(chunk)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line := fmt.Sprintf("[%.3f, \"o\", %s]\n", elapsed, payload)
|
||||
if _, err := r.writer.WriteString(line); err != nil {
|
||||
r.stopped = true
|
||||
return
|
||||
}
|
||||
r.written += int64(len(line))
|
||||
if r.written >= MaxRecordingBytes {
|
||||
r.stopped = true
|
||||
}
|
||||
}
|
||||
|
||||
// Close 落盘并关闭文件;可重复调用。
|
||||
func (r *castRecorder) Close() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.file == nil {
|
||||
return
|
||||
}
|
||||
_ = r.writer.Flush()
|
||||
_ = r.file.Close()
|
||||
r.file = nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeRecorder 收集写进来的输出,用于验证会话是否把输出接到录制器上。
|
||||
type fakeRecorder struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) Write(p []byte) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.buf.Write(p)
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) Close() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.closed = true
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) text() string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.buf.String()
|
||||
}
|
||||
|
||||
func (r *fakeRecorder) isClosed() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.closed
|
||||
}
|
||||
|
||||
// TestCastRecorderAndStore:录制写出合法的 asciinema v2 文件,列表能读到元信息,非法 ID 被拒。
|
||||
func TestCastRecorderAndStore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewRecordingStore(dir)
|
||||
started := time.Now().Add(-3 * time.Second)
|
||||
recorder, err := NewCastRecorder(store, "abcd1234", "服务端 #1", 120, 40, started)
|
||||
if err != nil {
|
||||
t.Fatalf("创建录制失败:%v", err)
|
||||
}
|
||||
recorder.Write([]byte("hello "))
|
||||
// 无效 UTF-8 必须被替换,否则会写出非法 JSON
|
||||
recorder.Write([]byte{0xff, 0xfe})
|
||||
recorder.Write([]byte("world\r\n"))
|
||||
recorder.Close()
|
||||
|
||||
list, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatalf("列表失败:%v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("期望 1 条录制,实际 %d 条", len(list))
|
||||
}
|
||||
if list[0].SessionID != "abcd1234" || list[0].Size == 0 || list[0].StartedAt == "" || list[0].EndedAt == "" {
|
||||
t.Fatalf("录制元信息不完整:%+v", list[0])
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "abcd1234.cast"))
|
||||
if err != nil {
|
||||
t.Fatalf("读取录制失败:%v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("期望 4 行(头 + 3 帧),实际 %d 行", len(lines))
|
||||
}
|
||||
var header struct {
|
||||
Version int `json:"version"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[0]), &header); err != nil || header.Version != 2 {
|
||||
t.Fatalf("cast 头不合法:%s", lines[0])
|
||||
}
|
||||
if header.Width != 120 || header.Height != 40 {
|
||||
t.Fatalf("cast 头宽高不符:%+v", header)
|
||||
}
|
||||
var frame []any
|
||||
if err := json.Unmarshal([]byte(lines[1]), &frame); err != nil {
|
||||
t.Fatalf("帧不是合法 JSON:%s", lines[1])
|
||||
}
|
||||
if len(frame) != 3 || frame[1] != "o" {
|
||||
t.Fatalf("帧结构不符:%+v", frame)
|
||||
}
|
||||
|
||||
// 路径穿越与畸形 ID 必须被拒
|
||||
for _, id := range []string{"../etc/passwd", "ab", "a/b", ""} {
|
||||
if _, ok := store.Path(id); ok {
|
||||
t.Fatalf("非法会话 ID %q 不应通过校验", id)
|
||||
}
|
||||
}
|
||||
|
||||
// 过期录制会被清理
|
||||
stale := filepath.Join(dir, "stale1234.cast")
|
||||
if err := os.WriteFile(stale, []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("准备过期录制失败:%v", err)
|
||||
}
|
||||
past := time.Now().Add(-48 * time.Hour)
|
||||
if err := os.Chtimes(stale, past, past); err != nil {
|
||||
t.Fatalf("调整时间失败:%v", err)
|
||||
}
|
||||
removed, err := store.Prune(time.Now().Add(-24 * time.Hour))
|
||||
if err != nil || removed != 1 {
|
||||
t.Fatalf("期望清理 1 条,实际 %d 条 err=%v", removed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerRecordsOutput:开启录制时输出会流进录制器,会话结束时录制器被关闭。
|
||||
func TestManagerRecordsOutput(t *testing.T) {
|
||||
open, created := openWith()
|
||||
manager := NewManager(testLogger(), open, Options{})
|
||||
recorder := &fakeRecorder{}
|
||||
manager.SetRecorderFactory(func(string, CreateOptions) (OutputRecorder, error) {
|
||||
return recorder, nil
|
||||
})
|
||||
|
||||
opts := testCreateOptions(1)
|
||||
opts.Record = true
|
||||
if _, err := manager.Create(context.Background(), opts); err != nil {
|
||||
t.Fatalf("创建会话失败:%v", err)
|
||||
}
|
||||
if _, err := (*created)[0].writer.Write([]byte("record-me")); err != nil {
|
||||
t.Fatalf("向后端写入失败:%v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && !strings.Contains(recorder.text(), "record-me") {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(recorder.text(), "record-me") {
|
||||
t.Fatalf("输出没有进入录制器:%q", recorder.text())
|
||||
}
|
||||
|
||||
manager.CloseAll("测试结束")
|
||||
if !recorder.isClosed() {
|
||||
t.Fatal("会话结束后录制器应当被关闭")
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerSkipsRecordingWhenOff:未开启录制时不创建录制器。
|
||||
func TestManagerSkipsRecordingWhenOff(t *testing.T) {
|
||||
open, _ := openWith()
|
||||
manager := NewManager(testLogger(), open, Options{})
|
||||
called := false
|
||||
manager.SetRecorderFactory(func(string, CreateOptions) (OutputRecorder, error) {
|
||||
called = true
|
||||
return &fakeRecorder{}, nil
|
||||
})
|
||||
if _, err := manager.Create(context.Background(), testCreateOptions(1)); err != nil {
|
||||
t.Fatalf("创建会话失败:%v", err)
|
||||
}
|
||||
manager.CloseAll("测试结束")
|
||||
if called {
|
||||
t.Fatal("未开启录制时不应创建录制器")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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:]...)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeTerminal 模拟后端终端:写入的内容原样回显,方便验证「输入 → 输出 → 订阅者」链路。
|
||||
type fakeTerminal struct {
|
||||
reader io.ReadCloser
|
||||
writer *io.PipeWriter
|
||||
|
||||
mu sync.Mutex
|
||||
resizes []string
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newFakeTerminal() *fakeTerminal {
|
||||
pr, pw := io.Pipe()
|
||||
return &fakeTerminal{reader: pr, writer: pw}
|
||||
}
|
||||
|
||||
func (f *fakeTerminal) Read(p []byte) (int, error) { return f.reader.Read(p) }
|
||||
|
||||
func (f *fakeTerminal) Write(p []byte) (int, error) { return f.writer.Write(p) }
|
||||
|
||||
func (f *fakeTerminal) Resize(cols, rows int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.resizes = append(f.resizes, fmt.Sprintf("%dx%d", cols, rows))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTerminal) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.closed = true
|
||||
return f.writer.CloseWithError(io.EOF)
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// openWith 返回一个总是成功、返回 fakeTerminal 的 Opener。
|
||||
func openWith(terms ...*fakeTerminal) (Opener, *[]*fakeTerminal) {
|
||||
created := &[]*fakeTerminal{}
|
||||
open := func(_ context.Context, _ int64, _, _ int) (Terminal, string, error) {
|
||||
term := newFakeTerminal()
|
||||
if len(terms) > 0 {
|
||||
term = terms[0]
|
||||
}
|
||||
*created = append(*created, term)
|
||||
return term, "测试服务端 · root@127.0.0.1", nil
|
||||
}
|
||||
return open, created
|
||||
}
|
||||
|
||||
// TestSessionInputEchoesToSubscriber:输入写入后端后,订阅者应收到回显;新订阅者能从回放缓冲补齐。
|
||||
func TestSessionInputEchoesToSubscriber(t *testing.T) {
|
||||
open, _ := openWith()
|
||||
manager := NewManager(testLogger(), open, Options{})
|
||||
session, err := manager.Create(context.Background(), testCreateOptions(1))
|
||||
if err != nil {
|
||||
t.Fatalf("创建会话失败:%v", err)
|
||||
}
|
||||
defer manager.CloseAll("测试结束")
|
||||
|
||||
chunks, cancel := session.Subscribe()
|
||||
defer cancel()
|
||||
if err := session.Input([]byte("hello")); err != nil {
|
||||
t.Fatalf("写入失败:%v", err)
|
||||
}
|
||||
if got := waitForOutput(t, chunks); !strings.Contains(got, "hello") {
|
||||
t.Fatalf("订阅者未收到回显,实际 %q", got)
|
||||
}
|
||||
|
||||
// 第二个订阅者应当先拿到回放缓冲
|
||||
replay, replayCancel := session.Subscribe()
|
||||
defer replayCancel()
|
||||
if got := waitForOutput(t, replay); !strings.Contains(got, "hello") {
|
||||
t.Fatalf("回放缓冲未包含历史输出,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerRejectsExtraSessions:并发上限生效。
|
||||
func TestManagerRejectsExtraSessions(t *testing.T) {
|
||||
open, _ := openWith()
|
||||
manager := NewManager(testLogger(), open, Options{MaxSessions: 1})
|
||||
if _, err := manager.Create(context.Background(), testCreateOptions(1)); err != nil {
|
||||
t.Fatalf("第一个会话应当成功:%v", err)
|
||||
}
|
||||
defer manager.CloseAll("测试结束")
|
||||
_, err := manager.Create(context.Background(), testCreateOptions(2))
|
||||
if !errors.Is(err, ErrTooManySessions) {
|
||||
t.Fatalf("期望 ErrTooManySessions,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSweepClosesIdleSession:空闲超时后会话被回收。
|
||||
func TestSweepClosesIdleSession(t *testing.T) {
|
||||
open, _ := openWith()
|
||||
manager := NewManager(testLogger(), open, Options{IdleTimeout: 10 * time.Millisecond, MaxLifetime: time.Hour})
|
||||
session, err := manager.Create(context.Background(), testCreateOptions(1))
|
||||
if err != nil {
|
||||
t.Fatalf("创建会话失败:%v", err)
|
||||
}
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
manager.sweep()
|
||||
if got := manager.List(); len(got) != 0 {
|
||||
t.Fatalf("空闲会话未被回收:%+v", got)
|
||||
}
|
||||
if snap := session.Snapshot(); !snap.Closed || snap.Reason != "空闲超时" {
|
||||
t.Fatalf("关闭原因不符:%+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisabledManager:未注入 Opener 时视为功能未启用。
|
||||
func TestDisabledManager(t *testing.T) {
|
||||
manager := NewManager(testLogger(), nil, Options{})
|
||||
if manager.Enabled() {
|
||||
t.Fatal("nil Opener 应当是未启用状态")
|
||||
}
|
||||
if _, err := manager.Create(context.Background(), testCreateOptions(1)); !errors.Is(err, ErrDisabled) {
|
||||
t.Fatalf("期望 ErrDisabled,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResizeForwarded:窗口尺寸会转发给后端终端。
|
||||
func TestResizeForwarded(t *testing.T) {
|
||||
term := newFakeTerminal()
|
||||
open, _ := openWith(term)
|
||||
manager := NewManager(testLogger(), open, Options{})
|
||||
session, err := manager.Create(context.Background(), testCreateOptions(1))
|
||||
if err != nil {
|
||||
t.Fatalf("创建会话失败:%v", err)
|
||||
}
|
||||
defer manager.CloseAll("测试结束")
|
||||
if err := session.Resize(120, 40); err != nil {
|
||||
t.Fatalf("resize 失败:%v", err)
|
||||
}
|
||||
term.mu.Lock()
|
||||
defer term.mu.Unlock()
|
||||
if len(term.resizes) != 1 || term.resizes[0] != "120x40" {
|
||||
t.Fatalf("resize 未转发到后端:%v", term.resizes)
|
||||
}
|
||||
}
|
||||
|
||||
// testCreateOptions 是测试用的建会话入参,带上审计需要的调用方身份。
|
||||
func testCreateOptions(serverID int64) CreateOptions {
|
||||
return CreateOptions{ServerID: serverID, Cols: 80, Rows: 24, User: "admin", ClientIP: "127.0.0.1"}
|
||||
}
|
||||
|
||||
// captureAudit 收集审计写入,供断言「谁、何时、连了哪台、多久、为什么断开」。
|
||||
type captureAudit struct {
|
||||
mu sync.Mutex
|
||||
entries []AuditEntry
|
||||
}
|
||||
|
||||
func (c *captureAudit) WriteAudit(_ context.Context, entry AuditEntry) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries = append(c.entries, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureAudit) list() []AuditEntry {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]AuditEntry(nil), c.entries...)
|
||||
}
|
||||
|
||||
// TestCloseWritesAudit:主动关闭时落一条审计,且重复关闭不会写第二条。
|
||||
func TestCloseWritesAudit(t *testing.T) {
|
||||
open, _ := openWith()
|
||||
manager := NewManager(testLogger(), open, Options{})
|
||||
sink := &captureAudit{}
|
||||
manager.SetAuditSink(sink)
|
||||
|
||||
session, err := manager.Create(context.Background(), CreateOptions{
|
||||
ServerID: 7, Cols: 120, Rows: 40, User: "admin", ClientIP: "192.168.1.9",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建会话失败:%v", err)
|
||||
}
|
||||
if err := session.Input([]byte("whoami\n")); err != nil {
|
||||
t.Fatalf("写入失败:%v", err)
|
||||
}
|
||||
manager.Close(session.ID, "用户关闭")
|
||||
|
||||
entries := sink.list()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("期望 1 条审计,实际 %d 条", len(entries))
|
||||
}
|
||||
entry := entries[0]
|
||||
if entry.SessionID != session.ID || entry.ServerID != 7 || entry.Target == "" {
|
||||
t.Fatalf("审计目标字段不符:%+v", entry)
|
||||
}
|
||||
if entry.User != "admin" || entry.ClientIP != "192.168.1.9" {
|
||||
t.Fatalf("审计来源字段不符:%+v", entry)
|
||||
}
|
||||
if entry.Reason != "用户关闭" {
|
||||
t.Fatalf("审计断开原因不符:%+v", entry)
|
||||
}
|
||||
if entry.StartedAt == "" || entry.EndedAt == "" || entry.DurationMS < 0 {
|
||||
t.Fatalf("审计时间字段不符:%+v", entry)
|
||||
}
|
||||
if entry.BytesIn != int64(len("whoami\n")) {
|
||||
t.Fatalf("审计入流量不符:%+v", entry)
|
||||
}
|
||||
|
||||
// 幂等:重复关闭不应写第二条
|
||||
manager.Close(session.ID, "用户关闭")
|
||||
if got := sink.list(); len(got) != 1 {
|
||||
t.Fatalf("重复关闭写入了重复审计:%d 条", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteCloseWritesAudit:远端自行退出时同样落审计,并从会话列表里移除。
|
||||
func TestRemoteCloseWritesAudit(t *testing.T) {
|
||||
term := newFakeTerminal()
|
||||
open, _ := openWith(term)
|
||||
manager := NewManager(testLogger(), open, Options{})
|
||||
sink := &captureAudit{}
|
||||
manager.SetAuditSink(sink)
|
||||
|
||||
if _, err := manager.Create(context.Background(), testCreateOptions(1)); err != nil {
|
||||
t.Fatalf("创建会话失败:%v", err)
|
||||
}
|
||||
// 远端断开:pump 读到 EOF 后应当自行收尾
|
||||
_ = term.Close()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(sink.list()) == 1 && len(manager.List()) == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
entries := sink.list()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("远端断开未写审计:%d 条", len(entries))
|
||||
}
|
||||
if entries[0].Reason != "会话已结束" {
|
||||
t.Fatalf("远端断开原因不符:%+v", entries[0])
|
||||
}
|
||||
if got := manager.List(); len(got) != 0 {
|
||||
t.Fatalf("已结束的会话仍留在列表:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForOutput 读取一帧输出,2 秒内没到就返回空串。
|
||||
func waitForOutput(t *testing.T, chunks <-chan []byte) string {
|
||||
t.Helper()
|
||||
select {
|
||||
case chunk, ok := <-chunks:
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return string(chunk)
|
||||
case <-time.After(2 * time.Second):
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// sshTerminal 把 x/crypto/ssh 的交互式会话包装成 Terminal。
|
||||
// 申请了 PTY 之后,远端 shell 的 stdout/stderr 会合并到同一条输出流,无需分别读取。
|
||||
type sshTerminal struct {
|
||||
client *ssh.Client
|
||||
session *ssh.Session
|
||||
stdin io.WriteCloser
|
||||
stdout io.Reader
|
||||
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// NewSSHTerminal 在已认证的 SSH 连接上申请 PTY 并启动登录 shell。
|
||||
func NewSSHTerminal(client *ssh.Client, cols, rows int) (Terminal, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建 SSH 会话失败: %w", err)
|
||||
}
|
||||
modes := ssh.TerminalModes{ssh.ECHO: 1, ssh.TTY_OP_ISPEED: 14400, ssh.TTY_OP_OSPEED: 14400}
|
||||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||
_ = session.Close()
|
||||
return nil, fmt.Errorf("申请 PTY 失败: %w", err)
|
||||
}
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
_ = session.Close()
|
||||
return nil, fmt.Errorf("打开 SSH 输入流失败: %w", err)
|
||||
}
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = session.Close()
|
||||
return nil, fmt.Errorf("打开 SSH 输出流失败: %w", err)
|
||||
}
|
||||
if err := session.Shell(); err != nil {
|
||||
_ = session.Close()
|
||||
return nil, fmt.Errorf("启动远端 shell 失败: %w", err)
|
||||
}
|
||||
return &sshTerminal{client: client, session: session, stdin: stdin, stdout: stdout}, nil
|
||||
}
|
||||
|
||||
// Read 读取远端输出。
|
||||
func (t *sshTerminal) Read(p []byte) (int, error) { return t.stdout.Read(p) }
|
||||
|
||||
// Write 把按键写入远端 PTY。
|
||||
func (t *sshTerminal) Write(p []byte) (int, error) { return t.stdin.Write(p) }
|
||||
|
||||
// Resize 通知远端窗口尺寸变化。
|
||||
func (t *sshTerminal) Resize(cols, rows int) error {
|
||||
if cols <= 0 || rows <= 0 {
|
||||
return nil
|
||||
}
|
||||
return t.session.WindowChange(rows, cols)
|
||||
}
|
||||
|
||||
// Close 关闭 SSH 会话与底层连接;重复调用安全。
|
||||
func (t *sshTerminal) Close() error {
|
||||
t.closeOnce.Do(func() {
|
||||
_ = t.session.Close()
|
||||
t.closeErr = t.client.Close()
|
||||
})
|
||||
return t.closeErr
|
||||
}
|
||||
Reference in New Issue
Block a user