feat: add session manager and API layer
Add the session manager (internal/sessions/manager.go) that orchestrates multiple simultaneous SSH, Telnet, and RAW TCP sessions with: - Protocol-specific connection logic using existing client implementations - Credential resolution via OS keychain → encrypted vault → user prompt - Wails runtime event emission for frontend state synchronisation - Full session lifecycle management (connect, disconnect, reconnect) - RAW session history and capture support - Telnet break signal forwarding Add the Wails-bound API service (internal/api/api.go) exposing: - Profile CRUD with search, filtering, favourites, and duplication - Session management (connect, disconnect, resize, write) - RAW-specific text/hex/escaped write methods - Secret store initialisation, unlock/lock, and per-profile secrets - Known host listing and deletion - Preferences get/save - Profile import/export with skip/overwrite/duplicate strategies - Audit log retrieval - Native file dialog wrappers and app info Remove conflicting protocol client stubs (client.go) that duplicated the comprehensive implementations in sshclient.go, telnetclient.go, and rawclient.go.
This commit is contained in:
@@ -1,310 +0,0 @@
|
||||
// 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...))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user