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:
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'));
|
||||
@@ -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
|
||||
@@ -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(¤t); 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)
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user