CI / verify (push) Canceled after 0s
- 内置 SSH 终端(/terminal):复用已保存的 SSH 密码/私钥与独立 known_hosts 校验 - 终端二次认证、会话审计(/terminal/audits)、录像审计与常用命令 - DDNS 服务商名称与自定义 IP,新增迁移 037/038/039 - CHANGELOG 与 VERSION 更新至 2.0.0
224 lines
5.8 KiB
Go
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
|
|
}
|