+
Nexus Terminal
+
Version 1.0.0
+
A premium desktop SSH, Telnet, and RAW TCP client built with Wails.
+
+
AES-256-GCM encrypted credential vault
+
Platform-native keychain integration
+
xterm.js terminal emulation
+
+
+ `;
+ }
+
+ // ── Form helpers ─────────────────────────────────────────────────
+
+ /**
+ * Renders a single settings form field.
+ * @param {string} type
+ * @param {string} name
+ * @param {string} label
+ * @param {*} value
+ * @param {Object} [opts]
+ * @returns {HTMLElement}
+ * @private
+ */
+ _settingField(type, name, label, value, opts = {}) {
+ const group = createElement('div', 'form-group settings-field');
+ const labelEl = createElement('label', 'form-label', { textContent: label, for: `setting-${name}` });
+ group.appendChild(labelEl);
+
+ let input;
+ switch (type) {
+ case 'text':
+ input = createElement('input', 'form-input', {
+ type: 'text',
+ id: `setting-${name}`,
+ name: `setting-${name}`,
+ value: value || '',
+ placeholder: opts.placeholder || '',
+ });
+ break;
+
+ case 'number':
+ input = createElement('input', 'form-input', {
+ type: 'number',
+ id: `setting-${name}`,
+ name: `setting-${name}`,
+ value: value != null ? String(value) : '',
+ });
+ if (opts.min != null) input.min = opts.min;
+ if (opts.max != null) input.max = opts.max;
+ break;
+
+ case 'select':
+ input = createElement('select', 'form-select', {
+ id: `setting-${name}`,
+ name: `setting-${name}`,
+ });
+ for (const opt of (opts.options || [])) {
+ const option = createElement('option', '', { value: opt.value, textContent: opt.label });
+ if (opt.value === value) option.selected = true;
+ input.appendChild(option);
+ }
+ break;
+
+ case 'toggle': {
+ const wrapper = createElement('div', 'form-toggle-wrapper');
+ input = createElement('input', 'form-toggle', {
+ type: 'checkbox',
+ id: `setting-${name}`,
+ name: `setting-${name}`,
+ });
+ input.checked = !!value;
+ const slider = createElement('label', 'form-toggle-label', { for: `setting-${name}` });
+ wrapper.appendChild(input);
+ wrapper.appendChild(slider);
+ group.appendChild(wrapper);
+ return group;
+ }
+
+ default:
+ input = createElement('input', 'form-input', {
+ type: 'text',
+ id: `setting-${name}`,
+ name: `setting-${name}`,
+ value: value || '',
+ });
+ }
+
+ group.appendChild(input);
+ return group;
+ }
+
+ /**
+ * Reads current form field values back into the working preferences copy.
+ * @private
+ */
+ _collectValues() {
+ if (!this._overlay) return;
+
+ const val = (name) => {
+ const el = this._overlay.querySelector(`[name="setting-${name}"]`);
+ if (!el) return undefined;
+ if (el.type === 'checkbox') return el.checked;
+ if (el.type === 'number') return el.value !== '' ? Number(el.value) : 0;
+ return el.value;
+ };
+
+ const fields = [
+ 'theme', 'defaultProtocol', 'confirmOnClose', 'confirmOnDisconnect',
+ 'showStatusBar', 'sidebarWidth', 'defaultFontFamily', 'defaultFontSize',
+ 'defaultScrollback', 'defaultCursorStyle',
+ ];
+
+ for (const field of fields) {
+ const v = val(field);
+ if (v !== undefined) {
+ this._prefs[field] = v;
+ }
+ }
+ }
+}
diff --git a/internal/app/app.go b/internal/app/app.go
index 9e738d8..f4e5825 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -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")
}
diff --git a/internal/knownhosts/knownhosts_test.go b/internal/knownhosts/knownhosts_test.go
new file mode 100644
index 0000000..b04e67a
--- /dev/null
+++ b/internal/knownhosts/knownhosts_test.go
@@ -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))
+ }
+}
diff --git a/internal/rawclient/encoding.go b/internal/rawclient/encoding.go
new file mode 100644
index 0000000..a7acc2b
--- /dev/null
+++ b/internal/rawclient/encoding.go
@@ -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')
+}
diff --git a/internal/rawclient/encoding_test.go b/internal/rawclient/encoding_test.go
new file mode 100644
index 0000000..949ef20
--- /dev/null
+++ b/internal/rawclient/encoding_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/rawclient/rawclient.go b/internal/rawclient/rawclient.go
new file mode 100644
index 0000000..d026256
--- /dev/null
+++ b/internal/rawclient/rawclient.go
@@ -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
+}
diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go
new file mode 100644
index 0000000..39103dc
--- /dev/null
+++ b/internal/sessions/manager_test.go
@@ -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")
+ }
+}
diff --git a/internal/sshclient/hostkey.go b/internal/sshclient/hostkey.go
new file mode 100644
index 0000000..3f74dfd
--- /dev/null
+++ b/internal/sshclient/hostkey.go
@@ -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: