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