Production-minded Wails v2 desktop application with: - Full SSH client (password, key, keyboard-interactive, jump hosts, keepalive) - Telnet client (RFC 854/855 negotiation, NAWS, TTYPE, encoding) - RAW TCP client (binary-safe, text/hex/split views, payload presets) - SQLite storage with migrations and repository abstraction - Encrypted secret storage (AES-256-GCM + Argon2id) with keychain fallback - Known hosts verification (strict/ask/accept-new/insecure) - Session manager with multi-session lifecycle and Wails events - Dark charcoal Material-inspired design system (CSS custom properties) - Tabbed session management with rename, reorder, pin, context menu - Profile editor with protocol-aware form sections - Command palette, notifications, settings modal - JSON import/export, audit logging, preferences - 50+ tests across validation, secrets, storage, encoding, sessions - Makefile, build.sh, .gitignore, comprehensive README
309 lines
6.5 KiB
Go
309 lines
6.5 KiB
Go
package rawclient
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/copilot-ssh-client/app/internal/models"
|
|
)
|
|
|
|
// Client manages a raw TCP connection.
|
|
// It provides binary-safe data handling with support for text and hex views,
|
|
// timestamps, direction markers, and payload presets.
|
|
type Client struct {
|
|
mu sync.Mutex
|
|
config *models.Profile
|
|
conn net.Conn
|
|
connected bool
|
|
onData func(data []byte, direction string, timestamp time.Time)
|
|
onError func(err error)
|
|
onClose func()
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
history []HistoryEntry
|
|
historyMu sync.RWMutex
|
|
}
|
|
|
|
// HistoryEntry records a single sent or received data frame with its timestamp.
|
|
type HistoryEntry struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Direction string `json:"direction"` // "send" or "recv"
|
|
Data []byte `json:"data"`
|
|
}
|
|
|
|
// NewClient creates a new raw TCP client bound to the given profile.
|
|
func NewClient(profile *models.Profile) *Client {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Client{
|
|
config: profile,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// SetCallbacks sets the data, error, and close callbacks. Must be called
|
|
// before Connect.
|
|
func (c *Client) SetCallbacks(onData func([]byte, string, time.Time), onError func(error), onClose func()) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.onData = onData
|
|
c.onError = onError
|
|
c.onClose = onClose
|
|
}
|
|
|
|
// rawConf returns the RAW-specific configuration, or a zero value if nil.
|
|
func (c *Client) rawConf() models.RAWConfig {
|
|
if c.config.RAW != nil {
|
|
return *c.config.RAW
|
|
}
|
|
return models.RAWConfig{}
|
|
}
|
|
|
|
// Connect establishes the raw TCP connection and starts the read loop.
|
|
func (c *Client) Connect() error {
|
|
c.mu.Lock()
|
|
if c.connected {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("already connected")
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
timeout := time.Duration(c.config.ConnectTimeout) * time.Second
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
port := c.config.Port
|
|
if port == 0 {
|
|
return fmt.Errorf("port must be specified for raw TCP connections")
|
|
}
|
|
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
|
|
|
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
|
if err != nil {
|
|
return fmt.Errorf("raw TCP connection to %s failed: %w", addr, err)
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.conn = conn
|
|
c.connected = true
|
|
c.mu.Unlock()
|
|
|
|
go c.readLoop()
|
|
return nil
|
|
}
|
|
|
|
// Write sends raw bytes to the connection and records the frame in history.
|
|
func (c *Client) Write(data []byte) error {
|
|
c.mu.Lock()
|
|
if !c.connected || c.conn == nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
conn := c.conn
|
|
c.mu.Unlock()
|
|
|
|
_, err := conn.Write(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now()
|
|
c.appendHistory(HistoryEntry{
|
|
Timestamp: now,
|
|
Direction: "send",
|
|
Data: copyBytes(data),
|
|
})
|
|
|
|
c.mu.Lock()
|
|
cb := c.onData
|
|
c.mu.Unlock()
|
|
if cb != nil {
|
|
cb(copyBytes(data), "send", now)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WriteText sends text with the configured line ending appended.
|
|
func (c *Client) WriteText(text string) error {
|
|
data := c.applyLineEnding(text)
|
|
return c.Write(data)
|
|
}
|
|
|
|
// WriteHex sends hex-encoded bytes (e.g. "48656c6c6f" or "48 65 6c 6c 6f").
|
|
func (c *Client) WriteHex(hexStr string) error {
|
|
data, err := ParseHexString(hexStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid hex input: %w", err)
|
|
}
|
|
return c.Write(data)
|
|
}
|
|
|
|
// WriteEscaped sends text with C-style escape sequences processed
|
|
// (\r, \n, \t, \x00, etc.).
|
|
func (c *Client) WriteEscaped(text string) error {
|
|
data, err := ParseEscaped(text)
|
|
if err != nil {
|
|
return fmt.Errorf("escape processing failed: %w", err)
|
|
}
|
|
return c.Write(data)
|
|
}
|
|
|
|
// Close shuts down the raw TCP connection and invokes the onClose callback.
|
|
func (c *Client) Close() error {
|
|
c.mu.Lock()
|
|
if !c.connected {
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
c.connected = false
|
|
conn := c.conn
|
|
onClose := c.onClose
|
|
c.mu.Unlock()
|
|
|
|
c.cancel()
|
|
|
|
var err error
|
|
if conn != nil {
|
|
err = conn.Close()
|
|
}
|
|
if onClose != nil {
|
|
onClose()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// IsConnected returns the current connection state.
|
|
func (c *Client) IsConnected() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.connected
|
|
}
|
|
|
|
// GetHistory returns a copy of the session data history.
|
|
func (c *Client) GetHistory() []HistoryEntry {
|
|
c.historyMu.RLock()
|
|
defer c.historyMu.RUnlock()
|
|
|
|
out := make([]HistoryEntry, len(c.history))
|
|
copy(out, c.history)
|
|
return out
|
|
}
|
|
|
|
// ClearHistory clears the session history buffer.
|
|
func (c *Client) ClearHistory() {
|
|
c.historyMu.Lock()
|
|
defer c.historyMu.Unlock()
|
|
c.history = nil
|
|
}
|
|
|
|
// SaveCapture writes the session history to a file in JSON format.
|
|
func (c *Client) SaveCapture(path string) error {
|
|
entries := c.GetHistory()
|
|
data, err := json.MarshalIndent(entries, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal capture data: %w", err)
|
|
}
|
|
return os.WriteFile(path, data, 0600)
|
|
}
|
|
|
|
// readLoop reads from the connection and dispatches received data to the
|
|
// onData callback.
|
|
func (c *Client) readLoop() {
|
|
buf := make([]byte, 4096)
|
|
|
|
for {
|
|
select {
|
|
case <-c.ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
c.mu.Lock()
|
|
conn := c.conn
|
|
connected := c.connected
|
|
c.mu.Unlock()
|
|
|
|
if !connected || conn == nil {
|
|
return
|
|
}
|
|
|
|
n, err := conn.Read(buf)
|
|
if n > 0 {
|
|
now := time.Now()
|
|
data := copyBytes(buf[:n])
|
|
|
|
c.appendHistory(HistoryEntry{
|
|
Timestamp: now,
|
|
Direction: "recv",
|
|
Data: data,
|
|
})
|
|
|
|
c.mu.Lock()
|
|
cb := c.onData
|
|
c.mu.Unlock()
|
|
if cb != nil {
|
|
cb(data, "recv", now)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
c.mu.Lock()
|
|
cb := c.onError
|
|
c.mu.Unlock()
|
|
if cb != nil {
|
|
cb(fmt.Errorf("read error: %w", err))
|
|
}
|
|
c.Close()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// appendHistory records a history entry in a thread-safe manner.
|
|
func (c *Client) appendHistory(entry HistoryEntry) {
|
|
c.historyMu.Lock()
|
|
defer c.historyMu.Unlock()
|
|
c.history = append(c.history, entry)
|
|
}
|
|
|
|
// applyLineEnding appends the configured line ending to text.
|
|
func (c *Client) applyLineEnding(text string) []byte {
|
|
rc := c.rawConf()
|
|
le := rc.LineEnding
|
|
if le == "" {
|
|
le = models.LineEndingCRLF
|
|
}
|
|
|
|
var suffix string
|
|
switch le {
|
|
case models.LineEndingCR:
|
|
suffix = "\r"
|
|
case models.LineEndingLF:
|
|
suffix = "\n"
|
|
case models.LineEndingCRLF:
|
|
suffix = "\r\n"
|
|
case models.LineEndingNone:
|
|
// No suffix.
|
|
}
|
|
|
|
if suffix != "" && !strings.HasSuffix(text, suffix) {
|
|
text += suffix
|
|
}
|
|
return []byte(text)
|
|
}
|
|
|
|
// copyBytes returns an independent copy of a byte slice.
|
|
func copyBytes(data []byte) []byte {
|
|
out := make([]byte, len(data))
|
|
copy(out, data)
|
|
return out
|
|
}
|