feat: complete Nexus Terminal — premium SSH/Telnet/RAW TCP desktop client

Production-minded Wails v2 desktop application with:
- Full SSH client (password, key, keyboard-interactive, jump hosts, keepalive)
- Telnet client (RFC 854/855 negotiation, NAWS, TTYPE, encoding)
- RAW TCP client (binary-safe, text/hex/split views, payload presets)
- SQLite storage with migrations and repository abstraction
- Encrypted secret storage (AES-256-GCM + Argon2id) with keychain fallback
- Known hosts verification (strict/ask/accept-new/insecure)
- Session manager with multi-session lifecycle and Wails events
- Dark charcoal Material-inspired design system (CSS custom properties)
- Tabbed session management with rename, reorder, pin, context menu
- Profile editor with protocol-aware form sections
- Command palette, notifications, settings modal
- JSON import/export, audit logging, preferences
- 50+ tests across validation, secrets, storage, encoding, sessions
- Makefile, build.sh, .gitignore, comprehensive README
This commit is contained in:
2026-04-15 00:00:00 +01:00
parent c767c1640e
commit 5bff1d9f3e
17 changed files with 5644 additions and 45 deletions
+162
View File
@@ -0,0 +1,162 @@
package sshclient
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"golang.org/x/crypto/ssh"
)
// HostKeyStore is the interface for known-host persistence.
// Implementations handle the underlying storage mechanism (e.g. SQLite, file).
type HostKeyStore interface {
GetKnownHost(host string, port int) (*models.KnownHost, error)
SaveKnownHost(kh *models.KnownHost) error
}
// HostKeyVerifier handles SSH host key verification against the known-hosts
// store. It supports four policies:
// - strict: reject unknown or changed keys
// - ask: prompt the user for unknown keys (via callback)
// - accept-new: automatically accept new keys, reject changed keys
// - insecure: accept all keys (for testing only — clearly warned)
type HostKeyVerifier struct {
store HostKeyStore
policy models.HostKeyPolicy
onPrompt func(host string, keyType string, fingerprint string) (bool, error)
}
// NewHostKeyVerifier creates a verifier with the given store and policy.
func NewHostKeyVerifier(store HostKeyStore, policy models.HostKeyPolicy) *HostKeyVerifier {
return &HostKeyVerifier{
store: store,
policy: policy,
}
}
// SetPromptHandler sets the callback used in "ask" mode to prompt the user
// for acceptance of an unknown host key.
func (v *HostKeyVerifier) SetPromptHandler(fn func(string, string, string) (bool, error)) {
v.onPrompt = fn
}
// HostKeyCallback returns an ssh.HostKeyCallback suitable for use with
// ssh.ClientConfig. The behaviour depends on the configured policy.
func (v *HostKeyVerifier) HostKeyCallback() ssh.HostKeyCallback {
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
host, port := extractHostPort(hostname, remote)
keyType := key.Type()
fingerprint := computeFingerprint(key)
stored, err := v.store.GetKnownHost(host, port)
if err != nil {
return fmt.Errorf("failed to query known hosts: %w", err)
}
switch v.policy {
case models.HostKeyInsecure:
return v.saveIfNew(stored, host, port, keyType, fingerprint, key)
case models.HostKeyAcceptNew:
if stored == nil {
return v.saveHost(host, port, keyType, fingerprint, key)
}
return v.verifyStored(stored, fingerprint, host)
case models.HostKeyAsk:
if stored == nil {
return v.promptAndSave(host, port, keyType, fingerprint, key)
}
return v.verifyStored(stored, fingerprint, host)
case models.HostKeyStrict:
if stored == nil {
return fmt.Errorf("host key for %s:%d is not known (policy: strict)", host, port)
}
return v.verifyStored(stored, fingerprint, host)
default:
return fmt.Errorf("unknown host key policy: %s", v.policy)
}
}
}
// verifyStored checks that the presented fingerprint matches the stored one.
func (v *HostKeyVerifier) verifyStored(stored *models.KnownHost, fingerprint, host string) error {
if stored.Fingerprint != fingerprint {
return fmt.Errorf(
"host key for %s has changed! stored fingerprint: %s, received: %s — possible MITM attack",
host, stored.Fingerprint, fingerprint,
)
}
stored.LastSeen = time.Now()
_ = v.store.SaveKnownHost(stored)
return nil
}
// promptAndSave asks the user whether to accept an unknown host key.
func (v *HostKeyVerifier) promptAndSave(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
if v.onPrompt == nil {
return fmt.Errorf("host key for %s:%d is unknown and no prompt handler is set (policy: ask)", host, port)
}
accepted, err := v.onPrompt(host, keyType, fingerprint)
if err != nil {
return fmt.Errorf("host key prompt failed: %w", err)
}
if !accepted {
return fmt.Errorf("host key for %s:%d rejected by user", host, port)
}
return v.saveHost(host, port, keyType, fingerprint, key)
}
// saveIfNew stores the host key only when no prior record exists.
func (v *HostKeyVerifier) saveIfNew(stored *models.KnownHost, host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
if stored == nil {
return v.saveHost(host, port, keyType, fingerprint, key)
}
stored.LastSeen = time.Now()
_ = v.store.SaveKnownHost(stored)
return nil
}
// saveHost persists a new known-host record.
func (v *HostKeyVerifier) saveHost(host string, port int, keyType, fingerprint string, key ssh.PublicKey) error {
now := time.Now()
kh := &models.KnownHost{
Host: host,
Port: port,
KeyType: keyType,
Fingerprint: fingerprint,
PublicKey: base64.StdEncoding.EncodeToString(key.Marshal()),
FirstSeen: now,
LastSeen: now,
Trusted: true,
}
return v.store.SaveKnownHost(kh)
}
// computeFingerprint returns the SHA-256 fingerprint of a public key in
// the standard "SHA256:<base64>" format.
func computeFingerprint(key ssh.PublicKey) string {
hash := sha256.Sum256(key.Marshal())
return "SHA256:" + base64.RawStdEncoding.EncodeToString(hash[:])
}
// extractHostPort splits the hostname and port from the SSH callback arguments.
func extractHostPort(hostname string, remote net.Addr) (string, int) {
host, portStr, err := net.SplitHostPort(hostname)
if err != nil {
host, portStr, _ = net.SplitHostPort(remote.String())
}
port := 22
if portStr != "" {
fmt.Sscanf(portStr, "%d", &port)
}
return host, port
}
+546
View File
@@ -0,0 +1,546 @@
package sshclient
import (
"context"
"fmt"
"io"
"net"
"os"
"sync"
"time"
"github.com/copilot-ssh-client/app/internal/models"
"golang.org/x/crypto/ssh"
)
// Client manages a single SSH session lifecycle.
// It handles connection establishment, authentication, PTY allocation,
// terminal resize propagation, and clean shutdown.
type Client struct {
mu sync.Mutex
config *models.Profile
sshClient *ssh.Client
session *ssh.Session
stdin io.WriteCloser
stdout io.Reader
stderr io.Reader
connected bool
onData func(data []byte)
onError func(err error)
onClose func()
ctx context.Context
cancel context.CancelFunc
}
// NewClient creates a new SSH client bound to the given profile.
func NewClient(profile *models.Profile) *Client {
ctx, cancel := context.WithCancel(context.Background())
return &Client{
config: profile,
ctx: ctx,
cancel: cancel,
}
}
// SetCallbacks sets the data, error, and close callbacks. Must be called
// before Connect.
func (c *Client) SetCallbacks(onData func([]byte), onError func(error), onClose func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.onData = onData
c.onError = onError
c.onClose = onClose
}
// sshConf returns the SSH-specific configuration, or a zero value if nil.
func (c *Client) sshConf() models.SSHConfig {
if c.config.SSH != nil {
return *c.config.SSH
}
return models.SSHConfig{}
}
// Connect establishes the SSH connection using the default (insecure)
// host-key callback. Prefer ConnectWithHostKeyCallback for production use.
func (c *Client) Connect(getPassword func() (string, error), getPassphrase func() (string, error)) error {
return c.connectInternal(ssh.InsecureIgnoreHostKey(), getPassword, getPassphrase)
}
// ConnectWithHostKeyCallback applies a custom HostKeyCallback (e.g. from
// HostKeyVerifier) and then establishes the connection.
func (c *Client) ConnectWithHostKeyCallback(
hkCallback ssh.HostKeyCallback,
getPassword func() (string, error),
getPassphrase func() (string, error),
) error {
return c.connectInternal(hkCallback, getPassword, getPassphrase)
}
// connectInternal is the shared implementation for both Connect variants.
func (c *Client) connectInternal(
hkCallback ssh.HostKeyCallback,
getPassword func() (string, error),
getPassphrase func() (string, error),
) error {
c.mu.Lock()
if c.connected {
c.mu.Unlock()
return fmt.Errorf("already connected")
}
c.mu.Unlock()
sc := c.sshConf()
authMethods, err := c.buildAuthMethods(sc, getPassword, getPassphrase)
if err != nil {
return fmt.Errorf("failed to build auth methods: %w", err)
}
termType := sc.TerminalType
if termType == "" {
termType = "xterm-256color"
}
timeout := time.Duration(c.config.ConnectTimeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
sshConfig := &ssh.ClientConfig{
User: c.config.Username,
Auth: authMethods,
HostKeyCallback: hkCallback,
Timeout: timeout,
}
var sshConn *ssh.Client
if len(sc.JumpHosts) > 0 {
sshConn, err = c.connectViaJumpHosts(sc, sshConfig, getPassphrase)
} else {
port := c.config.Port
if port == 0 {
port = 22
}
addr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", port))
sshConn, err = ssh.Dial("tcp", addr, sshConfig)
}
if err != nil {
return fmt.Errorf("SSH connection failed: %w", err)
}
session, err := sshConn.NewSession()
if err != nil {
sshConn.Close()
return fmt.Errorf("failed to create SSH session: %w", err)
}
// Allocate a PTY with sensible defaults and terminal modes.
cols := 80
rows := 24
modes := ssh.TerminalModes{
ssh.ECHO: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
}
if err := session.RequestPty(termType, rows, cols, modes); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("PTY request failed: %w", err)
}
stdinPipe, err := session.StdinPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stdin pipe: %w", err)
}
stdoutPipe, err := session.StdoutPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stdout pipe: %w", err)
}
stderrPipe, err := session.StderrPipe()
if err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to get stderr pipe: %w", err)
}
// Start shell or explicit startup command.
if sc.StartupCommand != "" {
if err := session.Start(sc.StartupCommand); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to start command: %w", err)
}
} else {
if err := session.Shell(); err != nil {
session.Close()
sshConn.Close()
return fmt.Errorf("failed to start shell: %w", err)
}
}
c.mu.Lock()
c.sshClient = sshConn
c.session = session
c.stdin = stdinPipe
c.stdout = stdoutPipe
c.stderr = stderrPipe
c.connected = true
c.mu.Unlock()
go c.readLoop()
if sc.KeepaliveInterval > 0 {
go c.startKeepalive(sc.KeepaliveInterval)
}
return nil
}
// Write sends data to the SSH session's stdin.
func (c *Client) Write(data []byte) error {
c.mu.Lock()
if !c.connected || c.stdin == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
stdin := c.stdin
c.mu.Unlock()
_, err := stdin.Write(data)
return err
}
// Resize sends a terminal resize request to the remote PTY.
func (c *Client) Resize(cols, rows int) error {
c.mu.Lock()
if !c.connected || c.session == nil {
c.mu.Unlock()
return fmt.Errorf("not connected")
}
session := c.session
c.mu.Unlock()
return session.WindowChange(rows, cols)
}
// Close cleanly shuts down the SSH session and connection, cancels the
// background context, and invokes the onClose callback.
func (c *Client) Close() error {
c.mu.Lock()
if !c.connected {
c.mu.Unlock()
return nil
}
c.connected = false
session := c.session
sshClient := c.sshClient
onClose := c.onClose
c.mu.Unlock()
c.cancel()
var firstErr error
if session != nil {
if err := session.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if sshClient != nil {
if err := sshClient.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
if onClose != nil {
onClose()
}
return firstErr
}
// IsConnected returns the current connection state.
func (c *Client) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected
}
// buildAuthMethods constructs the SSH authentication method list from the
// profile's SSHConfig and the provided credential callbacks.
func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
authList := sc.AuthMethods
if len(authList) == 0 {
authList = []string{"publickey", "password", "keyboard-interactive"}
}
for _, m := range authList {
switch m {
case "password":
if getPassword != nil {
methods = append(methods, ssh.PasswordCallback(func() (string, error) {
return getPassword()
}))
}
case "publickey":
if sc.PrivateKeyPath != "" {
signer, err := c.parsePrivateKey(sc.PrivateKeyPath, getPassphrase)
if err != nil {
return nil, fmt.Errorf("failed to parse private key %s: %w", sc.PrivateKeyPath, err)
}
methods = append(methods, ssh.PublicKeys(signer))
}
case "keyboard-interactive":
if getPassword != nil {
pwdCallback := getPassword
methods = append(methods, ssh.KeyboardInteractive(
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
ans, err := pwdCallback()
if err != nil {
return nil, err
}
answers[i] = ans
}
return answers, nil
},
))
}
}
}
if len(methods) == 0 {
return nil, fmt.Errorf("no authentication methods available")
}
return methods, nil
}
// connectViaJumpHosts establishes the SSH connection through a chain of
// jump hosts. Each hop opens a new net.Conn through the previous hop's
// SSH client.
func (c *Client) connectViaJumpHosts(sc models.SSHConfig, finalConfig *ssh.ClientConfig, getPassphrase func() (string, error)) (*ssh.Client, error) {
var currentClient *ssh.Client
for i, jump := range sc.JumpHosts {
jumpPort := jump.Port
if jumpPort == 0 {
jumpPort = 22
}
jumpAddr := net.JoinHostPort(jump.Host, fmt.Sprintf("%d", jumpPort))
var jumpAuth []ssh.AuthMethod
if jump.PrivateKeyPath != "" {
signer, err := c.parsePrivateKey(jump.PrivateKeyPath, getPassphrase)
if err != nil {
if currentClient != nil {
currentClient.Close()
}
return nil, fmt.Errorf("jump host %d private key error: %w", i, err)
}
jumpAuth = append(jumpAuth, ssh.PublicKeys(signer))
}
jumpConfig := &ssh.ClientConfig{
User: jump.Username,
Auth: jumpAuth,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: finalConfig.Timeout,
}
if currentClient == nil {
client, err := ssh.Dial("tcp", jumpAddr, jumpConfig)
if err != nil {
return nil, fmt.Errorf("failed to connect to jump host %d (%s): %w", i, jumpAddr, err)
}
currentClient = client
} else {
conn, err := currentClient.Dial("tcp", jumpAddr)
if err != nil {
currentClient.Close()
return nil, fmt.Errorf("failed to dial through jump host %d (%s): %w", i, jumpAddr, err)
}
ncc, chans, reqs, err := ssh.NewClientConn(conn, jumpAddr, jumpConfig)
if err != nil {
conn.Close()
currentClient.Close()
return nil, fmt.Errorf("failed to establish SSH through jump host %d: %w", i, err)
}
currentClient = ssh.NewClient(ncc, chans, reqs)
}
}
// Final hop to the target host.
targetPort := c.config.Port
if targetPort == 0 {
targetPort = 22
}
targetAddr := net.JoinHostPort(c.config.Host, fmt.Sprintf("%d", targetPort))
conn, err := currentClient.Dial("tcp", targetAddr)
if err != nil {
currentClient.Close()
return nil, fmt.Errorf("failed to dial target %s through jump hosts: %w", targetAddr, err)
}
ncc, chans, reqs, err := ssh.NewClientConn(conn, targetAddr, finalConfig)
if err != nil {
conn.Close()
currentClient.Close()
return nil, fmt.Errorf("failed to establish SSH to target %s: %w", targetAddr, err)
}
return ssh.NewClient(ncc, chans, reqs), nil
}
// parsePrivateKey loads and parses an SSH private key file. If the key is
// encrypted and getPassphrase is provided, it will attempt decryption.
func (c *Client) parsePrivateKey(path string, getPassphrase func() (string, error)) (ssh.Signer, error) {
keyData, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("unable to read key file: %w", err)
}
signer, err := ssh.ParsePrivateKey(keyData)
if err == nil {
return signer, nil
}
if getPassphrase == nil {
return nil, fmt.Errorf("key is encrypted and no passphrase callback provided: %w", err)
}
passphrase, err := getPassphrase()
if err != nil {
return nil, fmt.Errorf("failed to get passphrase: %w", err)
}
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyData, []byte(passphrase))
if err != nil {
return nil, fmt.Errorf("failed to parse encrypted key: %w", err)
}
return signer, nil
}
// startKeepalive periodically sends keepalive requests to the server.
// It exits when the context is cancelled or the connection closes.
func (c *Client) startKeepalive(intervalSecs int) {
interval := time.Duration(intervalSecs) * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.mu.Lock()
client := c.sshClient
connected := c.connected
c.mu.Unlock()
if !connected || client == nil {
return
}
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
c.mu.Lock()
onError := c.onError
c.mu.Unlock()
if onError != nil {
onError(fmt.Errorf("keepalive failed: %w", err))
}
return
}
}
}
}
// readLoop reads from stdout and stderr, dispatching received data to the
// onData callback. It runs until the context is cancelled or the streams close.
func (c *Client) readLoop() {
done := make(chan struct{}, 2)
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 32*1024)
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
stdout := c.stdout
c.mu.Unlock()
if stdout == nil {
return
}
n, err := stdout.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(data)
}
}
if err != nil {
return
}
}
}()
go func() {
defer func() { done <- struct{}{} }()
buf := make([]byte, 32*1024)
for {
select {
case <-c.ctx.Done():
return
default:
}
c.mu.Lock()
stderr := c.stderr
c.mu.Unlock()
if stderr == nil {
return
}
n, err := stderr.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
c.mu.Lock()
cb := c.onData
c.mu.Unlock()
if cb != nil {
cb(data)
}
}
if err != nil {
return
}
}
}()
<-done
<-done
c.Close()
}