- 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
353 lines
7.1 KiB
Go
353 lines
7.1 KiB
Go
// 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...))
|
|
}
|
|
}
|