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
+271
View File
@@ -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 ""
}
}