feat: complete Nexus Terminal — premium SSH/Telnet/RAW TCP desktop client
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
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user