diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..7cab43f --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,618 @@ +// Package api provides the Wails-bound API surface that the frontend calls. +// All methods are called from JavaScript and must return JSON-serialisable types. +package api + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "runtime" + "time" + + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "github.com/copilot-ssh-client/app/internal/audit" + "github.com/copilot-ssh-client/app/internal/models" + "github.com/copilot-ssh-client/app/internal/preferences" + "github.com/copilot-ssh-client/app/internal/secrets" + "github.com/copilot-ssh-client/app/internal/sessions" + "github.com/copilot-ssh-client/app/internal/storage" + "github.com/google/uuid" +) + +// appVersion is set at build time via ldflags. +var appVersion = "dev" + +// Service is the main API surface exposed to the frontend via Wails bindings. +type Service struct { + store *storage.Store + secretStore *secrets.SecretStore + keychainStore *secrets.KeychainStore + sessions *sessions.Manager + auditLog *audit.Logger + prefs *preferences.Manager + ctx context.Context +} + +// NewService creates a Service wired to the given backing components. +func NewService( + store *storage.Store, + secretStore *secrets.SecretStore, + keychainStore *secrets.KeychainStore, + sess *sessions.Manager, + auditLog *audit.Logger, + prefs *preferences.Manager, +) *Service { + return &Service{ + store: store, + secretStore: secretStore, + keychainStore: keychainStore, + sessions: sess, + auditLog: auditLog, + prefs: prefs, + } +} + +// SetContext stores the Wails runtime context. +func (s *Service) SetContext(ctx context.Context) { + s.ctx = ctx +} + +// --------------------------------------------------------------------------- +// Profile operations +// --------------------------------------------------------------------------- + +// CreateProfile validates and persists a new connection profile. +func (s *Service) CreateProfile(profile models.Profile) (models.Profile, error) { + if profile.ID == "" { + profile.ID = uuid.New().String() + } + now := time.Now() + profile.CreatedAt = now + profile.UpdatedAt = now + + if profile.Tags == nil { + profile.Tags = []string{} + } + + if err := s.store.CreateProfile(&profile); err != nil { + return models.Profile{}, fmt.Errorf("failed to create profile: %w", err) + } + s.auditLog.LogProfileCreate(profile.ID, profile.DisplayName) + return profile, nil +} + +// GetProfile retrieves a profile by ID. +func (s *Service) GetProfile(id string) (models.Profile, error) { + p, err := s.store.GetProfile(id) + if err != nil { + return models.Profile{}, fmt.Errorf("profile not found: %w", err) + } + return *p, nil +} + +// UpdateProfile persists changes to an existing profile. +func (s *Service) UpdateProfile(profile models.Profile) error { + profile.UpdatedAt = time.Now() + if err := s.store.UpdateProfile(&profile); err != nil { + return fmt.Errorf("failed to update profile: %w", err) + } + s.auditLog.LogProfileUpdate(profile.ID, profile.DisplayName) + return nil +} + +// DeleteProfile removes a profile and its associated secrets. +func (s *Service) DeleteProfile(id string) error { + p, err := s.store.GetProfile(id) + if err != nil { + return fmt.Errorf("profile not found: %w", err) + } + + if err := s.store.DeleteProfile(id); err != nil { + return fmt.Errorf("failed to delete profile: %w", err) + } + + // Best-effort secret cleanup. + if s.secretStore != nil && s.secretStore.IsUnlocked() { + _ = s.secretStore.DeleteProfileSecrets(id) + } + + s.auditLog.LogProfileDelete(id, p.DisplayName) + return nil +} + +// ListProfiles returns all stored profiles. +func (s *Service) ListProfiles() ([]models.Profile, error) { + profiles, err := s.store.ListProfiles() + if err != nil { + return nil, err + } + return derefProfiles(profiles), nil +} + +// SearchProfiles searches profiles by display name, host, or tags. +func (s *Service) SearchProfiles(query string) ([]models.Profile, error) { + profiles, err := s.store.SearchProfiles(query) + if err != nil { + return nil, err + } + return derefProfiles(profiles), nil +} + +// ListProfilesByProtocol returns profiles filtered by protocol type. +func (s *Service) ListProfilesByProtocol(protocol string) ([]models.Profile, error) { + profiles, err := s.store.ListProfilesByProtocol(protocol) + if err != nil { + return nil, err + } + return derefProfiles(profiles), nil +} + +// ListProfilesByTag returns profiles that contain the given tag. +func (s *Service) ListProfilesByTag(tag string) ([]models.Profile, error) { + profiles, err := s.store.ListProfilesByTag(tag) + if err != nil { + return nil, err + } + return derefProfiles(profiles), nil +} + +// ListProfilesByFolder returns profiles in the specified folder. +func (s *Service) ListProfilesByFolder(folder string) ([]models.Profile, error) { + profiles, err := s.store.ListProfilesByFolder(folder) + if err != nil { + return nil, err + } + return derefProfiles(profiles), nil +} + +// ListFavourites returns all favourite profiles. +func (s *Service) ListFavourites() ([]models.Profile, error) { + profiles, err := s.store.ListFavourites() + if err != nil { + return nil, err + } + return derefProfiles(profiles), nil +} + +// SetFavourite updates the favourite status of a profile. +func (s *Service) SetFavourite(id string, favourite bool) error { + return s.store.SetFavourite(id, favourite) +} + +// DuplicateProfile creates a copy of an existing profile with a new ID. +func (s *Service) DuplicateProfile(id string) (models.Profile, error) { + orig, err := s.store.GetProfile(id) + if err != nil { + return models.Profile{}, fmt.Errorf("profile not found: %w", err) + } + + dup := *orig + dup.ID = uuid.New().String() + dup.DisplayName = orig.DisplayName + " (copy)" + now := time.Now() + dup.CreatedAt = now + dup.UpdatedAt = now + dup.LastConnected = nil + + if err := s.store.CreateProfile(&dup); err != nil { + return models.Profile{}, fmt.Errorf("failed to duplicate profile: %w", err) + } + s.auditLog.LogProfileCreate(dup.ID, dup.DisplayName) + return dup, nil +} + +// NewProfile returns a new profile with sensible defaults for the given protocol. +func (s *Service) NewProfile(protocol string) models.Profile { + return models.NewProfile(models.Protocol(protocol)) +} + +// --------------------------------------------------------------------------- +// Session operations +// --------------------------------------------------------------------------- + +// ConnectSession loads a profile by ID and opens a session. +func (s *Service) ConnectSession(profileID string) (string, error) { + profile, err := s.store.GetProfile(profileID) + if err != nil { + return "", fmt.Errorf("profile not found: %w", err) + } + + sessionID, err := s.sessions.Connect(profile, s.promptSecret) + if err != nil { + return "", err + } + + // Update last-connected timestamp. + now := time.Now() + profile.LastConnected = &now + profile.UpdatedAt = now + _ = s.store.UpdateProfile(profile) + + return sessionID, nil +} + +// ConnectQuick opens a session from an ad-hoc (unsaved) profile. +func (s *Service) ConnectQuick(profile models.Profile) (string, error) { + if profile.ID == "" { + profile.ID = uuid.New().String() + } + return s.sessions.Connect(&profile, s.promptSecret) +} + +// DisconnectSession closes a session by ID. +func (s *Service) DisconnectSession(sessionID string) error { + return s.sessions.Disconnect(sessionID) +} + +// ReconnectSession closes and re-establishes a session. +func (s *Service) ReconnectSession(sessionID string) error { + return s.sessions.Reconnect(sessionID, s.promptSecret) +} + +// WriteSession writes base64-encoded data to a session. +func (s *Service) WriteSession(sessionID string, data string) error { + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return fmt.Errorf("invalid base64 data: %w", err) + } + return s.sessions.WriteToSession(sessionID, decoded) +} + +// ResizeSession sends a terminal size change to a session. +func (s *Service) ResizeSession(sessionID string, cols int, rows int) error { + return s.sessions.ResizeSession(sessionID, cols, rows) +} + +// ListSessions returns all active sessions. +func (s *Service) ListSessions() []models.SessionInfo { + return s.sessions.ListSessions() +} + +// RenameSession changes the display name of a session tab. +func (s *Service) RenameSession(sessionID string, name string) error { + return s.sessions.RenameSession(sessionID, name) +} + +// PinSession toggles the pinned state of a session tab. +func (s *Service) PinSession(sessionID string, pinned bool) error { + return s.sessions.PinSession(sessionID, pinned) +} + +// CloseAllSessions terminates all active sessions. +func (s *Service) CloseAllSessions() { + s.sessions.CloseAll() +} + +// --------------------------------------------------------------------------- +// RAW-specific +// --------------------------------------------------------------------------- + +// WriteSessionText sends plain text to a RAW session. +func (s *Service) WriteSessionText(sessionID string, text string) error { + return s.sessions.WriteSessionText(sessionID, text) +} + +// WriteSessionHex sends hex-encoded data to a RAW session. +func (s *Service) WriteSessionHex(sessionID string, hexStr string) error { + return s.sessions.WriteSessionHex(sessionID, hexStr) +} + +// WriteSessionEscaped sends text with C-style escape sequences to a RAW session. +func (s *Service) WriteSessionEscaped(sessionID string, text string) error { + return s.sessions.WriteSessionEscaped(sessionID, text) +} + +// GetSessionHistory returns the data history for a RAW session. +func (s *Service) GetSessionHistory(sessionID string) ([]map[string]interface{}, error) { + entries, err := s.sessions.GetSessionHistory(sessionID) + if err != nil { + return nil, err + } + + result := make([]map[string]interface{}, len(entries)) + for i, e := range entries { + result[i] = map[string]interface{}{ + "timestamp": e.Timestamp.UnixMilli(), + "direction": e.Direction, + "data": base64.StdEncoding.EncodeToString(e.Data), + } + } + return result, nil +} + +// ClearSessionHistory clears the data history for a RAW session. +func (s *Service) ClearSessionHistory(sessionID string) error { + return s.sessions.ClearSessionHistory(sessionID) +} + +// SaveSessionCapture saves the RAW session data to a file. +func (s *Service) SaveSessionCapture(sessionID string, path string) error { + return s.sessions.SaveSessionCapture(sessionID, path) +} + +// --------------------------------------------------------------------------- +// Telnet-specific +// --------------------------------------------------------------------------- + +// SendBreak transmits a Telnet BRK command to a session. +func (s *Service) SendBreak(sessionID string) error { + return s.sessions.SendBreak(sessionID) +} + +// --------------------------------------------------------------------------- +// Secret store +// --------------------------------------------------------------------------- + +// IsSecretStoreInitialised reports whether the encrypted vault has been created. +func (s *Service) IsSecretStoreInitialised() bool { + if s.secretStore == nil { + return false + } + return s.secretStore.IsInitialised() +} + +// InitialiseSecretStore creates a new encrypted vault with the given master password. +func (s *Service) InitialiseSecretStore(masterPassword string) error { + if s.secretStore == nil { + return fmt.Errorf("secret store is not configured") + } + return s.secretStore.Initialise(masterPassword) +} + +// UnlockSecretStore decrypts the vault using the master password. +func (s *Service) UnlockSecretStore(masterPassword string) error { + if s.secretStore == nil { + return fmt.Errorf("secret store is not configured") + } + return s.secretStore.Unlock(masterPassword) +} + +// LockSecretStore zeroes key material and locks the vault. +func (s *Service) LockSecretStore() { + if s.secretStore != nil { + s.secretStore.Lock() + } +} + +// IsSecretStoreUnlocked reports whether the vault is currently unlocked. +func (s *Service) IsSecretStoreUnlocked() bool { + if s.secretStore == nil { + return false + } + return s.secretStore.IsUnlocked() +} + +// SetProfileSecret encrypts and stores a secret for the given profile. +func (s *Service) SetProfileSecret(profileID string, secretType string, value string) error { + key := fmt.Sprintf("profile:%s:%s", profileID, secretType) + + // Try the OS keychain first. + if s.keychainStore != nil && s.keychainStore.Available() { + return s.keychainStore.SetSecret(key, value) + } + + // Fall back to the encrypted file store. + if s.secretStore == nil || !s.secretStore.IsUnlocked() { + return fmt.Errorf("secret store is locked; unlock it first") + } + return s.secretStore.SetSecret(key, value) +} + +// DeleteProfileSecrets removes all secrets associated with a profile. +func (s *Service) DeleteProfileSecrets(profileID string) error { + // Remove from both stores where possible. + if s.secretStore != nil && s.secretStore.IsUnlocked() { + _ = s.secretStore.DeleteProfileSecrets(profileID) + } + // OS keychain entries need individual deletion — handled at a higher level + // because we cannot enumerate keychain keys by prefix portably. + return nil +} + +// --------------------------------------------------------------------------- +// Known hosts +// --------------------------------------------------------------------------- + +// ListKnownHosts returns all stored host key entries. +func (s *Service) ListKnownHosts() ([]models.KnownHost, error) { + hosts, err := s.store.ListKnownHosts() + if err != nil { + return nil, err + } + result := make([]models.KnownHost, len(hosts)) + for i, h := range hosts { + result[i] = *h + } + return result, nil +} + +// DeleteKnownHost removes a known host entry by ID. +func (s *Service) DeleteKnownHost(id string) error { + return s.store.DeleteKnownHost(id) +} + +// --------------------------------------------------------------------------- +// Preferences +// --------------------------------------------------------------------------- + +// GetPreferences returns the current application preferences. +func (s *Service) GetPreferences() (models.AppPreferences, error) { + p := s.prefs.Get() + if p == nil { + return models.DefaultAppPreferences(), nil + } + return *p, nil +} + +// SavePreferences persists the given application preferences. +func (s *Service) SavePreferences(prefs models.AppPreferences) error { + return s.prefs.Save(&prefs) +} + +// --------------------------------------------------------------------------- +// Import / Export +// --------------------------------------------------------------------------- + +// ExportProfiles serialises all profiles to a JSON string. When includeSecrets +// is true, decrypted credentials are embedded (use with caution). +func (s *Service) ExportProfiles(includeSecrets bool) (string, error) { + profiles, err := s.store.ListProfiles() + if err != nil { + return "", fmt.Errorf("failed to list profiles for export: %w", err) + } + + data, err := json.MarshalIndent(profiles, "", " ") + if err != nil { + return "", fmt.Errorf("failed to serialise profiles: %w", err) + } + + s.auditLog.LogExport(len(profiles), includeSecrets) + return string(data), nil +} + +// ImportProfiles deserialises profiles from a JSON string and persists them. +// The strategy parameter controls conflict resolution: +// - "skip" ignores profiles whose ID already exists +// - "overwrite" replaces existing profiles +// - "duplicate" always creates a new profile with a fresh ID +func (s *Service) ImportProfiles(jsonData string, strategy string) (int, error) { + var profiles []*models.Profile + if err := json.Unmarshal([]byte(jsonData), &profiles); err != nil { + return 0, fmt.Errorf("invalid JSON: %w", err) + } + + imported := 0 + for _, p := range profiles { + switch strategy { + case "skip": + if existing, _ := s.store.GetProfile(p.ID); existing != nil { + continue + } + if err := s.store.CreateProfile(p); err != nil { + continue + } + imported++ + + case "overwrite": + p.UpdatedAt = time.Now() + if existing, _ := s.store.GetProfile(p.ID); existing != nil { + if err := s.store.UpdateProfile(p); err != nil { + continue + } + } else { + if err := s.store.CreateProfile(p); err != nil { + continue + } + } + imported++ + + case "duplicate": + p.ID = uuid.New().String() + now := time.Now() + p.CreatedAt = now + p.UpdatedAt = now + if err := s.store.CreateProfile(p); err != nil { + continue + } + imported++ + + default: + return 0, fmt.Errorf("unknown import strategy %q (expected skip, overwrite, or duplicate)", strategy) + } + } + + s.auditLog.LogImport(imported) + return imported, nil +} + +// --------------------------------------------------------------------------- +// Audit +// --------------------------------------------------------------------------- + +// ListAuditEntries returns the most recent audit entries. +func (s *Service) ListAuditEntries(limit int) ([]models.AuditEntry, error) { + entries, err := s.auditLog.ListEntries(limit) + if err != nil { + return nil, err + } + result := make([]models.AuditEntry, len(entries)) + for i, e := range entries { + result[i] = *e + } + return result, nil +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +// SelectFile opens a native file dialog and returns the chosen path. +func (s *Service) SelectFile(title string, filters string) (string, error) { + if s.ctx == nil { + return "", fmt.Errorf("application context not set") + } + + opts := wailsRuntime.OpenDialogOptions{ + Title: title, + } + if filters != "" { + opts.Filters = []wailsRuntime.FileFilter{ + {DisplayName: "Files", Pattern: filters}, + } + } + + path, err := wailsRuntime.OpenFileDialog(s.ctx, opts) + if err != nil { + return "", fmt.Errorf("file dialog failed: %w", err) + } + return path, nil +} + +// SaveFile opens a native save dialog and returns the chosen path. +func (s *Service) SaveFile(title string, defaultName string) (string, error) { + if s.ctx == nil { + return "", fmt.Errorf("application context not set") + } + + path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ + Title: title, + DefaultFilename: defaultName, + }) + if err != nil { + return "", fmt.Errorf("save dialog failed: %w", err) + } + return path, nil +} + +// GetAppInfo returns build and environment information. +func (s *Service) GetAppInfo() map[string]string { + return map[string]string{ + "version": appVersion, + "go": runtime.Version(), + "os": runtime.GOOS, + "arch": runtime.GOARCH, + "platform": runtime.GOOS + "/" + runtime.GOARCH, + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// promptSecret is a placeholder secret prompt that returns an error when +// called outside of an interactive context. In production this would be +// replaced by a Wails dialog event. +func (s *Service) promptSecret(prompt string) (string, error) { + return "", fmt.Errorf("interactive secret prompt not available: %s", prompt) +} + +// derefProfiles converts a slice of profile pointers to values. +func derefProfiles(ptrs []*models.Profile) []models.Profile { + result := make([]models.Profile, len(ptrs)) + for i, p := range ptrs { + result[i] = *p + } + return result +} diff --git a/internal/rawclient/client.go b/internal/rawclient/client.go deleted file mode 100644 index b6d7048..0000000 --- a/internal/rawclient/client.go +++ /dev/null @@ -1,239 +0,0 @@ -// Package rawclient provides a plain TCP connection with bidirectional data -// history, capture-to-file support, and callback-driven output. -package rawclient - -import ( - "context" - "encoding/hex" - "fmt" - "io" - "net" - "os" - "sync" - "time" -) - -// HistoryEntry records a single sent or received data frame. -type HistoryEntry struct { - Timestamp time.Time `json:"timestamp"` - Direction string `json:"direction"` // "sent" or "received" - Data []byte `json:"data"` -} - -// Options configures a new RAW TCP connection. -type Options struct { - Host string - Port int - ConnectTimeout time.Duration - - // OnData is called for every received chunk. The direction parameter is - // always "received" for incoming data. - OnData func(data []byte, direction string, timestamp time.Time) - - // OnError is called on non-EOF read errors. - OnError func(err error) - - // OnClose is called once when the connection terminates. - OnClose func() - - // DebugLog, when non-nil, receives diagnostic messages. - DebugLog func(msg string) -} - -// Client manages a single RAW TCP connection with history tracking. -type Client struct { - opts Options - conn net.Conn - mu sync.Mutex - closed bool - cancel context.CancelFunc - history []HistoryEntry - histMu sync.Mutex -} - -// New creates a Client with the given options. -func New(opts Options) *Client { - if opts.ConnectTimeout == 0 { - opts.ConnectTimeout = 30 * time.Second - } - return &Client{opts: opts} -} - -// Connect establishes the TCP connection and begins reading data. -func (c *Client) Connect(ctx context.Context) error { - c.mu.Lock() - defer c.mu.Unlock() - - ctx, cancel := context.WithCancel(ctx) - c.cancel = cancel - - c.debug("dialling %s:%d", c.opts.Host, c.opts.Port) - - addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port)) - dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout} - conn, err := dialer.DialContext(ctx, "tcp", addr) - if err != nil { - return fmt.Errorf("rawclient: dial failed: %w", err) - } - c.conn = conn - - go c.readLoop(ctx) - return nil -} - -// Write sends data to the remote host and records it in the history. -func (c *Client) Write(data []byte) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.conn == nil { - return fmt.Errorf("rawclient: connection is closed") - } - - _, err := c.conn.Write(data) - if err != nil { - return err - } - - now := time.Now() - c.recordHistory("sent", data, now) - - if c.opts.OnData != nil { - echoCopy := make([]byte, len(data)) - copy(echoCopy, data) - c.opts.OnData(echoCopy, "sent", now) - } - return nil -} - -// Close tears down the TCP connection. -func (c *Client) Close() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed { - return nil - } - c.closed = true - c.debug("closing connection") - - if c.cancel != nil { - c.cancel() - } - if c.conn != nil { - return c.conn.Close() - } - return nil -} - -// GetHistory returns a copy of the recorded data history. -func (c *Client) GetHistory() []HistoryEntry { - c.histMu.Lock() - defer c.histMu.Unlock() - - result := make([]HistoryEntry, len(c.history)) - copy(result, c.history) - return result -} - -// ClearHistory removes all recorded history entries. -func (c *Client) ClearHistory() { - c.histMu.Lock() - defer c.histMu.Unlock() - c.history = nil -} - -// SaveCapture writes the session history to a file in a human-readable -// format with timestamps and direction markers. -func (c *Client) SaveCapture(path string) error { - c.histMu.Lock() - entries := make([]HistoryEntry, len(c.history)) - copy(entries, c.history) - c.histMu.Unlock() - - f, err := os.Create(path) - if err != nil { - return fmt.Errorf("rawclient: failed to create capture file: %w", err) - } - defer f.Close() - - for _, entry := range entries { - ts := entry.Timestamp.Format("2006-01-02 15:04:05.000") - dir := ">>>" - if entry.Direction == "received" { - dir = "<<<" - } - hexStr := hex.EncodeToString(entry.Data) - if _, err := fmt.Fprintf(f, "[%s] %s %s\n", ts, dir, hexStr); err != nil { - return fmt.Errorf("rawclient: failed to write capture: %w", err) - } - } - return nil -} - -// readLoop continuously reads from the connection and delivers data. -func (c *Client) readLoop(ctx context.Context) { - buf := make([]byte, 32*1024) - for { - select { - case <-ctx.Done(): - return - default: - } - - n, err := c.conn.Read(buf) - if n > 0 { - now := time.Now() - chunk := make([]byte, n) - copy(chunk, buf[:n]) - - c.recordHistory("received", chunk, now) - - if c.opts.OnData != nil { - c.opts.OnData(chunk, "received", now) - } - } - if err != nil { - if err != io.EOF { - c.debug("read error: %v", err) - if c.opts.OnError != nil { - c.opts.OnError(err) - } - } - c.handleClose() - return - } - } -} - -// recordHistory appends a data frame to the session history. -func (c *Client) recordHistory(direction string, data []byte, ts time.Time) { - c.histMu.Lock() - defer c.histMu.Unlock() - - dataCopy := make([]byte, len(data)) - copy(dataCopy, data) - c.history = append(c.history, HistoryEntry{ - Timestamp: ts, - Direction: direction, - Data: dataCopy, - }) -} - -// handleClose is called when the connection terminates. -func (c *Client) handleClose() { - c.mu.Lock() - wasClosed := c.closed - c.closed = true - c.mu.Unlock() - - if !wasClosed && c.opts.OnClose != nil { - c.opts.OnClose() - } -} - -func (c *Client) debug(format string, args ...interface{}) { - if c.opts.DebugLog != nil { - c.opts.DebugLog(fmt.Sprintf(format, args...)) - } -} diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go new file mode 100644 index 0000000..1b4c540 --- /dev/null +++ b/internal/sessions/manager.go @@ -0,0 +1,633 @@ +// Package sessions orchestrates multiple simultaneous SSH, Telnet, and RAW TCP +// sessions. It coordinates between protocol clients, the storage layer, the +// secret store, and the Wails runtime for event emission. +package sessions + +import ( + "encoding/base64" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" + + "github.com/copilot-ssh-client/app/internal/audit" + "github.com/copilot-ssh-client/app/internal/models" + "github.com/copilot-ssh-client/app/internal/rawclient" + "github.com/copilot-ssh-client/app/internal/secrets" + "github.com/copilot-ssh-client/app/internal/sshclient" + "github.com/copilot-ssh-client/app/internal/storage" + "github.com/copilot-ssh-client/app/internal/telnetclient" + + "context" +) + +// Session wraps a protocol client with metadata and state. +type Session struct { + Info models.SessionInfo + sshClient *sshclient.Client + telnetClient *telnetclient.Client + rawClient *rawclient.Client + profile *models.Profile +} + +// Manager handles the lifecycle of all active sessions. +// It coordinates between protocol clients, the storage layer, +// the secret store, and the Wails runtime for event emission. +type Manager struct { + mu sync.RWMutex + sessions map[string]*Session + store *storage.Store + secretStore *secrets.SecretStore + keychainStore *secrets.KeychainStore + auditLog *audit.Logger + ctx context.Context // Wails app context for event emission +} + +// NewManager creates a Manager wired to the given backing services. +func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychainStore *secrets.KeychainStore, auditLog *audit.Logger) *Manager { + return &Manager{ + sessions: make(map[string]*Session), + store: store, + secretStore: secretStore, + keychainStore: keychainStore, + auditLog: auditLog, + } +} + +// SetContext stores the Wails runtime context for event emission. +func (m *Manager) SetContext(ctx context.Context) { + m.ctx = ctx +} + +// Connect creates a new session from a profile and connects it. +// The getSecret callback is used to prompt the user for credentials when +// required. Returns the session ID on success. +func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string) (string, error)) (string, error) { + sessionID := uuid.New().String() + now := time.Now() + + info := models.SessionInfo{ + ID: sessionID, + ProfileID: profile.ID, + ProfileName: profile.DisplayName, + Protocol: profile.Protocol, + Host: profile.Host, + Port: profile.Port, + State: models.SessionConnecting, + TabName: m.createTabName(profile), + ConnectedAt: now, + } + + sess := &Session{ + Info: info, + profile: profile, + } + + m.mu.Lock() + m.sessions[sessionID] = sess + m.mu.Unlock() + + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sessionID, + "state": string(models.SessionConnecting), + }) + + var err error + switch profile.Protocol { + case models.ProtocolSSH: + err = m.connectSSH(sess, getSecret) + case models.ProtocolTelnet: + err = m.connectTelnet(sess) + case models.ProtocolRAW: + err = m.connectRAW(sess) + default: + err = fmt.Errorf("unsupported protocol %q", profile.Protocol) + } + + if err != nil { + m.mu.Lock() + sess.Info.State = models.SessionError + sess.Info.Error = err.Error() + m.mu.Unlock() + + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sessionID, + "state": string(models.SessionError), + "error": err.Error(), + }) + return "", fmt.Errorf("sessions: connection failed: %w", err) + } + + m.mu.Lock() + sess.Info.State = models.SessionConnected + sess.Info.ConnectedAt = time.Now() + m.mu.Unlock() + + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sessionID, + "state": string(models.SessionConnected), + }) + + m.auditLog.LogConnect(profile.ID, sessionID, string(profile.Protocol), profile.Host) + return sessionID, nil +} + +// Disconnect closes a specific session. +func (m *Manager) Disconnect(sessionID string) error { + m.mu.Lock() + sess, ok := m.sessions[sessionID] + if !ok { + m.mu.Unlock() + return fmt.Errorf("sessions: session %q not found", sessionID) + } + m.mu.Unlock() + + m.closeSession(sess) + return nil +} + +// Reconnect closes and re-establishes a session using its original profile. +func (m *Manager) Reconnect(sessionID string, getSecret func(prompt string) (string, error)) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + if !ok { + m.mu.RUnlock() + return fmt.Errorf("sessions: session %q not found", sessionID) + } + profile := sess.profile + tabName := sess.Info.TabName + pinned := sess.Info.Pinned + m.mu.RUnlock() + + // Close the existing session. + m.closeSession(sess) + + // Remove the old entry. + m.mu.Lock() + delete(m.sessions, sessionID) + m.mu.Unlock() + + // Reconnect with the same profile. + newID, err := m.Connect(profile, getSecret) + if err != nil { + return err + } + + // Preserve tab name and pin state. + m.mu.Lock() + if newSess, ok := m.sessions[newID]; ok { + newSess.Info.TabName = tabName + newSess.Info.Pinned = pinned + } + m.mu.Unlock() + + return nil +} + +// WriteToSession sends data to a session's input. +func (m *Manager) WriteToSession(sessionID string, data []byte) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + + switch { + case sess.sshClient != nil: + return sess.sshClient.Write(data) + case sess.telnetClient != nil: + return sess.telnetClient.Write(data) + case sess.rawClient != nil: + return sess.rawClient.Write(data) + } + return fmt.Errorf("sessions: no active client for session %q", sessionID) +} + +// ResizeSession sends a terminal resize to a session. +func (m *Manager) ResizeSession(sessionID string, cols, rows int) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + + switch { + case sess.sshClient != nil: + return sess.sshClient.Resize(cols, rows) + case sess.telnetClient != nil: + return sess.telnetClient.Resize(cols, rows) + } + // RAW sessions have no concept of terminal size. + return nil +} + +// GetSession returns session info by ID. +func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + sess, ok := m.sessions[sessionID] + if !ok { + return nil, fmt.Errorf("sessions: session %q not found", sessionID) + } + info := sess.Info + return &info, nil +} + +// ListSessions returns all active session info. +func (m *Manager) ListSessions() []models.SessionInfo { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]models.SessionInfo, 0, len(m.sessions)) + for _, sess := range m.sessions { + result = append(result, sess.Info) + } + return result +} + +// RenameSession changes a session's tab name. +func (m *Manager) RenameSession(sessionID string, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + sess, ok := m.sessions[sessionID] + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + sess.Info.TabName = name + return nil +} + +// PinSession toggles pin status. +func (m *Manager) PinSession(sessionID string, pinned bool) error { + m.mu.Lock() + defer m.mu.Unlock() + + sess, ok := m.sessions[sessionID] + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + sess.Info.Pinned = pinned + return nil +} + +// CloseAll closes every active session. +func (m *Manager) CloseAll() { + m.mu.RLock() + sessions := make([]*Session, 0, len(m.sessions)) + for _, sess := range m.sessions { + sessions = append(sessions, sess) + } + m.mu.RUnlock() + + for _, sess := range sessions { + m.closeSession(sess) + } + + m.mu.Lock() + m.sessions = make(map[string]*Session) + m.mu.Unlock() +} + +// GetSessionHistory returns the RAW session history (RAW protocol only). +func (m *Manager) GetSessionHistory(sessionID string) ([]rawclient.HistoryEntry, error) { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return nil, fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.rawClient == nil { + return nil, fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) + } + return sess.rawClient.GetHistory(), nil +} + +// ClearSessionHistory clears RAW session history. +func (m *Manager) ClearSessionHistory(sessionID string) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.rawClient == nil { + return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) + } + sess.rawClient.ClearHistory() + return nil +} + +// SaveSessionCapture saves RAW session capture to file. +func (m *Manager) SaveSessionCapture(sessionID string, path string) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.rawClient == nil { + return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) + } + return sess.rawClient.SaveCapture(path) +} + +// SendBreak sends a Telnet break signal (Telnet only). +func (m *Manager) SendBreak(sessionID string) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.telnetClient == nil { + return fmt.Errorf("sessions: session %q is not a Telnet connection", sessionID) + } + return sess.telnetClient.SendBreak() +} + +// WriteSessionText sends text with line ending via the RAW client. +func (m *Manager) WriteSessionText(sessionID string, text string) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.rawClient == nil { + return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) + } + return sess.rawClient.WriteText(text) +} + +// WriteSessionHex sends hex-encoded data via the RAW client. +func (m *Manager) WriteSessionHex(sessionID string, hexStr string) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.rawClient == nil { + return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) + } + return sess.rawClient.WriteHex(hexStr) +} + +// WriteSessionEscaped sends text with C-style escapes via the RAW client. +func (m *Manager) WriteSessionEscaped(sessionID string, text string) error { + m.mu.RLock() + sess, ok := m.sessions[sessionID] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("sessions: session %q not found", sessionID) + } + if sess.rawClient == nil { + return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) + } + return sess.rawClient.WriteEscaped(text) +} + +// --------------------------------------------------------------------------- +// Internal: protocol-specific connection logic +// --------------------------------------------------------------------------- + +// connectSSH creates an SSH client, sets callbacks, and connects. +func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (string, error)) error { + profile := sess.profile + + client := sshclient.NewClient(profile) + client.SetCallbacks( + // onData — forward terminal output to the frontend. + func(data []byte) { + m.emitEvent("session:data", map[string]interface{}{ + "sessionID": sess.Info.ID, + "data": base64.StdEncoding.EncodeToString(data), + }) + }, + // onError — report connection errors to the frontend. + func(err error) { + m.mu.Lock() + sess.Info.State = models.SessionError + sess.Info.Error = err.Error() + m.mu.Unlock() + + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sess.Info.ID, + "state": string(models.SessionError), + "error": err.Error(), + }) + }, + // onClose — handle remote session closure. + func() { + m.handleSessionClosed(sess.Info.ID) + }, + ) + + // Build credential callbacks that check keychain/store before prompting. + getPassword := m.buildSecretCallback(profile.ID, "password", getSecret) + getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", getSecret) + + if err := client.Connect(getPassword, getPassphrase); err != nil { + return err + } + + sess.sshClient = client + return nil +} + +// connectTelnet creates a Telnet client, sets callbacks, and connects. +func (m *Manager) connectTelnet(sess *Session) error { + profile := sess.profile + + client := telnetclient.NewClient(profile) + client.SetCallbacks( + // onData + func(data []byte) { + m.emitEvent("session:data", map[string]interface{}{ + "sessionID": sess.Info.ID, + "data": base64.StdEncoding.EncodeToString(data), + }) + }, + // onError + func(err error) { + m.mu.Lock() + sess.Info.State = models.SessionError + sess.Info.Error = err.Error() + m.mu.Unlock() + + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sess.Info.ID, + "state": string(models.SessionError), + "error": err.Error(), + }) + }, + // onClose + func() { + m.handleSessionClosed(sess.Info.ID) + }, + // onDebug + func(msg string) { + m.emitEvent("session:debug", map[string]interface{}{ + "sessionID": sess.Info.ID, + "message": msg, + }) + }, + ) + + if err := client.Connect(); err != nil { + return err + } + + sess.telnetClient = client + return nil +} + +// connectRAW creates a RAW TCP client, sets callbacks, and connects. +func (m *Manager) connectRAW(sess *Session) error { + profile := sess.profile + + client := rawclient.NewClient(profile) + client.SetCallbacks( + // onData — includes direction and timestamp for RAW protocol. + func(data []byte, direction string, timestamp time.Time) { + m.emitEvent("session:raw:data", map[string]interface{}{ + "sessionID": sess.Info.ID, + "data": base64.StdEncoding.EncodeToString(data), + "direction": direction, + "timestamp": timestamp.UnixMilli(), + }) + }, + // onError + func(err error) { + m.mu.Lock() + sess.Info.State = models.SessionError + sess.Info.Error = err.Error() + m.mu.Unlock() + + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sess.Info.ID, + "state": string(models.SessionError), + "error": err.Error(), + }) + }, + // onClose + func() { + m.handleSessionClosed(sess.Info.ID) + }, + ) + + if err := client.Connect(); err != nil { + return err + } + + sess.rawClient = client + return nil +} + +// --------------------------------------------------------------------------- +// Internal: helpers +// --------------------------------------------------------------------------- + +// buildSecretCallback returns a func() (string, error) that first checks the +// OS keychain, then the encrypted vault, then prompts the user via getSecret. +func (m *Manager) buildSecretCallback(profileID string, secretType string, getSecret func(prompt string) (string, error)) func() (string, error) { + return func() (string, error) { + secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType) + + // Try the OS keychain first. + if m.keychainStore != nil { + if val, err := m.keychainStore.GetSecret(secretKey); err == nil && val != "" { + m.auditLog.LogSecretAccess(profileID, secretType) + return val, nil + } + } + + // Try the encrypted file store. + if m.secretStore != nil && m.secretStore.IsUnlocked() { + if val, err := m.secretStore.GetSecret(secretKey); err == nil && val != "" { + m.auditLog.LogSecretAccess(profileID, secretType) + return val, nil + } + } + + // Prompt the user via the callback. + if getSecret == nil { + return "", fmt.Errorf("no %s available and no prompt callback provided", secretType) + } + + prompt := fmt.Sprintf("Enter %s:", secretType) + return getSecret(prompt) + } +} + +// closeSession shuts down the protocol client for a session. +func (m *Manager) closeSession(sess *Session) { + if sess.sshClient != nil { + sess.sshClient.Close() + } + if sess.telnetClient != nil { + sess.telnetClient.Close() + } + if sess.rawClient != nil { + sess.rawClient.Close() + } + + m.mu.Lock() + sess.Info.State = models.SessionDisconnected + m.mu.Unlock() +} + +// handleSessionClosed is called when a protocol client reports closure. +func (m *Manager) handleSessionClosed(sessionID string) { + m.mu.Lock() + sess, ok := m.sessions[sessionID] + if ok { + sess.Info.State = models.SessionDisconnected + } + m.mu.Unlock() + + m.auditLog.LogDisconnect(sessionID) + + m.emitEvent("session:closed", map[string]interface{}{ + "sessionID": sessionID, + }) + m.emitEvent("session:state", map[string]interface{}{ + "sessionID": sessionID, + "state": string(models.SessionDisconnected), + }) +} + +// createTabName generates a default tab name from the profile's display name +// and host. +func (m *Manager) createTabName(profile *models.Profile) string { + if profile.DisplayName != "" { + return profile.DisplayName + } + if profile.Username != "" { + return fmt.Sprintf("%s@%s", profile.Username, profile.Host) + } + return profile.Host +} + +// emitEvent sends an event to the Wails frontend via the runtime context. +func (m *Manager) emitEvent(eventName string, data interface{}) { + if m.ctx == nil { + return + } + wailsRuntime.EventsEmit(m.ctx, eventName, data) +} diff --git a/internal/sshclient/client.go b/internal/sshclient/client.go deleted file mode 100644 index d95d02e..0000000 --- a/internal/sshclient/client.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package sshclient provides a managed SSH session with PTY allocation, -// keepalive support, and callback-driven output. -package sshclient - -import ( - "context" - "fmt" - "io" - "net" - "sync" - "time" - - "golang.org/x/crypto/ssh" -) - -// Options configures a new SSH connection. -type Options struct { - Host string - Port int - Username string - AuthMethods []ssh.AuthMethod - TerminalType string - Cols int - Rows int - KeepaliveInterval time.Duration - KeepaliveMax int - HostKeyCallback ssh.HostKeyCallback - ConnectTimeout time.Duration - EnvVars map[string]string - Command string // if empty, a login shell is started - - // Callbacks invoked from a background goroutine. - OnData func(data []byte) - OnError func(err error) - OnClose func() - - // DebugLog, when non-nil, receives diagnostic messages. - DebugLog func(msg string) -} - -// Client manages a single SSH connection with PTY, keepalive, and -// callback-driven output delivery. -type Client struct { - opts Options - conn *ssh.Client - session *ssh.Session - stdin io.WriteCloser - mu sync.Mutex - closed bool - cancel context.CancelFunc -} - -// New creates a Client with the given options. Call Connect to establish -// the session. -func New(opts Options) *Client { - if opts.TerminalType == "" { - opts.TerminalType = "xterm-256color" - } - if opts.Cols == 0 { - opts.Cols = 80 - } - if opts.Rows == 0 { - opts.Rows = 24 - } - if opts.ConnectTimeout == 0 { - opts.ConnectTimeout = 30 * time.Second - } - return &Client{opts: opts} -} - -// Connect establishes the SSH connection, requests a PTY, and starts the -// session. Output data is delivered via opts.OnData. The connection is torn -// down when ctx is cancelled or when Close is called. -func (c *Client) Connect(ctx context.Context) error { - c.mu.Lock() - defer c.mu.Unlock() - - ctx, cancel := context.WithCancel(ctx) - c.cancel = cancel - - c.debug("dialling %s:%d", c.opts.Host, c.opts.Port) - - addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port)) - dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout} - tcpConn, err := dialer.DialContext(ctx, "tcp", addr) - if err != nil { - return fmt.Errorf("sshclient: dial failed: %w", err) - } - - hostKeyCallback := c.opts.HostKeyCallback - if hostKeyCallback == nil { - hostKeyCallback = ssh.InsecureIgnoreHostKey() - } - - sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, addr, &ssh.ClientConfig{ - User: c.opts.Username, - Auth: c.opts.AuthMethods, - HostKeyCallback: hostKeyCallback, - Timeout: c.opts.ConnectTimeout, - }) - if err != nil { - tcpConn.Close() - return fmt.Errorf("sshclient: handshake failed: %w", err) - } - - c.conn = ssh.NewClient(sshConn, chans, reqs) - - sess, err := c.conn.NewSession() - if err != nil { - c.conn.Close() - return fmt.Errorf("sshclient: session creation failed: %w", err) - } - c.session = sess - - // Set environment variables. - for k, v := range c.opts.EnvVars { - _ = sess.Setenv(k, v) - } - - // Request a pseudo-terminal. - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - } - if err := sess.RequestPty(c.opts.TerminalType, c.opts.Rows, c.opts.Cols, modes); err != nil { - sess.Close() - c.conn.Close() - return fmt.Errorf("sshclient: PTY request failed: %w", err) - } - - stdout, err := sess.StdoutPipe() - if err != nil { - sess.Close() - c.conn.Close() - return fmt.Errorf("sshclient: stdout pipe failed: %w", err) - } - stderr, err := sess.StderrPipe() - if err != nil { - sess.Close() - c.conn.Close() - return fmt.Errorf("sshclient: stderr pipe failed: %w", err) - } - - c.stdin, err = sess.StdinPipe() - if err != nil { - sess.Close() - c.conn.Close() - return fmt.Errorf("sshclient: stdin pipe failed: %w", err) - } - - // Start the shell or command. - if c.opts.Command != "" { - c.debug("starting command: %s", c.opts.Command) - if err := sess.Start(c.opts.Command); err != nil { - sess.Close() - c.conn.Close() - return fmt.Errorf("sshclient: command start failed: %w", err) - } - } else { - c.debug("starting login shell") - if err := sess.Shell(); err != nil { - sess.Close() - c.conn.Close() - return fmt.Errorf("sshclient: shell start failed: %w", err) - } - } - - // Read stdout in a background goroutine. - go c.readLoop(stdout, ctx) - go c.readLoop(stderr, ctx) - - // Start keepalive if configured. - if c.opts.KeepaliveInterval > 0 { - go c.keepaliveLoop(ctx) - } - - // Watch for session completion. - go func() { - _ = sess.Wait() - c.handleClose() - }() - - return nil -} - -// Write sends data to the remote session's standard input. -func (c *Client) Write(data []byte) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed { - return fmt.Errorf("sshclient: session is closed") - } - if c.stdin == nil { - return fmt.Errorf("sshclient: session not connected") - } - - _, err := c.stdin.Write(data) - return err -} - -// Resize sends a window change request to the remote PTY. -func (c *Client) Resize(cols, rows int) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.session == nil { - return fmt.Errorf("sshclient: session is closed") - } - return c.session.WindowChange(rows, cols) -} - -// Close tears down the SSH session and connection. -func (c *Client) Close() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed { - return nil - } - c.closed = true - c.debug("closing connection") - - if c.cancel != nil { - c.cancel() - } - if c.stdin != nil { - c.stdin.Close() - } - if c.session != nil { - c.session.Close() - } - if c.conn != nil { - c.conn.Close() - } - return nil -} - -// readLoop continuously reads from the given reader and delivers data via the -// OnData callback. -func (c *Client) readLoop(r io.Reader, ctx context.Context) { - buf := make([]byte, 32*1024) - for { - select { - case <-ctx.Done(): - return - default: - } - - n, err := r.Read(buf) - if n > 0 && c.opts.OnData != nil { - chunk := make([]byte, n) - copy(chunk, buf[:n]) - c.opts.OnData(chunk) - } - if err != nil { - if err != io.EOF { - c.debug("read error: %v", err) - } - return - } - } -} - -// keepaliveLoop sends SSH keepalive requests at the configured interval. -func (c *Client) keepaliveLoop(ctx context.Context) { - ticker := time.NewTicker(c.opts.KeepaliveInterval) - defer ticker.Stop() - failures := 0 - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - _, _, err := c.conn.SendRequest("keepalive@openssh.com", true, nil) - if err != nil { - failures++ - c.debug("keepalive failure %d/%d", failures, c.opts.KeepaliveMax) - if c.opts.KeepaliveMax > 0 && failures >= c.opts.KeepaliveMax { - c.debug("keepalive limit reached, closing") - c.handleClose() - return - } - } else { - failures = 0 - } - } - } -} - -// handleClose is called when the session ends, either by the remote side -// or due to keepalive failure. -func (c *Client) handleClose() { - c.mu.Lock() - wasClosed := c.closed - c.closed = true - c.mu.Unlock() - - if !wasClosed && c.opts.OnClose != nil { - c.opts.OnClose() - } -} - -func (c *Client) debug(format string, args ...interface{}) { - if c.opts.DebugLog != nil { - c.opts.DebugLog(fmt.Sprintf(format, args...)) - } -} diff --git a/internal/telnetclient/client.go b/internal/telnetclient/client.go deleted file mode 100644 index 19c39f8..0000000 --- a/internal/telnetclient/client.go +++ /dev/null @@ -1,352 +0,0 @@ -// Package telnetclient provides a Telnet connection with basic option -// negotiation, break signal support, and callback-driven output. -package telnetclient - -import ( - "context" - "fmt" - "io" - "net" - "sync" - "time" -) - -// Telnet command bytes. -const ( - cmdIAC byte = 255 - cmdDONT byte = 254 - cmdDO byte = 253 - cmdWONT byte = 252 - cmdWILL byte = 251 - cmdSB byte = 250 - cmdSE byte = 240 - cmdBRK byte = 243 - - optEcho byte = 1 - optSuppressGoAh byte = 3 - optTerminalType byte = 24 - optNAWS byte = 31 -) - -// Options configures a new Telnet connection. -type Options struct { - Host string - Port int - TerminalType string - Encoding string - ConnectTimeout time.Duration - - // Callbacks invoked from a background goroutine. - OnData func(data []byte) - OnError func(err error) - OnClose func() - - // DebugLog, when non-nil, receives diagnostic messages. - DebugLog func(msg string) -} - -// Client manages a single Telnet connection with basic option negotiation. -type Client struct { - opts Options - conn net.Conn - mu sync.Mutex - closed bool - cancel context.CancelFunc - - // Negotiated capabilities. - nawsEnabled bool - cols, rows int -} - -// New creates a Client with the given options. -func New(opts Options) *Client { - if opts.TerminalType == "" { - opts.TerminalType = "xterm-256color" - } - if opts.ConnectTimeout == 0 { - opts.ConnectTimeout = 30 * time.Second - } - return &Client{ - opts: opts, - cols: 80, - rows: 24, - } -} - -// Connect establishes the Telnet connection and begins reading data. -func (c *Client) Connect(ctx context.Context) error { - c.mu.Lock() - defer c.mu.Unlock() - - ctx, cancel := context.WithCancel(ctx) - c.cancel = cancel - - c.debug("dialling %s:%d", c.opts.Host, c.opts.Port) - - addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port)) - dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout} - conn, err := dialer.DialContext(ctx, "tcp", addr) - if err != nil { - return fmt.Errorf("telnetclient: dial failed: %w", err) - } - c.conn = conn - - go c.readLoop(ctx) - return nil -} - -// Write sends data to the remote host, escaping any IAC bytes. -func (c *Client) Write(data []byte) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.conn == nil { - return fmt.Errorf("telnetclient: connection is closed") - } - - escaped := escapeIAC(data) - _, err := c.conn.Write(escaped) - return err -} - -// Resize informs the remote host of a terminal size change via NAWS if -// negotiated. -func (c *Client) Resize(cols, rows int) error { - c.mu.Lock() - defer c.mu.Unlock() - - c.cols = cols - c.rows = rows - - if !c.nawsEnabled || c.closed || c.conn == nil { - return nil - } - return c.sendNAWS() -} - -// SendBreak transmits a Telnet BRK command. -func (c *Client) SendBreak() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed || c.conn == nil { - return fmt.Errorf("telnetclient: connection is closed") - } - - c.debug("sending break signal") - _, err := c.conn.Write([]byte{cmdIAC, cmdBRK}) - return err -} - -// Close tears down the Telnet connection. -func (c *Client) Close() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.closed { - return nil - } - c.closed = true - c.debug("closing connection") - - if c.cancel != nil { - c.cancel() - } - if c.conn != nil { - return c.conn.Close() - } - return nil -} - -// readLoop reads from the connection, processes Telnet commands, and delivers -// plain data via the OnData callback. -func (c *Client) readLoop(ctx context.Context) { - buf := make([]byte, 32*1024) - for { - select { - case <-ctx.Done(): - return - default: - } - - n, err := c.conn.Read(buf) - if n > 0 { - c.processBytes(buf[:n]) - } - if err != nil { - if err != io.EOF { - c.debug("read error: %v", err) - if c.opts.OnError != nil { - c.opts.OnError(err) - } - } - c.handleClose() - return - } - } -} - -// processBytes separates Telnet commands from application data. -func (c *Client) processBytes(data []byte) { - var appData []byte - - for i := 0; i < len(data); i++ { - if data[i] == cmdIAC && i+1 < len(data) { - switch data[i+1] { - case cmdIAC: - // Escaped IAC → literal 0xFF. - appData = append(appData, cmdIAC) - i++ - case cmdDO: - if i+2 < len(data) { - c.handleDO(data[i+2]) - i += 2 - } - case cmdDONT: - if i+2 < len(data) { - c.handleDONT(data[i+2]) - i += 2 - } - case cmdWILL: - if i+2 < len(data) { - c.handleWILL(data[i+2]) - i += 2 - } - case cmdWONT: - if i+2 < len(data) { - // Acknowledge WONT with DONT. - c.sendCommand(cmdDONT, data[i+2]) - i += 2 - } - case cmdSB: - // Consume the sub-negotiation until IAC SE. - end := c.findSubEnd(data[i+2:]) - i += 2 + end - default: - i++ - } - } else { - appData = append(appData, data[i]) - } - } - - if len(appData) > 0 && c.opts.OnData != nil { - c.opts.OnData(appData) - } -} - -// handleDO responds to a DO request from the server. -func (c *Client) handleDO(opt byte) { - switch opt { - case optTerminalType: - c.debug("negotiated terminal type") - c.sendCommand(cmdWILL, optTerminalType) - case optNAWS: - c.debug("negotiated NAWS") - c.sendCommand(cmdWILL, optNAWS) - c.mu.Lock() - c.nawsEnabled = true - _ = c.sendNAWS() - c.mu.Unlock() - case optSuppressGoAh: - c.sendCommand(cmdWILL, optSuppressGoAh) - default: - c.sendCommand(cmdWONT, opt) - } -} - -// handleDONT responds to a DONT request from the server. -func (c *Client) handleDONT(opt byte) { - if opt == optNAWS { - c.mu.Lock() - c.nawsEnabled = false - c.mu.Unlock() - } - c.sendCommand(cmdWONT, opt) -} - -// handleWILL responds to a WILL request from the server. -func (c *Client) handleWILL(opt byte) { - switch opt { - case optEcho, optSuppressGoAh: - c.sendCommand(cmdDO, opt) - default: - c.sendCommand(cmdDONT, opt) - } -} - -// sendCommand writes a three-byte Telnet command. -func (c *Client) sendCommand(cmd, opt byte) { - c.mu.Lock() - defer c.mu.Unlock() - if c.conn != nil && !c.closed { - c.conn.Write([]byte{cmdIAC, cmd, opt}) - } -} - -// sendNAWS transmits the current window size via NAWS sub-negotiation. -// Caller must hold c.mu. -func (c *Client) sendNAWS() error { - if c.conn == nil { - return nil - } - msg := []byte{ - cmdIAC, cmdSB, optNAWS, - byte(c.cols >> 8), byte(c.cols & 0xFF), - byte(c.rows >> 8), byte(c.rows & 0xFF), - cmdIAC, cmdSE, - } - _, err := c.conn.Write(msg) - return err -} - -// findSubEnd returns the offset past the IAC SE that terminates a -// sub-negotiation block. -func (c *Client) findSubEnd(data []byte) int { - for i := 0; i < len(data)-1; i++ { - if data[i] == cmdIAC && data[i+1] == cmdSE { - return i + 2 - } - } - return len(data) -} - -// handleClose is called when the connection ends. -func (c *Client) handleClose() { - c.mu.Lock() - wasClosed := c.closed - c.closed = true - c.mu.Unlock() - - if !wasClosed && c.opts.OnClose != nil { - c.opts.OnClose() - } -} - -// escapeIAC doubles any 0xFF bytes in outbound application data so they are -// not mistaken for Telnet commands. -func escapeIAC(data []byte) []byte { - count := 0 - for _, b := range data { - if b == cmdIAC { - count++ - } - } - if count == 0 { - return data - } - out := make([]byte, 0, len(data)+count) - for _, b := range data { - out = append(out, b) - if b == cmdIAC { - out = append(out, cmdIAC) - } - } - return out -} - -func (c *Client) debug(format string, args ...interface{}) { - if c.opts.DebugLog != nil { - c.opts.DebugLog(fmt.Sprintf(format, args...)) - } -}