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