Security Fixes

This commit is contained in:
Andy Dixon
2026-06-16 09:41:51 +01:00
parent 9bbd9ceccf
commit fcb19c96c8
22 changed files with 1578 additions and 134 deletions
+199 -19
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
@@ -43,8 +44,10 @@ var appVersion = "dev"
var appUpdateURL = ""
const (
maxProfileImportJSONBytes = 5 * 1024 * 1024
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
maxProfileImportJSONBytes = 5 * 1024 * 1024
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
maxSFTPUploadChunkBytes = 1024 * 1024
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
)
// Service is the main API surface exposed to the frontend via Wails bindings.
@@ -60,6 +63,17 @@ type Service struct {
// secretChans maps request IDs to channels awaiting user secret input.
secretMu sync.Mutex
secretChans map[string]chan secretResponse
uploadMu sync.Mutex
uploads map[string]*sftpUpload
}
type sftpUpload struct {
mu sync.Mutex
writer io.WriteCloser
sessionID string
remotePath string
written int64
}
// NewService creates a Service wired to the given backing components.
@@ -78,6 +92,7 @@ func NewService(
sessions: sess,
auditLog: auditLog,
prefs: prefs,
uploads: make(map[string]*sftpUpload),
}
}
@@ -1088,6 +1103,39 @@ func (s *Service) DeleteRecording(id string) error {
return s.sessions.DeleteRecording(id)
}
// GetRecordingStorageInfo returns aggregate recording storage usage and limits.
func (s *Service) GetRecordingStorageInfo() (map[string]interface{}, error) {
info, err := s.sessions.RecordingStorageInfo()
if err != nil {
return nil, err
}
result := map[string]interface{}{
"count": info.Count,
"totalBytes": info.TotalBytes,
"maxRecordingBytes": info.MaxRecordingBytes,
"maxPlaybackBytes": info.MaxPlaybackBytes,
}
if info.OldestCreatedAt != nil {
result["oldestCreatedAt"] = info.OldestCreatedAt.Format(time.RFC3339)
}
if info.NewestCreatedAt != nil {
result["newestCreatedAt"] = info.NewestCreatedAt.Format(time.RFC3339)
}
return result, nil
}
// CleanupRecordings removes recordings by age and total storage limit.
func (s *Service) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (map[string]interface{}, error) {
result, err := s.sessions.CleanupRecordings(maxAgeDays, maxTotalBytes)
if err != nil {
return nil, err
}
return map[string]interface{}{
"deleted": result.Deleted,
"freedBytes": result.FreedBytes,
}, nil
}
// ---------------------------------------------------------------------------
// Encrypted export / import
// ---------------------------------------------------------------------------
@@ -1349,15 +1397,120 @@ func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName st
return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes)
}
safeName := filepath.Base(fileName)
if safeName == "" || safeName == "." || safeName == "/" {
return fmt.Errorf("invalid file name")
safeName, err := safeSFTPUploadName(fileName)
if err != nil {
return err
}
destPath := path.Join(remoteDir, safeName)
return s.sessions.SFTPUploadBytes(sessionID, destPath, data)
}
// SFTPUploadStart opens a remote SFTP file for chunked browser uploads.
func (s *Service) SFTPUploadStart(sessionID string, remoteDir string, fileName string, totalBytes int64) (string, error) {
if s.sessions == nil {
return "", fmt.Errorf("sessions service is not configured")
}
if totalBytes < 0 {
return "", fmt.Errorf("upload size must not be negative")
}
if totalBytes > maxStreamingSFTPUploadSize {
return "", fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
}
safeName, err := safeSFTPUploadName(fileName)
if err != nil {
return "", err
}
remotePath := path.Join(remoteDir, safeName)
writer, err := s.sessions.SFTPOpenUpload(sessionID, remotePath)
if err != nil {
return "", err
}
uploadID := uuid.New().String()
s.uploadMu.Lock()
s.uploads[uploadID] = &sftpUpload{
writer: writer,
sessionID: sessionID,
remotePath: remotePath,
}
s.uploadMu.Unlock()
return uploadID, nil
}
// SFTPUploadChunk writes a base64-encoded chunk to an active browser upload.
func (s *Service) SFTPUploadChunk(uploadID string, base64Chunk string) error {
if base64Chunk == "" {
return fmt.Errorf("empty upload chunk")
}
if base64.StdEncoding.DecodedLen(len(base64Chunk)) > maxSFTPUploadChunkBytes {
return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes)
}
data, err := base64.StdEncoding.DecodeString(base64Chunk)
if err != nil {
return fmt.Errorf("failed to decode upload chunk: %w", err)
}
if len(data) > maxSFTPUploadChunkBytes {
return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes)
}
upload, err := s.lookupSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
defer upload.mu.Unlock()
if upload.written+int64(len(data)) > maxStreamingSFTPUploadSize {
return fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
}
n, err := upload.writer.Write(data)
upload.written += int64(n)
if err != nil {
s.removeSFTPUpload(uploadID)
_ = upload.writer.Close()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return fmt.Errorf("failed to write upload chunk: %w", err)
}
if n != len(data) {
s.removeSFTPUpload(uploadID)
_ = upload.writer.Close()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return io.ErrShortWrite
}
return nil
}
// SFTPUploadFinish closes an active browser upload.
func (s *Service) SFTPUploadFinish(uploadID string) error {
upload, err := s.takeSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
defer upload.mu.Unlock()
return upload.writer.Close()
}
// SFTPUploadCancel closes and removes a partially uploaded remote file.
func (s *Service) SFTPUploadCancel(uploadID string) error {
upload, err := s.takeSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
closeErr := upload.writer.Close()
upload.mu.Unlock()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return closeErr
}
// SFTPPwd returns the current working directory on the remote host.
func (s *Service) SFTPPwd(sessionID string) (string, error) {
return s.sessions.SFTPPwd(sessionID)
@@ -1751,6 +1904,46 @@ func validateSSHKeyName(name string) (string, error) {
return name, nil
}
func safeSFTPUploadName(fileName string) (string, error) {
name := strings.TrimSpace(fileName)
if name == "" {
return "", fmt.Errorf("invalid file name")
}
name = strings.ReplaceAll(name, "\\", "/")
safeName := path.Base(name)
if safeName == "" || safeName == "." || safeName == "/" || strings.ContainsRune(safeName, 0) {
return "", fmt.Errorf("invalid file name")
}
return safeName, nil
}
func (s *Service) lookupSFTPUpload(uploadID string) (*sftpUpload, error) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
upload := s.uploads[uploadID]
if upload == nil {
return nil, fmt.Errorf("upload session not found")
}
return upload, nil
}
func (s *Service) takeSFTPUpload(uploadID string) (*sftpUpload, error) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
upload := s.uploads[uploadID]
if upload == nil {
return nil, fmt.Errorf("upload session not found")
}
delete(s.uploads, uploadID)
return upload, nil
}
func (s *Service) removeSFTPUpload(uploadID string) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
delete(s.uploads, uploadID)
}
func validateImportStrategy(strategy string) error {
switch strategy {
case "skip", "overwrite", "duplicate":
@@ -1774,20 +1967,7 @@ func validateImportProfiles(profiles []*models.Profile) error {
}
func normaliseProfileForSave(profile *models.Profile) {
if profile == nil {
return
}
if profile.Tags == nil {
profile.Tags = []string{}
}
if profile.VNC != nil {
if profile.VNC.SecretMode == "prompt" {
profile.VNC.SecretMode = models.SecretModeAskEveryTime
}
if profile.VNC.SSHSecretMode == "prompt" {
profile.VNC.SSHSecretMode = models.SecretModeAskEveryTime
}
}
models.NormalizeProfileDefaults(profile)
}
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
+76
View File
@@ -113,6 +113,82 @@ func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T)
}
}
func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxSFTPUploadChunkBytes+1))
err := svc.SFTPUploadChunk("missing-upload", payload)
if err == nil {
t.Fatal("expected oversized upload chunk to be rejected")
}
if !strings.Contains(err.Error(), "chunk is too large") {
t.Fatalf("error = %q, want chunk is too large", err.Error())
}
}
func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
got, err := safeSFTPUploadName(`../report <final>.txt`)
if err != nil {
t.Fatalf("safeSFTPUploadName() error = %v", err)
}
if got != "report <final>.txt" {
t.Fatalf("safe name = %q, want HTML characters preserved in base name", got)
}
}
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
svc := newTestService(t)
data := `[
{
"displayName": "Raw Device",
"protocol": "raw",
"host": "device.local",
"raw": {}
}
]`
count, err := svc.ImportProfiles(data, "skip")
if err != nil {
t.Fatalf("ImportProfiles() error = %v", err)
}
if count != 1 {
t.Fatalf("imported = %d, want 1", count)
}
profiles, err := svc.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles() error = %v", err)
}
if len(profiles) != 1 {
t.Fatalf("profiles = %d, want 1", len(profiles))
}
if profiles[0].Port != models.DefaultRAWPort {
t.Fatalf("port = %d, want %d", profiles[0].Port, models.DefaultRAWPort)
}
if profiles[0].RAW.Encoding != "utf-8" {
t.Fatalf("raw encoding = %q, want utf-8", profiles[0].RAW.Encoding)
}
}
func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolVNC)
profile.DisplayName = "VNC"
profile.Host = "vnc.local"
profile.VNC.SecretMode = "prompt"
profile.VNC.SSHSecretMode = "prompt"
got, err := svc.CreateProfile(profile)
if err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
if got.VNC.SecretMode != models.SecretModeAskEveryTime {
t.Fatalf("secret mode = %q, want %q", got.VNC.SecretMode, models.SecretModeAskEveryTime)
}
if got.VNC.SSHSecretMode != models.SecretModeAskEveryTime {
t.Fatalf("ssh secret mode = %q, want %q", got.VNC.SSHSecretMode, models.SecretModeAskEveryTime)
}
}
func newTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))
+214
View File
@@ -126,3 +126,217 @@ func NewProfile(protocol Protocol) Profile {
return p
}
// NormalizeProfileDefaults fills missing default values on profiles loaded
// from older exports, third-party imports, or sparse frontend payloads.
func NormalizeProfileDefaults(p *Profile) {
if p == nil {
return
}
if p.Protocol == "" {
p.Protocol = ProtocolSSH
}
defaults := NewProfile(p.Protocol)
if p.ID == "" {
p.ID = defaults.ID
}
if p.Tags == nil {
p.Tags = []string{}
}
if p.ConnectTimeout == 0 {
p.ConnectTimeout = DefaultConnectTimeout
}
if p.TerminalFontFamily == "" {
p.TerminalFontFamily = DefaultFontFamily
}
if p.TerminalFontSize == 0 {
p.TerminalFontSize = DefaultFontSize
}
if p.TerminalScrollback == 0 {
p.TerminalScrollback = DefaultScrollback
}
if p.TerminalCursorStyle == "" {
p.TerminalCursorStyle = DefaultCursorStyle
}
if p.CreatedAt.IsZero() {
p.CreatedAt = defaults.CreatedAt
}
if p.UpdatedAt.IsZero() {
p.UpdatedAt = defaults.UpdatedAt
}
if p.Port == 0 {
p.Port = defaults.Port
}
if p.DisplayName == "" {
p.DisplayName = defaultProfileName(p)
}
switch p.Protocol {
case ProtocolSSH:
normalizeSSHConfig(p, defaults.SSH)
case ProtocolTelnet:
normalizeTelnetConfig(p, defaults.Telnet)
case ProtocolRAW:
normalizeRAWConfig(p, defaults.RAW)
case ProtocolGitHub:
if p.Host == "" {
p.Host = defaults.Host
}
normalizeGitHubConfig(p, defaults.GitHub)
case ProtocolVNC:
normalizeVNCConfig(p, defaults.VNC)
}
}
func defaultProfileName(p *Profile) string {
if p.Host != "" {
return p.Host
}
if p.VNC != nil && p.VNC.VNCHost != "" {
return p.VNC.VNCHost
}
switch p.Protocol {
case ProtocolGitHub:
return "GitHub"
case ProtocolVNC:
return "VNC"
case ProtocolRAW:
return "RAW TCP"
case ProtocolTelnet:
return "Telnet"
default:
return "SSH"
}
}
func normalizeSecretMode(mode SecretMode, fallback SecretMode) SecretMode {
switch mode {
case SecretModeSaved, SecretModeSessionOnly, SecretModeAskEveryTime:
return mode
case "prompt":
return SecretModeAskEveryTime
case "":
return fallback
default:
return mode
}
}
func normalizeSSHConfig(p *Profile, defaults *SSHConfig) {
if p.SSH == nil {
cfg := *defaults
p.SSH = &cfg
return
}
cfg := p.SSH
if len(cfg.AuthMethods) == 0 {
cfg.AuthMethods = append([]string{}, defaults.AuthMethods...)
}
cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode)
cfg.KeySecretMode = normalizeSecretMode(cfg.KeySecretMode, defaults.KeySecretMode)
if cfg.HostKeyPolicy == "" {
cfg.HostKeyPolicy = defaults.HostKeyPolicy
}
if cfg.TerminalType == "" {
cfg.TerminalType = defaults.TerminalType
}
if cfg.KeepaliveInterval == 0 {
cfg.KeepaliveInterval = defaults.KeepaliveInterval
}
if cfg.KeepaliveCountMax == 0 {
cfg.KeepaliveCountMax = defaults.KeepaliveCountMax
}
if cfg.JumpHosts == nil {
cfg.JumpHosts = []JumpHost{}
}
if cfg.PortForwards == nil {
cfg.PortForwards = []PortForward{}
}
if cfg.EnvVars == nil {
cfg.EnvVars = map[string]string{}
}
}
func normalizeTelnetConfig(p *Profile, defaults *TelnetConfig) {
if p.Telnet == nil {
cfg := *defaults
p.Telnet = &cfg
return
}
cfg := p.Telnet
if cfg.LineEnding == "" {
cfg.LineEnding = defaults.LineEnding
}
if cfg.TerminalType == "" {
cfg.TerminalType = defaults.TerminalType
}
if cfg.Encoding == "" {
cfg.Encoding = defaults.Encoding
}
}
func normalizeRAWConfig(p *Profile, defaults *RAWConfig) {
if p.RAW == nil {
cfg := *defaults
p.RAW = &cfg
return
}
cfg := p.RAW
if cfg.LineEnding == "" {
cfg.LineEnding = defaults.LineEnding
}
if cfg.Encoding == "" {
cfg.Encoding = defaults.Encoding
}
if cfg.PayloadPresets == nil {
cfg.PayloadPresets = []PayloadPreset{}
}
for i := range cfg.PayloadPresets {
if cfg.PayloadPresets[i].ID == "" {
cfg.PayloadPresets[i].ID = uuid.New().String()
}
if cfg.PayloadPresets[i].Format == "" {
cfg.PayloadPresets[i].Format = "text"
}
}
}
func normalizeGitHubConfig(p *Profile, defaults *GitHubConfig) {
if p.GitHub == nil {
cfg := *defaults
p.GitHub = &cfg
return
}
cfg := p.GitHub
if cfg.BaseURL == "" {
cfg.BaseURL = defaults.BaseURL
}
cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode)
if cfg.RefreshIntervalSecs == 0 {
cfg.RefreshIntervalSecs = defaults.RefreshIntervalSecs
}
}
func normalizeVNCConfig(p *Profile, defaults *VNCConfig) {
if p.VNC == nil {
cfg := *defaults
p.VNC = &cfg
return
}
cfg := p.VNC
if cfg.VNCPort == 0 {
cfg.VNCPort = defaults.VNCPort
}
cfg.SecretMode = normalizeSecretMode(cfg.SecretMode, defaults.SecretMode)
if cfg.Quality < 0 {
cfg.Quality = defaults.Quality
}
if cfg.SSHPort == 0 {
cfg.SSHPort = defaults.SSHPort
}
if cfg.SSHAuthMethod == "" {
cfg.SSHAuthMethod = defaults.SSHAuthMethod
}
cfg.SSHSecretMode = normalizeSecretMode(cfg.SSHSecretMode, defaults.SSHSecretMode)
}
+51
View File
@@ -0,0 +1,51 @@
package rawclient
import (
"bytes"
"testing"
"github.com/andydixon/nexus/internal/models"
)
func TestHistoryIsCappedByEntryCount(t *testing.T) {
client := NewClient(&models.Profile{})
for i := 0; i < maxHistoryEntries+25; i++ {
client.appendHistory(HistoryEntry{Direction: "recv", Data: []byte{byte(i)}})
}
history := client.GetHistory()
if len(history) != maxHistoryEntries {
t.Fatalf("history length = %d, want %d", len(history), maxHistoryEntries)
}
}
func TestHistoryIsCappedByBytes(t *testing.T) {
client := NewClient(&models.Profile{})
chunk := bytes.Repeat([]byte("a"), 1024)
for i := 0; i < (maxHistoryBytes/len(chunk))+10; i++ {
client.appendHistory(HistoryEntry{Direction: "recv", Data: chunk})
}
total := 0
for _, entry := range client.GetHistory() {
total += len(entry.Data)
}
if total > maxHistoryBytes {
t.Fatalf("history bytes = %d, want <= %d", total, maxHistoryBytes)
}
}
func TestGetHistoryReturnsDeepCopy(t *testing.T) {
client := NewClient(&models.Profile{})
client.appendHistory(HistoryEntry{Direction: "recv", Data: []byte("secret")})
history := client.GetHistory()
history[0].Data[0] = 'X'
again := client.GetHistory()
if string(again[0].Data) != "secret" {
t.Fatalf("history was mutated through returned slice: %q", string(again[0].Data))
}
}
+36 -12
View File
@@ -13,21 +13,27 @@ import (
"github.com/andydixon/nexus/internal/models"
)
const (
maxHistoryEntries = 10000
maxHistoryBytes = 10 * 1024 * 1024
)
// Client manages a raw TCP connection.
// It provides binary-safe data handling with support for text and hex views,
// timestamps, direction markers, and payload presets.
type Client struct {
mu sync.Mutex
config *models.Profile
conn net.Conn
connected bool
onData func(data []byte, direction string, timestamp time.Time)
onError func(err error)
onClose func()
ctx context.Context
cancel context.CancelFunc
history []HistoryEntry
historyMu sync.RWMutex
mu sync.Mutex
config *models.Profile
conn net.Conn
connected bool
onData func(data []byte, direction string, timestamp time.Time)
onError func(err error)
onClose func()
ctx context.Context
cancel context.CancelFunc
history []HistoryEntry
historyBytes int
historyMu sync.RWMutex
}
// HistoryEntry records a single sent or received data frame with its timestamp.
@@ -193,7 +199,10 @@ func (c *Client) GetHistory() []HistoryEntry {
defer c.historyMu.RUnlock()
out := make([]HistoryEntry, len(c.history))
copy(out, c.history)
for i, entry := range c.history {
out[i] = entry
out[i].Data = copyBytes(entry.Data)
}
return out
}
@@ -202,6 +211,7 @@ func (c *Client) ClearHistory() {
c.historyMu.Lock()
defer c.historyMu.Unlock()
c.history = nil
c.historyBytes = 0
}
// SaveCapture writes the session history to a file in JSON format.
@@ -271,7 +281,21 @@ func (c *Client) readLoop() {
func (c *Client) appendHistory(entry HistoryEntry) {
c.historyMu.Lock()
defer c.historyMu.Unlock()
if len(entry.Data) > maxHistoryBytes {
entry.Data = copyBytes(entry.Data[len(entry.Data)-maxHistoryBytes:])
}
c.history = append(c.history, entry)
c.historyBytes += len(entry.Data)
for len(c.history) > maxHistoryEntries || c.historyBytes > maxHistoryBytes {
if len(c.history) == 0 {
c.historyBytes = 0
return
}
c.historyBytes -= len(c.history[0].Data)
c.history[0].Data = nil
c.history = c.history[1:]
}
}
// applyLineEnding appends the configured line ending to text.
+181 -22
View File
@@ -9,6 +9,7 @@
package recording
import (
"bytes"
"encoding/json"
"fmt"
"os"
@@ -27,6 +28,11 @@ const (
EventOutput EventType = "o"
// EventInput represents data sent from the user to the remote host.
EventInput EventType = "i"
// MaxRecordingBytes bounds a single active recording file.
MaxRecordingBytes = int64(250 * 1024 * 1024)
// MaxPlaybackBytes bounds the file size loaded for playback.
MaxPlaybackBytes = int64(100 * 1024 * 1024)
)
// Header is the asciicast v2 file header (first line).
@@ -66,18 +72,23 @@ type RecordingMeta struct {
// Recorder captures terminal I/O for a single session.
type Recorder struct {
mu sync.Mutex
file *os.File
encoder *json.Encoder
startTime time.Time
header Header
eventCount int
closed bool
filePath string
mu sync.Mutex
file *os.File
startTime time.Time
header Header
eventCount int
closed bool
filePath string
bytesWritten int64
maxBytes int64
}
// NewRecorder creates a new recording file and writes the header.
func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) {
return newRecorderWithLimit(dir, id, title, cols, rows, MaxRecordingBytes)
}
func newRecorderWithLimit(dir, id, title string, cols, rows int, maxBytes int64) (*Recorder, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("recording: failed to create directory: %w", err)
}
@@ -102,20 +113,26 @@ func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) {
},
}
enc := json.NewEncoder(f)
enc.SetEscapeHTML(false)
if err := enc.Encode(hdr); err != nil {
headerLine, err := marshalJSONLine(hdr)
if err != nil {
f.Close()
os.Remove(path)
return nil, fmt.Errorf("recording: failed to encode header: %w", err)
}
n, err := f.Write(headerLine)
if err != nil {
f.Close()
os.Remove(path)
return nil, fmt.Errorf("recording: failed to write header: %w", err)
}
return &Recorder{
file: f,
encoder: enc,
startTime: now,
header: hdr,
filePath: path,
file: f,
startTime: now,
header: hdr,
filePath: path,
bytesWritten: int64(n),
maxBytes: maxBytes,
}, nil
}
@@ -145,11 +162,23 @@ func (r *Recorder) writeEvent(eventType EventType, data []byte) {
Data: string(data),
}
// Write as a JSON array line: [elapsed, "o"/"i", "data"]
if err := r.encoder.Encode(evt); err != nil {
// Non-fatal — continue recording even if one event fails.
line, err := marshalJSONLine(evt)
if err != nil {
return
}
if r.maxBytes > 0 && r.bytesWritten+int64(len(line)) > r.maxBytes {
r.closed = true
_ = r.file.Sync()
_ = r.file.Close()
r.file = nil
return
}
n, err := r.file.Write(line)
if err != nil {
return
}
r.bytesWritten += int64(n)
r.eventCount++
}
@@ -175,7 +204,9 @@ func (r *Recorder) Close() error {
r.file.Close()
return fmt.Errorf("recording: sync failed: %w", err)
}
return r.file.Close()
err := r.file.Close()
r.file = nil
return err
}
return nil
}
@@ -252,7 +283,21 @@ func (s *Store) List() ([]RecordingMeta, error) {
// Get reads the full recording (header + events) from a file.
func (s *Store) Get(id string) (*Header, []Event, error) {
path := filepath.Join(s.dir, id+".cast")
return s.getWithLimit(id, MaxPlaybackBytes)
}
func (s *Store) getWithLimit(id string, maxBytes int64) (*Header, []Event, error) {
path, err := s.recordingPath(id)
if err != nil {
return nil, nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, nil, fmt.Errorf("recording: failed to stat file: %w", err)
}
if maxBytes > 0 && info.Size() > maxBytes {
return nil, nil, fmt.Errorf("recording: file is too large for playback (max %d bytes)", maxBytes)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, nil, fmt.Errorf("recording: failed to read file: %w", err)
@@ -308,13 +353,127 @@ func (s *Store) Get(id string) (*Header, []Event, error) {
// Delete removes a recording file.
func (s *Store) Delete(id string) error {
path := filepath.Join(s.dir, id+".cast")
path, err := s.recordingPath(id)
if err != nil {
return err
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("recording: failed to delete: %w", err)
}
return nil
}
// StorageInfo summarizes recording storage usage.
type StorageInfo struct {
Count int `json:"count"`
TotalBytes int64 `json:"totalBytes"`
OldestCreatedAt *time.Time `json:"oldestCreatedAt,omitempty"`
NewestCreatedAt *time.Time `json:"newestCreatedAt,omitempty"`
MaxRecordingBytes int64 `json:"maxRecordingBytes"`
MaxPlaybackBytes int64 `json:"maxPlaybackBytes"`
}
// CleanupResult describes files removed by retention cleanup.
type CleanupResult struct {
Deleted int `json:"deleted"`
FreedBytes int64 `json:"freedBytes"`
}
// StorageInfo returns aggregate metadata for recordings.
func (s *Store) StorageInfo() (StorageInfo, error) {
metas, err := s.List()
if err != nil {
return StorageInfo{}, err
}
info := StorageInfo{
Count: len(metas),
MaxRecordingBytes: MaxRecordingBytes,
MaxPlaybackBytes: MaxPlaybackBytes,
}
for i := range metas {
meta := metas[i]
info.TotalBytes += meta.Size
if info.OldestCreatedAt == nil || meta.CreatedAt.Before(*info.OldestCreatedAt) {
t := meta.CreatedAt
info.OldestCreatedAt = &t
}
if info.NewestCreatedAt == nil || meta.CreatedAt.After(*info.NewestCreatedAt) {
t := meta.CreatedAt
info.NewestCreatedAt = &t
}
}
return info, nil
}
// Cleanup removes recordings older than maxAgeDays and/or enough oldest files
// to bring total storage below maxTotalBytes. Non-positive limits are ignored.
func (s *Store) Cleanup(maxAgeDays int, maxTotalBytes int64) (CleanupResult, error) {
metas, err := s.List()
if err != nil {
return CleanupResult{}, err
}
result := CleanupResult{}
deleted := map[string]bool{}
total := int64(0)
for _, meta := range metas {
total += meta.Size
}
deleteMeta := func(meta RecordingMeta) {
if deleted[meta.ID] {
return
}
if err := s.Delete(meta.ID); err == nil {
deleted[meta.ID] = true
result.Deleted++
result.FreedBytes += meta.Size
total -= meta.Size
}
}
if maxAgeDays > 0 {
cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
for _, meta := range metas {
if meta.CreatedAt.Before(cutoff) {
deleteMeta(meta)
}
}
}
if maxTotalBytes > 0 && total > maxTotalBytes {
sort.Slice(metas, func(i, j int) bool {
return metas[i].CreatedAt.Before(metas[j].CreatedAt)
})
for _, meta := range metas {
if total <= maxTotalBytes {
break
}
deleteMeta(meta)
}
}
return result, nil
}
func (s *Store) recordingPath(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\`) {
return "", fmt.Errorf("recording: invalid recording ID")
}
return filepath.Join(s.dir, id+".cast"), nil
}
func marshalJSONLine(v interface{}) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// readMeta reads just the header from a .cast file to produce metadata.
func (s *Store) readMeta(filename string) (*RecordingMeta, error) {
path := filepath.Join(s.dir, filename)
+118
View File
@@ -163,3 +163,121 @@ func TestDoubleClose(t *testing.T) {
t.Fatalf("second Close failed: %v", err)
}
}
func TestRecorderStopsAtSizeLimit(t *testing.T) {
dir := t.TempDir()
rec, err := newRecorderWithLimit(dir, "limited", "Limited", 80, 24, 512)
if err != nil {
t.Fatalf("newRecorderWithLimit failed: %v", err)
}
for i := 0; i < 50; i++ {
rec.WriteOutput([]byte(strings.Repeat("x", 80)))
}
if rec.EventCount() >= 50 {
t.Fatalf("expected recording to stop before all events were written")
}
if err := rec.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
info, err := os.Stat(filepath.Join(dir, "limited.cast"))
if err != nil {
t.Fatalf("stat failed: %v", err)
}
if info.Size() > 512 {
t.Fatalf("recording size = %d, want <= 512", info.Size())
}
}
func TestStoreGetRejectsOversizedPlayback(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "huge.cast")
header := `{"version":2,"width":80,"height":24,"timestamp":1}` + "\n"
if err := os.WriteFile(path, []byte(header+strings.Repeat("x", 256)), 0600); err != nil {
t.Fatalf("write failed: %v", err)
}
store := NewStore(dir)
_, _, err := store.getWithLimit("huge", 128)
if err == nil {
t.Fatal("expected oversized playback to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("error = %q, want too large", err.Error())
}
}
func TestStoreCleanupByAgeAndSize(t *testing.T) {
dir := t.TempDir()
makeRecording := func(id string, created time.Time, payload string) {
rec, err := NewRecorder(dir, id, id, 80, 24)
if err != nil {
t.Fatalf("NewRecorder(%s) failed: %v", id, err)
}
rec.WriteOutput([]byte(payload))
if err := rec.Close(); err != nil {
t.Fatalf("Close(%s) failed: %v", id, err)
}
path := filepath.Join(dir, id+".cast")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s) failed: %v", id, err)
}
parts := strings.SplitN(string(data), "\n", 2)
var hdr Header
if err := json.Unmarshal([]byte(parts[0]), &hdr); err != nil {
t.Fatalf("header parse(%s) failed: %v", id, err)
}
hdr.Timestamp = created.Unix()
line, err := marshalJSONLine(hdr)
if err != nil {
t.Fatalf("header encode(%s) failed: %v", id, err)
}
rest := ""
if len(parts) == 2 {
rest = parts[1]
}
if err := os.WriteFile(path, append(line, []byte(rest)...), 0600); err != nil {
t.Fatalf("WriteFile(%s) failed: %v", id, err)
}
if err := os.Chtimes(path, created, created); err != nil {
t.Fatalf("Chtimes(%s) failed: %v", id, err)
}
}
old := time.Now().Add(-45 * 24 * time.Hour)
recent := time.Now().Add(-2 * time.Hour)
makeRecording("old", old, "old")
makeRecording("recent", recent, strings.Repeat("recent", 50))
store := NewStore(dir)
result, err := store.Cleanup(30, 0)
if err != nil {
t.Fatalf("Cleanup by age failed: %v", err)
}
if result.Deleted != 1 {
t.Fatalf("deleted = %d, want 1", result.Deleted)
}
if _, err := os.Stat(filepath.Join(dir, "old.cast")); !os.IsNotExist(err) {
t.Fatalf("old recording still exists, stat err=%v", err)
}
info, err := store.StorageInfo()
if err != nil {
t.Fatalf("StorageInfo failed: %v", err)
}
result, err = store.Cleanup(0, info.TotalBytes-1)
if err != nil {
t.Fatalf("Cleanup by size failed: %v", err)
}
if result.Deleted != 1 {
t.Fatalf("deleted = %d, want 1", result.Deleted)
}
}
func TestStoreRejectsTraversalRecordingID(t *testing.T) {
store := NewStore(t.TempDir())
if err := store.Delete("../outside"); err == nil {
t.Fatal("expected traversal recording ID to be rejected")
}
}
+26
View File
@@ -7,6 +7,7 @@ import (
"context"
"encoding/base64"
"fmt"
"io"
"net"
"os"
"strings"
@@ -1198,6 +1199,22 @@ func (m *Manager) DeleteRecording(id string) error {
return m.recordStore.Delete(id)
}
// RecordingStorageInfo returns aggregate recording storage usage.
func (m *Manager) RecordingStorageInfo() (recording.StorageInfo, error) {
if m.recordStore == nil {
return recording.StorageInfo{}, fmt.Errorf("sessions: recording store not configured")
}
return m.recordStore.StorageInfo()
}
// CleanupRecordings applies age and total-size retention limits.
func (m *Manager) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (recording.CleanupResult, error) {
if m.recordStore == nil {
return recording.CleanupResult{}, fmt.Errorf("sessions: recording store not configured")
}
return m.recordStore.Cleanup(maxAgeDays, maxTotalBytes)
}
// ---------------------------------------------------------------------------
// SFTP file browser
// ---------------------------------------------------------------------------
@@ -1340,6 +1357,15 @@ func (m *Manager) SFTPUploadBytes(sessionID, remotePath string, data []byte) err
})
}
// SFTPOpenUpload opens a remote file for a caller-managed streaming upload.
func (m *Manager) SFTPOpenUpload(sessionID, remotePath string) (io.WriteCloser, error) {
sc, err := m.getSFTP(sessionID)
if err != nil {
return nil, err
}
return sc.CreateWriter(remotePath)
}
// SFTPPwd returns the current working directory on the remote host.
func (m *Manager) SFTPPwd(sessionID string) (string, error) {
sc, err := m.getSFTP(sessionID)
+15
View File
@@ -267,6 +267,21 @@ func (c *Client) UploadBytes(remotePath string, data []byte, onProgress func(Tra
return c.copyWithProgress(reader, remote, int64(len(data)), remotePath, onProgress)
}
// CreateWriter creates or truncates a remote file and returns a streaming
// writer for callers that already manage progress and chunking.
func (c *Client) CreateWriter(remotePath string) (io.WriteCloser, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.sftp == nil {
return nil, fmt.Errorf("sftpclient: client is closed")
}
remote, err := c.sftp.Create(remotePath)
if err != nil {
return nil, fmt.Errorf("sftpclient: failed to create remote file: %w", err)
}
return remote, nil
}
// Pwd returns the current working directory on the remote host.
func (c *Client) Pwd() (string, error) {
c.mu.Lock()