feat: add session manager and API layer

Add the session manager (internal/sessions/manager.go) that orchestrates
multiple simultaneous SSH, Telnet, and RAW TCP sessions with:
- Protocol-specific connection logic using existing client implementations
- Credential resolution via OS keychain → encrypted vault → user prompt
- Wails runtime event emission for frontend state synchronisation
- Full session lifecycle management (connect, disconnect, reconnect)
- RAW session history and capture support
- Telnet break signal forwarding

Add the Wails-bound API service (internal/api/api.go) exposing:
- Profile CRUD with search, filtering, favourites, and duplication
- Session management (connect, disconnect, resize, write)
- RAW-specific text/hex/escaped write methods
- Secret store initialisation, unlock/lock, and per-profile secrets
- Known host listing and deletion
- Preferences get/save
- Profile import/export with skip/overwrite/duplicate strategies
- Audit log retrieval
- Native file dialog wrappers and app info

Remove conflicting protocol client stubs (client.go) that duplicated
the comprehensive implementations in sshclient.go, telnetclient.go,
and rawclient.go.
This commit is contained in:
2026-04-15 00:00:00 +01:00
parent 61e99e9fca
commit bd7cf6cbf9
5 changed files with 1251 additions and 901 deletions
+618
View File
@@ -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
}
-239
View File
@@ -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...))
}
}
+633
View File
@@ -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)
}
-310
View File
@@ -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...))
}
}
-352
View File
@@ -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...))
}
}