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:
2026-04-15 00:00:00 +01:00
parent c767c1640e
commit 5bff1d9f3e
17 changed files with 5644 additions and 45 deletions
+60 -35
View File
@@ -5,35 +5,21 @@ package app
import (
"context"
"fmt"
"log"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/copilot-ssh-client/app/internal/api"
"github.com/copilot-ssh-client/app/internal/audit"
"github.com/copilot-ssh-client/app/internal/config"
"github.com/copilot-ssh-client/app/internal/knownhosts"
"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"
)
// Storage abstracts the persistent data layer (SQLite).
type Storage interface {
Close() error
}
// SessionManager tracks active connection sessions.
type SessionManager interface {
CloseAll()
}
// Preferences manages user application preferences.
type Preferences interface{}
// KnownHostStore manages trusted SSH host key records.
type KnownHostStore interface{}
// AuditLog records auditable application events.
type AuditLog interface{}
// App is the central application struct. Wails binds its lifecycle methods
// (Startup, Shutdown, DomReady) and it holds references to every subsystem.
type App struct {
@@ -41,18 +27,31 @@ type App struct {
mu sync.Mutex
Config *config.AppConfig
Storage Storage
Store *storage.Store
SecretStore *secrets.SecretStore
SessionManager SessionManager
Preferences Preferences
KnownHostStore KnownHostStore
AuditLog AuditLog
KeychainStore *secrets.KeychainStore
SessionManager *sessions.Manager
Prefs *preferences.Manager
KnownHosts *knownhosts.Store
AuditLog *audit.Logger
Service *api.Service
}
// NewApp creates a new App instance. Subsystems are not initialised until
// Startup is called by the Wails runtime.
// Startup is called by the Wails runtime. The API service is pre-created
// so it can be bound by Wails before Startup runs.
func NewApp() *App {
return &App{}
a := &App{}
// The API service is created eagerly so Wails can bind it. Its backing
// stores are nil at this point; they are wired during Startup.
a.Service = &api.Service{}
return a
}
// APIService returns the Wails-bound API service instance. This must be
// called before wails.Run so the binding is registered.
func (a *App) APIService() *api.Service {
return a.Service
}
// Startup is called by Wails when the application starts. It loads
@@ -64,6 +63,7 @@ func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
// Load platform-specific configuration paths.
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load configuration: %v", err)
@@ -74,12 +74,37 @@ func (a *App) Startup(ctx context.Context) {
log.Fatalf("failed to create application directories: %v", err)
}
// Initialise the encrypted secret store.
a.SecretStore = secrets.NewSecretStore(cfg.SecretPath)
// Initialise SQLite storage (migrations run automatically on open).
store, err := storage.NewStore(cfg.DBPath)
if err != nil {
log.Fatalf("failed to open storage: %v", err)
}
a.Store = store
// Initialise secret stores (keychain preferred, encrypted file fallback).
a.SecretStore = secrets.NewSecretStore(cfg.SecretPath)
a.KeychainStore = secrets.NewKeychainStore("NexusTerminal")
// Initialise the audit logger backed by SQLite.
a.AuditLog = audit.NewLogger(store)
// Initialise known hosts store.
a.KnownHosts = knownhosts.NewStore(store)
// Initialise preferences manager.
a.Prefs = preferences.NewManager(store)
if err := a.Prefs.Load(); err != nil {
log.Printf("warning: failed to load preferences, using defaults: %v", err)
}
// Initialise the session manager.
a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog)
a.SessionManager.SetContext(ctx)
// Wire the API service with all backing stores now that they exist.
*a.Service = *api.NewService(store, a.SecretStore, a.KeychainStore, a.SessionManager, a.AuditLog, a.Prefs)
a.Service.SetContext(ctx)
// Storage, session manager, preferences, known host store and audit log
// will be wired in when their implementations are available.
// For now we log successful initialisation.
log.Printf("Nexus Terminal initialised – data directory: %s", cfg.DataDir)
}
@@ -93,8 +118,8 @@ func (a *App) Shutdown(ctx context.Context) {
a.SessionManager.CloseAll()
}
if a.Storage != nil {
if err := a.Storage.Close(); err != nil {
if a.Store != nil {
if err := a.Store.Close(); err != nil {
log.Printf("warning: error closing storage: %v", err)
}
}
@@ -113,5 +138,5 @@ func (a *App) DomReady(ctx context.Context) {
runtime.EventsEmit(ctx, "backend:ready", map[string]interface{}{
"message": "Backend is ready",
})
fmt.Println("DOM ready – backend:ready event emitted")
log.Println("DOM ready – backend:ready event emitted")
}
+142
View File
@@ -0,0 +1,142 @@
package knownhosts
import (
"testing"
"github.com/copilot-ssh-client/app/internal/models"
)
// ---------------------------------------------------------------------------
// Mock storage implementation
// ---------------------------------------------------------------------------
type mockStorage struct {
hosts map[string]*models.KnownHost // keyed by "host:port"
}
func newMockStorage() *mockStorage {
return &mockStorage{hosts: make(map[string]*models.KnownHost)}
}
func (m *mockStorage) key(host string, port int) string {
return host + ":" + string(rune(port))
}
func (m *mockStorage) GetKnownHost(host string, port int) (*models.KnownHost, error) {
kh, ok := m.hosts[m.key(host, port)]
if !ok {
return nil, nil
}
return kh, nil
}
func (m *mockStorage) SaveKnownHost(kh *models.KnownHost) error {
m.hosts[m.key(kh.Host, kh.Port)] = kh
return nil
}
func (m *mockStorage) ListKnownHosts() ([]*models.KnownHost, error) {
result := make([]*models.KnownHost, 0, len(m.hosts))
for _, kh := range m.hosts {
result = append(result, kh)
}
return result, nil
}
func (m *mockStorage) DeleteKnownHost(id string) error {
for k, kh := range m.hosts {
if kh.ID == id {
delete(m.hosts, k)
return nil
}
}
return nil
}
// ---------------------------------------------------------------------------
// Verify unknown host returns false (no error)
// ---------------------------------------------------------------------------
func TestVerifyUnknownHost(t *testing.T) {
store := NewStore(newMockStorage())
trusted, err := store.Verify("unknown.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey")
if err != nil {
t.Fatalf("Verify() unexpected error = %v", err)
}
if trusted {
t.Error("expected trusted = false for unknown host")
}
}
// ---------------------------------------------------------------------------
// Accept then verify returns true
// ---------------------------------------------------------------------------
func TestAcceptThenVerify(t *testing.T) {
store := NewStore(newMockStorage())
if err := store.Accept("server.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey"); err != nil {
t.Fatalf("Accept() error = %v", err)
}
trusted, err := store.Verify("server.example.com", 22, "ssh-ed25519", "SHA256:abc", "pubkey")
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
if !trusted {
t.Error("expected trusted = true after Accept()")
}
}
// ---------------------------------------------------------------------------
// Changed fingerprint returns error
// ---------------------------------------------------------------------------
func TestVerifyChangedFingerprint(t *testing.T) {
store := NewStore(newMockStorage())
// Accept with fingerprint A.
if err := store.Accept("host.local", 22, "ssh-rsa", "SHA256:original", "pubkeyA"); err != nil {
t.Fatalf("Accept() error = %v", err)
}
// Verify with fingerprint B — should return an error indicating a change.
trusted, err := store.Verify("host.local", 22, "ssh-rsa", "SHA256:changed", "pubkeyB")
if err == nil {
t.Fatal("expected error for changed fingerprint")
}
if trusted {
t.Error("expected trusted = false for changed fingerprint")
}
}
// ---------------------------------------------------------------------------
// List and Delete operations
// ---------------------------------------------------------------------------
func TestListAndDelete(t *testing.T) {
mock := newMockStorage()
store := NewStore(mock)
_ = store.Accept("host-a.local", 22, "ssh-ed25519", "fp-a", "pk-a")
_ = store.Accept("host-b.local", 22, "ssh-rsa", "fp-b", "pk-b")
entries, err := store.List()
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
// Delete the first entry.
if err := store.Delete(entries[0].ID); err != nil {
t.Fatalf("Delete() error = %v", err)
}
entries, _ = store.List()
if len(entries) != 1 {
t.Errorf("expected 1 entry after delete, got %d", len(entries))
}
}
+229
View File
@@ -0,0 +1,229 @@
package rawclient
import (
"encoding/hex"
"fmt"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding/charmap"
)
// ParseHexString converts a hex string (with optional spaces and 0x prefixes)
// to raw bytes.
//
// Accepted formats:
//
// "48 65 6c 6c 6f"
// "48656c6c6f"
// "0x48 0x65 0x6c 0x6c 0x6f"
func ParseHexString(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, fmt.Errorf("empty hex string")
}
// Strip 0x/0X prefixes.
s = strings.ReplaceAll(s, "0x", "")
s = strings.ReplaceAll(s, "0X", "")
// Collect only hex digit characters.
var cleaned strings.Builder
for _, r := range s {
if unicode.IsSpace(r) {
continue
}
if !isHexDigit(r) {
return nil, fmt.Errorf("invalid character in hex string: %q", r)
}
cleaned.WriteRune(r)
}
if cleaned.Len()%2 != 0 {
return nil, fmt.Errorf("hex string has odd number of digits")
}
return hex.DecodeString(cleaned.String())
}
// FormatHex formats bytes as a space-separated hex string.
// Example output: "48 65 6c 6c 6f"
func FormatHex(data []byte) string {
if len(data) == 0 {
return ""
}
parts := make([]string, len(data))
for i, b := range data {
parts[i] = fmt.Sprintf("%02x", b)
}
return strings.Join(parts, " ")
}
// FormatHexDump formats bytes as a traditional hex dump with offset, hex,
// and ASCII columns. Each line shows 16 bytes.
//
// Example output:
//
// 00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 |Hello World! |
func FormatHexDump(data []byte) string {
if len(data) == 0 {
return ""
}
var sb strings.Builder
for offset := 0; offset < len(data); offset += 16 {
fmt.Fprintf(&sb, "%08x ", offset)
end := offset + 16
if end > len(data) {
end = len(data)
}
chunk := data[offset:end]
// Hex columns (two groups of eight bytes).
for i := 0; i < 16; i++ {
if i == 8 {
sb.WriteByte(' ')
}
if i < len(chunk) {
fmt.Fprintf(&sb, "%02x ", chunk[i])
} else {
sb.WriteString(" ")
}
}
// ASCII column.
sb.WriteString(" |")
for _, b := range chunk {
if b >= 0x20 && b <= 0x7e {
sb.WriteByte(b)
} else {
sb.WriteByte('.')
}
}
for i := len(chunk); i < 16; i++ {
sb.WriteByte(' ')
}
sb.WriteString("|\n")
}
return sb.String()
}
// ParseEscaped processes C-style escape sequences in a string.
// Supported sequences: \r \n \t \\ \0 \xNN
func ParseEscaped(s string) ([]byte, error) {
var result []byte
i := 0
for i < len(s) {
if s[i] != '\\' {
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size <= 1 {
result = append(result, s[i])
i++
} else {
result = append(result, s[i:i+size]...)
i += size
}
continue
}
if i+1 >= len(s) {
return nil, fmt.Errorf("trailing backslash at position %d", i)
}
next := s[i+1]
switch next {
case 'r':
result = append(result, '\r')
i += 2
case 'n':
result = append(result, '\n')
i += 2
case 't':
result = append(result, '\t')
i += 2
case '\\':
result = append(result, '\\')
i += 2
case '0':
result = append(result, 0)
i += 2
case 'x', 'X':
if i+3 >= len(s) {
return nil, fmt.Errorf("incomplete \\x escape at position %d", i)
}
hexStr := s[i+2 : i+4]
b, err := strconv.ParseUint(hexStr, 16, 8)
if err != nil {
return nil, fmt.Errorf("invalid \\x escape at position %d: %s", i, hexStr)
}
result = append(result, byte(b))
i += 4
default:
return nil, fmt.Errorf("unknown escape sequence \\%c at position %d", next, i)
}
}
return result, nil
}
// ToDisplayEncoding converts raw bytes to a display string using the
// specified encoding. Supported encodings: "utf-8" (default), "ascii",
// "iso-8859-1" (also "latin1").
func ToDisplayEncoding(data []byte, encoding string) string {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "ascii":
return toASCII(data)
case "iso-8859-1", "latin1", "latin-1":
return toLatin1(data)
default:
return toUTF8(data)
}
}
// toASCII replaces non-ASCII bytes with '.', preserving common control chars.
func toASCII(data []byte) string {
var sb strings.Builder
sb.Grow(len(data))
for _, b := range data {
if b >= 0x20 && b <= 0x7e {
sb.WriteByte(b)
} else if b == '\n' || b == '\r' || b == '\t' {
sb.WriteByte(b)
} else {
sb.WriteByte('.')
}
}
return sb.String()
}
// toLatin1 decodes bytes as ISO 8859-1.
func toLatin1(data []byte) string {
decoded, err := charmap.ISO8859_1.NewDecoder().Bytes(data)
if err != nil {
return string(data)
}
return string(decoded)
}
// toUTF8 ensures the output is valid UTF-8, replacing invalid bytes with
// the Unicode replacement character.
func toUTF8(data []byte) string {
if utf8.Valid(data) {
return string(data)
}
var sb strings.Builder
sb.Grow(len(data))
for len(data) > 0 {
r, size := utf8.DecodeRune(data)
sb.WriteRune(r)
data = data[size:]
}
return sb.String()
}
// isHexDigit reports whether r is a valid hexadecimal digit.
func isHexDigit(r rune) bool {
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
}
+226
View File
@@ -0,0 +1,226 @@
package rawclient
import (
"bytes"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// ParseHexString
// ---------------------------------------------------------------------------
func TestParseHexString(t *testing.T) {
tests := []struct {
name string
input string
want []byte
wantErr bool
}{
{
name: "contiguous hex for Hello",
input: "48656c6c6f",
want: []byte("Hello"),
},
{
name: "space-separated hex",
input: "48 65 6c 6c 6f",
want: []byte("Hello"),
},
{
name: "0x prefix",
input: "0x48 0x65 0x6c 0x6c 0x6f",
want: []byte("Hello"),
},
{
name: "empty string",
input: "",
wantErr: true,
},
{
name: "invalid characters",
input: "ZZZZ",
wantErr: true,
},
{
name: "null bytes",
input: "0000",
want: []byte{0x00, 0x00},
},
{
name: "0xff bytes",
input: "ff ff",
want: []byte{0xff, 0xff},
},
{
name: "odd number of digits",
input: "abc",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseHexString(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseHexString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr && !bytes.Equal(got, tt.want) {
t.Errorf("ParseHexString(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// ParseEscaped
// ---------------------------------------------------------------------------
func TestParseEscaped(t *testing.T) {
tests := []struct {
name string
input string
want []byte
wantErr bool
}{
{name: "plain text", input: "hello", want: []byte("hello")},
{name: "newline escape", input: `\n`, want: []byte{'\n'}},
{name: "carriage return + newline", input: `\r\n`, want: []byte{'\r', '\n'}},
{name: "tab escape", input: `\t`, want: []byte{'\t'}},
{name: "null byte", input: `\0`, want: []byte{0x00}},
{name: "hex escape 0xff", input: `\xff`, want: []byte{0xff}},
{name: "escaped backslash", input: `\\`, want: []byte{'\\'}},
{name: "mixed AT command", input: `AT\r\n`, want: []byte{'A', 'T', '\r', '\n'}},
{name: "hex escape 0x00", input: `\x00`, want: []byte{0x00}},
{name: "trailing backslash", input: `hello\`, wantErr: true},
{name: "unknown escape", input: `\q`, wantErr: true},
{name: "incomplete hex escape", input: `\x0`, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseEscaped(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseEscaped(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr && !bytes.Equal(got, tt.want) {
t.Errorf("ParseEscaped(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// FormatHexDump
// ---------------------------------------------------------------------------
func TestFormatHexDump(t *testing.T) {
t.Run("empty data", func(t *testing.T) {
got := FormatHexDump(nil)
if got != "" {
t.Errorf("FormatHexDump(nil) = %q, want empty", got)
}
})
t.Run("small data", func(t *testing.T) {
got := FormatHexDump([]byte("Hi"))
if !strings.Contains(got, "48 69") {
t.Errorf("expected hex for 'Hi', got:\n%s", got)
}
// Should contain the ASCII display column.
if !strings.Contains(got, "|Hi") {
t.Errorf("expected ASCII column with 'Hi', got:\n%s", got)
}
})
t.Run("16+ bytes spans multiple lines", func(t *testing.T) {
data := []byte("ABCDEFGHIJKLMNOPQRST") // 20 bytes
got := FormatHexDump(data)
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
if len(lines) < 2 {
t.Errorf("expected at least 2 lines for 20 bytes, got %d", len(lines))
}
// First line should start with offset 00000000.
if !strings.HasPrefix(lines[0], "00000000") {
t.Errorf("first line should start with 00000000, got %q", lines[0])
}
// Second line should start with offset 00000010.
if !strings.HasPrefix(lines[1], "00000010") {
t.Errorf("second line should start with 00000010, got %q", lines[1])
}
})
}
// ---------------------------------------------------------------------------
// FormatHex
// ---------------------------------------------------------------------------
func TestFormatHex(t *testing.T) {
tests := []struct {
name string
input []byte
want string
}{
{name: "empty", input: nil, want: ""},
{name: "single byte", input: []byte{0x42}, want: "42"},
{name: "Hello", input: []byte("Hello"), want: "48 65 6c 6c 6f"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatHex(tt.input)
if got != tt.want {
t.Errorf("FormatHex() = %q, want %q", got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// ToDisplayEncoding
// ---------------------------------------------------------------------------
func TestToDisplayEncoding(t *testing.T) {
tests := []struct {
name string
data []byte
encoding string
contains string
}{
{
name: "utf-8 passthrough",
data: []byte("Hello, World!"),
encoding: "utf-8",
contains: "Hello, World!",
},
{
name: "ascii replaces non-printable",
data: []byte{0x80, 0x41},
encoding: "ascii",
contains: ".A",
},
{
name: "latin-1 decodes high bytes",
data: []byte{0xe9}, // é in ISO 8859-1
encoding: "latin-1",
contains: "é",
},
{
name: "iso-8859-1 alias",
data: []byte{0xe9},
encoding: "iso-8859-1",
contains: "é",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ToDisplayEncoding(tt.data, tt.encoding)
if !strings.Contains(got, tt.contains) {
t.Errorf("ToDisplayEncoding() = %q, expected to contain %q", got, tt.contains)
}
})
}
}
+308
View File
@@ -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
}
+150
View File
@@ -0,0 +1,150 @@
package sessions
import (
"testing"
"github.com/copilot-ssh-client/app/internal/models"
)
// ---------------------------------------------------------------------------
// NewManager creates with empty sessions map
// ---------------------------------------------------------------------------
func TestNewManager(t *testing.T) {
// NewManager requires backing services; pass nil for lightweight tests
// where no actual connections are made.
mgr := NewManager(nil, nil, nil, nil)
if mgr == nil {
t.Fatal("NewManager() returned nil")
}
if mgr.sessions == nil {
t.Fatal("expected sessions map to be initialised")
}
if len(mgr.sessions) != 0 {
t.Errorf("expected empty sessions map, got %d entries", len(mgr.sessions))
}
}
// ---------------------------------------------------------------------------
// ListSessions on an empty manager
// ---------------------------------------------------------------------------
func TestListSessionsEmpty(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
sessions := mgr.ListSessions()
if len(sessions) != 0 {
t.Errorf("expected 0 sessions, got %d", len(sessions))
}
}
// ---------------------------------------------------------------------------
// createTabName logic
// ---------------------------------------------------------------------------
func TestCreateTabName(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
tests := []struct {
name string
profile *models.Profile
want string
}{
{
name: "uses display name when set",
profile: &models.Profile{
DisplayName: "Production DB",
Host: "db.prod.com",
Username: "admin",
},
want: "Production DB",
},
{
name: "falls back to user@host when no display name",
profile: &models.Profile{
DisplayName: "",
Host: "server.local",
Username: "root",
},
want: "root@server.local",
},
{
name: "falls back to host only when no display name or username",
profile: &models.Profile{
DisplayName: "",
Host: "switch.local",
Username: "",
},
want: "switch.local",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mgr.createTabName(tt.profile)
if got != tt.want {
t.Errorf("createTabName() = %q, want %q", got, tt.want)
}
})
}
}
// ---------------------------------------------------------------------------
// RenameSession on non-existent session
// ---------------------------------------------------------------------------
func TestRenameSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
err := mgr.RenameSession("nonexistent-id", "New Name")
if err == nil {
t.Error("expected error when renaming non-existent session")
}
}
// ---------------------------------------------------------------------------
// PinSession on non-existent session
// ---------------------------------------------------------------------------
func TestPinSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
err := mgr.PinSession("nonexistent-id", true)
if err == nil {
t.Error("expected error when pinning non-existent session")
}
}
// ---------------------------------------------------------------------------
// GetSession on non-existent session
// ---------------------------------------------------------------------------
func TestGetSessionNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
_, err := mgr.GetSession("nonexistent-id")
if err == nil {
t.Error("expected error when getting non-existent session")
}
}
// ---------------------------------------------------------------------------
// Disconnect on non-existent session
// ---------------------------------------------------------------------------
func TestDisconnectNotFound(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
err := mgr.Disconnect("nonexistent-id")
if err == nil {
t.Error("expected error when disconnecting non-existent session")
}
}
// ---------------------------------------------------------------------------
// CloseAll on empty manager does not panic
// ---------------------------------------------------------------------------
func TestCloseAllEmpty(t *testing.T) {
mgr := NewManager(nil, nil, nil, nil)
// Should not panic.
mgr.CloseAll()
if len(mgr.ListSessions()) != 0 {
t.Error("expected 0 sessions after CloseAll")
}
}
+162
View File
@@ -0,0 +1,162 @@
package sshclient
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"golang.org/x/crypto/ssh"
)
// HostKeyStore is the interface for known-host persistence.
// Implementations handle the underlying storage mechanism (e.g. SQLite, file).
type HostKeyStore interface {
GetKnownHost(host string, port int) (*models.KnownHost, error)
SaveKnownHost(kh *models.KnownHost) error
}
// HostKeyVerifier handles SSH host key verification against the known-hosts
// store. It supports four policies:
// - strict: reject unknown or changed keys
// - ask: prompt the user for unknown keys (via callback)
// - accept-new: automatically accept new keys, reject changed keys
// - insecure: accept all keys (for testing only — clearly warned)
type HostKeyVerifier struct {
store HostKeyStore
policy models.HostKeyPolicy
onPrompt func(host string, keyType string, fingerprint string) (bool, error)
}
// NewHostKeyVerifier creates a verifier with the given store and policy.
func NewHostKeyVerifier(store HostKeyStore, policy models.HostKeyPolicy) *HostKeyVerifier {
return &HostKeyVerifier{
store: store,
policy: policy,
}
}
// SetPromptHandler sets the callback used in "ask" mode to prompt the user
// for acceptance of an unknown host key.
func (v *HostKeyVerifier) SetPromptHandler(fn func(string, string, string) (bool, error)) {
v.onPrompt = fn
}
// HostKeyCallback returns an ssh.HostKeyCallback suitable for use with
// ssh.ClientConfig. The behaviour depends on the configured policy.
func (v *HostKeyVerifier) HostKeyCallback() ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
host, port := extractHostPort(hostname, remote)
keyType := key.Type()
fingerprint := computeFingerprint(key)
stored, err := v.store.GetKnownHost(host, port)
if err != nil {
return fmt.Errorf("failed to query known hosts: %w", err)
}
switch v.policy {
case models.HostKeyInsecure:
return v.saveIfNew(stored, host, port, keyType, fingerprint, key)
case models.HostKeyAcceptNew:
if stored == nil {
return v.saveHost(host, port, keyType, fingerprint, key)
}
return v.verifyStored(stored, fingerprint, host)
case models.HostKeyAsk:
if stored == nil {
return v.promptAndSave(host, port, keyType, fingerprint, key)
}
return v.verifyStored(stored, fingerprint, host)
case models.HostKeyStrict:
if stored == nil {
return fmt.Errorf("host key for %s:%d is not known (policy: strict)", host, port)
}
return v.verifyStored(stored, fingerprint, host)
default:
return fmt.Errorf("unknown host key policy: %s", v.policy)
}
}
}
// verifyStored checks that the presented fingerprint matches the stored one.
func (v *HostKeyVerifier) verifyStored(stored *models.KnownHost, fingerprint, host string) error {
if stored.Fingerprint != fingerprint {
return fmt.Errorf(
"host key for %s has changed! stored fingerprint: %s, received: %s — possible MITM attack",
host, stored.Fingerprint, fingerprint,
)
}
stored.LastSeen = time.Now()
_ = v.store.SaveKnownHost(stored)
return nil
}
// promptAndSave asks the user whether to accept an unknown host key.
func (v *HostKeyVerifier) promptAndSave(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
if v.onPrompt == nil {
return fmt.Errorf("host key for %s:%d is unknown and no prompt handler is set (policy: ask)", host, port)
}
accepted, err := v.onPrompt(host, keyType, fingerprint)
if err != nil {
return fmt.Errorf("host key prompt failed: %w", err)
}
if !accepted {
return fmt.Errorf("host key for %s:%d rejected by user", host, port)
}
return v.saveHost(host, port, keyType, fingerprint, key)
}
// saveIfNew stores the host key only when no prior record exists.
func (v *HostKeyVerifier) saveIfNew(stored *models.KnownHost, host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
if stored == nil {
return v.saveHost(host, port, keyType, fingerprint, key)
}
stored.LastSeen = time.Now()
_ = v.store.SaveKnownHost(stored)
return nil
}
// saveHost persists a new known-host record.
func (v *HostKeyVerifier) saveHost(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
now := time.Now()
kh := &models.KnownHost{
Host: host,
Port: port,
KeyType: keyType,
Fingerprint: fingerprint,
PublicKey: base64.StdEncoding.EncodeToString(key.Marshal()),
FirstSeen: now,
LastSeen: now,
Trusted: true,
}
return v.store.SaveKnownHost(kh)
}
// computeFingerprint returns the SHA-256 fingerprint of a public key in
// the standard "SHA256:<base64>" format.
func computeFingerprint(key ssh.PublicKey) string {
hash := sha256.Sum256(key.Marshal())
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
}
// extractHostPort splits the hostname and port from the SSH callback arguments.
func extractHostPort(hostname string, remote net.Addr) (string, int) {
host, portStr, err := net.SplitHostPort(hostname)
if err != nil {
host, portStr, _ = net.SplitHostPort(remote.String())
}
port := 22
if portStr != "" {
fmt.Sscanf(portStr, "%d", &port)
}
return host, port
}
+546
View File
@@ -0,0 +1,546 @@
package sshclient
import (
"context"
"fmt"
"io"
"net"
"os"
"sync"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"golang.org/x/crypto/ssh"
)
// Client manages a single SSH session lifecycle.
// It handles connection establishment, authentication, PTY allocation,
// terminal resize propagation, and clean shutdown.
type Client struct {
mu sync.Mutex
config *models.Profile
sshClient *ssh.Client
session *ssh.Session
stdin io.WriteCloser
stdout io.Reader
stderr io.Reader
connected bool
onData func(data []byte)
onError func(err error)
onClose func()
ctx context.Context
cancel context.CancelFunc
}
// NewClient creates a new SSH 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), onError func(error), onClose func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.onData = onData
c.onError = onError
c.onClose = onClose
}
// sshConf returns the SSH-specific configuration, or a zero value if nil.
func (c *Client) sshConf() models.SSHConfig {
if c.config.SSH != nil {
return *c.config.SSH
}
return models.SSHConfig{}
}
// Connect establishes the SSH connection using the default (insecure)
// host-key callback. Prefer ConnectWithHostKeyCallback for production use.
func (c *Client) Connect(getPassword func() (string, error), getPassphrase func() (string, error)) error {
return c.connectInternal(ssh.InsecureIgnoreHostKey(), getPassword, getPassphrase)
}
// ConnectWithHostKeyCallback applies a custom HostKeyCallback (e.g. from
// HostKeyVerifier) and then establishes the connection.
func (c *Client) ConnectWithHostKeyCallback(
hkCallback ssh.HostKeyCallback,
getPassword func() (string, error),
getPassphrase func() (string, error),
) error {
return c.connectInternal(hkCallback, getPassword, getPassphrase)
}
// connectInternal is the shared implementation for both Connect variants.
func (c *Client) connectInternal(
hkCallback ssh.HostKeyCallback,
getPassword func() (string, error),
getPassphrase func() (string, error),
) error {
c.mu.Lock()
if c.connected {
c.mu.Unlock()
return fmt.Errorf("already connected")
}
c.mu.Unlock()
sc := c.sshConf()
authMethods, err := c.buildAuthMethods(sc, getPassword, getPassphrase)
if err != nil {
return fmt.Errorf("failed to build auth methods: %w", err)
}
termType := sc.TerminalType
if termType == "" {
termType = "xterm-256color"
}
timeout := time.Duration(c.config.ConnectTimeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
sshConfig := &ssh.ClientConfig{
User: c.config.Username,
Auth: authMethods,
HostKeyCallback: hkCallback,
Timeout: timeout,
}
var sshConn *ssh.Client
if len(sc.JumpHosts) > 0 {
sshConn, err = c.connectViaJumpHosts(sc, sshConfig, getPassphrase)
} else {
port := c.config.Port
if port == 0 {
port = 22
}
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
sshConn, err = ssh.Dial("tcp", addr, sshConfig)
}
if err != nil {
return fmt.Errorf("SSH connection failed: %w", err)
}
session, err := sshConn.NewSession()
if err != nil {
sshConn.Close()
return fmt.Errorf("failed to create SSH session: %w", err)
}
// Allocate a PTY with sensible defaults and terminal modes.
cols := 80
rows := 24
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := session.RequestPty(termType, rows, cols, modes); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("PTY request failed: %w", err)
}
stdinPipe, err := session.StdinPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stdin pipe: %w", err)
}
stdoutPipe, err := session.StdoutPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stdout pipe: %w", err)
}
stderrPipe, err := session.StderrPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stderr pipe: %w", err)
}
// Start shell or explicit startup command.
if sc.StartupCommand != "" {
if err := session.Start(sc.StartupCommand); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to start command: %w", err)
}
} else {
if err := session.Shell(); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to start shell: %w", err)
}
}
c.mu.Lock()
c.sshClient = sshConn
c.session = session
c.stdin = stdinPipe
c.stdout = stdoutPipe
c.stderr = stderrPipe
c.connected = true
c.mu.Unlock()
go c.readLoop()
if sc.KeepaliveInterval > 0 {
go c.startKeepalive(sc.KeepaliveInterval)
}
return nil
}
// Write sends data to the SSH session's stdin.
func (c *Client) Write(data []byte) error {
c.mu.Lock()
if !c.connected || c.stdin == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
stdin := c.stdin
c.mu.Unlock()
_, err := stdin.Write(data)
return err
}
// Resize sends a terminal resize request to the remote PTY.
func (c *Client) Resize(cols, rows int) error {
c.mu.Lock()
if !c.connected || c.session == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
session := c.session
c.mu.Unlock()
return session.WindowChange(rows, cols)
}
// Close cleanly shuts down the SSH session and connection, cancels the
// background context, and invokes the onClose callback.
func (c *Client) Close() error {
c.mu.Lock()
if !c.connected {
c.mu.Unlock()
return nil
}
c.connected = false
session := c.session
sshClient := c.sshClient
onClose := c.onClose
c.mu.Unlock()
c.cancel()
var firstErr error
if session != nil {
if err := session.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if sshClient != nil {
if err := sshClient.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if onClose != nil {
onClose()
}
return firstErr
}
// IsConnected returns the current connection state.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// buildAuthMethods constructs the SSH authentication method list from the
// profile's SSHConfig and the provided credential callbacks.
func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
authList := sc.AuthMethods
if len(authList) == 0 {
authList = []string{"publickey", "password", "keyboard-interactive"}
}
for _, m := range authList {
switch m {
case "password":
if getPassword != nil {
methods = append(methods, ssh.PasswordCallback(func() (string, error) {
return getPassword()
}))
}
case "publickey":
if sc.PrivateKeyPath != "" {
signer, err := c.parsePrivateKey(sc.PrivateKeyPath, getPassphrase)
if err != nil {
return nil, fmt.Errorf("failed to parse private key %s: %w", sc.PrivateKeyPath, err)
}
methods = append(methods, ssh.PublicKeys(signer))
}
case "keyboard-interactive":
if getPassword != nil {
pwdCallback := getPassword
methods = append(methods, ssh.KeyboardInteractive(
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
ans, err := pwdCallback()
if err != nil {
return nil, err
}
answers[i] = ans
}
return answers, nil
},
))
}
}
}
if len(methods) == 0 {
return nil, fmt.Errorf("no authentication methods available")
}
return methods, nil
}
// connectViaJumpHosts establishes the SSH connection through a chain of
// jump hosts. Each hop opens a new net.Conn through the previous hop's
// SSH client.
func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.ClientConfig, getPassphrase func() (string, error)) (*ssh.Client, error) {
var currentClient *ssh.Client
for i, jump := range sc.JumpHosts {
jumpPort := jump.Port
if jumpPort == 0 {
jumpPort = 22
}
jumpAddr := net.JoinHostPort(jump.Host, fmt.Sprintf("%d", jumpPort))
var jumpAuth []ssh.AuthMethod
if jump.PrivateKeyPath != "" {
signer, err := c.parsePrivateKey(jump.PrivateKeyPath, getPassphrase)
if err != nil {
if currentClient != nil {
currentClient.Close()
}
return nil, fmt.Errorf("jump host %d private key error: %w", i, err)
}
jumpAuth = append(jumpAuth, ssh.PublicKeys(signer))
}
jumpConfig := &ssh.ClientConfig{
User: jump.Username,
Auth: jumpAuth,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: finalConfig.Timeout,
}
if currentClient == nil {
client, err := ssh.Dial("tcp", jumpAddr, jumpConfig)
if err != nil {
return nil, fmt.Errorf("failed to connect to jump host %d (%s): %w", i, jumpAddr, err)
}
currentClient = client
} else {
conn, err := currentClient.Dial("tcp", jumpAddr)
if err != nil {
currentClient.Close()
return nil, fmt.Errorf("failed to dial through jump host %d (%s): %w", i, jumpAddr, err)
}
ncc, chans, reqs, err := ssh.NewClientConn(conn, jumpAddr, jumpConfig)
if err != nil {
conn.Close()
currentClient.Close()
return nil, fmt.Errorf("failed to establish SSH through jump host %d: %w", i, err)
}
currentClient = ssh.NewClient(ncc, chans, reqs)
}
}
// Final hop to the target host.
targetPort := c.config.Port
if targetPort == 0 {
targetPort = 22
}
targetAddr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", targetPort))
conn, err := currentClient.Dial("tcp", targetAddr)
if err != nil {
currentClient.Close()
return nil, fmt.Errorf("failed to dial target %s through jump hosts: %w", targetAddr, err)
}
ncc, chans, reqs, err := ssh.NewClientConn(conn, targetAddr, finalConfig)
if err != nil {
conn.Close()
currentClient.Close()
return nil, fmt.Errorf("failed to establish SSH to target %s: %w", targetAddr, err)
}
return ssh.NewClient(ncc, chans, reqs), nil
}
// parsePrivateKey loads and parses an SSH private key file. If the key is
// encrypted and getPassphrase is provided, it will attempt decryption.
func (c *Client) parsePrivateKey(path string, getPassphrase func() (string, error)) (ssh.Signer, error) {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("unable to read key file: %w", err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err == nil {
return signer, nil
}
if getPassphrase == nil {
return nil, fmt.Errorf("key is encrypted and no passphrase callback provided: %w", err)
}
passphrase, err := getPassphrase()
if err != nil {
return nil, fmt.Errorf("failed to get passphrase: %w", err)
}
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(passphrase))
if err != nil {
return nil, fmt.Errorf("failed to parse encrypted key: %w", err)
}
return signer, nil
}
// startKeepalive periodically sends keepalive requests to the server.
// It exits when the context is cancelled or the connection closes.
func (c *Client) startKeepalive(intervalSecs int) {
interval := time.Duration(intervalSecs) * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.mu.Lock()
client := c.sshClient
connected := c.connected
c.mu.Unlock()
if !connected || client == nil {
return
}
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
c.mu.Lock()
onError := c.onError
c.mu.Unlock()
if onError != nil {
onError(fmt.Errorf("keepalive failed: %w", err))
}
return
}
}
}
}
// readLoop reads from stdout and stderr, dispatching received data to the
// onData callback. It runs until the context is cancelled or the streams close.
func (c *Client) readLoop() {
done := make(chan struct{}, 2)
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 32*1024)
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
stdout := c.stdout
c.mu.Unlock()
if stdout == nil {
return
}
n, err := stdout.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(data)
}
}
if err != nil {
return
}
}
}()
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 32*1024)
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
stderr := c.stderr
c.mu.Unlock()
if stderr == nil {
return
}
n, err := stderr.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(data)
}
}
if err != nil {
return
}
}
}()
<-done
<-done
c.Close()
}
+20 -5
View File
@@ -302,15 +302,30 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
store := setupTestStore(t)
prefs := models.DefaultAppPreferences()
prefs.ConfirmOnClose = true
prefs.DefaultFontSize = 16
prefs.Theme = "custom-dark"
if err := store.SavePreferences(&prefs); err != nil {
t.Fatalf("SavePreferences() error = %v", err)
}
// NOTE: GetPreferences round-trip is not tested here because the
// current implementation stores all values (including booleans) as
// plain strings in the key-value table. json.Unmarshal cannot
// decode string "true" into a Go bool field. This is a pre-existing
// limitation of the storage layer, not a test defect.
got, err := store.GetPreferences()
if err != nil {
t.Fatalf("GetPreferences() error = %v", err)
}
if got == nil {
t.Fatal("GetPreferences() returned nil after save")
}
if got.ConfirmOnClose != true {
t.Errorf("ConfirmOnClose = %v, want true", got.ConfirmOnClose)
}
if got.DefaultFontSize != 16 {
t.Errorf("DefaultFontSize = %d, want 16", got.DefaultFontSize)
}
if got.Theme != "custom-dark" {
t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark")
}
}
// ---------------------------------------------------------------------------
+603
View File
@@ -0,0 +1,603 @@
package telnetclient
import (
"context"
"encoding/binary"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"golang.org/x/text/encoding/charmap"
)
// Telnet command bytes (RFC 854/855).
const (
IAC byte = 255 // Interpret As Command
DONT byte = 254
DO byte = 253
WONT byte = 252
WILL byte = 251
SB byte = 250 // Sub-negotiation Begin
SE byte = 240 // Sub-negotiation End
// Common options.
OptEcho byte = 1
OptSGA byte = 3 // Suppress Go Ahead
OptTermType byte = 24 // Terminal Type
OptNAWS byte = 31 // Negotiate About Window Size
OptLineMode byte = 34
)
// Sub-negotiation constants.
const (
subIS byte = 0 // IS (used in terminal-type sub-negotiation)
subSEND byte = 1 // SEND (server requests terminal type)
)
// Client manages a single Telnet session.
// It handles Telnet option negotiation (RFC 854/855), terminal-type
// sub-negotiation, window-size negotiation (NAWS), and data framing.
type Client struct {
mu sync.Mutex
config *models.Profile
conn net.Conn
connected bool
onData func(data []byte)
onError func(err error)
onClose func()
onDebug func(msg string)
ctx context.Context
cancel context.CancelFunc
localEcho bool
encoding string
cols int
rows int
// Track which options we have agreed to support.
willOptions map[byte]bool
doOptions map[byte]bool
}
// NewClient creates a new Telnet client bound to the given profile.
func NewClient(profile *models.Profile) *Client {
ctx, cancel := context.WithCancel(context.Background())
localEcho := false
enc := "utf-8"
if profile.Telnet != nil {
localEcho = profile.Telnet.LocalEcho
if profile.Telnet.Encoding != "" {
enc = profile.Telnet.Encoding
}
}
return &Client{
config: profile,
ctx: ctx,
cancel: cancel,
localEcho: localEcho,
encoding: enc,
cols: 80,
rows: 24,
willOptions: make(map[byte]bool),
doOptions: make(map[byte]bool),
}
}
// SetCallbacks sets the data, error, close, and debug callbacks.
// Must be called before Connect.
func (c *Client) SetCallbacks(onData func([]byte), onError func(error), onClose func(), onDebug func(string)) {
c.mu.Lock()
defer c.mu.Unlock()
c.onData = onData
c.onError = onError
c.onClose = onClose
c.onDebug = onDebug
}
// telnetConf returns the Telnet-specific configuration, or a zero value
// if nil.
func (c *Client) telnetConf() models.TelnetConfig {
if c.config.Telnet != nil {
return *c.config.Telnet
}
return models.TelnetConfig{}
}
// Connect establishes the Telnet 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 {
port = 23
}
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("Telnet 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 data to the Telnet session, applying line-ending conversion
// and escaping any literal IAC bytes in the payload.
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()
converted := c.convertLineEnding(data)
escaped := c.escapeIAC(converted)
encoded := c.encodeOutgoing(escaped)
_, err := conn.Write(encoded)
return err
}
// Resize sends a NAWS (Negotiate About Window Size) update to the remote host.
func (c *Client) Resize(cols, rows int) error {
c.mu.Lock()
if !c.connected || c.conn == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
c.cols = cols
c.rows = rows
conn := c.conn
c.mu.Unlock()
return c.sendNAWS(conn, cols, rows)
}
// Close shuts down the Telnet 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 connection state.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// SendBreak transmits a Telnet break signal (IAC BRK).
func (c *Client) SendBreak() 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([]byte{IAC, 243}) // 243 = BRK
return err
}
// readLoop reads from the connection, processes IAC sequences, and dispatches
// plain data to the onData callback.
func (c *Client) readLoop() {
buf := make([]byte, 4096)
var dataBuf []byte
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 {
raw := buf[:n]
dataBuf = dataBuf[:0]
i := 0
for i < len(raw) {
if raw[i] == IAC {
if len(dataBuf) > 0 {
c.dispatchData(dataBuf)
dataBuf = dataBuf[:0]
}
consumed := c.handleIAC(raw[i:])
if consumed == 0 {
break
}
i += consumed
} else {
dataBuf = append(dataBuf, raw[i])
i++
}
}
if len(dataBuf) > 0 {
c.dispatchData(dataBuf)
}
}
if err != nil {
c.mu.Lock()
onError := c.onError
c.mu.Unlock()
if onError != nil {
onError(fmt.Errorf("read error: %w", err))
}
c.Close()
return
}
}
}
// handleIAC processes a Telnet IAC sequence starting at data[0].
// Returns the number of bytes consumed.
func (c *Client) handleIAC(data []byte) int {
if len(data) < 2 {
return 0
}
// IAC IAC = escaped 0xFF byte.
if data[1] == IAC {
c.dispatchData([]byte{IAC})
return 2
}
switch data[1] {
case DO:
if len(data) < 3 {
return 0
}
c.handleDO(data[2])
return 3
case DONT:
if len(data) < 3 {
return 0
}
c.handleDONT(data[2])
return 3
case WILL:
if len(data) < 3 {
return 0
}
c.handleWILL(data[2])
return 3
case WONT:
if len(data) < 3 {
return 0
}
c.handleWONT(data[2])
return 3
case SB:
return c.handleSubnegotiation(data)
default:
c.debug("received IAC command: %d", data[1])
return 2
}
}
// handleDO processes a DO request from the server.
func (c *Client) handleDO(option byte) {
c.debug("received DO %d", option)
switch option {
case OptTermType:
c.negotiate(WILL, option)
c.mu.Lock()
c.willOptions[option] = true
c.mu.Unlock()
case OptNAWS:
c.negotiate(WILL, option)
c.mu.Lock()
c.willOptions[option] = true
cols := c.cols
rows := c.rows
conn := c.conn
c.mu.Unlock()
if conn != nil {
_ = c.sendNAWS(conn, cols, rows)
}
case OptSGA:
c.negotiate(WILL, option)
c.mu.Lock()
c.willOptions[option] = true
c.mu.Unlock()
default:
c.negotiate(WONT, option)
}
}
// handleDONT processes a DONT request from the server.
func (c *Client) handleDONT(option byte) {
c.debug("received DONT %d", option)
c.mu.Lock()
delete(c.willOptions, option)
c.mu.Unlock()
c.negotiate(WONT, option)
}
// handleWILL processes a WILL offer from the server.
func (c *Client) handleWILL(option byte) {
c.debug("received WILL %d", option)
switch option {
case OptEcho:
c.negotiate(DO, option)
c.mu.Lock()
c.doOptions[option] = true
c.localEcho = false
c.mu.Unlock()
case OptSGA:
c.negotiate(DO, option)
c.mu.Lock()
c.doOptions[option] = true
c.mu.Unlock()
default:
c.negotiate(DONT, option)
}
}
// handleWONT processes a WONT refusal from the server.
func (c *Client) handleWONT(option byte) {
c.debug("received WONT %d", option)
if option == OptEcho {
c.mu.Lock()
c.localEcho = true
c.mu.Unlock()
}
c.mu.Lock()
delete(c.doOptions, option)
c.mu.Unlock()
c.negotiate(DONT, option)
}
// handleSubnegotiation processes an SB...SE sub-negotiation block.
// Returns the total number of bytes consumed (including IAC SB and IAC SE).
func (c *Client) handleSubnegotiation(data []byte) int {
for i := 2; i < len(data)-1; i++ {
if data[i] == IAC && data[i+1] == SE {
payload := data[2:i]
c.processSubnegotiation(payload)
return i + 2
}
}
return 0
}
// processSubnegotiation handles the payload between SB and SE.
func (c *Client) processSubnegotiation(payload []byte) {
if len(payload) < 1 {
return
}
option := payload[0]
c.debug("sub-negotiation for option %d", option)
switch option {
case OptTermType:
if len(payload) >= 2 && payload[1] == subSEND {
c.sendTermType()
}
}
}
// sendTermType responds to a terminal-type SEND request with an IS response.
func (c *Client) sendTermType() {
tc := c.telnetConf()
termType := tc.TerminalType
if termType == "" {
termType = "xterm-256color"
}
c.debug("sending terminal type: %s", termType)
resp := []byte{IAC, SB, OptTermType, subIS}
resp = append(resp, []byte(strings.ToUpper(termType))...)
resp = append(resp, IAC, SE)
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn != nil {
_, _ = conn.Write(resp)
}
}
// negotiate sends a WILL/WONT/DO/DONT response for the given option.
func (c *Client) negotiate(command, option byte) {
c.debug("sending %s %d", commandName(command), option)
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn != nil {
_, _ = conn.Write([]byte{IAC, command, option})
}
}
// sendNAWS sends the current window size via NAWS sub-negotiation.
// Columns and rows are each sent as big-endian uint16 values.
func (c *Client) sendNAWS(conn net.Conn, cols, rows int) error {
c.debug("sending NAWS: %dx%d", cols, rows)
var buf [9]byte
buf[0] = IAC
buf[1] = SB
buf[2] = OptNAWS
binary.BigEndian.PutUint16(buf[3:5], uint16(cols))
binary.BigEndian.PutUint16(buf[5:7], uint16(rows))
buf[7] = IAC
buf[8] = SE
_, err := conn.Write(buf[:])
return err
}
// convertLineEnding applies the configured line-ending conversion on
// outgoing data.
func (c *Client) convertLineEnding(data []byte) []byte {
tc := c.telnetConf()
le := tc.LineEnding
if le == "" {
le = models.LineEndingCRLF
}
if le == models.LineEndingNone {
return data
}
s := string(data)
// Normalise all line endings to LF first.
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
switch le {
case models.LineEndingCR:
s = strings.ReplaceAll(s, "\n", "\r")
case models.LineEndingCRLF:
s = strings.ReplaceAll(s, "\n", "\r\n")
case models.LineEndingLF:
// Already LF.
}
return []byte(s)
}
// escapeIAC doubles any literal 0xFF bytes in the data so they are not
// misinterpreted as IAC commands.
func (c *Client) escapeIAC(data []byte) []byte {
count := 0
for _, b := range data {
if b == IAC {
count++
}
}
if count == 0 {
return data
}
escaped := make([]byte, 0, len(data)+count)
for _, b := range data {
escaped = append(escaped, b)
if b == IAC {
escaped = append(escaped, IAC)
}
}
return escaped
}
// encodeOutgoing converts outgoing text from UTF-8 to the configured encoding.
func (c *Client) encodeOutgoing(data []byte) []byte {
switch strings.ToLower(c.encoding) {
case "iso-8859-1", "latin1", "latin-1":
encoded, err := charmap.ISO8859_1.NewEncoder().Bytes(data)
if err != nil {
return data
}
return encoded
default:
return data
}
}
// dispatchData sends received data to the onData callback.
func (c *Client) dispatchData(data []byte) {
out := make([]byte, len(data))
copy(out, data)
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(out)
}
}
// debug emits a formatted debug message if a debug callback is registered.
func (c *Client) debug(format string, args ...interface{}) {
c.mu.Lock()
cb := c.onDebug
c.mu.Unlock()
if cb != nil {
cb(fmt.Sprintf(format, args...))
}
}
// commandName returns a human-readable name for a Telnet negotiation command.
func commandName(cmd byte) string {
switch cmd {
case WILL:
return "WILL"
case WONT:
return "WONT"
case DO:
return "DO"
case DONT:
return "DONT"
default:
return fmt.Sprintf("CMD(%d)", cmd)
}
}
+397
View File
@@ -0,0 +1,397 @@
package validation
import (
"testing"
"github.com/copilot-ssh-client/app/internal/models"
)
// ---------------------------------------------------------------------------
// ValidateHost
// ---------------------------------------------------------------------------
func TestValidateHost(t *testing.T) {
tests := []struct {
name string
host string
wantErr bool
}{
{name: "valid hostname", host: "example.com", wantErr: false},
{name: "valid IP address", host: "192.168.1.1", wantErr: false},
{name: "valid IPv6 address", host: "::1", wantErr: false},
{name: "empty string", host: "", wantErr: true},
{name: "only spaces", host: " ", wantErr: true},
{name: "contains spaces", host: "host name", wantErr: true},
{name: "contains tab", host: "host\tname", wantErr: true},
{name: "URI scheme present", host: "ssh://example.com", wantErr: true},
{name: "leading hyphen", host: "-bad-host", wantErr: true},
{name: "subdomain hostname", host: "server.internal.corp", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateHost(tt.host)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateHost(%q) error = %v, wantErr %v", tt.host, err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidatePort
// ---------------------------------------------------------------------------
func TestValidatePort(t *testing.T) {
tests := []struct {
name string
port int
wantErr bool
}{
{name: "port 1 is valid", port: 1, wantErr: false},
{name: "port 22 is valid", port: 22, wantErr: false},
{name: "port 443 is valid", port: 443, wantErr: false},
{name: "port 65535 is valid", port: 65535, wantErr: false},
{name: "port 0 is invalid", port: 0, wantErr: true},
{name: "negative port is invalid", port: -1, wantErr: true},
{name: "port 65536 is invalid", port: 65536, wantErr: true},
{name: "very large port is invalid", port: 99999, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidatePort(tt.port)
if (err != nil) != tt.wantErr {
t.Errorf("ValidatePort(%d) error = %v, wantErr %v", tt.port, err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateUsername
// ---------------------------------------------------------------------------
func TestValidateUsername(t *testing.T) {
tests := []struct {
name string
username string
wantErr bool
}{
{name: "valid username", username: "admin", wantErr: false},
{name: "empty string is invalid", username: "", wantErr: true},
{name: "only whitespace is invalid", username: " ", wantErr: true},
{name: "username with spaces is allowed", username: "my user", wantErr: false},
{name: "very long username is valid", username: "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789", wantErr: false},
{name: "contains control character", username: "bad\x01user", wantErr: true},
{name: "contains DEL character", username: "bad\x7fuser", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUsername(tt.username)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateUsername(%q) error = %v, wantErr %v", tt.username, err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateProfile
// ---------------------------------------------------------------------------
func TestValidateProfile(t *testing.T) {
// Helper to build a valid SSH profile.
validSSH := func() *models.Profile {
p := models.NewProfile(models.ProtocolSSH)
p.DisplayName = "Test SSH"
p.Host = "example.com"
p.Username = "root"
return &p
}
// Helper to build a valid Telnet profile.
validTelnet := func() *models.Profile {
p := models.NewProfile(models.ProtocolTelnet)
p.DisplayName = "Test Telnet"
p.Host = "switch.local"
return &p
}
// Helper to build a valid RAW profile.
validRAW := func() *models.Profile {
p := models.NewProfile(models.ProtocolRAW)
p.DisplayName = "Test RAW"
p.Host = "device.local"
return &p
}
tests := []struct {
name string
profile *models.Profile
wantErr bool
}{
{name: "full valid SSH profile", profile: validSSH(), wantErr: false},
{name: "full valid Telnet profile", profile: validTelnet(), wantErr: false},
{name: "full valid RAW profile", profile: validRAW(), wantErr: false},
{
name: "missing host",
profile: func() *models.Profile {
p := validSSH()
p.Host = ""
return p
}(),
wantErr: true,
},
{
name: "missing port (zero)",
profile: func() *models.Profile {
p := validSSH()
p.Port = 0
return p
}(),
wantErr: true,
},
{
name: "invalid protocol",
profile: func() *models.Profile {
p := validSSH()
p.Protocol = "ftp"
return p
}(),
wantErr: true,
},
{
name: "SSH missing username",
profile: func() *models.Profile {
p := validSSH()
p.Username = ""
return p
}(),
wantErr: true,
},
{
name: "SSH nil config",
profile: func() *models.Profile {
p := validSSH()
p.SSH = nil
return p
}(),
wantErr: true,
},
{
name: "negative connect timeout",
profile: func() *models.Profile {
p := validSSH()
p.ConnectTimeout = -1
return p
}(),
wantErr: true,
},
{
name: "font size out of range",
profile: func() *models.Profile {
p := validSSH()
p.TerminalFontSize = 0
return p
}(),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateProfile(tt.profile)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateProfile() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateSSHConfig
// ---------------------------------------------------------------------------
func TestValidateSSHConfig(t *testing.T) {
validCfg := func() *models.SSHConfig {
return &models.SSHConfig{
AuthMethods: []string{"password"},
SecretMode: models.SecretModeSaved,
KeySecretMode: models.SecretModeAskEveryTime,
HostKeyPolicy: models.HostKeyAsk,
KeepaliveInterval: 30,
KeepaliveCountMax: 3,
JumpHosts: []models.JumpHost{},
}
}
tests := []struct {
name string
cfg *models.SSHConfig
wantErr bool
}{
{name: "valid config", cfg: validCfg(), wantErr: false},
{
name: "empty auth methods",
cfg: func() *models.SSHConfig {
c := validCfg()
c.AuthMethods = nil
return c
}(),
wantErr: true,
},
{
name: "invalid auth method",
cfg: func() *models.SSHConfig {
c := validCfg()
c.AuthMethods = []string{"oauth"}
return c
}(),
wantErr: true,
},
{
name: "invalid secret mode",
cfg: func() *models.SSHConfig {
c := validCfg()
c.SecretMode = "wrong"
return c
}(),
wantErr: true,
},
{
name: "invalid host key policy",
cfg: func() *models.SSHConfig {
c := validCfg()
c.HostKeyPolicy = "yolo"
return c
}(),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSSHConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateSSHConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateTelnetConfig
// ---------------------------------------------------------------------------
func TestValidateTelnetConfig(t *testing.T) {
tests := []struct {
name string
cfg *models.TelnetConfig
wantErr bool
}{
{
name: "valid config",
cfg: &models.TelnetConfig{
LineEnding: models.LineEndingCRLF,
Encoding: "utf-8",
},
wantErr: false,
},
{
name: "invalid line ending",
cfg: &models.TelnetConfig{
LineEnding: "bad",
Encoding: "utf-8",
},
wantErr: true,
},
{
name: "empty encoding",
cfg: &models.TelnetConfig{
LineEnding: models.LineEndingLF,
Encoding: "",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTelnetConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateTelnetConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// ---------------------------------------------------------------------------
// ValidateRAWConfig
// ---------------------------------------------------------------------------
func TestValidateRAWConfig(t *testing.T) {
tests := []struct {
name string
cfg *models.RAWConfig
wantErr bool
}{
{
name: "valid config",
cfg: &models.RAWConfig{
LineEnding: models.LineEndingCRLF,
Encoding: "utf-8",
PayloadPresets: []models.PayloadPreset{},
},
wantErr: false,
},
{
name: "invalid line ending",
cfg: &models.RAWConfig{
LineEnding: "bad",
Encoding: "utf-8",
},
wantErr: true,
},
{
name: "empty encoding",
cfg: &models.RAWConfig{
LineEnding: models.LineEndingLF,
Encoding: "",
},
wantErr: true,
},
{
name: "preset with invalid format",
cfg: &models.RAWConfig{
LineEnding: models.LineEndingCRLF,
Encoding: "utf-8",
PayloadPresets: []models.PayloadPreset{
{Name: "ping", Format: "binary"},
},
},
wantErr: true,
},
{
name: "preset with empty name",
cfg: &models.RAWConfig{
LineEnding: models.LineEndingCRLF,
Encoding: "utf-8",
PayloadPresets: []models.PayloadPreset{
{Name: "", Format: "text"},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateRAWConfig(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateRAWConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}