Files
Havline/internal/terminal/recording.go
T
mrlee 302248a907
CI / verify (push) Canceled after 0s
chore(release): 发布 v2.0.0
- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验
- 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令
- DDNS 服务商名称与自定义 IP,新增迁移 037/038/039
- CHANGELOG 与 VERSION 更新至 2.0.0
2026-10-16 20:18:04 +08:00

224 lines
5.8 KiB
Go

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
}