fix: harden security resource lifecycles

This commit is contained in:
Andy Dixon
2026-07-29 12:37:02 +01:00
parent f0a1a0b8f2
commit b5c7528160
17 changed files with 460 additions and 76 deletions
+99 -6
View File
@@ -16,6 +16,7 @@ import (
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -48,6 +49,7 @@ const (
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
maxSFTPUploadChunkBytes = 1024 * 1024
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
maxConcurrentSFTPUploads = 16
maxSSHPublicKeyBytes = 64 * 1024
sshKeygenTimeout = 30 * time.Second
)
@@ -64,8 +66,10 @@ type Service struct {
eventSink func(string, interface{})
// secretChans maps request IDs to channels awaiting user secret input.
secretMu sync.Mutex
secretChans map[string]chan secretResponse
secretMu sync.Mutex
secretChans map[string]chan secretResponse
secretListenerRegistered bool
secretListenerCancel func()
uploadMu sync.Mutex
uploads map[string]*sftpUpload
@@ -108,6 +112,30 @@ func (s *Service) SetContext(ctx context.Context) {
s.RegisterSecretResponseListener()
}
// Shutdown releases active uploads, prompt waiters, and app-wide Wails
// listeners. It is safe to call when no Wails context was configured.
func (s *Service) Shutdown() {
s.cancelSFTPUploads("")
s.secretMu.Lock()
cancel := s.secretListenerCancel
s.secretListenerCancel = nil
s.secretListenerRegistered = false
waiters := s.secretChans
s.secretChans = make(map[string]chan secretResponse)
s.secretMu.Unlock()
if cancel != nil {
cancel()
}
for _, ch := range waiters {
select {
case ch <- secretResponse{cancelled: true}:
default:
}
}
}
// SetEventSink registers an alternate UI event sink for non-Wails hosts such
// as the VS Code bridge.
func (s *Service) SetEventSink(sink func(string, interface{})) {
@@ -438,11 +466,13 @@ func (s *Service) RestartLocalSession(sessionID string, cols int, rows int) erro
// DisconnectSession closes a session by ID.
func (s *Service) DisconnectSession(sessionID string) error {
s.cancelSFTPUploads(sessionID)
return s.sessions.Disconnect(sessionID)
}
// ReconnectSession closes and re-establishes a session.
func (s *Service) ReconnectSession(sessionID string) error {
s.cancelSFTPUploads(sessionID)
return s.sessions.Reconnect(sessionID, s.promptSecret)
}
@@ -482,6 +512,7 @@ func (s *Service) PinSession(sessionID string, pinned bool) error {
// CloseAllSessions terminates all active sessions.
func (s *Service) CloseAllSessions() {
s.cancelSFTPUploads("")
s.sessions.CloseAll()
}
@@ -1538,6 +1569,9 @@ func (s *Service) SFTPUploadStart(sessionID string, remoteDir string, fileName s
if totalBytes > maxStreamingSFTPUploadSize {
return "", fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
}
if err := s.checkSFTPUploadCapacity(); err != nil {
return "", err
}
safeName, err := safeSFTPUploadName(fileName)
if err != nil {
@@ -1551,6 +1585,14 @@ func (s *Service) SFTPUploadStart(sessionID string, remoteDir string, fileName s
uploadID := uuid.New().String()
s.uploadMu.Lock()
if len(s.uploads) >= maxConcurrentSFTPUploads {
s.uploadMu.Unlock()
_ = writer.Close()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(sessionID, remotePath)
}
return "", fmt.Errorf("too many active uploads (max %d)", maxConcurrentSFTPUploads)
}
s.uploads[uploadID] = &sftpUpload{
writer: writer,
sessionID: sessionID,
@@ -1716,7 +1758,7 @@ func (s *Service) TestConnection(host string, port int) (map[string]interface{},
return nil, fmt.Errorf("port must be between 1 and 65535")
}
addr := fmt.Sprintf("%s:%d", host, port)
addr := net.JoinHostPort(host, strconv.Itoa(port))
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
@@ -1798,7 +1840,16 @@ func (s *Service) RegisterSecretResponseListener() {
if s.ctx == nil {
return
}
wailsRuntime.EventsOn(s.ctx, "session:secret:response", func(optionalData ...interface{}) {
s.secretMu.Lock()
if s.secretListenerRegistered {
s.secretMu.Unlock()
return
}
s.secretListenerRegistered = true
s.secretMu.Unlock()
cancel := wailsRuntime.EventsOn(s.ctx, "session:secret:response", func(optionalData ...interface{}) {
if len(optionalData) == 0 {
return
}
@@ -1815,9 +1866,15 @@ func (s *Service) RegisterSecretResponseListener() {
s.secretMu.Unlock()
if exists {
ch <- secretResponse{value: value, cancelled: cancelled}
select {
case ch <- secretResponse{value: value, cancelled: cancelled}:
default:
}
}
})
s.secretMu.Lock()
s.secretListenerCancel = cancel
s.secretMu.Unlock()
}
// RespondSecret supplies a credential prompt response for non-Wails hosts.
@@ -1827,7 +1884,10 @@ func (s *Service) RespondSecret(requestID string, value string, cancelled bool)
s.secretMu.Unlock()
if exists {
ch <- secretResponse{value: value, cancelled: cancelled}
select {
case ch <- secretResponse{value: value, cancelled: cancelled}:
default:
}
}
}
@@ -2127,6 +2187,39 @@ func (s *Service) removeSFTPUpload(uploadID string) {
delete(s.uploads, uploadID)
}
func (s *Service) checkSFTPUploadCapacity() error {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
if len(s.uploads) >= maxConcurrentSFTPUploads {
return fmt.Errorf("too many active uploads (max %d)", maxConcurrentSFTPUploads)
}
return nil
}
// cancelSFTPUploads closes and removes active uploads. An empty session ID
// cancels every upload; otherwise only uploads owned by that session are
// affected.
func (s *Service) cancelSFTPUploads(sessionID string) {
s.uploadMu.Lock()
uploads := make([]*sftpUpload, 0)
for uploadID, upload := range s.uploads {
if sessionID == "" || upload.sessionID == sessionID {
delete(s.uploads, uploadID)
uploads = append(uploads, upload)
}
}
s.uploadMu.Unlock()
for _, upload := range uploads {
upload.mu.Lock()
_ = upload.writer.Close()
upload.mu.Unlock()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
}
}
func validateImportStrategy(strategy string) error {
switch strategy {
case "skip", "overwrite", "duplicate":
+72
View File
@@ -13,6 +13,19 @@ import (
"github.com/andydixon/nexus/internal/storage"
)
type closeTrackingWriter struct {
closed int
}
func (w *closeTrackingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
func (w *closeTrackingWriter) Close() error {
w.closed++
return nil
}
func TestSSHKeyPairPathsAcceptsSafeName(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
@@ -128,6 +141,65 @@ func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) {
}
}
func TestCancelSFTPUploadsClosesOnlyMatchingSession(t *testing.T) {
first := &closeTrackingWriter{}
second := &closeTrackingWriter{}
svc := &Service{
uploads: map[string]*sftpUpload{
"first": {writer: first, sessionID: "session-1", remotePath: "/first"},
"second": {writer: second, sessionID: "session-2", remotePath: "/second"},
},
}
svc.cancelSFTPUploads("session-1")
if first.closed != 1 {
t.Fatalf("matching writer closed %d times, want 1", first.closed)
}
if second.closed != 0 {
t.Fatalf("unrelated writer closed %d times, want 0", second.closed)
}
if len(svc.uploads) != 1 || svc.uploads["second"] == nil {
t.Fatalf("remaining uploads = %#v, want only session-2", svc.uploads)
}
}
func TestSFTPUploadCapacityIsBounded(t *testing.T) {
svc := &Service{uploads: make(map[string]*sftpUpload)}
for i := 0; i < maxConcurrentSFTPUploads; i++ {
svc.uploads[string(rune('a'+i))] = &sftpUpload{writer: &closeTrackingWriter{}}
}
if err := svc.checkSFTPUploadCapacity(); err == nil {
t.Fatal("expected active upload limit to be enforced")
}
}
func TestServiceShutdownReleasesUploadsAndSecretWaiters(t *testing.T) {
writer := &closeTrackingWriter{}
waiter := make(chan secretResponse, 1)
svc := &Service{
uploads: map[string]*sftpUpload{"upload": {writer: writer}},
secretChans: map[string]chan secretResponse{"prompt": waiter},
}
svc.Shutdown()
if writer.closed != 1 {
t.Fatalf("writer closed %d times, want 1", writer.closed)
}
if len(svc.uploads) != 0 {
t.Fatalf("active uploads = %d, want 0", len(svc.uploads))
}
select {
case response := <-waiter:
if !response.cancelled {
t.Fatal("shutdown response was not cancelled")
}
default:
t.Fatal("shutdown did not release secret prompt waiter")
}
}
func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
got, err := safeSFTPUploadName(`../report <final>.txt`)
if err != nil {
+5 -1
View File
@@ -118,8 +118,12 @@ func (a *App) Shutdown(ctx context.Context) {
a.mu.Lock()
defer a.mu.Unlock()
if a.Service != nil {
a.Service.Shutdown()
}
if a.SessionManager != nil {
a.SessionManager.CloseAll()
a.SessionManager.Shutdown()
}
if a.Store != nil {
+14 -3
View File
@@ -68,6 +68,7 @@ func EncryptAndExport(profiles []models.Profile, password, filePath string) erro
if err != nil {
return fmt.Errorf("exportimport: failed to serialise profiles: %w", err)
}
defer clear(plaintext)
// Generate random salt and nonce.
salt := make([]byte, saltLength)
@@ -81,14 +82,17 @@ func EncryptAndExport(profiles []models.Profile, password, filePath string) erro
}
// Derive encryption key from password.
passwordBytes := []byte(password)
defer clear(passwordBytes)
key := argon2.IDKey(
[]byte(password),
passwordBytes,
salt,
exportArgonTime,
exportArgonMemory,
exportArgonThreads,
exportArgonKeyLen,
)
defer clear(key)
// Encrypt with AES-256-GCM.
block, err := aes.NewCipher(key)
@@ -104,11 +108,14 @@ func EncryptAndExport(profiles []models.Profile, password, filePath string) erro
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
// Write the file: version + salt + nonce + ciphertext.
f, err := os.Create(filePath)
f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("exportimport: failed to create file: %w", err)
}
defer f.Close()
if err := f.Chmod(0600); err != nil {
return fmt.Errorf("exportimport: failed to restrict file permissions: %w", err)
}
if _, err := f.Write([]byte{formatVersion}); err != nil {
return fmt.Errorf("exportimport: write version failed: %w", err)
@@ -163,14 +170,17 @@ func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
ciphertext := data[1+saltLength+nonceLength:]
// Derive decryption key.
passwordBytes := []byte(password)
defer clear(passwordBytes)
key := argon2.IDKey(
[]byte(password),
passwordBytes,
salt,
exportArgonTime,
exportArgonMemory,
exportArgonThreads,
exportArgonKeyLen,
)
defer clear(key)
// Decrypt with AES-256-GCM.
block, err := aes.NewCipher(key)
@@ -187,6 +197,7 @@ func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
if err != nil {
return nil, errors.New("exportimport: decryption failed — wrong password or corrupt file")
}
defer clear(plaintext)
// Parse the JSON bundle.
var bundle ExportBundle
@@ -3,6 +3,7 @@ package exportimport
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/andydixon/nexus/internal/models"
@@ -104,6 +105,26 @@ func TestEncryptEmptyProfiles(t *testing.T) {
}
}
func TestEncryptAndExportRestrictsExistingFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX permission bits are not enforced on Windows")
}
path := filepath.Join(t.TempDir(), "profiles.nexus")
if err := os.WriteFile(path, []byte("old"), 0644); err != nil {
t.Fatal(err)
}
if err := EncryptAndExport([]models.Profile{{ID: "1"}}, "password", path); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0600 {
t.Fatalf("export permissions = %04o, want 0600", got)
}
}
func TestEncryptEmptyPassword(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.nexus")
+1 -1
View File
@@ -608,7 +608,7 @@ func (c *Client) newSSHDialer() (*sshDialer, error) {
if err != nil {
return nil, err
}
sshAddr := fmt.Sprintf("%s:%d", c.cfg.SSHHost, c.cfg.SSHPort)
sshAddr := net.JoinHostPort(c.cfg.SSHHost, strconv.Itoa(c.cfg.SSHPort))
sshConfig := &ssh.ClientConfig{
User: c.cfg.SSHUsername,
Auth: authMethods,
+2 -2
View File
@@ -134,11 +134,11 @@ func CarveResult(data []byte, result SignatureResult, outputDir string) error {
if result.Offset < 0 || result.Offset+result.Size > len(data) {
return fmt.Errorf("binsnoop: result offset/size exceeds data bounds")
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
if err := os.MkdirAll(outputDir, 0700); err != nil {
return fmt.Errorf("binsnoop: mkdir: %w", err)
}
outPath := filepath.Join(outputDir, fmt.Sprintf("%x_%s.bin", result.Offset, result.Name))
return os.WriteFile(outPath, data[result.Offset:result.Offset+result.Size], 0644)
return os.WriteFile(outPath, data[result.Offset:result.Offset+result.Size], 0600)
}
// SignatureCount returns the number of loaded signatures.
@@ -3,6 +3,7 @@ package binsnoop
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -20,3 +21,27 @@ func TestReadFileLimitedRejectsOversizedFile(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCarveUsesPrivatePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX permission bits are not enforced on Windows")
}
path := filepath.Join(t.TempDir(), "private", "carved.bin")
if err := Carve(path, []byte("secret"), 0, 6); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0600 {
t.Fatalf("carved file permissions = %04o, want 0600", got)
}
dirInfo, err := os.Stat(filepath.Dir(path))
if err != nil {
t.Fatal(err)
}
if got := dirInfo.Mode().Perm(); got != 0700 {
t.Fatalf("carve directory permissions = %04o, want 0700", got)
}
}
+5 -5
View File
@@ -14,10 +14,10 @@ func Carve(outputPath string, data []byte, offset, size int) error {
return fmt.Errorf("carve: offset %d + size %d exceeds data length %d", offset, size, len(data))
}
dir := filepath.Dir(outputPath)
if err := os.MkdirAll(dir, 0755); err != nil {
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("carve: mkdir: %w", err)
}
return os.WriteFile(outputPath, data[offset:offset+size], 0644)
return os.WriteFile(outputPath, data[offset:offset+size], 0600)
}
// executeExternal runs an external extraction tool.
@@ -27,7 +27,7 @@ func executeExternal(ext *Extractor, carvedFilePath, outputDir string) *Extracti
OutputDirectory: outputDir,
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
if err := os.MkdirAll(outputDir, 0700); err != nil {
return result
}
@@ -100,7 +100,7 @@ func ExecuteExtraction(data []byte, filePath string, sig *SignatureResult, ext *
case ExtractorTypeExternal:
// Carve data to a temp file for external tools
if err := os.MkdirAll(outputDir, 0755); err != nil {
if err := os.MkdirAll(outputDir, 0700); err != nil {
return &ExtractionResult{OutputDirectory: outputDir}
}
@@ -109,7 +109,7 @@ func ExecuteExtraction(data []byte, filePath string, sig *SignatureResult, ext *
if endOffset > len(data) {
endOffset = len(data)
}
if err := os.WriteFile(carvedFile, data[sig.Offset:endOffset], 0644); err != nil {
if err := os.WriteFile(carvedFile, data[sig.Offset:endOffset], 0600); err != nil {
return &ExtractionResult{OutputDirectory: outputDir}
}
+105 -34
View File
@@ -82,8 +82,16 @@ type Manager struct {
hostKeyMu sync.Mutex
hostKeyChans map[string]chan bool
hostKeyListenerRegistered bool
hostKeyListenerCancel func()
secretMu sync.Mutex
secretChans map[string]chan string
secretChans map[string]chan secretPromptResponse
secretListenerRegistered bool
secretListenerCancel func()
}
type secretPromptResponse struct {
value string
cancelled bool
}
// NewManager creates a Manager wired to the given backing services.
@@ -102,6 +110,7 @@ func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychain
func (m *Manager) SetContext(ctx context.Context) {
m.ctx = ctx
m.registerHostKeyResponseListener()
m.registerSecretResponseListener()
}
// SetEventSink registers an alternate UI event sink for non-Wails hosts.
@@ -662,6 +671,46 @@ func (m *Manager) CloseAll() {
}
}
// Shutdown closes all sessions, unregisters app-wide Wails listeners, and
// releases any prompts still waiting for frontend input.
func (m *Manager) Shutdown() {
m.CloseAll()
m.hostKeyMu.Lock()
hostKeyCancel := m.hostKeyListenerCancel
m.hostKeyListenerCancel = nil
m.hostKeyListenerRegistered = false
hostKeyChans := m.hostKeyChans
m.hostKeyChans = make(map[string]chan bool)
m.hostKeyMu.Unlock()
if hostKeyCancel != nil {
hostKeyCancel()
}
for _, ch := range hostKeyChans {
select {
case ch <- false:
default:
}
}
m.secretMu.Lock()
secretCancel := m.secretListenerCancel
m.secretListenerCancel = nil
m.secretListenerRegistered = false
secretChans := m.secretChans
m.secretChans = make(map[string]chan secretPromptResponse)
m.secretMu.Unlock()
if secretCancel != nil {
secretCancel()
}
for _, ch := range secretChans {
select {
case ch <- secretPromptResponse{cancelled: true}:
default:
}
}
}
// GetSessionHistory returns the RAW session history (RAW protocol only).
func (m *Manager) GetSessionHistory(sessionID string) ([]rawclient.HistoryEntry, error) {
m.mu.RLock()
@@ -1990,7 +2039,10 @@ func (m *Manager) registerHostKeyResponseListener() {
m.hostKeyListenerRegistered = true
m.hostKeyMu.Unlock()
wailsRuntime.EventsOn(ctx, "session:hostkey:response", m.handleHostKeyResponse)
cancel := wailsRuntime.EventsOn(ctx, "session:hostkey:response", m.handleHostKeyResponse)
m.hostKeyMu.Lock()
m.hostKeyListenerCancel = cancel
m.hostKeyMu.Unlock()
}
func (m *Manager) handleHostKeyResponse(optionalData ...interface{}) {
@@ -2467,13 +2519,13 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
var err error
switch sess.profile.Protocol {
case models.ProtocolSSH:
err = m.connectSSH(sess, m.buildInteractiveSecretCallback(sessionID))
err = m.connectSSH(sess, m.buildInteractiveSecretCallback(ctx, sessionID))
case models.ProtocolTelnet:
err = m.connectTelnet(sess)
case models.ProtocolRAW:
err = m.connectRAW(sess)
case models.ProtocolIRC:
err = m.connectIRC(sess, m.buildInteractiveSecretCallback(sessionID))
err = m.connectIRC(sess, m.buildInteractiveSecretCallback(ctx, sessionID))
default:
err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol)
}
@@ -2522,14 +2574,14 @@ func (m *Manager) scheduleReconnect(sess *Session, sessionID string) {
// buildInteractiveSecretCallback creates a getSecret callback that prompts
// the user via Wails events. Used during auto-reconnect flows where the
// original callback is no longer available.
func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt string) (string, error) {
func (m *Manager) buildInteractiveSecretCallback(ctx context.Context, sessionID string) func(prompt string) (string, error) {
return func(prompt string) (string, error) {
requestID := uuid.New().String()
ch := make(chan string, 1)
ch := make(chan secretPromptResponse, 1)
m.secretMu.Lock()
if m.secretChans == nil {
m.secretChans = make(map[string]chan string)
m.secretChans = make(map[string]chan secretPromptResponse)
}
m.secretChans[requestID] = ch
m.secretMu.Unlock()
@@ -2546,48 +2598,67 @@ func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt s
"message": prompt,
})
// Wait for the response with a timeout.
var response string
if m.ctx != nil {
wailsRuntime.EventsOn(m.ctx, "session:secret:response", func(optionalData ...interface{}) {
if len(optionalData) == 0 {
return
}
data, ok := optionalData[0].(map[string]interface{})
if !ok {
return
}
if rid, _ := data["requestID"].(string); rid == requestID {
val, _ := data["value"].(string)
select {
case ch <- val:
default:
}
}
})
}
select {
case response = <-ch:
case response := <-ch:
if response.cancelled {
return "", fmt.Errorf("credential prompt cancelled during reconnect")
}
return response.value, nil
case <-ctx.Done():
return "", fmt.Errorf("credential prompt cancelled during reconnect: %w", ctx.Err())
case <-time.After(60 * time.Second):
return "", fmt.Errorf("credential prompt timed out during reconnect")
}
return response, nil
}
}
func (m *Manager) registerSecretResponseListener() {
ctx := m.ctx
if ctx == nil {
return
}
m.secretMu.Lock()
if m.secretChans == nil {
m.secretChans = make(map[string]chan secretPromptResponse)
}
if m.secretListenerRegistered {
m.secretMu.Unlock()
return
}
m.secretListenerRegistered = true
m.secretMu.Unlock()
cancel := wailsRuntime.EventsOn(ctx, "session:secret:response", m.handleSecretResponse)
m.secretMu.Lock()
m.secretListenerCancel = cancel
m.secretMu.Unlock()
}
func (m *Manager) handleSecretResponse(optionalData ...interface{}) {
if len(optionalData) == 0 {
return
}
data, ok := optionalData[0].(map[string]interface{})
if !ok {
return
}
requestID, _ := data["requestID"].(string)
value, _ := data["value"].(string)
cancelled, _ := data["cancelled"].(bool)
m.RespondSecret(requestID, value, cancelled)
}
// RespondSecret supplies a credential prompt response for reconnect prompts in
// non-Wails hosts.
func (m *Manager) RespondSecret(requestID string, value string) {
func (m *Manager) RespondSecret(requestID string, value string, cancelled bool) {
m.secretMu.Lock()
ch, exists := m.secretChans[requestID]
m.secretMu.Unlock()
if exists {
select {
case ch <- value:
case ch <- secretPromptResponse{value: value, cancelled: cancelled}:
default:
}
}
+96
View File
@@ -1,6 +1,7 @@
package sessions
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
@@ -377,6 +378,101 @@ func TestHandleHostKeyResponseIgnoresOtherRequestIDs(t *testing.T) {
}
}
func TestHandleSecretResponseRoutesByRequestID(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
ch := make(chan secretPromptResponse, 1)
mgr.secretChans = map[string]chan secretPromptResponse{"request-1": ch}
mgr.handleSecretResponse(map[string]interface{}{
"requestID": "request-1",
"value": "secret",
"cancelled": false,
})
select {
case response := <-ch:
if response.value != "secret" || response.cancelled {
t.Fatalf("response = %#v, want non-cancelled secret", response)
}
default:
t.Fatal("expected secret response to be routed")
}
}
func TestInteractiveSecretPromptIsReleasedByReconnectCancellation(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
prompts := make(chan map[string]interface{}, 1)
mgr.SetEventSink(func(name string, data interface{}) {
if name == "session:secret:prompt" {
prompts <- data.(map[string]interface{})
}
})
ctx, cancel := context.WithCancel(context.Background())
result := make(chan error, 1)
go func() {
_, err := mgr.buildInteractiveSecretCallback(ctx, "session-1")("Password")
result <- err
}()
select {
case <-prompts:
case <-time.After(time.Second):
t.Fatal("timed out waiting for secret prompt")
}
cancel()
select {
case err := <-result:
if err == nil || !strings.Contains(err.Error(), "cancelled") {
t.Fatalf("callback error = %v, want cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("secret prompt did not stop after reconnect cancellation")
}
mgr.secretMu.Lock()
pending := len(mgr.secretChans)
mgr.secretMu.Unlock()
if pending != 0 {
t.Fatalf("pending secret prompts = %d, want 0", pending)
}
}
func TestInteractiveSecretPromptHonoursFrontendCancellation(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil, nil)
prompts := make(chan map[string]interface{}, 1)
mgr.SetEventSink(func(name string, data interface{}) {
if name == "session:secret:prompt" {
prompts <- data.(map[string]interface{})
}
})
result := make(chan error, 1)
go func() {
_, err := mgr.buildInteractiveSecretCallback(context.Background(), "session-1")("Password")
result <- err
}()
var requestID string
select {
case prompt := <-prompts:
requestID, _ = prompt["requestID"].(string)
case <-time.After(time.Second):
t.Fatal("timed out waiting for secret prompt")
}
mgr.RespondSecret(requestID, "", true)
select {
case err := <-result:
if err == nil || !strings.Contains(err.Error(), "cancelled") {
t.Fatalf("callback error = %v, want frontend cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("secret prompt did not stop after frontend cancellation")
}
}
func testPublicKey(t *testing.T) ssh.PublicKey {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -85,11 +86,11 @@ func (c *Client) Connect(cfg Config) (string, error) {
var vncConn net.Conn
var err error
vncAddr := fmt.Sprintf("%s:%d", cfg.VNCHost, cfg.VNCPort)
vncAddr := net.JoinHostPort(cfg.VNCHost, strconv.Itoa(cfg.VNCPort))
if cfg.SSHTunnel {
// Establish SSH connection first
sshAddr := fmt.Sprintf("%s:%d", cfg.SSHHost, cfg.SSHPort)
sshAddr := net.JoinHostPort(cfg.SSHHost, strconv.Itoa(cfg.SSHPort))
if cfg.SSHHostKeyCallback == nil {
return "", fmt.Errorf("SSH host key callback is required")
}