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:
@@ -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
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user