feat: rebuild browser ssh from the ground up

This commit is contained in:
Aarnav Tale
2026-04-09 21:56:06 -04:00
parent d255098128
commit 0f19fdf0da
39 changed files with 1292 additions and 1231 deletions
+21 -60
View File
@@ -14,7 +14,6 @@ import (
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem"
// "tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
@@ -24,102 +23,74 @@ import (
"tailscale.com/wgengine/netstack"
)
// Represents an in-state Tailscale backend that is WASM friendly.
// The bare minimum to have userspace Wireguard networking is a dialer,
// a server, and a backend.
type TsWasmIpn struct {
// The options used to initialize the TsWasmNet module.
options *TsWasmNetOptions
// The Tailscale dialer, which is used to establish connections.
dialer *tsdial.Dialer
// The Tailscale server, which handles incoming connections and requests.
server *ipnserver.Server
// The Tailscale backend, which manages the local state and operations.
options *IPNConfig
dialer *tsdial.Dialer
server *ipnserver.Server
backend *ipnlocal.LocalBackend
}
// NewTsWasmIpn initializes a new TsWasmIpn instance with the provided options.
// This intentionally does not initialize Logtail, as it is only available in
// the Tailscale SaaS and not on self-hosted instances.
func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*TsWasmIpn, error) {
logf := log.Printf // TODO: Update
func NewTsWasmIpn(options *IPNConfig, callbacks *IPNCallbacks) (*TsWasmIpn, error) {
logf := log.Printf
netns.SetEnabled(false)
netns.SetEnabled(false) // netns is a separate process (not WASM friendly)
// Base system (NewSystem() creates a bus automatically)
// We supply an in-memory store
sys := tsd.NewSystem()
// bus := sys.Bus.Get()
sys.Set(new(mem.Store))
dialer := &tsdial.Dialer{Logf: logf}
// netmon, err := netmon.New(bus, logf)
// if err != nil {
// return nil, err
// }
// Userspace Wireguard engine
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
// NetMon: netmon,
Dialer: dialer,
SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(),
HealthTracker: sys.HealthTracker(),
Metrics: sys.UserMetricsRegistry(),
EventBus: sys.Bus.Get(),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create userspace engine: %w", err)
}
sys.Set(engine)
tun := sys.Tun.Get()
msock := sys.MagicSock.Get()
dnsman := sys.DNSManager.Get()
proxymap := sys.ProxyMapper()
wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap)
sys.Set(wgstack)
wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create netstack: %w", err)
}
// Configure the local Netstack and Dialer
sys.Set(wgstack)
wgstack.ProcessLocalIPs = true
wgstack.ProcessSubnets = true
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
return true
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return wgstack.DialContextTCP(ctx, dst)
}
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return wgstack.DialContextUDP(ctx, dst)
}
// Dummy logid for the Tailscale backend
logid := logid.PublicID{}
logID := logid.PublicID{}
sys.NetstackRouter.Set(true)
sys.Tun.Get().Start()
server := ipnserver.New(logf, logid, sys.NetMon.Get())
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
server := ipnserver.New(logf, logID, sys.NetMon.Get())
backend, err := ipnlocal.NewLocalBackend(logf, logid, sys, flags)
backend, err := ipnlocal.NewLocalBackend(logf, logID, sys, controlclient.LoginEphemeral)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create local backend: %w", err)
}
err = wgstack.Start(backend)
if err != nil {
return nil, err
if err := wgstack.Start(backend); err != nil {
return nil, fmt.Errorf("failed to start netstack: %w", err)
}
server.SetLocalBackend(backend)
@@ -133,26 +104,18 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts
}, nil
}
// Starts the WASM backend which will connect to the Tailscale tailnet and
// register an ephemeral node viewable in the Tailscale admin console.
func (t *TsWasmIpn) Start(ctx context.Context) error {
// Blank "socket" is a requirement for WASM
// This NEEDS to happen before the LocalBackend is started,
listener, err := safesocket.Listen("")
if err != nil {
return fmt.Errorf("failed to create safesocket listener: %w", err)
}
// Start the server BEFORE the LocalBackend is started
go func() {
err := t.server.Run(ctx, listener)
if err != nil {
// TODO: Handle this dispatch using a chan
log.Printf("Failed to run Tailscale server: %v", err)
if err := t.server.Run(ctx, listener); err != nil {
log.Printf("Tailscale server exited: %v", err)
}
}()
// Start the LocalBackend
err = t.backend.Start(ipn.Options{
AuthKey: t.options.PreAuthKey,
UpdatePrefs: &ipn.Prefs{
@@ -163,11 +126,9 @@ func (t *TsWasmIpn) Start(ctx context.Context) error {
LoggedOut: false,
},
})
if err != nil {
return fmt.Errorf("failed to start Tailscale backend: %w", err)
}
log.Printf("Tailscale backend started successfully with hostname: %s", t.options.Hostname)
return nil
}
+24 -87
View File
@@ -3,15 +3,35 @@
package hp_ipn
import (
"errors"
"fmt"
"syscall/js"
"tailscale.com/ipn"
"tailscale.com/types/netmap"
)
// Maps ipn.State values to their string representations for the frontend.
type IPNCallbacks struct {
OnReady func()
OnError func(string)
}
func ParseIPNCallbacks(obj js.Value) *IPNCallbacks {
cb := &IPNCallbacks{
OnReady: func() {},
OnError: func(string) {},
}
onReady := obj.Get("onReady")
if onReady.Type() == js.TypeFunction {
cb.OnReady = func() { onReady.Invoke() }
}
onError := obj.Get("onError")
if onError.Type() == js.TypeFunction {
cb.OnError = func(msg string) { onError.Invoke(msg) }
}
return cb
}
var BackendState = map[ipn.State]string{
ipn.NoState: "NoState",
ipn.Stopped: "Stopped",
@@ -21,86 +41,3 @@ var BackendState = map[ipn.State]string{
ipn.NeedsMachineAuth: "NeedsMachineAuth",
ipn.NeedsLogin: "NeedsLogin",
}
// Represents the callbacks that the TsWasmNet module can invoke to register
// data retrieval and notifications on the frontend.
type TsWasmNetCallbacks struct {
// Changes in the backend state.
NotifyState func(ipn.State)
// Updates to the backend's network map.
NotifyNetMap func(*netmap.NetworkMap)
// If interactive login is required, this passes a login URL.
NotifyBrowseToURL func(string)
// If the process panics, this function is called in go.recover.
NotifyPanicRecover func(string)
}
// Parses a JavaScript object containing the necessary callbacks for the
// TsWasmNet module to properly interact with the frontend.
func ParseTsWasmNetCallbacks(obj js.Value) (*TsWasmNetCallbacks, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("callbacks object is undefined or null")
}
state, err := validateCallback("NotifyState", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyState: %w", err)
}
// TODO: This is complicated, as the NetworkMap is a complex type.
_, err = validateCallback("NotifyNetMap", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyNetMap: %w", err)
}
browseURL, err := validateCallback("NotifyBrowseToURL", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyBrowseToURL: %w", err)
}
panicRecover, err := validateCallback("NotifyPanicRecover", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyPanicRecover: %w", err)
}
return &TsWasmNetCallbacks{
NotifyState: func(ipnState ipn.State) {
state.Invoke(BackendState[ipnState])
},
NotifyNetMap: func(nm *netmap.NetworkMap) {
// We need to build a JSON representation of the NetworkMap
// For now we just pass the NodeKey since that's what we need.
jsObj := js.ValueOf(map[string]any{
"NodeKey": nm.NodeKey.String(),
})
obj.Get("NotifyNetMap").Invoke(jsObj)
},
NotifyBrowseToURL: func(url string) {
browseURL.Invoke(url)
},
NotifyPanicRecover: func(msg string) {
panicRecover.Invoke(msg)
},
}, nil
}
// Validates the specified key is a JS function and returns it.
func validateCallback(key string, obj js.Value) (*js.Value, error) {
val := obj.Get(key)
if val.IsUndefined() || val.IsNull() {
return nil, errors.New("callback is undefined or null")
}
if val.Type() != js.TypeFunction {
return nil, errors.New("callback is not a function")
}
return &val, nil
}
+38 -71
View File
@@ -7,114 +7,83 @@ import (
"syscall/js"
)
// Represents the options needed to initialize the TsWasmNet module.
type TsWasmNetOptions struct {
// Tailscale control URL, e.g., "https://controlplane.tailscale.com"
type IPNConfig struct {
ControlURL string
// Pre-authentication key for the Tailnet
PreAuthKey string
// Optional hostname, autogenerated if not provided.
Hostname string
Hostname string
}
// Parses the provided JS object to validate and extract the TsWasmNetOptions.
func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
func ParseIPNConfig(obj js.Value) (*IPNConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("TsWasmNetOptions cannot be undefined or null")
return nil, errors.New("config cannot be undefined or null")
}
cUrl := safeString("ControlURL", obj)
preAuthKey := safeString("PreAuthKey", obj)
hostname := safeString("Hostname", obj)
controlURL := safeString("controlURL", obj)
preAuthKey := safeString("preAuthKey", obj)
hostname := safeString("hostname", obj)
if cUrl == "" || preAuthKey == "" || hostname == "" {
return nil, errors.New("missing required fields in TsWasmNetOptions")
if controlURL == "" || preAuthKey == "" || hostname == "" {
return nil, errors.New("missing required fields: controlURL, preAuthKey, hostname")
}
return &TsWasmNetOptions{
ControlURL: cUrl,
return &IPNConfig{
ControlURL: controlURL,
PreAuthKey: preAuthKey,
Hostname: hostname,
}, nil
}
// Options passed from the JS side to pass data to xterm.js.
type SSHXtermConfig struct {
Timeout int // Timeout in seconds for the PTY connection.
Rows int // Number of rows in the PTY.
Cols int // Number of columns in the PTY.
OnStdout func(data js.Value) // Fires when the PTY has output.
OnStderr func(error js.Value) // Fires when the PTY has an error.
OnStdin js.Value // Passes a function to the JS side to provide input.
OnConnect func() // Fires when the PTY is opened.
OnDisconnect func() // Fires when the PTY is closed.
type TunnelConfig struct {
IPAddress string
Username string
Timeout int
OnData func(data string)
OnConnect func()
OnDisconnect func()
}
// Parses the provided JS object to validate and extract SSHXtermConfig.
func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
func ParseTunnelConfig(obj js.Value) (*TunnelConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("SSHXtermConfig cannot be undefined or null")
return nil, errors.New("tunnel config cannot be undefined or null")
}
ipAddress := safeString("ipAddress", obj)
username := safeString("username", obj)
if ipAddress == "" || username == "" {
return nil, errors.New("missing required fields: ipAddress, username")
}
timeout := safeInt("timeout", obj)
rows := safeInt("rows", obj)
cols := safeInt("cols", obj)
if rows <= 0 || cols <= 0 {
return nil, errors.New("`rows` and `cols` must be positive integers")
}
if timeout <= 0 {
timeout = 30 // Default timeout to 30 seconds if not specified
timeout = 30
}
config := &SSHXtermConfig{
Timeout: timeout,
Rows: rows,
Cols: cols,
config := &TunnelConfig{
IPAddress: ipAddress,
Username: username,
Timeout: timeout,
}
onStdout := obj.Get("onStdout")
if onStdout.IsUndefined() || onStdout.IsNull() || (onStdout.Type() != js.TypeFunction) {
return nil, errors.New("`onStdout` is required and must be a function")
onData := obj.Get("onData")
if onData.IsUndefined() || onData.IsNull() || onData.Type() != js.TypeFunction {
return nil, errors.New("`onData` is required and must be a function")
}
config.OnStdout = func(data js.Value) {
onStdout.Invoke(data)
config.OnData = func(data string) {
onData.Invoke(data)
}
onStderr := obj.Get("onStderr")
if onStderr.IsUndefined() || onStderr.IsNull() || (onStderr.Type() != js.TypeFunction) {
return nil, errors.New("`onStderr` is required and must be a function")
}
config.OnStderr = func(error js.Value) {
onStderr.Invoke(error)
}
onStdin := obj.Get("onStdin")
if onStdin.IsUndefined() || onStdin.IsNull() || (onStdin.Type() != js.TypeFunction) {
return nil, errors.New("`onStdin` is required and must be a function")
}
config.OnStdin = onStdin
onConnect := obj.Get("onConnect")
if onConnect.IsUndefined() || onConnect.IsNull() || (onConnect.Type() != js.TypeFunction) {
if onConnect.IsUndefined() || onConnect.IsNull() || onConnect.Type() != js.TypeFunction {
return nil, errors.New("`onConnect` is required and must be a function")
}
config.OnConnect = func() {
onConnect.Invoke()
}
onDisconnect := obj.Get("onDisconnect")
if onDisconnect.IsUndefined() || onDisconnect.IsNull() || (onDisconnect.Type() != js.TypeFunction) {
if onDisconnect.IsUndefined() || onDisconnect.IsNull() || onDisconnect.Type() != js.TypeFunction {
return nil, errors.New("`onDisconnect` is required and must be a function")
}
config.OnDisconnect = func() {
onDisconnect.Invoke()
}
@@ -122,7 +91,6 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
return config, nil
}
// Retrieves a string value from a JS object safely.
func safeString(key string, obj js.Value) string {
if obj.IsUndefined() || obj.IsNull() {
return ""
@@ -136,7 +104,6 @@ func safeString(key string, obj js.Value) string {
return val.String()
}
// Retrieves an integer value from a JS object safely.
func safeInt(key string, obj js.Value) int {
if obj.IsUndefined() || obj.IsNull() {
return 0
+11 -69
View File
@@ -6,94 +6,36 @@ import (
"context"
"fmt"
"log"
"sync"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
)
func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBackend) {
lb.SetNotifyCallback(func(n ipn.Notify) {
// Panics should be treated with care in a JS/wasm environment.
// If a panic occurs, notify the user and either automatically reload
// or give the option to reload.
func registerNotifyCallback(callbacks *IPNCallbacks, lb *ipnlocal.LocalBackend) {
var readyOnce sync.Once
lb.SetNotifyCallback(func(n ipn.Notify) {
defer func() {
rec := recover()
if rec != nil {
callbacks.NotifyPanicRecover(fmt.Sprint(rec))
if rec := recover(); rec != nil {
callbacks.OnError(fmt.Sprint(rec))
}
}()
if n.State != nil {
callbacks.NotifyState(*n.State)
if *n.State == ipn.Running {
readyOnce.Do(callbacks.OnReady)
}
if *n.State == ipn.NeedsLogin {
// If the state is NeedsLogin, we need to force an interactive login.
go forceInteractiveLogin(lb)
}
}
if n.BrowseToURL != nil {
callbacks.NotifyBrowseToURL(*n.BrowseToURL)
}
if n.NetMap != nil {
callbacks.NotifyNetMap(n.NetMap)
}
log.Printf("NOTIFY: %+v", n)
// if nm := n.NetMap; nm != nil {
// jsNetMap := jsNetMap{
// Self: jsNetMapSelfNode{
// jsNetMapNode: jsNetMapNode{
// Name: nm.Name,
// Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
// NodeKey: nm.NodeKey.String(),
// MachineKey: nm.MachineKey.String(),
// },
// MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
// },
// Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
// name := p.Name()
// if name == "" {
// // In practice this should only happen for Hello.
// name = p.Hostinfo().Hostname()
// }
// addrs := make([]string, p.Addresses().Len())
// for i, ap := range p.Addresses().All() {
// addrs[i] = ap.Addr().String()
// }
// return jsNetMapPeerNode{
// jsNetMapNode: jsNetMapNode{
// Name: name,
// Addresses: addrs,
// MachineKey: p.Machine().String(),
// NodeKey: p.Key().String(),
// },
// Online: p.Online().Clone(),
// TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
// }
// }),
// LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
// }
// if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
// jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
// } else {
// log.Printf("Could not generate JSON netmap: %v", err)
// }
// }
// if n.BrowseToURL != nil {
// jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
// }
})
}
// To get auth to work, even with a pre-auth key, we need to
// force an interactive login on the NeedsLogin state.
func forceInteractiveLogin(lb *ipnlocal.LocalBackend) {
err := lb.StartLoginInteractive(context.Background())
if err != nil {
fmt.Printf("Error starting interactive login: %v\n", err)
if err := lb.StartLoginInteractive(context.Background()); err != nil {
log.Printf("Error starting interactive login: %v", err)
}
}
+72 -126
View File
@@ -8,83 +8,71 @@ import (
"io"
"log"
"net"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
)
// Represents an SSH session over the Tailnet.
type SSHSession struct {
// Hostname on the Tailnet.
Hostname string
IPAddress string
Username string
Config *TunnelConfig
Ipn *TsWasmIpn
Pty *ssh.Session
// Username for the SSH connection.
Username string
// Xterm configuration for the SSH session.
TermConfig *SSHXtermConfig
// Handle to the current IPN connection.
Ipn *TsWasmIpn
// Handle to the current SSH session.
Pty *ssh.Session
// Tracks resize notifications for rows.
ResizeRows int
// Tracks resize notifications for columns.
ResizeCols int
// Reference to our stdin handler, released on close.
stdinHandler *js.Func
stdin io.Writer
resizeCols int
resizeRows int
cancel context.CancelFunc
}
// Creates a new SSH session given a hostname and username.
func (i *TsWasmIpn) NewSSHSession(hostname, username string, termConfig *SSHXtermConfig) *SSHSession {
func (i *TsWasmIpn) NewSSHSession(config *TunnelConfig) *SSHSession {
return &SSHSession{
Hostname: hostname,
Username: username,
TermConfig: termConfig,
Ipn: i,
IPAddress: config.IPAddress,
Username: config.Username,
Config: config,
Ipn: i,
}
}
func (s *SSHSession) ConnectAndRun() {
defer s.TermConfig.OnDisconnect()
defer s.Config.OnDisconnect()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.TermConfig.Timeout)*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.Config.Timeout)*time.Second)
s.cancel = cancel
defer cancel()
// TODO: Log here
log.Printf("Attempting SSH dial to host: %s", net.JoinHostPort(s.Hostname, "22"))
conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.Hostname, "22"))
conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.IPAddress, "22"))
if err != nil {
log.Printf("SSH dial error: %v", err)
s.writeError("Dial", err)
return
}
defer conn.Close()
// In Go WASM, gVisor's netstack conn.Read blocks indefinitely without
// a deadline because the single-threaded goroutine scheduler needs the
// deadline machinery to yield to the browser event loop and process
// inbound WireGuard packets. We set a deadline that covers the entire
// SSH handshake and clear it once the session is established.
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
sshConf := &ssh.ClientConfig{
User: s.Username,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
// Tailscale SSH doesn't use host keys
// TODO: Log that the connection was established
return nil
},
}
// TODO: LOG: Starting SSH Client
sshConn, _, _, err := ssh.NewClientConn(conn, s.Hostname, sshConf)
sshConn, chans, reqs, err := ssh.NewClientConn(conn, s.IPAddress, sshConf)
if err != nil {
s.writeError("SSH", err)
return
}
defer sshConn.Close()
sshClient := ssh.NewClient(sshConn, nil, nil)
conn.SetReadDeadline(time.Time{})
sshClient := ssh.NewClient(sshConn, chans, reqs)
defer sshClient.Close()
pty, err := sshClient.NewSession()
@@ -92,30 +80,28 @@ func (s *SSHSession) ConnectAndRun() {
s.writeError("SSH", err)
return
}
defer pty.Close()
s.Pty = pty
rows := s.TermConfig.Rows
if s.ResizeRows != 0 {
rows = s.ResizeRows
rows := 24
if s.resizeRows != 0 {
rows = s.resizeRows
}
cols := s.TermConfig.Cols
if s.ResizeCols != 0 {
cols = s.ResizeCols
cols := 80
if s.resizeCols != 0 {
cols = s.resizeCols
}
err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{
ssh.ECHO: 1, // enable echoing
ssh.ICANON: 1, // canonical mode
ssh.ISIG: 1, // enable signals
ssh.ICRNL: 1, // map CR to NL on input
ssh.IUTF8: 1, // input is UTF-8
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
err = pty.RequestPty("xterm-256color", rows, cols, ssh.TerminalModes{
ssh.ECHO: 1,
ssh.ICANON: 1,
ssh.ISIG: 1,
ssh.ICRNL: 1,
ssh.IUTF8: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
})
if err != nil {
s.writeError("SSH", err)
return
@@ -126,8 +112,7 @@ func (s *SSHSession) ConnectAndRun() {
s.writeError("SSH", err)
return
}
s.wireStdinHandler(stdin)
s.stdin = stdin
stdout, err := pty.StdoutPipe()
if err != nil {
@@ -141,100 +126,61 @@ func (s *SSHSession) ConnectAndRun() {
return
}
go io.Copy(XtermPipe{s.TermConfig.OnStdout}, stdout)
go io.Copy(XtermPipe{s.TermConfig.OnStderr}, stderr)
go io.Copy(DataPipe{s.Config.OnData}, stdout)
go io.Copy(DataPipe{s.Config.OnData}, stderr)
// Create our shell
err = pty.Shell()
if err != nil {
s.writeError("SSH", err)
return
}
s.TermConfig.OnConnect()
err = pty.Wait()
if err != nil {
s.writeError("SSH", err)
return
s.Config.OnConnect()
if err := pty.Wait(); err != nil {
log.Printf("SSH session ended: %v", err)
}
}
// Resize resizes the terminal for the SSH session.
// TODO: This does NOT work correctly from Xterm.js
func (s *SSHSession) Resize(rows, cols int) error {
// Used to handle resizes while still connecting.
func (s *SSHSession) WriteInput(data string) {
if s.stdin != nil {
s.stdin.Write([]byte(data))
}
}
// Resize takes cols and rows (JS convention: cols first, rows second)
// and translates to SSH's WindowChange(rows, cols) order.
func (s *SSHSession) Resize(cols, rows int) error {
if s.Pty == nil {
s.ResizeRows = rows
s.ResizeCols = cols
s.resizeCols = cols
s.resizeRows = rows
return nil
}
return s.Pty.WindowChange(cols, rows)
return s.Pty.WindowChange(rows, cols)
}
// Closes the SSH session.
func (s *SSHSession) Close() error {
if s.stdinHandler != nil {
s.stdinHandler.Release()
s.stdinHandler = nil
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
if s.Pty != nil {
err := s.Pty.Close()
if err != nil {
return err
}
return s.Pty.Close()
}
return nil
}
// Wires up the stdin handler to pass data from JS to the SSH session.
func (s *SSHSession) wireStdinHandler(w io.Writer) {
if s.stdinHandler != nil {
s.stdinHandler.Release()
s.stdinHandler = nil
}
cb := js.FuncOf(func(this js.Value, args []js.Value) any {
v := args[0] // This is ALWAYS a Uint8Array technically
len := v.Get("byteLength").Int()
buf := make([]byte, len)
js.CopyBytesToGo(buf, v)
if _, err := w.Write(buf); err != nil {
s.writeError("SSH Stdin", err)
return nil
}
// TODO: Remove debug log
log.Printf("SSH wrote %d bytes: %v (%q)", len, buf, string(buf))
return nil
})
s.stdinHandler = &cb
s.TermConfig.OnStdin.Invoke(cb)
}
// Quick easy formatter for writing errors to the terminal.
func (s *SSHSession) writeError(label string, err error) {
o := fmt.Sprintf("%s error: %v\r\n", label, err)
uint8Array := js.Global().Get("Uint8Array").New(len(o))
js.CopyBytesToJS(uint8Array, []byte(o))
s.TermConfig.OnStderr(uint8Array)
s.Config.OnData(fmt.Sprintf("%s error: %v\r\n", label, err))
}
// io.Writer "emulator" to pass to the ssh module.
type XtermPipe struct {
// Function to call when data is written.
Send func(data js.Value)
type DataPipe struct {
Send func(data string)
}
// Write implements the io.Writer interface for XtermPipe.
func (x XtermPipe) Write(data []byte) (int, error) {
uint8Array := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(uint8Array, data)
x.Send(uint8Array)
func (p DataPipe) Write(data []byte) (int, error) {
p.Send(string(data))
return len(data), nil
}