- 内置 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,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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user