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