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:
2026-04-15 00:00:00 +01:00
commit 35bc9c75b1
36 changed files with 6226 additions and 0 deletions
+352
View File
@@ -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...))
}
}