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:
+60
-35
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user