Security Fixes
This commit is contained in:
+199
-19
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user