Add Go project scaffold for Wails v2 SSH/Telnet/RAW TCP client

- go.mod with Wails v2.9.2, x/crypto, go-sqlite3, go-keyring, uuid
- cmd/app/main.go: Wails entry point (Nexus Terminal, 1400x900, #1a1a2e)
- internal/app/app.go: lifecycle (Startup/Shutdown/DomReady)
- internal/models/models.go: full model definitions (Profile, SSHConfig,
  TelnetConfig, RAWConfig, SessionInfo, KnownHost, AppPreferences, etc.)
- internal/models/defaults.go: default values and NewProfile factory
- internal/validation/validation.go: thorough field-level validation
- frontend/embed.go: embedded frontend assets
- Updated existing storage, secrets, config to match new model structure
This commit is contained in:
2026-04-15 00:00:00 +01:00
commit 35bc9c75b1
36 changed files with 6226 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
// Package app provides the core application lifecycle for Nexus Terminal.
// It wires together storage, secrets, sessions, preferences, known hosts,
// and the audit log, and exposes Wails lifecycle hooks.
package app
import (
"context"
"fmt"
"log"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/copilot-ssh-client/app/internal/config"
"github.com/copilot-ssh-client/app/internal/secrets"
)
// 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 {
ctx context.Context
mu sync.Mutex
Config *config.AppConfig
Storage Storage
SecretStore *secrets.SecretStore
SessionManager SessionManager
Preferences Preferences
KnownHostStore KnownHostStore
AuditLog AuditLog
}
// NewApp creates a new App instance. Subsystems are not initialised until
// Startup is called by the Wails runtime.
func NewApp() *App {
return &App{}
}
// Startup is called by Wails when the application starts. It loads
// configuration, ensures required directories exist, and initialises all
// subsystems (storage, secret store, session manager, preferences).
func (a *App) Startup(ctx context.Context) {
a.mu.Lock()
defer a.mu.Unlock()
a.ctx = ctx
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load configuration: %v", err)
}
a.Config = cfg
if err := cfg.EnsureDirectories(); err != nil {
log.Fatalf("failed to create application directories: %v", err)
}
// Initialise the encrypted secret store.
a.SecretStore = secrets.NewSecretStore(cfg.SecretPath)
// 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)
}
// Shutdown is called by Wails when the application is closing. It ensures all
// sessions are cleanly terminated and persistent storage is flushed.
func (a *App) Shutdown(ctx context.Context) {
a.mu.Lock()
defer a.mu.Unlock()
if a.SessionManager != nil {
a.SessionManager.CloseAll()
}
if a.Storage != nil {
if err := a.Storage.Close(); err != nil {
log.Printf("warning: error closing storage: %v", err)
}
}
// Lock the secret store to zero key material.
if a.SecretStore != nil {
a.SecretStore.Lock()
}
log.Println("Nexus Terminal shut down cleanly")
}
// DomReady is called by Wails once the frontend DOM is fully loaded. It emits
// a "backend:ready" event so the frontend knows the backend is prepared.
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")
}
+100
View File
@@ -0,0 +1,100 @@
// Package audit provides structured audit logging for security-relevant
// operations. Secrets are never included in audit entries.
package audit
import (
"fmt"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/google/uuid"
)
// AuditStore is the persistence interface used by the Logger.
type AuditStore interface {
AddAuditEntry(entry *models.AuditEntry) error
ListAuditEntries(limit int) ([]*models.AuditEntry, error)
}
// Logger writes structured audit entries for connection events, profile
// changes, secret access, and import/export operations.
type Logger struct {
store AuditStore
}
// NewLogger creates a Logger backed by the given store.
func NewLogger(store AuditStore) *Logger {
return &Logger{store: store}
}
// Log records a generic audit entry. Fields left empty are stored as-is.
func (l *Logger) Log(action string, profileID string, sessionID string, details string) {
entry := &models.AuditEntry{
ID: uuid.New().String(),
Timestamp: time.Now(),
Action: action,
ProfileID: profileID,
SessionID: sessionID,
Details: details,
}
// Best-effort logging — errors are silently discarded to avoid
// disrupting the caller's operation.
_ = l.store.AddAuditEntry(entry)
}
// LogConnect records a session connection event.
func (l *Logger) LogConnect(profileID string, sessionID string, protocol string, host string) {
l.Log("connect", profileID, sessionID,
fmt.Sprintf("Connected via %s to %s", protocol, host))
}
// LogDisconnect records a session disconnection event.
func (l *Logger) LogDisconnect(sessionID string) {
l.Log("disconnect", "", sessionID, "Session disconnected")
}
// LogProfileCreate records the creation of a new profile.
func (l *Logger) LogProfileCreate(profileID string, name string) {
l.Log("profile.create", profileID, "",
fmt.Sprintf("Created profile %q", name))
}
// LogProfileUpdate records an update to an existing profile.
func (l *Logger) LogProfileUpdate(profileID string, name string) {
l.Log("profile.update", profileID, "",
fmt.Sprintf("Updated profile %q", name))
}
// LogProfileDelete records the deletion of a profile.
func (l *Logger) LogProfileDelete(profileID string, name string) {
l.Log("profile.delete", profileID, "",
fmt.Sprintf("Deleted profile %q", name))
}
// LogSecretAccess records access to stored credentials. The secret value is
// never recorded — only the type of secret accessed.
func (l *Logger) LogSecretAccess(profileID string, secretType string) {
l.Log("secret.access", profileID, "",
fmt.Sprintf("Accessed %s credential", secretType))
}
// LogImport records a bulk profile import.
func (l *Logger) LogImport(count int) {
l.Log("import", "", "",
fmt.Sprintf("Imported %d profile(s)", count))
}
// LogExport records a profile export, noting whether secrets were included.
func (l *Logger) LogExport(count int, includeSecrets bool) {
secretsNote := "without secrets"
if includeSecrets {
secretsNote = "with secrets"
}
l.Log("export", "", "",
fmt.Sprintf("Exported %d profile(s) %s", count, secretsNote))
}
// ListEntries returns recent audit entries, delegating to the backing store.
func (l *Logger) ListEntries(limit int) ([]*models.AuditEntry, error) {
return l.store.ListAuditEntries(limit)
}
+87
View File
@@ -0,0 +1,87 @@
// Package config resolves platform-specific application paths and configuration.
package config
import (
"os"
"path/filepath"
"runtime"
)
const appName = "NexusTerminal"
// AppConfig holds the resolved application configuration paths.
type AppConfig struct {
DataDir string // base data directory
DBPath string // SQLite database path
SecretPath string // encrypted vault directory
LogPath string // log file path
}
// Load resolves the application configuration.
// Data directory follows platform conventions:
// - macOS: ~/Library/Application Support/NexusTerminal
// - Linux: ~/.config/NexusTerminal
// - Windows: %APPDATA%/NexusTerminal
func Load() (*AppConfig, error) {
dataDir, err := resolveDataDir()
if err != nil {
return nil, err
}
cfg := &AppConfig{
DataDir: dataDir,
DBPath: filepath.Join(dataDir, "nexus.db"),
SecretPath: filepath.Join(dataDir, "vault"),
LogPath: filepath.Join(dataDir, "nexus.log"),
}
return cfg, nil
}
// EnsureDirectories creates required directories if they don't exist.
func (c *AppConfig) EnsureDirectories() error {
dirs := []string{
c.DataDir,
c.SecretPath,
filepath.Dir(c.LogPath),
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0700); err != nil {
return err
}
}
return nil
}
// resolveDataDir determines the base data directory for the current platform.
func resolveDataDir() (string, error) {
var base string
switch runtime.GOOS {
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, "Library", "Application Support")
case "windows":
base = os.Getenv("APPDATA")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, "AppData", "Roaming")
}
default: // Linux and other UNIX-likes
base = os.Getenv("XDG_CONFIG_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, ".config")
}
}
return filepath.Join(base, appName), nil
}
+98
View File
@@ -0,0 +1,98 @@
// Package knownhosts manages SSH known host entries, providing verification,
// storage, and management of host key fingerprints.
package knownhosts
import (
"fmt"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/google/uuid"
)
// KnownHostStorage is the persistence interface used by the Store.
type KnownHostStorage interface {
GetKnownHost(host string, port int) (*models.KnownHost, error)
SaveKnownHost(kh *models.KnownHost) error
ListKnownHosts() ([]*models.KnownHost, error)
DeleteKnownHost(id string) error
}
// Store manages SSH known host entries. It provides verification against
// previously accepted host keys and allows new keys to be trusted.
type Store struct {
storage KnownHostStorage
}
// NewStore creates a Store backed by the given storage implementation.
func NewStore(storage KnownHostStorage) *Store {
return &Store{storage: storage}
}
// Verify checks whether the presented host key matches a previously accepted
// entry. Returns true if the fingerprint matches, false if there is no record,
// and an error if the key has changed (possible MITM).
func (s *Store) Verify(host string, port int, keyType string, fingerprint string, publicKey string) (bool, error) {
existing, err := s.storage.GetKnownHost(host, port)
if err != nil {
return false, fmt.Errorf("knownhosts: lookup failed: %w", err)
}
if existing == nil {
// No prior record — the caller should prompt the user.
return false, nil
}
if existing.Fingerprint == fingerprint {
// Key matches — update the last-seen timestamp.
existing.LastSeen = time.Now()
_ = s.storage.SaveKnownHost(existing)
return true, nil
}
// Fingerprint mismatch — the host key has changed.
return false, fmt.Errorf(
"knownhosts: host key for %s:%d has changed (was %s %s, now %s %s); "+
"this could indicate a man-in-the-middle attack",
host, port,
existing.KeyType, existing.Fingerprint,
keyType, fingerprint,
)
}
// Accept stores a new host key as trusted. If an entry already exists for the
// host/port combination it is overwritten.
func (s *Store) Accept(host string, port int, keyType string, fingerprint string, publicKey string) error {
now := time.Now()
kh := &models.KnownHost{
ID: uuid.New().String(),
Host: host,
Port: port,
KeyType: keyType,
Fingerprint: fingerprint,
PublicKey: publicKey,
FirstSeen: now,
LastSeen: now,
Trusted: true,
}
if err := s.storage.SaveKnownHost(kh); err != nil {
return fmt.Errorf("knownhosts: failed to accept key: %w", err)
}
return nil
}
// List returns all stored known host entries.
func (s *Store) List() ([]*models.KnownHost, error) {
entries, err := s.storage.ListKnownHosts()
if err != nil {
return nil, fmt.Errorf("knownhosts: failed to list entries: %w", err)
}
return entries, nil
}
// Delete removes a known host entry by ID.
func (s *Store) Delete(id string) error {
if err := s.storage.DeleteKnownHost(id); err != nil {
return fmt.Errorf("knownhosts: failed to delete entry: %w", err)
}
return nil
}
+102
View File
@@ -0,0 +1,102 @@
package models
import (
"time"
"github.com/google/uuid"
)
// Default port numbers for each protocol.
const (
DefaultSSHPort = 22
DefaultTelnetPort = 23
DefaultRAWPort = 23
)
// Default timeout values (in seconds).
const (
DefaultConnectTimeout = 30
DefaultReadTimeout = 0 // no timeout
)
// Default terminal rendering settings.
const (
DefaultScrollback = 10000
DefaultFontSize = 14
DefaultFontFamily = "JetBrains Mono, Menlo, Monaco, Consolas, monospace"
DefaultCursorStyle = "block"
DefaultTerminalType = "xterm-256color"
)
// Default SSH keepalive settings.
const (
DefaultKeepaliveInterval = 30
DefaultKeepaliveCountMax = 3
)
// DefaultAppPreferences returns sensible default application preferences.
func DefaultAppPreferences() AppPreferences {
return AppPreferences{
Theme: "dark",
DefaultProtocol: string(ProtocolSSH),
ConfirmOnClose: true,
ConfirmOnDisconnect: true,
ShowStatusBar: true,
SidebarWidth: 280,
DefaultFontFamily: DefaultFontFamily,
DefaultFontSize: DefaultFontSize,
DefaultScrollback: DefaultScrollback,
DefaultCursorStyle: DefaultCursorStyle,
}
}
// NewProfile creates a new Profile with sensible defaults for the given protocol.
func NewProfile(protocol Protocol) Profile {
now := time.Now()
p := Profile{
ID: uuid.New().String(),
Protocol: protocol,
Tags: []string{},
ConnectTimeout: DefaultConnectTimeout,
ReadTimeout: DefaultReadTimeout,
TerminalFontFamily: DefaultFontFamily,
TerminalFontSize: DefaultFontSize,
TerminalScrollback: DefaultScrollback,
TerminalCursorStyle: DefaultCursorStyle,
CreatedAt: now,
UpdatedAt: now,
}
switch protocol {
case ProtocolSSH:
p.Port = DefaultSSHPort
p.SSH = &SSHConfig{
AuthMethods: []string{"publickey", "password", "keyboard-interactive"},
SecretMode: SecretModeAskEveryTime,
KeySecretMode: SecretModeAskEveryTime,
HostKeyPolicy: HostKeyAsk,
TerminalType: DefaultTerminalType,
KeepaliveInterval: DefaultKeepaliveInterval,
KeepaliveCountMax: DefaultKeepaliveCountMax,
JumpHosts: []JumpHost{},
EnvVars: map[string]string{},
}
case ProtocolTelnet:
p.Port = DefaultTelnetPort
p.Telnet = &TelnetConfig{
LocalEcho: false,
LineEnding: LineEndingCRLF,
TerminalType: DefaultTerminalType,
Encoding: "utf-8",
}
case ProtocolRAW:
p.Port = DefaultRAWPort
p.RAW = &RAWConfig{
LineEnding: LineEndingCRLF,
Encoding: "utf-8",
PayloadPresets: []PayloadPreset{},
}
}
return p
}
+195
View File
@@ -0,0 +1,195 @@
// Package models defines the core data structures used throughout
// the Nexus Terminal application.
package models
import "time"
// Protocol represents the connection protocol for a session.
type Protocol string
const (
ProtocolSSH Protocol = "ssh"
ProtocolTelnet Protocol = "telnet"
ProtocolRAW Protocol = "raw"
)
// HostKeyPolicy determines how unknown or changed SSH host keys are handled.
type HostKeyPolicy string
const (
HostKeyStrict HostKeyPolicy = "strict"
HostKeyAsk HostKeyPolicy = "ask"
HostKeyAcceptNew HostKeyPolicy = "accept-new"
HostKeyInsecure HostKeyPolicy = "insecure"
)
// SecretMode controls how credentials are persisted.
type SecretMode string
const (
SecretModeSaved SecretMode = "saved"
SecretModeSessionOnly SecretMode = "session-only"
SecretModeAskEveryTime SecretMode = "ask-every-time"
)
// LineEnding specifies the line-ending convention for Telnet and RAW connections.
type LineEnding string
const (
LineEndingLF LineEnding = "lf"
LineEndingCR LineEnding = "cr"
LineEndingCRLF LineEnding = "crlf"
LineEndingNone LineEnding = "none"
)
// Profile holds the full configuration for a connection profile.
type Profile struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Protocol Protocol `json:"protocol"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Tags []string `json:"tags"`
Folder string `json:"folder"`
Environment string `json:"environment"`
Notes string `json:"notes"`
Favourite bool `json:"favourite"`
LastConnected *time.Time `json:"lastConnected"`
ConnectTimeout int `json:"connectTimeout"` // seconds
ReadTimeout int `json:"readTimeout"` // seconds
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Terminal preferences
TerminalFontFamily string `json:"terminalFontFamily"`
TerminalFontSize int `json:"terminalFontSize"`
TerminalScrollback int `json:"terminalScrollback"`
TerminalCursorStyle string `json:"terminalCursorStyle"`
// SSH-specific configuration
SSH *SSHConfig `json:"ssh,omitempty"`
// Telnet-specific configuration
Telnet *TelnetConfig `json:"telnet,omitempty"`
// RAW-specific configuration
RAW *RAWConfig `json:"raw,omitempty"`
}
// SSHConfig holds SSH-specific settings for a connection profile.
type SSHConfig struct {
AuthMethods []string `json:"authMethods"` // password, publickey, keyboard-interactive
SecretMode SecretMode `json:"secretMode"`
PrivateKeyPath string `json:"privateKeyPath"`
KeySecretMode SecretMode `json:"keySecretMode"`
HostKeyPolicy HostKeyPolicy `json:"hostKeyPolicy"`
TerminalType string `json:"terminalType"`
KeepaliveInterval int `json:"keepaliveInterval"`
KeepaliveCountMax int `json:"keepaliveCountMax"`
Compression bool `json:"compression"`
JumpHosts []JumpHost `json:"jumpHosts"`
StartupCommand string `json:"startupCommand"`
EnvVars map[string]string `json:"envVars"`
}
// JumpHost describes a proxy/bastion host used for SSH multi-hop connections.
type JumpHost struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
AuthMethod string `json:"authMethod"`
SecretMode SecretMode `json:"secretMode"`
PrivateKeyPath string `json:"privateKeyPath"`
}
// TelnetConfig holds Telnet-specific settings for a connection profile.
type TelnetConfig struct {
LocalEcho bool `json:"localEcho"`
LineEnding LineEnding `json:"lineEnding"`
TerminalType string `json:"terminalType"`
Encoding string `json:"encoding"`
NegotiationDebug bool `json:"negotiationDebug"`
LineMode bool `json:"lineMode"`
}
// RAWConfig holds RAW TCP-specific settings for a connection profile.
type RAWConfig struct {
AutoReconnect bool `json:"autoReconnect"`
LineEnding LineEnding `json:"lineEnding"`
Encoding string `json:"encoding"`
ShowTimestamps bool `json:"showTimestamps"`
ShowDirection bool `json:"showDirection"`
PayloadPresets []PayloadPreset `json:"payloadPresets"`
}
// PayloadPreset stores a reusable payload for RAW TCP connections.
type PayloadPreset struct {
ID string `json:"id"`
Name string `json:"name"`
Payload string `json:"payload"` // base64-encoded for binary safety
Format string `json:"format"` // text, hex, escaped
}
// SessionState represents the lifecycle state of a connection session.
type SessionState string
const (
SessionConnecting SessionState = "connecting"
SessionConnected SessionState = "connected"
SessionDisconnected SessionState = "disconnected"
SessionError SessionState = "error"
)
// SessionInfo provides metadata about an active or recent session.
type SessionInfo struct {
ID string `json:"id"`
ProfileID string `json:"profileId"`
ProfileName string `json:"profileName"`
Protocol Protocol `json:"protocol"`
Host string `json:"host"`
Port int `json:"port"`
State SessionState `json:"state"`
TabName string `json:"tabName"`
Pinned bool `json:"pinned"`
ConnectedAt time.Time `json:"connectedAt"`
Error string `json:"error,omitempty"`
}
// KnownHost records a previously-seen SSH host key fingerprint.
type KnownHost struct {
ID string `json:"id"`
Host string `json:"host"`
Port int `json:"port"`
KeyType string `json:"keyType"`
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"publicKey"`
FirstSeen time.Time `json:"firstSeen"`
LastSeen time.Time `json:"lastSeen"`
Trusted bool `json:"trusted"`
}
// AppPreferences stores user-level application settings.
type AppPreferences struct {
Theme string `json:"theme"`
DefaultProtocol string `json:"defaultProtocol"`
ConfirmOnClose bool `json:"confirmOnClose"`
ConfirmOnDisconnect bool `json:"confirmOnDisconnect"`
ShowStatusBar bool `json:"showStatusBar"`
SidebarWidth int `json:"sidebarWidth"`
DefaultFontFamily string `json:"defaultFontFamily"`
DefaultFontSize int `json:"defaultFontSize"`
DefaultScrollback int `json:"defaultScrollback"`
DefaultCursorStyle string `json:"defaultCursorStyle"`
}
// AuditEntry records an auditable action within the application.
type AuditEntry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Action string `json:"action"`
ProfileID string `json:"profileId,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Details string `json:"details"`
}
+107
View File
@@ -0,0 +1,107 @@
// Package preferences manages application preferences with in-memory caching
// and persistence to a backing store.
package preferences
import (
"fmt"
"sync"
"github.com/copilot-ssh-client/app/internal/models"
)
// PreferenceStore is the persistence interface used by the Manager.
type PreferenceStore interface {
GetPreferences() (*models.AppPreferences, error)
SavePreferences(prefs *models.AppPreferences) error
}
// Manager handles application preferences with defaults and caching.
// It is safe for concurrent use.
type Manager struct {
store PreferenceStore
cache *models.AppPreferences
mu sync.RWMutex
}
// NewManager creates a preferences Manager backed by the given store.
// Call Load to populate the cache from disc.
func NewManager(store PreferenceStore) *Manager {
return &Manager{store: store}
}
// Load reads preferences from the backing store into memory. If no
// preferences have been saved yet, sensible defaults are applied.
func (m *Manager) Load() error {
m.mu.Lock()
defer m.mu.Unlock()
prefs, err := m.store.GetPreferences()
if err != nil {
return fmt.Errorf("preferences: failed to load: %w", err)
}
if prefs == nil {
defaults := models.DefaultAppPreferences()
m.cache = &defaults
} else {
m.cache = prefs
m.applyDefaults()
}
return nil
}
// Get returns the current preferences. If Load has not been called, defaults
// are returned.
func (m *Manager) Get() *models.AppPreferences {
m.mu.RLock()
defer m.mu.RUnlock()
if m.cache == nil {
defaults := models.DefaultAppPreferences()
return &defaults
}
// Return a copy to prevent callers mutating the cache.
cp := *m.cache
return &cp
}
// Save persists the given preferences to the backing store and updates the
// in-memory cache.
func (m *Manager) Save(prefs *models.AppPreferences) error {
m.mu.Lock()
defer m.mu.Unlock()
if err := m.store.SavePreferences(prefs); err != nil {
return fmt.Errorf("preferences: failed to save: %w", err)
}
cp := *prefs
m.cache = &cp
return nil
}
// applyDefaults fills in zero-valued fields from the default preferences.
// Caller must hold m.mu.
func (m *Manager) applyDefaults() {
defaults := models.DefaultAppPreferences()
if m.cache.Theme == "" {
m.cache.Theme = defaults.Theme
}
if m.cache.DefaultProtocol == "" {
m.cache.DefaultProtocol = defaults.DefaultProtocol
}
if m.cache.SidebarWidth == 0 {
m.cache.SidebarWidth = defaults.SidebarWidth
}
if m.cache.DefaultFontFamily == "" {
m.cache.DefaultFontFamily = defaults.DefaultFontFamily
}
if m.cache.DefaultFontSize == 0 {
m.cache.DefaultFontSize = defaults.DefaultFontSize
}
if m.cache.DefaultScrollback == 0 {
m.cache.DefaultScrollback = defaults.DefaultScrollback
}
if m.cache.DefaultCursorStyle == "" {
m.cache.DefaultCursorStyle = defaults.DefaultCursorStyle
}
}
+239
View File
@@ -0,0 +1,239 @@
// Package rawclient provides a plain TCP connection with bidirectional data
// history, capture-to-file support, and callback-driven output.
package rawclient
import (
"context"
"encoding/hex"
"fmt"
"io"
"net"
"os"
"sync"
"time"
)
// HistoryEntry records a single sent or received data frame.
type HistoryEntry struct {
Timestamp time.Time `json:"timestamp"`
Direction string `json:"direction"` // "sent" or "received"
Data []byte `json:"data"`
}
// Options configures a new RAW TCP connection.
type Options struct {
Host string
Port int
ConnectTimeout time.Duration
// OnData is called for every received chunk. The direction parameter is
// always "received" for incoming data.
OnData func(data []byte, direction string, timestamp time.Time)
// OnError is called on non-EOF read errors.
OnError func(err error)
// OnClose is called once when the connection terminates.
OnClose func()
// DebugLog, when non-nil, receives diagnostic messages.
DebugLog func(msg string)
}
// Client manages a single RAW TCP connection with history tracking.
type Client struct {
opts Options
conn net.Conn
mu sync.Mutex
closed bool
cancel context.CancelFunc
history []HistoryEntry
histMu sync.Mutex
}
// New creates a Client with the given options.
func New(opts Options) *Client {
if opts.ConnectTimeout == 0 {
opts.ConnectTimeout = 30 * time.Second
}
return &Client{opts: opts}
}
// Connect establishes the TCP connection and begins reading data.
func (c *Client) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
ctx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.debug("dialling %s:%d", c.opts.Host, c.opts.Port)
addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port))
dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("rawclient: dial failed: %w", err)
}
c.conn = conn
go c.readLoop(ctx)
return nil
}
// Write sends data to the remote host and records it in the history.
func (c *Client) Write(data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.conn == nil {
return fmt.Errorf("rawclient: connection is closed")
}
_, err := c.conn.Write(data)
if err != nil {
return err
}
now := time.Now()
c.recordHistory("sent", data, now)
if c.opts.OnData != nil {
echoCopy := make([]byte, len(data))
copy(echoCopy, data)
c.opts.OnData(echoCopy, "sent", now)
}
return nil
}
// Close tears down the TCP connection.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
c.debug("closing connection")
if c.cancel != nil {
c.cancel()
}
if c.conn != nil {
return c.conn.Close()
}
return nil
}
// GetHistory returns a copy of the recorded data history.
func (c *Client) GetHistory() []HistoryEntry {
c.histMu.Lock()
defer c.histMu.Unlock()
result := make([]HistoryEntry, len(c.history))
copy(result, c.history)
return result
}
// ClearHistory removes all recorded history entries.
func (c *Client) ClearHistory() {
c.histMu.Lock()
defer c.histMu.Unlock()
c.history = nil
}
// SaveCapture writes the session history to a file in a human-readable
// format with timestamps and direction markers.
func (c *Client) SaveCapture(path string) error {
c.histMu.Lock()
entries := make([]HistoryEntry, len(c.history))
copy(entries, c.history)
c.histMu.Unlock()
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("rawclient: failed to create capture file: %w", err)
}
defer f.Close()
for _, entry := range entries {
ts := entry.Timestamp.Format("2006-01-02 15:04:05.000")
dir := ">>>"
if entry.Direction == "received" {
dir = "<<<"
}
hexStr := hex.EncodeToString(entry.Data)
if _, err := fmt.Fprintf(f, "[%s] %s %s\n", ts, dir, hexStr); err != nil {
return fmt.Errorf("rawclient: failed to write capture: %w", err)
}
}
return nil
}
// readLoop continuously reads from the connection and delivers data.
func (c *Client) readLoop(ctx context.Context) {
buf := make([]byte, 32*1024)
for {
select {
case <-ctx.Done():
return
default:
}
n, err := c.conn.Read(buf)
if n > 0 {
now := time.Now()
chunk := make([]byte, n)
copy(chunk, buf[:n])
c.recordHistory("received", chunk, now)
if c.opts.OnData != nil {
c.opts.OnData(chunk, "received", now)
}
}
if err != nil {
if err != io.EOF {
c.debug("read error: %v", err)
if c.opts.OnError != nil {
c.opts.OnError(err)
}
}
c.handleClose()
return
}
}
}
// recordHistory appends a data frame to the session history.
func (c *Client) recordHistory(direction string, data []byte, ts time.Time) {
c.histMu.Lock()
defer c.histMu.Unlock()
dataCopy := make([]byte, len(data))
copy(dataCopy, data)
c.history = append(c.history, HistoryEntry{
Timestamp: ts,
Direction: direction,
Data: dataCopy,
})
}
// handleClose is called when the connection terminates.
func (c *Client) handleClose() {
c.mu.Lock()
wasClosed := c.closed
c.closed = true
c.mu.Unlock()
if !wasClosed && c.opts.OnClose != nil {
c.opts.OnClose()
}
}
func (c *Client) debug(format string, args ...interface{}) {
if c.opts.DebugLog != nil {
c.opts.DebugLog(fmt.Sprintf(format, args...))
}
}
+66
View File
@@ -0,0 +1,66 @@
package secrets
import (
"fmt"
"github.com/zalando/go-keyring"
)
// KeychainStore attempts to use the OS keychain for secret storage.
// On macOS this uses the Keychain, on Linux it uses the Secret Service API,
// and on Windows it uses the Credential Manager.
//
// This is the preferred storage method when available. Falls back to
// the encrypted file-based SecretStore when the keychain is not accessible.
type KeychainStore struct {
serviceName string
}
// NewKeychainStore creates a KeychainStore that stores credentials under the
// given service name in the operating system's native keychain.
func NewKeychainStore(serviceName string) *KeychainStore {
return &KeychainStore{serviceName: serviceName}
}
// Available checks whether the OS keychain is accessible by performing a
// harmless write-then-delete probe. Returns true if the keychain responded
// without error.
func (k *KeychainStore) Available() bool {
// Attempt a round-trip to verify the keychain is usable.
probeKey := "__nexus_keychain_probe__"
probeVal := "probe"
if err := keyring.Set(k.serviceName, probeKey, probeVal); err != nil {
return false
}
_ = keyring.Delete(k.serviceName, probeKey)
return true
}
// SetSecret stores a secret in the OS keychain under the configured service
// name with the given key.
func (k *KeychainStore) SetSecret(key, value string) error {
if err := keyring.Set(k.serviceName, key, value); err != nil {
return fmt.Errorf("keychain: failed to store secret %q: %w", key, err)
}
return nil
}
// GetSecret retrieves a secret from the OS keychain. Returns an error if the
// key does not exist or the keychain is inaccessible.
func (k *KeychainStore) GetSecret(key string) (string, error) {
val, err := keyring.Get(k.serviceName, key)
if err != nil {
return "", fmt.Errorf("keychain: failed to retrieve secret %q: %w", key, err)
}
return val, nil
}
// DeleteSecret removes a secret from the OS keychain. Returns an error if the
// key does not exist or the keychain is inaccessible.
func (k *KeychainStore) DeleteSecret(key string) error {
if err := keyring.Delete(k.serviceName, key); err != nil {
return fmt.Errorf("keychain: failed to delete secret %q: %w", key, err)
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
// Package secrets provides encrypted credential storage and redaction utilities.
package secrets
import "strings"
// RedactSecret replaces a secret string with a redacted placeholder.
// Used for logging and UI display to prevent accidental secret exposure.
func RedactSecret(s string) string {
if len(s) == 0 {
return ""
}
return "••••••••"
}
// RedactInString replaces all occurrences of a secret within a larger string
// with a redacted placeholder. This is useful for sanitising log output that
// may inadvertently contain sensitive material.
func RedactInString(text, secret string) string {
if secret == "" {
return text
}
return strings.ReplaceAll(text, secret, "••••••••")
}
// RedactMap returns a shallow copy of the map with every value replaced by
// a redacted placeholder. The original map is not modified.
func RedactMap(m map[string]string) map[string]string {
redacted := make(map[string]string, len(m))
for k, v := range m {
if v == "" {
redacted[k] = ""
} else {
redacted[k] = "••••••••"
}
}
return redacted
}
+345
View File
@@ -0,0 +1,345 @@
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/argon2"
)
const (
vaultFileName = "vault.json"
vaultVersion = 1
// Argon2id key derivation parameters.
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
saltLen = 32
)
// SecretStore manages encrypted credential storage.
// It uses AES-256-GCM with Argon2id key derivation from a user-controlled
// master password. No hard-coded secrets are used.
//
// Threat model:
// - Protects secrets at rest on the local filesystem
// - Requires the master password to decrypt
// - Does NOT protect against a compromised running process
// - Does NOT protect against memory forensics on a compromised machine
// - The master password is held in memory only for the duration of the session
type SecretStore struct {
mu sync.RWMutex
storePath string
key []byte // derived from master password, held in memory
vault *secretVault
unlocked bool
}
type secretVault struct {
Version int `json:"version"`
Salt string `json:"salt"` // base64-encoded
Secrets map[string]string `json:"secrets"` // key -> base64(nonce + ciphertext)
}
// NewSecretStore creates a new SecretStore that persists its vault file in the
// given directory path. The vault is not unlocked until Unlock is called.
func NewSecretStore(storePath string) *SecretStore {
return &SecretStore{
storePath: storePath,
}
}
// IsInitialised returns true if a vault file already exists on disc.
func (s *SecretStore) IsInitialised() bool {
_, err := os.Stat(s.vaultPath())
return err == nil
}
// Initialise creates a new vault with the given master password. A random
// 32-byte salt is generated and the key is derived using Argon2id. Returns an
// error if a vault already exists.
func (s *SecretStore) Initialise(masterPassword string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.IsInitialised() {
return errors.New("secrets: vault already initialised")
}
salt := make([]byte, saltLen)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return fmt.Errorf("secrets: failed to generate salt: %w", err)
}
s.vault = &secretVault{
Version: vaultVersion,
Salt: base64.StdEncoding.EncodeToString(salt),
Secrets: make(map[string]string),
}
s.key = deriveKey(masterPassword, salt)
s.unlocked = true
if err := os.MkdirAll(s.storePath, 0700); err != nil {
return fmt.Errorf("secrets: failed to create store directory: %w", err)
}
if err := s.save(); err != nil {
return fmt.Errorf("secrets: failed to save initial vault: %w", err)
}
return nil
}
// Unlock decrypts the vault using the provided master password. The derived
// key is verified by attempting to decrypt the first stored secret; if no
// secrets exist the password is accepted optimistically.
func (s *SecretStore) Unlock(masterPassword string) error {
s.mu.Lock()
defer s.mu.Unlock()
vault, err := s.load()
if err != nil {
return fmt.Errorf("secrets: failed to load vault: %w", err)
}
salt, err := base64.StdEncoding.DecodeString(vault.Salt)
if err != nil {
return fmt.Errorf("secrets: corrupt vault salt: %w", err)
}
s.vault = vault
s.key = deriveKey(masterPassword, salt)
s.unlocked = true
// Attempt to decrypt any existing secret to verify the password.
for _, encVal := range vault.Secrets {
if _, err := s.decrypt(encVal); err != nil {
s.lockInternal()
return fmt.Errorf("secrets: incorrect master password or corrupt vault")
}
break
}
return nil
}
// Lock zeroes the derived key material and locks the vault. After calling Lock
// no secret operations are possible until Unlock is called again.
func (s *SecretStore) Lock() {
s.mu.Lock()
defer s.mu.Unlock()
s.lockInternal()
}
// lockInternal performs the actual lock without acquiring the mutex. The caller
// must already hold s.mu.
func (s *SecretStore) lockInternal() {
for i := range s.key {
s.key[i] = 0
}
s.key = nil
s.vault = nil
s.unlocked = false
}
// IsUnlocked returns whether the vault is currently unlocked and ready for use.
func (s *SecretStore) IsUnlocked() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.unlocked
}
// SetSecret encrypts and stores a secret under the given key. The key format
// should follow "profile:{id}:password" or "profile:{id}:passphrase".
func (s *SecretStore) SetSecret(key string, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.unlocked {
return errors.New("secrets: vault is locked")
}
encrypted, err := s.encrypt(value)
if err != nil {
return fmt.Errorf("secrets: encryption failed: %w", err)
}
s.vault.Secrets[key] = encrypted
return s.save()
}
// GetSecret retrieves and decrypts a secret by key. Returns an error if the
// key does not exist or decryption fails.
func (s *SecretStore) GetSecret(key string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.unlocked {
return "", errors.New("secrets: vault is locked")
}
encVal, ok := s.vault.Secrets[key]
if !ok {
return "", fmt.Errorf("secrets: key %q not found", key)
}
return s.decrypt(encVal)
}
// DeleteSecret removes a secret by key. Returns an error if the vault is
// locked or the save fails.
func (s *SecretStore) DeleteSecret(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.unlocked {
return errors.New("secrets: vault is locked")
}
delete(s.vault.Secrets, key)
return s.save()
}
// DeleteProfileSecrets removes all secrets whose key begins with
// "profile:{profileID}:".
func (s *SecretStore) DeleteProfileSecrets(profileID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.unlocked {
return errors.New("secrets: vault is locked")
}
prefix := "profile:" + profileID + ":"
for k := range s.vault.Secrets {
if strings.HasPrefix(k, prefix) {
delete(s.vault.Secrets, k)
}
}
return s.save()
}
// ListKeys returns all secret keys stored in the vault. Secret values are
// never returned by this method.
func (s *SecretStore) ListKeys() []string {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.unlocked || s.vault == nil {
return nil
}
keys := make([]string, 0, len(s.vault.Secrets))
for k := range s.vault.Secrets {
keys = append(keys, k)
}
return keys
}
// --- internal helpers --------------------------------------------------------
// vaultPath returns the full filesystem path to the vault JSON file.
func (s *SecretStore) vaultPath() string {
return filepath.Join(s.storePath, vaultFileName)
}
// deriveKey uses Argon2id to derive a 256-bit key from the master password and
// salt. The parameters are intentionally hard-coded to ensure consistency
// across vault open/close cycles.
func deriveKey(password string, salt []byte) []byte {
return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
}
// encrypt encrypts plaintext using AES-256-GCM. A random nonce is generated
// for each call and prepended to the ciphertext. The result is returned as a
// base64-encoded string.
func (s *SecretStore) encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// decrypt decodes a base64 string and decrypts it using AES-256-GCM. The
// nonce is expected to be prepended to the ciphertext.
func (s *SecretStore) decrypt(encoded string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("failed to decode secret: %w", err)
}
block, err := aes.NewCipher(s.key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decryption failed: %w", err)
}
return string(plaintext), nil
}
// save writes the vault to disc as a JSON file with restricted permissions.
func (s *SecretStore) save() error {
data, err := json.MarshalIndent(s.vault, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal vault: %w", err)
}
return os.WriteFile(s.vaultPath(), data, 0600)
}
// load reads and unmarshals the vault from disc.
func (s *SecretStore) load() (*secretVault, error) {
data, err := os.ReadFile(s.vaultPath())
if err != nil {
return nil, fmt.Errorf("failed to read vault file: %w", err)
}
var vault secretVault
if err := json.Unmarshal(data, &vault); err != nil {
return nil, fmt.Errorf("failed to parse vault file: %w", err)
}
return &vault, nil
}
+310
View File
@@ -0,0 +1,310 @@
// Package sshclient provides a managed SSH session with PTY allocation,
// keepalive support, and callback-driven output.
package sshclient
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// Options configures a new SSH connection.
type Options struct {
Host string
Port int
Username string
AuthMethods []ssh.AuthMethod
TerminalType string
Cols int
Rows int
KeepaliveInterval time.Duration
KeepaliveMax int
HostKeyCallback ssh.HostKeyCallback
ConnectTimeout time.Duration
EnvVars map[string]string
Command string // if empty, a login shell is started
// Callbacks invoked from a background goroutine.
OnData func(data []byte)
OnError func(err error)
OnClose func()
// DebugLog, when non-nil, receives diagnostic messages.
DebugLog func(msg string)
}
// Client manages a single SSH connection with PTY, keepalive, and
// callback-driven output delivery.
type Client struct {
opts Options
conn *ssh.Client
session *ssh.Session
stdin io.WriteCloser
mu sync.Mutex
closed bool
cancel context.CancelFunc
}
// New creates a Client with the given options. Call Connect to establish
// the session.
func New(opts Options) *Client {
if opts.TerminalType == "" {
opts.TerminalType = "xterm-256color"
}
if opts.Cols == 0 {
opts.Cols = 80
}
if opts.Rows == 0 {
opts.Rows = 24
}
if opts.ConnectTimeout == 0 {
opts.ConnectTimeout = 30 * time.Second
}
return &Client{opts: opts}
}
// Connect establishes the SSH connection, requests a PTY, and starts the
// session. Output data is delivered via opts.OnData. The connection is torn
// down when ctx is cancelled or when Close is called.
func (c *Client) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
ctx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.debug("dialling %s:%d", c.opts.Host, c.opts.Port)
addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port))
dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout}
tcpConn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("sshclient: dial failed: %w", err)
}
hostKeyCallback := c.opts.HostKeyCallback
if hostKeyCallback == nil {
hostKeyCallback = ssh.InsecureIgnoreHostKey()
}
sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, addr, &ssh.ClientConfig{
User: c.opts.Username,
Auth: c.opts.AuthMethods,
HostKeyCallback: hostKeyCallback,
Timeout: c.opts.ConnectTimeout,
})
if err != nil {
tcpConn.Close()
return fmt.Errorf("sshclient: handshake failed: %w", err)
}
c.conn = ssh.NewClient(sshConn, chans, reqs)
sess, err := c.conn.NewSession()
if err != nil {
c.conn.Close()
return fmt.Errorf("sshclient: session creation failed: %w", err)
}
c.session = sess
// Set environment variables.
for k, v := range c.opts.EnvVars {
_ = sess.Setenv(k, v)
}
// Request a pseudo-terminal.
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := sess.RequestPty(c.opts.TerminalType, c.opts.Rows, c.opts.Cols, modes); err != nil {
sess.Close()
c.conn.Close()
return fmt.Errorf("sshclient: PTY request failed: %w", err)
}
stdout, err := sess.StdoutPipe()
if err != nil {
sess.Close()
c.conn.Close()
return fmt.Errorf("sshclient: stdout pipe failed: %w", err)
}
stderr, err := sess.StderrPipe()
if err != nil {
sess.Close()
c.conn.Close()
return fmt.Errorf("sshclient: stderr pipe failed: %w", err)
}
c.stdin, err = sess.StdinPipe()
if err != nil {
sess.Close()
c.conn.Close()
return fmt.Errorf("sshclient: stdin pipe failed: %w", err)
}
// Start the shell or command.
if c.opts.Command != "" {
c.debug("starting command: %s", c.opts.Command)
if err := sess.Start(c.opts.Command); err != nil {
sess.Close()
c.conn.Close()
return fmt.Errorf("sshclient: command start failed: %w", err)
}
} else {
c.debug("starting login shell")
if err := sess.Shell(); err != nil {
sess.Close()
c.conn.Close()
return fmt.Errorf("sshclient: shell start failed: %w", err)
}
}
// Read stdout in a background goroutine.
go c.readLoop(stdout, ctx)
go c.readLoop(stderr, ctx)
// Start keepalive if configured.
if c.opts.KeepaliveInterval > 0 {
go c.keepaliveLoop(ctx)
}
// Watch for session completion.
go func() {
_ = sess.Wait()
c.handleClose()
}()
return nil
}
// Write sends data to the remote session's standard input.
func (c *Client) Write(data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return fmt.Errorf("sshclient: session is closed")
}
if c.stdin == nil {
return fmt.Errorf("sshclient: session not connected")
}
_, err := c.stdin.Write(data)
return err
}
// Resize sends a window change request to the remote PTY.
func (c *Client) Resize(cols, rows int) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.session == nil {
return fmt.Errorf("sshclient: session is closed")
}
return c.session.WindowChange(rows, cols)
}
// Close tears down the SSH session and connection.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
c.debug("closing connection")
if c.cancel != nil {
c.cancel()
}
if c.stdin != nil {
c.stdin.Close()
}
if c.session != nil {
c.session.Close()
}
if c.conn != nil {
c.conn.Close()
}
return nil
}
// readLoop continuously reads from the given reader and delivers data via the
// OnData callback.
func (c *Client) readLoop(r io.Reader, ctx context.Context) {
buf := make([]byte, 32*1024)
for {
select {
case <-ctx.Done():
return
default:
}
n, err := r.Read(buf)
if n > 0 && c.opts.OnData != nil {
chunk := make([]byte, n)
copy(chunk, buf[:n])
c.opts.OnData(chunk)
}
if err != nil {
if err != io.EOF {
c.debug("read error: %v", err)
}
return
}
}
}
// keepaliveLoop sends SSH keepalive requests at the configured interval.
func (c *Client) keepaliveLoop(ctx context.Context) {
ticker := time.NewTicker(c.opts.KeepaliveInterval)
defer ticker.Stop()
failures := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_, _, err := c.conn.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
failures++
c.debug("keepalive failure %d/%d", failures, c.opts.KeepaliveMax)
if c.opts.KeepaliveMax > 0 && failures >= c.opts.KeepaliveMax {
c.debug("keepalive limit reached, closing")
c.handleClose()
return
}
} else {
failures = 0
}
}
}
}
// handleClose is called when the session ends, either by the remote side
// or due to keepalive failure.
func (c *Client) handleClose() {
c.mu.Lock()
wasClosed := c.closed
c.closed = true
c.mu.Unlock()
if !wasClosed && c.opts.OnClose != nil {
c.opts.OnClose()
}
}
func (c *Client) debug(format string, args ...interface{}) {
if c.opts.DebugLog != nil {
c.opts.DebugLog(fmt.Sprintf(format, args...))
}
}
@@ -0,0 +1,63 @@
-- Initial schema for Nexus Terminal
-- Creates the core tables for profiles, known hosts, preferences, and audit log.
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT DEFAULT '',
protocol TEXT NOT NULL CHECK(protocol IN ('ssh', 'telnet', 'raw')),
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
folder TEXT DEFAULT '',
environment TEXT DEFAULT '',
notes TEXT DEFAULT '',
favourite INTEGER DEFAULT 0,
last_connected TEXT,
connect_timeout INTEGER DEFAULT 30,
read_timeout INTEGER DEFAULT 0,
terminal_font_family TEXT DEFAULT '',
terminal_font_size INTEGER DEFAULT 0,
terminal_scrollback INTEGER DEFAULT 0,
terminal_cursor_style TEXT DEFAULT '',
ssh_config TEXT,
telnet_config TEXT,
raw_config TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS known_hosts (
id TEXT PRIMARY KEY,
host TEXT NOT NULL,
port INTEGER NOT NULL,
key_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
public_key TEXT NOT NULL,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
trusted INTEGER DEFAULT 1,
UNIQUE(host, port, key_type)
);
CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
action TEXT NOT NULL,
profile_id TEXT DEFAULT '',
session_id TEXT DEFAULT '',
details TEXT DEFAULT ''
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, datetime('now'));
+10
View File
@@ -0,0 +1,10 @@
// Package migrations provides embedded SQL migration files for the storage layer.
package migrations
import "embed"
// MigrationFS contains all embedded SQL migration files.
// Each migration is numbered sequentially (e.g. 001_initial.sql).
//
//go:embed *.sql
var MigrationFS embed.FS
+661
View File
@@ -0,0 +1,661 @@
// Package storage provides a SQLite-backed persistence layer for profiles,
// known hosts, audit entries, and application preferences.
package storage
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/copilot-ssh-client/app/internal/models"
"github.com/copilot-ssh-client/app/internal/storage/migrations"
"github.com/google/uuid"
)
// Store wraps a SQLite database and exposes typed CRUD methods for
// all application entities.
type Store struct {
db *sql.DB
}
// NewStore opens (or creates) the SQLite database at dbPath, applies any
// pending migrations, and returns an initialised Store.
func NewStore(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("storage: failed to open database: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("storage: database not reachable: %w", err)
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
return nil, fmt.Errorf("storage: migration failed: %w", err)
}
return s, nil
}
// Close shuts down the database connection.
func (s *Store) Close() error {
return s.db.Close()
}
// ---------------------------------------------------------------------------
// Migrations
// ---------------------------------------------------------------------------
// migrate reads embedded SQL files and applies them in order.
func (s *Store) migrate() error {
// Ensure the schema_version table exists before querying it.
_, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)`)
if err != nil {
return fmt.Errorf("failed to create schema_version table: %w", err)
}
var current int
row := s.db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version")
if err := row.Scan(&current); err != nil {
return fmt.Errorf("failed to read schema version: %w", err)
}
entries, err := migrations.MigrationFS.ReadDir(".")
if err != nil {
return fmt.Errorf("failed to read migration files: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".sql") {
continue
}
// Extract version number from filename prefix (e.g. "001_initial.sql" → 1).
var ver int
if _, err := fmt.Sscanf(name, "%03d_", &ver); err != nil {
continue
}
if ver <= current {
continue
}
data, err := migrations.MigrationFS.ReadFile(name)
if err != nil {
return fmt.Errorf("failed to read migration %s: %w", name, err)
}
if _, err := s.db.Exec(string(data)); err != nil {
return fmt.Errorf("failed to apply migration %s: %w", name, err)
}
}
return nil
}
// ---------------------------------------------------------------------------
// Profile operations
// ---------------------------------------------------------------------------
// CreateProfile inserts a new profile into the database.
func (s *Store) CreateProfile(p *models.Profile) error {
tagsJSON, _ := json.Marshal(p.Tags)
sshJSON, _ := json.Marshal(p.SSH)
telnetJSON, _ := json.Marshal(p.Telnet)
rawJSON, _ := json.Marshal(p.RAW)
_, err := s.db.Exec(`INSERT INTO profiles (
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config,
created_at, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
p.ID, p.DisplayName, p.Description, string(p.Protocol),
p.Host, p.Port, p.Username,
string(tagsJSON), p.Folder, p.Environment, p.Notes,
boolToInt(p.Favourite), timeToStr(p.LastConnected),
p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
p.CreatedAt.Format(time.RFC3339), p.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("storage: failed to create profile: %w", err)
}
return nil
}
// GetProfile retrieves a single profile by ID.
func (s *Store) GetProfile(id string) (*models.Profile, error) {
row := s.db.QueryRow(`SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config,
created_at, updated_at
FROM profiles WHERE id = ?`, id)
p, err := scanProfile(row)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("storage: profile %q not found", id)
}
if err != nil {
return nil, fmt.Errorf("storage: failed to get profile: %w", err)
}
return p, nil
}
// UpdateProfile overwrites an existing profile.
func (s *Store) UpdateProfile(p *models.Profile) error {
tagsJSON, _ := json.Marshal(p.Tags)
sshJSON, _ := json.Marshal(p.SSH)
telnetJSON, _ := json.Marshal(p.Telnet)
rawJSON, _ := json.Marshal(p.RAW)
res, err := s.db.Exec(`UPDATE profiles SET
display_name=?, description=?, protocol=?, host=?, port=?, username=?,
tags=?, folder=?, environment=?, notes=?, favourite=?, last_connected=?,
connect_timeout=?, read_timeout=?,
terminal_font_family=?, terminal_font_size=?, terminal_scrollback=?, terminal_cursor_style=?,
ssh_config=?, telnet_config=?, raw_config=?,
updated_at=?
WHERE id=?`,
p.DisplayName, p.Description, string(p.Protocol),
p.Host, p.Port, p.Username,
string(tagsJSON), p.Folder, p.Environment, p.Notes,
boolToInt(p.Favourite), timeToStr(p.LastConnected),
p.ConnectTimeout, p.ReadTimeout,
p.TerminalFontFamily, p.TerminalFontSize, p.TerminalScrollback, p.TerminalCursorStyle,
nullableJSON(p.SSH, sshJSON), nullableJSON(p.Telnet, telnetJSON), nullableJSON(p.RAW, rawJSON),
p.UpdatedAt.Format(time.RFC3339),
p.ID,
)
if err != nil {
return fmt.Errorf("storage: failed to update profile: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("storage: profile %q not found", p.ID)
}
return nil
}
// DeleteProfile removes a profile by ID.
func (s *Store) DeleteProfile(id string) error {
res, err := s.db.Exec("DELETE FROM profiles WHERE id = ?", id)
if err != nil {
return fmt.Errorf("storage: failed to delete profile: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("storage: profile %q not found", id)
}
return nil
}
// ListProfiles returns all profiles ordered by display name.
func (s *Store) ListProfiles() ([]*models.Profile, error) {
rows, err := s.db.Query(`SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config,
created_at, updated_at
FROM profiles ORDER BY display_name`)
if err != nil {
return nil, fmt.Errorf("storage: failed to list profiles: %w", err)
}
defer rows.Close()
return scanProfiles(rows)
}
// SearchProfiles returns profiles whose display name, host, or tags contain
// the query string (case-insensitive).
func (s *Store) SearchProfiles(query string) ([]*models.Profile, error) {
q := "%" + query + "%"
rows, err := s.db.Query(`SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config,
created_at, updated_at
FROM profiles
WHERE display_name LIKE ? OR host LIKE ? OR tags LIKE ?
ORDER BY display_name`, q, q, q)
if err != nil {
return nil, fmt.Errorf("storage: failed to search profiles: %w", err)
}
defer rows.Close()
return scanProfiles(rows)
}
// ListProfilesByProtocol returns profiles filtered by protocol.
func (s *Store) ListProfilesByProtocol(protocol string) ([]*models.Profile, error) {
rows, err := s.db.Query(`SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config,
created_at, updated_at
FROM profiles WHERE protocol = ? ORDER BY display_name`, protocol)
if err != nil {
return nil, fmt.Errorf("storage: failed to list profiles by protocol: %w", err)
}
defer rows.Close()
return scanProfiles(rows)
}
// ListProfilesByTag returns profiles that contain the specified tag.
func (s *Store) ListProfilesByTag(tag string) ([]*models.Profile, error) {
q := `%"` + tag + `"%`
rows, err := s.db.Query(`SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config,
created_at, updated_at
FROM profiles WHERE tags LIKE ? ORDER BY display_name`, q)
if err != nil {
return nil, fmt.Errorf("storage: failed to list profiles by tag: %w", err)
}
defer rows.Close()
return scanProfiles(rows)
}
// ListProfilesByFolder returns profiles in the specified folder.
func (s *Store) ListProfilesByFolder(folder string) ([]*models.Profile, error) {
rows, err := s.db.Query(`SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config,
created_at, updated_at
FROM profiles WHERE folder = ? ORDER BY display_name`, folder)
if err != nil {
return nil, fmt.Errorf("storage: failed to list profiles by folder: %w", err)
}
defer rows.Close()
return scanProfiles(rows)
}
// ListFavourites returns all profiles marked as favourites.
func (s *Store) ListFavourites() ([]*models.Profile, error) {
rows, err := s.db.Query(`SELECT
id, display_name, description, protocol, host, port, username,
tags, folder, environment, notes, favourite, last_connected,
connect_timeout, read_timeout,
terminal_font_family, terminal_font_size, terminal_scrollback, terminal_cursor_style,
ssh_config, telnet_config, raw_config,
created_at, updated_at
FROM profiles WHERE favourite = 1 ORDER BY display_name`)
if err != nil {
return nil, fmt.Errorf("storage: failed to list favourites: %w", err)
}
defer rows.Close()
return scanProfiles(rows)
}
// SetFavourite updates the favourite flag on a profile.
func (s *Store) SetFavourite(id string, favourite bool) error {
res, err := s.db.Exec("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?",
boolToInt(favourite), time.Now().Format(time.RFC3339), id)
if err != nil {
return fmt.Errorf("storage: failed to update favourite: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("storage: profile %q not found", id)
}
return nil
}
// ---------------------------------------------------------------------------
// Known hosts
// ---------------------------------------------------------------------------
// GetKnownHost retrieves a known host entry by host, port, and key type.
func (s *Store) GetKnownHost(host string, port int) (*models.KnownHost, error) {
row := s.db.QueryRow(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts WHERE host = ? AND port = ?`, host, port)
kh := &models.KnownHost{}
var firstSeen, lastSeen string
var trusted int
if err := row.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint,
&kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("storage: failed to get known host: %w", err)
}
kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen)
kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen)
kh.Trusted = trusted == 1
return kh, nil
}
// SaveKnownHost inserts or updates a known host entry.
func (s *Store) SaveKnownHost(kh *models.KnownHost) error {
if kh.ID == "" {
kh.ID = uuid.New().String()
}
_, err := s.db.Exec(`INSERT INTO known_hosts (id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(host, port, key_type) DO UPDATE SET
fingerprint=excluded.fingerprint, public_key=excluded.public_key,
last_seen=excluded.last_seen, trusted=excluded.trusted`,
kh.ID, kh.Host, kh.Port, kh.KeyType, kh.Fingerprint, kh.PublicKey,
kh.FirstSeen.Format(time.RFC3339), kh.LastSeen.Format(time.RFC3339),
boolToInt(kh.Trusted))
if err != nil {
return fmt.Errorf("storage: failed to save known host: %w", err)
}
return nil
}
// ListKnownHosts returns all stored known host entries.
func (s *Store) ListKnownHosts() ([]*models.KnownHost, error) {
rows, err := s.db.Query(`SELECT id, host, port, key_type, fingerprint, public_key, first_seen, last_seen, trusted
FROM known_hosts ORDER BY host, port`)
if err != nil {
return nil, fmt.Errorf("storage: failed to list known hosts: %w", err)
}
defer rows.Close()
var hosts []*models.KnownHost
for rows.Next() {
kh := &models.KnownHost{}
var firstSeen, lastSeen string
var trusted int
if err := rows.Scan(&kh.ID, &kh.Host, &kh.Port, &kh.KeyType, &kh.Fingerprint,
&kh.PublicKey, &firstSeen, &lastSeen, &trusted); err != nil {
return nil, fmt.Errorf("storage: failed to scan known host: %w", err)
}
kh.FirstSeen, _ = time.Parse(time.RFC3339, firstSeen)
kh.LastSeen, _ = time.Parse(time.RFC3339, lastSeen)
kh.Trusted = trusted == 1
hosts = append(hosts, kh)
}
return hosts, rows.Err()
}
// DeleteKnownHost removes a known host entry by ID.
func (s *Store) DeleteKnownHost(id string) error {
res, err := s.db.Exec("DELETE FROM known_hosts WHERE id = ?", id)
if err != nil {
return fmt.Errorf("storage: failed to delete known host: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("storage: known host %q not found", id)
}
return nil
}
// ---------------------------------------------------------------------------
// Audit log
// ---------------------------------------------------------------------------
// AddAuditEntry inserts an audit log entry.
func (s *Store) AddAuditEntry(entry *models.AuditEntry) error {
_, err := s.db.Exec(`INSERT INTO audit_log (id, timestamp, action, profile_id, session_id, details)
VALUES (?, ?, ?, ?, ?, ?)`,
entry.ID, entry.Timestamp.Format(time.RFC3339), entry.Action,
entry.ProfileID, entry.SessionID, entry.Details)
if err != nil {
return fmt.Errorf("storage: failed to add audit entry: %w", err)
}
return nil
}
// ListAuditEntries returns the most recent audit entries, limited to count.
func (s *Store) ListAuditEntries(limit int) ([]*models.AuditEntry, error) {
rows, err := s.db.Query(`SELECT id, timestamp, action, profile_id, session_id, details
FROM audit_log ORDER BY timestamp DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("storage: failed to list audit entries: %w", err)
}
defer rows.Close()
var entries []*models.AuditEntry
for rows.Next() {
e := &models.AuditEntry{}
var ts string
if err := rows.Scan(&e.ID, &ts, &e.Action, &e.ProfileID, &e.SessionID, &e.Details); err != nil {
return nil, fmt.Errorf("storage: failed to scan audit entry: %w", err)
}
e.Timestamp, _ = time.Parse(time.RFC3339, ts)
entries = append(entries, e)
}
return entries, rows.Err()
}
// ---------------------------------------------------------------------------
// Preferences (key-value)
// ---------------------------------------------------------------------------
// GetPreferences reads all preference keys and deserialises them into an
// AppPreferences struct.
func (s *Store) GetPreferences() (*models.AppPreferences, error) {
rows, err := s.db.Query("SELECT key, value FROM preferences")
if err != nil {
return nil, fmt.Errorf("storage: failed to read preferences: %w", err)
}
defer rows.Close()
kv := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("storage: failed to scan preference: %w", err)
}
kv[k] = v
}
if err := rows.Err(); err != nil {
return nil, err
}
// If no preferences stored yet, return nil to signal defaults should be used.
if len(kv) == 0 {
return nil, nil
}
data, err := json.Marshal(kv)
if err != nil {
return nil, fmt.Errorf("storage: failed to marshal preference map: %w", err)
}
prefs := &models.AppPreferences{}
if err := json.Unmarshal(data, prefs); err != nil {
return nil, fmt.Errorf("storage: failed to unmarshal preferences: %w", err)
}
return prefs, nil
}
// SavePreferences serialises the AppPreferences into key-value pairs and
// persists them to the preferences table.
func (s *Store) SavePreferences(prefs *models.AppPreferences) error {
data, err := json.Marshal(prefs)
if err != nil {
return fmt.Errorf("storage: failed to marshal preferences: %w", err)
}
var kv map[string]interface{}
if err := json.Unmarshal(data, &kv); err != nil {
return fmt.Errorf("storage: failed to unmarshal preferences map: %w", err)
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("storage: failed to begin transaction: %w", err)
}
for k, v := range kv {
valStr := fmt.Sprintf("%v", v)
if _, err := tx.Exec(`INSERT INTO preferences (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, valStr); err != nil {
tx.Rollback()
return fmt.Errorf("storage: failed to save preference %q: %w", k, err)
}
}
return tx.Commit()
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// scanProfile reads a single profile row from the given scanner.
func scanProfile(row *sql.Row) (*models.Profile, error) {
p := &models.Profile{}
var (
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
)
err := row.Scan(
&p.ID, &p.DisplayName, &p.Description, &protocol,
&p.Host, &p.Port, &p.Username,
&tagsJSON, &p.Folder, &p.Environment, &p.Notes,
&favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON,
&createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
p.Protocol = models.Protocol(protocol)
p.Favourite = favourite == 1
p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
if lastConn.Valid && lastConn.String != "" {
t, err := time.Parse(time.RFC3339, lastConn.String)
if err == nil {
p.LastConnected = &t
}
}
_ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
if p.Tags == nil {
p.Tags = []string{}
}
if sshJSON.Valid && sshJSON.String != "" {
p.SSH = &models.SSHConfig{}
_ = json.Unmarshal([]byte(sshJSON.String), p.SSH)
}
if telnetJSON.Valid && telnetJSON.String != "" {
p.Telnet = &models.TelnetConfig{}
_ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet)
}
if rawJSON.Valid && rawJSON.String != "" {
p.RAW = &models.RAWConfig{}
_ = json.Unmarshal([]byte(rawJSON.String), p.RAW)
}
return p, nil
}
// scanProfiles iterates over rows and returns a slice of profiles.
func scanProfiles(rows *sql.Rows) ([]*models.Profile, error) {
var result []*models.Profile
for rows.Next() {
p := &models.Profile{}
var (
protocol, tagsJSON string
sshJSON, telnetJSON, rawJSON sql.NullString
lastConn sql.NullString
favourite int
createdAt, updatedAt string
)
err := rows.Scan(
&p.ID, &p.DisplayName, &p.Description, &protocol,
&p.Host, &p.Port, &p.Username,
&tagsJSON, &p.Folder, &p.Environment, &p.Notes,
&favourite, &lastConn,
&p.ConnectTimeout, &p.ReadTimeout,
&p.TerminalFontFamily, &p.TerminalFontSize, &p.TerminalScrollback, &p.TerminalCursorStyle,
&sshJSON, &telnetJSON, &rawJSON,
&createdAt, &updatedAt,
)
if err != nil {
return nil, fmt.Errorf("storage: failed to scan profile row: %w", err)
}
p.Protocol = models.Protocol(protocol)
p.Favourite = favourite == 1
p.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
p.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
if lastConn.Valid && lastConn.String != "" {
t, err := time.Parse(time.RFC3339, lastConn.String)
if err == nil {
p.LastConnected = &t
}
}
_ = json.Unmarshal([]byte(tagsJSON), &p.Tags)
if p.Tags == nil {
p.Tags = []string{}
}
if sshJSON.Valid && sshJSON.String != "" {
p.SSH = &models.SSHConfig{}
_ = json.Unmarshal([]byte(sshJSON.String), p.SSH)
}
if telnetJSON.Valid && telnetJSON.String != "" {
p.Telnet = &models.TelnetConfig{}
_ = json.Unmarshal([]byte(telnetJSON.String), p.Telnet)
}
if rawJSON.Valid && rawJSON.String != "" {
p.RAW = &models.RAWConfig{}
_ = json.Unmarshal([]byte(rawJSON.String), p.RAW)
}
result = append(result, p)
}
return result, rows.Err()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func timeToStr(t *time.Time) interface{} {
if t == nil {
return nil
}
return t.Format(time.RFC3339)
}
// nullableJSON returns nil when the source object pointer is nil,
// otherwise the marshalled JSON bytes as a string.
func nullableJSON(ptr interface{}, data []byte) interface{} {
if ptr == nil || string(data) == "null" {
return nil
}
return string(data)
}
+352
View File
@@ -0,0 +1,352 @@
// Package telnetclient provides a Telnet connection with basic option
// negotiation, break signal support, and callback-driven output.
package telnetclient
import (
"context"
"fmt"
"io"
"net"
"sync"
"time"
)
// Telnet command bytes.
const (
cmdIAC byte = 255
cmdDONT byte = 254
cmdDO byte = 253
cmdWONT byte = 252
cmdWILL byte = 251
cmdSB byte = 250
cmdSE byte = 240
cmdBRK byte = 243
optEcho byte = 1
optSuppressGoAh byte = 3
optTerminalType byte = 24
optNAWS byte = 31
)
// Options configures a new Telnet connection.
type Options struct {
Host string
Port int
TerminalType string
Encoding string
ConnectTimeout time.Duration
// Callbacks invoked from a background goroutine.
OnData func(data []byte)
OnError func(err error)
OnClose func()
// DebugLog, when non-nil, receives diagnostic messages.
DebugLog func(msg string)
}
// Client manages a single Telnet connection with basic option negotiation.
type Client struct {
opts Options
conn net.Conn
mu sync.Mutex
closed bool
cancel context.CancelFunc
// Negotiated capabilities.
nawsEnabled bool
cols, rows int
}
// New creates a Client with the given options.
func New(opts Options) *Client {
if opts.TerminalType == "" {
opts.TerminalType = "xterm-256color"
}
if opts.ConnectTimeout == 0 {
opts.ConnectTimeout = 30 * time.Second
}
return &Client{
opts: opts,
cols: 80,
rows: 24,
}
}
// Connect establishes the Telnet connection and begins reading data.
func (c *Client) Connect(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
ctx, cancel := context.WithCancel(ctx)
c.cancel = cancel
c.debug("dialling %s:%d", c.opts.Host, c.opts.Port)
addr := net.JoinHostPort(c.opts.Host, fmt.Sprintf("%d", c.opts.Port))
dialer := &net.Dialer{Timeout: c.opts.ConnectTimeout}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("telnetclient: dial failed: %w", err)
}
c.conn = conn
go c.readLoop(ctx)
return nil
}
// Write sends data to the remote host, escaping any IAC bytes.
func (c *Client) Write(data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.conn == nil {
return fmt.Errorf("telnetclient: connection is closed")
}
escaped := escapeIAC(data)
_, err := c.conn.Write(escaped)
return err
}
// Resize informs the remote host of a terminal size change via NAWS if
// negotiated.
func (c *Client) Resize(cols, rows int) error {
c.mu.Lock()
defer c.mu.Unlock()
c.cols = cols
c.rows = rows
if !c.nawsEnabled || c.closed || c.conn == nil {
return nil
}
return c.sendNAWS()
}
// SendBreak transmits a Telnet BRK command.
func (c *Client) SendBreak() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed || c.conn == nil {
return fmt.Errorf("telnetclient: connection is closed")
}
c.debug("sending break signal")
_, err := c.conn.Write([]byte{cmdIAC, cmdBRK})
return err
}
// Close tears down the Telnet connection.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
c.debug("closing connection")
if c.cancel != nil {
c.cancel()
}
if c.conn != nil {
return c.conn.Close()
}
return nil
}
// readLoop reads from the connection, processes Telnet commands, and delivers
// plain data via the OnData callback.
func (c *Client) readLoop(ctx context.Context) {
buf := make([]byte, 32*1024)
for {
select {
case <-ctx.Done():
return
default:
}
n, err := c.conn.Read(buf)
if n > 0 {
c.processBytes(buf[:n])
}
if err != nil {
if err != io.EOF {
c.debug("read error: %v", err)
if c.opts.OnError != nil {
c.opts.OnError(err)
}
}
c.handleClose()
return
}
}
}
// processBytes separates Telnet commands from application data.
func (c *Client) processBytes(data []byte) {
var appData []byte
for i := 0; i < len(data); i++ {
if data[i] == cmdIAC && i+1 < len(data) {
switch data[i+1] {
case cmdIAC:
// Escaped IAC → literal 0xFF.
appData = append(appData, cmdIAC)
i++
case cmdDO:
if i+2 < len(data) {
c.handleDO(data[i+2])
i += 2
}
case cmdDONT:
if i+2 < len(data) {
c.handleDONT(data[i+2])
i += 2
}
case cmdWILL:
if i+2 < len(data) {
c.handleWILL(data[i+2])
i += 2
}
case cmdWONT:
if i+2 < len(data) {
// Acknowledge WONT with DONT.
c.sendCommand(cmdDONT, data[i+2])
i += 2
}
case cmdSB:
// Consume the sub-negotiation until IAC SE.
end := c.findSubEnd(data[i+2:])
i += 2 + end
default:
i++
}
} else {
appData = append(appData, data[i])
}
}
if len(appData) > 0 && c.opts.OnData != nil {
c.opts.OnData(appData)
}
}
// handleDO responds to a DO request from the server.
func (c *Client) handleDO(opt byte) {
switch opt {
case optTerminalType:
c.debug("negotiated terminal type")
c.sendCommand(cmdWILL, optTerminalType)
case optNAWS:
c.debug("negotiated NAWS")
c.sendCommand(cmdWILL, optNAWS)
c.mu.Lock()
c.nawsEnabled = true
_ = c.sendNAWS()
c.mu.Unlock()
case optSuppressGoAh:
c.sendCommand(cmdWILL, optSuppressGoAh)
default:
c.sendCommand(cmdWONT, opt)
}
}
// handleDONT responds to a DONT request from the server.
func (c *Client) handleDONT(opt byte) {
if opt == optNAWS {
c.mu.Lock()
c.nawsEnabled = false
c.mu.Unlock()
}
c.sendCommand(cmdWONT, opt)
}
// handleWILL responds to a WILL request from the server.
func (c *Client) handleWILL(opt byte) {
switch opt {
case optEcho, optSuppressGoAh:
c.sendCommand(cmdDO, opt)
default:
c.sendCommand(cmdDONT, opt)
}
}
// sendCommand writes a three-byte Telnet command.
func (c *Client) sendCommand(cmd, opt byte) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil && !c.closed {
c.conn.Write([]byte{cmdIAC, cmd, opt})
}
}
// sendNAWS transmits the current window size via NAWS sub-negotiation.
// Caller must hold c.mu.
func (c *Client) sendNAWS() error {
if c.conn == nil {
return nil
}
msg := []byte{
cmdIAC, cmdSB, optNAWS,
byte(c.cols >> 8), byte(c.cols & 0xFF),
byte(c.rows >> 8), byte(c.rows & 0xFF),
cmdIAC, cmdSE,
}
_, err := c.conn.Write(msg)
return err
}
// findSubEnd returns the offset past the IAC SE that terminates a
// sub-negotiation block.
func (c *Client) findSubEnd(data []byte) int {
for i := 0; i < len(data)-1; i++ {
if data[i] == cmdIAC && data[i+1] == cmdSE {
return i + 2
}
}
return len(data)
}
// handleClose is called when the connection ends.
func (c *Client) handleClose() {
c.mu.Lock()
wasClosed := c.closed
c.closed = true
c.mu.Unlock()
if !wasClosed && c.opts.OnClose != nil {
c.opts.OnClose()
}
}
// escapeIAC doubles any 0xFF bytes in outbound application data so they are
// not mistaken for Telnet commands.
func escapeIAC(data []byte) []byte {
count := 0
for _, b := range data {
if b == cmdIAC {
count++
}
}
if count == 0 {
return data
}
out := make([]byte, 0, len(data)+count)
for _, b := range data {
out = append(out, b)
if b == cmdIAC {
out = append(out, cmdIAC)
}
}
return out
}
func (c *Client) debug(format string, args ...interface{}) {
if c.opts.DebugLog != nil {
c.opts.DebugLog(fmt.Sprintf(format, args...))
}
}
+279
View File
@@ -0,0 +1,279 @@
// Package validation provides thorough validation for connection profiles
// and their protocol-specific configurations.
package validation
import (
"fmt"
"net"
"strings"
"github.com/copilot-ssh-client/app/internal/models"
)
// ValidationError describes a single field-level validation failure.
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// Error satisfies the error interface.
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidationErrors collects multiple validation failures into one error.
type ValidationErrors struct {
Errors []ValidationError `json:"errors"`
}
// Error satisfies the error interface, returning a summary of all failures.
func (e *ValidationErrors) Error() string {
msgs := make([]string, len(e.Errors))
for i, err := range e.Errors {
msgs[i] = err.Error()
}
return fmt.Sprintf("validation failed: %s", strings.Join(msgs, "; "))
}
// HasErrors reports whether any validation errors have been collected.
func (e *ValidationErrors) HasErrors() bool {
return len(e.Errors) > 0
}
// Add appends a validation error to the collection.
func (e *ValidationErrors) Add(field, message string) {
e.Errors = append(e.Errors, ValidationError{Field: field, Message: message})
}
// ValidateProfile validates all fields of a connection profile, including
// protocol-specific configuration blocks.
func ValidateProfile(p *models.Profile) error {
errs := &ValidationErrors{}
if strings.TrimSpace(p.ID) == "" {
errs.Add("id", "profile ID must not be empty")
}
if strings.TrimSpace(p.DisplayName) == "" {
errs.Add("displayName", "display name must not be empty")
}
if err := ValidateHost(p.Host); err != nil {
errs.Add("host", err.(*ValidationError).Message)
}
if err := ValidatePort(p.Port); err != nil {
errs.Add("port", err.(*ValidationError).Message)
}
switch p.Protocol {
case models.ProtocolSSH:
if strings.TrimSpace(p.Username) == "" {
errs.Add("username", "username is required for SSH connections")
} else if err := ValidateUsername(p.Username); err != nil {
errs.Add("username", err.(*ValidationError).Message)
}
if p.SSH == nil {
errs.Add("ssh", "SSH configuration is required for SSH protocol")
} else if err := ValidateSSHConfig(p.SSH); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
case models.ProtocolTelnet:
if p.Telnet == nil {
errs.Add("telnet", "Telnet configuration is required for Telnet protocol")
} else if err := ValidateTelnetConfig(p.Telnet); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
case models.ProtocolRAW:
if p.RAW == nil {
errs.Add("raw", "RAW configuration is required for RAW protocol")
} else if err := ValidateRAWConfig(p.RAW); err != nil {
if ve, ok := err.(*ValidationErrors); ok {
errs.Errors = append(errs.Errors, ve.Errors...)
}
}
default:
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
}
if p.ConnectTimeout < 0 {
errs.Add("connectTimeout", "connect timeout must not be negative")
}
if p.ReadTimeout < 0 {
errs.Add("readTimeout", "read timeout must not be negative")
}
if p.TerminalFontSize < 1 || p.TerminalFontSize > 72 {
errs.Add("terminalFontSize", "font size must be between 1 and 72")
}
if p.TerminalScrollback < 0 {
errs.Add("terminalScrollback", "scrollback must not be negative")
}
if errs.HasErrors() {
return errs
}
return nil
}
// ValidateHost ensures the host string is non-empty and syntactically valid.
func ValidateHost(host string) error {
h := strings.TrimSpace(host)
if h == "" {
return &ValidationError{Field: "host", Message: "host must not be empty"}
}
// Reject hosts that contain whitespace.
if strings.ContainsAny(h, " \t\n\r") {
return &ValidationError{Field: "host", Message: "host must not contain whitespace"}
}
// Reject hosts that contain a scheme (e.g. "ssh://").
if strings.Contains(h, "://") {
return &ValidationError{Field: "host", Message: "host must not include a URI scheme"}
}
// If it looks like an IP address, parse it properly.
if ip := net.ParseIP(h); ip == nil {
// Not a plain IP — verify it is a plausible hostname.
if strings.HasPrefix(h, "-") {
return &ValidationError{Field: "host", Message: "host must not begin with a hyphen"}
}
}
return nil
}
// ValidatePort checks that the port number is within the valid TCP range.
func ValidatePort(port int) error {
if port < 1 || port > 65535 {
return &ValidationError{Field: "port", Message: "port must be between 1 and 65535"}
}
return nil
}
// ValidateUsername checks that the username is non-empty and free of control characters.
func ValidateUsername(username string) error {
u := strings.TrimSpace(username)
if u == "" {
return &ValidationError{Field: "username", Message: "username must not be empty"}
}
for _, r := range u {
if r < 0x20 || r == 0x7f {
return &ValidationError{Field: "username", Message: "username must not contain control characters"}
}
}
return nil
}
// ValidateSSHConfig validates the SSH-specific configuration block.
func ValidateSSHConfig(cfg *models.SSHConfig) error {
errs := &ValidationErrors{}
if len(cfg.AuthMethods) == 0 {
errs.Add("ssh.authMethods", "at least one authentication method is required")
}
validAuth := map[string]bool{
"password": true, "publickey": true, "keyboard-interactive": true,
}
for _, m := range cfg.AuthMethods {
if !validAuth[m] {
errs.Add("ssh.authMethods", fmt.Sprintf("unsupported authentication method %q", m))
}
}
validSecret := map[models.SecretMode]bool{
models.SecretModeSaved: true, models.SecretModeSessionOnly: true, models.SecretModeAskEveryTime: true,
}
if !validSecret[cfg.SecretMode] {
errs.Add("ssh.secretMode", fmt.Sprintf("unsupported secret mode %q", cfg.SecretMode))
}
if !validSecret[cfg.KeySecretMode] {
errs.Add("ssh.keySecretMode", fmt.Sprintf("unsupported key secret mode %q", cfg.KeySecretMode))
}
validPolicy := map[models.HostKeyPolicy]bool{
models.HostKeyStrict: true, models.HostKeyAsk: true,
models.HostKeyAcceptNew: true, models.HostKeyInsecure: true,
}
if !validPolicy[cfg.HostKeyPolicy] {
errs.Add("ssh.hostKeyPolicy", fmt.Sprintf("unsupported host key policy %q", cfg.HostKeyPolicy))
}
if cfg.KeepaliveInterval < 0 {
errs.Add("ssh.keepaliveInterval", "keepalive interval must not be negative")
}
if cfg.KeepaliveCountMax < 0 {
errs.Add("ssh.keepaliveCountMax", "keepalive count max must not be negative")
}
// Validate each jump host entry.
for i, jh := range cfg.JumpHosts {
prefix := fmt.Sprintf("ssh.jumpHosts[%d]", i)
if strings.TrimSpace(jh.Host) == "" {
errs.Add(prefix+".host", "jump host address must not be empty")
}
if jh.Port < 1 || jh.Port > 65535 {
errs.Add(prefix+".port", "jump host port must be between 1 and 65535")
}
if strings.TrimSpace(jh.Username) == "" {
errs.Add(prefix+".username", "jump host username must not be empty")
}
}
if errs.HasErrors() {
return errs
}
return nil
}
// ValidateTelnetConfig validates the Telnet-specific configuration block.
func ValidateTelnetConfig(cfg *models.TelnetConfig) error {
errs := &ValidationErrors{}
validEnding := map[models.LineEnding]bool{
models.LineEndingLF: true, models.LineEndingCR: true,
models.LineEndingCRLF: true, models.LineEndingNone: true,
}
if !validEnding[cfg.LineEnding] {
errs.Add("telnet.lineEnding", fmt.Sprintf("unsupported line ending %q", cfg.LineEnding))
}
if strings.TrimSpace(cfg.Encoding) == "" {
errs.Add("telnet.encoding", "encoding must not be empty")
}
if errs.HasErrors() {
return errs
}
return nil
}
// ValidateRAWConfig validates the RAW TCP-specific configuration block.
func ValidateRAWConfig(cfg *models.RAWConfig) error {
errs := &ValidationErrors{}
validEnding := map[models.LineEnding]bool{
models.LineEndingLF: true, models.LineEndingCR: true,
models.LineEndingCRLF: true, models.LineEndingNone: true,
}
if !validEnding[cfg.LineEnding] {
errs.Add("raw.lineEnding", fmt.Sprintf("unsupported line ending %q", cfg.LineEnding))
}
if strings.TrimSpace(cfg.Encoding) == "" {
errs.Add("raw.encoding", "encoding must not be empty")
}
validFormat := map[string]bool{"text": true, "hex": true, "escaped": true}
for i, preset := range cfg.PayloadPresets {
prefix := fmt.Sprintf("raw.payloadPresets[%d]", i)
if strings.TrimSpace(preset.Name) == "" {
errs.Add(prefix+".name", "payload preset name must not be empty")
}
if !validFormat[preset.Format] {
errs.Add(prefix+".format", fmt.Sprintf("unsupported payload format %q", preset.Format))
}
}
if errs.HasErrors() {
return errs
}
return nil
}