feat: cleanup and streamline webssh capability

This commit is contained in:
Aarnav Tale
2025-06-09 17:46:09 -04:00
parent 0f9bf73b82
commit f4af5b920d
8 changed files with 589 additions and 862 deletions
-633
View File
@@ -1,633 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/netip"
"strings"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem"
"tailscale.com/logpolicy"
"tailscale.com/logtail"
"tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tailcfg"
"tailscale.com/tsd"
"tailscale.com/types/logid"
"tailscale.com/types/views"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
)
// ControlURL defines the URL to be used for connection to Control.
var ControlURL = ipn.DefaultControlURL
func NewIPN(jsConfig js.Value) map[string]any {
netns.SetEnabled(false)
store := &mem.Store{}
controlURL := ControlURL
if jsControlURL := jsConfig.Get("controlURL"); jsControlURL.Type() == js.TypeString {
controlURL = jsControlURL.String()
}
var authKey string
if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString {
authKey = jsAuthKey.String()
}
var hostname string
if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString {
hostname = jsHostname.String()
} else {
hostname = "blah"
}
lpc := getOrCreateLogPolicyConfig(store)
c := logtail.Config{
Collection: lpc.Collection,
PrivateID: lpc.PrivateID,
// Compressed requests set HTTP headers that are not supported by the
// no-cors fetching mode:
CompressLogs: false,
HTTPC: &http.Client{Transport: &noCORSTransport{http.DefaultTransport}},
}
logtail := logtail.NewLogger(c, log.Printf)
logf := logtail.Logf
// Instead of new(tsd.System), we use tsd.NewSystem() to ensure that
// the bus is initialized correctly and that the system is set up
sys := tsd.NewSystem()
sys.Set(store)
dialer := &tsdial.Dialer{Logf: logf}
netmon, err := netmon.New(sys.Bus.Get(), logf)
if err != nil {
log.Fatalf("netmon.New: %v", err)
return nil
}
eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(),
HealthTracker: sys.HealthTracker(),
NetMon: netmon,
Metrics: sys.UserMetricsRegistry(),
})
if err != nil {
log.Fatal(err)
}
sys.Set(eng)
ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper())
if err != nil {
log.Fatalf("netstack.Create: %v", err)
}
sys.Set(ns)
ns.ProcessLocalIPs = true
ns.ProcessSubnets = true
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
return true
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return ns.DialContextTCP(ctx, dst)
}
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return ns.DialContextUDP(ctx, dst)
}
sys.NetstackRouter.Set(true)
sys.Tun.Get().Start()
logid := logid.PublicID{}
srv := ipnserver.New(logf, logid, sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginDefault)
if err != nil {
log.Fatalf("ipnlocal.NewLocalBackend: %v", err)
}
if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err)
}
srv.SetLocalBackend(lb)
log.Printf("Using control URL and auth key: %q, %q", controlURL, authKey)
jsIPN := &jsIPN{
dialer: dialer,
srv: srv,
lb: lb,
controlURL: controlURL,
authKey: authKey,
hostname: hostname,
}
ipnO := ipn.Options{
AuthKey: authKey,
UpdatePrefs: &ipn.Prefs{
ControlURL: controlURL,
Hostname: hostname,
WantRunning: true,
LoggedOut: false,
},
}
state := lb.State()
log.Printf("State: %+v", state)
lb.SetNotifyCallback(func(n ipn.Notify) {
log.Printf("NOTIFY: %+v", n)
st := n.State
if st != nil {
log.Printf("State changed: %s", *st)
if *st == ipn.NeedsLogin {
go func() {
if err := lb.StartLoginInteractive(context.Background()); err != nil {
log.Printf("StartLoginInteractive failed: %v", err)
} else {
log.Printf("Started login interactive")
}
}()
}
}
})
// Start safesocket listener before lb.Start
ln, err := safesocket.Listen("")
if err != nil {
log.Fatalf("safesocket.Listen: %v", err)
}
// Start server loop before backend Start
go func() {
if err := srv.Run(context.Background(), ln); err != nil && !errors.Is(err, context.Canceled) {
log.Fatalf("ipnserver.Run exited: %v", err)
}
}()
// Now start the backend (this can trigger login)
log.Printf("Starting local backend with options: %+v", ipnO)
if err := lb.Start(ipnO); err != nil {
log.Fatalf("Failed to start local backend: %v", err)
}
return map[string]any{
// "run": js.FuncOf(func(this js.Value, args []js.Value) any {
// if len(args) != 1 {
// log.Fatal(`Usage: run({
// notifyState(state: int): void,
// notifyNetMap(netMap: object): void,
// notifyBrowseToURL(url: string): void,
// notifyPanicRecover(err: string): void,
// })`)
// return nil
// }
// jsIPN.run(args[0])
// return nil
// }),
"ssh": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Printf("Usage: ssh(hostname, userName, termConfig)")
return nil
}
return jsIPN.ssh(
args[0].String(),
args[1].String(),
args[2])
}),
"login": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 0 {
log.Printf("Usage: login()")
return nil
}
jsIPN.login()
return nil
}),
}
}
func (i *jsIPN) login() {
go i.lb.StartLoginInteractive(context.Background())
}
type jsIPN struct {
dialer *tsdial.Dialer
srv *ipnserver.Server
lb *ipnlocal.LocalBackend
controlURL string
authKey string
hostname string
}
var jsIPNState = map[ipn.State]string{
ipn.NoState: "NoState",
ipn.InUseOtherUser: "InUseOtherUser",
ipn.NeedsLogin: "NeedsLogin",
ipn.NeedsMachineAuth: "NeedsMachineAuth",
ipn.Stopped: "Stopped",
ipn.Starting: "Starting",
ipn.Running: "Running",
}
var jsMachineStatus = map[tailcfg.MachineStatus]string{
tailcfg.MachineUnknown: "MachineUnknown",
tailcfg.MachineUnauthorized: "MachineUnauthorized",
tailcfg.MachineAuthorized: "MachineAuthorized",
tailcfg.MachineInvalid: "MachineInvalid",
}
func (i *jsIPN) Run(jsCallbacks js.Value) {
notifyState := func(state ipn.State) {
jsCallbacks.Call("notifyState", jsIPNState[state])
}
notifyState(ipn.NoState)
i.lb.SetNotifyCallback(func(n ipn.Notify) {
// Panics in the notify callback are likely due to be due to bugs in
// this bridging module (as opposed to actual bugs in Tailscale) and
// thus may be recoverable. Let the UI know, and allow the user to
// choose if they want to reload the page.
defer func() {
if r := recover(); r != nil {
fmt.Println("Panic recovered:", r)
jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r))
}
}()
log.Printf("NOTIFY: %+v", n)
if n.Health != nil {
log.Printf("NOTIFY HEALTH: %+v", n.Health)
for warning := range n.Health.Warnings {
log.Printf("Health warning: %s", warning)
}
}
if n.State != nil {
notifyState(*n.State)
}
log.Printf("NOTIFY NETMAP: %+v", n.NetMap)
// 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)
// }
})
go func() {
o := ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: i.controlURL,
RouteAll: false,
WantRunning: true,
Hostname: i.hostname,
},
AuthKey: i.authKey,
}
log.Printf("Fucking options: %+v", o)
err := i.lb.Start(o)
if err != nil {
log.Printf("Start error: %v", err)
}
}()
// go func() {
// ln, err := safesocket.Listen("")
// if err != nil {
// log.Fatalf("safesocket.Listen: %v", err)
// }
// err = i.srv.Run(context.Background(), ln)
// log.Fatalf("ipnserver.Run exited: %v", err)
// }()
}
func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any {
jsSSHSession := &jsSSHSession{
jsIPN: i,
host: host,
username: username,
termConfig: termConfig,
}
go jsSSHSession.Run()
return map[string]any{
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
return jsSSHSession.Close() != nil
}),
"resize": js.FuncOf(func(this js.Value, args []js.Value) any {
rows := args[0].Int()
cols := args[1].Int()
return jsSSHSession.Resize(rows, cols) != nil
}),
}
}
type jsSSHSession struct {
jsIPN *jsIPN
host string
username string
termConfig js.Value
session *ssh.Session
pendingResizeRows int
pendingResizeCols int
}
func (s *jsSSHSession) Run() {
writeFn := s.termConfig.Get("writeFn")
writeErrorFn := s.termConfig.Get("writeErrorFn")
setReadFn := s.termConfig.Get("setReadFn")
rows := s.termConfig.Get("rows").Int()
cols := s.termConfig.Get("cols").Int()
timeoutSeconds := 5.0
if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber {
timeoutSeconds = jsTimeoutSeconds.Float()
}
onConnectionProgress := s.termConfig.Get("onConnectionProgress")
onConnected := s.termConfig.Get("onConnected")
onDone := s.termConfig.Get("onDone")
defer onDone.Invoke()
writeError := func(label string, err error) {
writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err))
}
reportProgress := func(message string) {
onConnectionProgress.Invoke(message)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second)))
defer cancel()
reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0]))
c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22"))
if err != nil {
writeError("Dial", err)
return
}
defer c.Close()
config := &ssh.ClientConfig{
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
// Host keys are not used with Tailscale SSH, but we can use this
// callback to know that the connection has been established.
reportProgress("SSH connection established…")
return nil
},
User: s.username,
}
reportProgress("Starting SSH client…")
sshConn, _, _, err := ssh.NewClientConn(c, s.host, config)
if err != nil {
writeError("SSH Connection", err)
return
}
defer sshConn.Close()
sshClient := ssh.NewClient(sshConn, nil, nil)
defer sshClient.Close()
session, err := sshClient.NewSession()
if err != nil {
writeError("SSH Session", err)
return
}
s.session = session
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
writeError("SSH Stdin", err)
return
}
session.Stdout = termWriter{writeFn}
session.Stderr = termWriter{writeFn}
setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any {
input := args[0].String()
_, err := stdin.Write([]byte(input))
if err != nil {
writeError("Write Input", err)
}
return nil
}))
// We might have gotten a resize notification since we started opening the
// session, pick up the latest size.
if s.pendingResizeRows != 0 {
rows = s.pendingResizeRows
}
if s.pendingResizeCols != 0 {
cols = s.pendingResizeCols
}
err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
if err != nil {
writeError("Pseudo Terminal", err)
return
}
err = session.Shell()
if err != nil {
writeError("Shell", err)
return
}
onConnected.Invoke()
err = session.Wait()
if err != nil {
writeError("Wait", err)
return
}
}
func (s *jsSSHSession) Close() error {
if s.session == nil {
// We never had a chance to open the session, ignore the close request.
return nil
}
return s.session.Close()
}
func (s *jsSSHSession) Resize(rows, cols int) error {
if s.session == nil {
s.pendingResizeRows = rows
s.pendingResizeCols = cols
return nil
}
return s.session.WindowChange(rows, cols)
}
type termWriter struct {
f js.Value
}
func (w termWriter) Write(p []byte) (n int, err error) {
r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1)
w.f.Invoke(string(r))
return len(p), nil
}
type jsNetMap struct {
Self jsNetMapSelfNode `json:"self"`
Peers []jsNetMapPeerNode `json:"peers"`
LockedOut bool `json:"lockedOut"`
}
type jsNetMapNode struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
MachineKey string `json:"machineKey"`
NodeKey string `json:"nodeKey"`
}
type jsNetMapSelfNode struct {
jsNetMapNode
MachineStatus string `json:"machineStatus"`
}
type jsNetMapPeerNode struct {
jsNetMapNode
Online *bool `json:"online,omitempty"`
TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"`
}
type jsStateStore struct {
jsStateStorage js.Value
}
func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) {
jsValue := s.jsStateStorage.Call("getState", string(id))
if jsValue.String() == "" {
return nil, ipn.ErrStateNotExist
}
return hex.DecodeString(jsValue.String())
}
func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error {
s.jsStateStorage.Call("setState", string(id), hex.EncodeToString(bs))
return nil
}
func mapSlice[T any, M any](a []T, f func(T) M) []M {
n := make([]M, len(a))
for i, e := range a {
n[i] = f(e)
}
return n
}
func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M {
n := make([]M, a.Len())
for i, v := range a.All() {
n[i] = f(v)
}
return n
}
const logPolicyStateKey = "log-policy"
func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config {
if configBytes, err := state.ReadState(logPolicyStateKey); err == nil {
if config, err := logpolicy.ConfigFromBytes(configBytes); err == nil {
return config
} else {
log.Printf("Could not parse log policy config: %v", err)
}
} else if err != ipn.ErrStateNotExist {
log.Printf("Could not get log policy config from state store: %v", err)
}
config := logpolicy.NewConfig(logtail.CollectionNode)
if err := state.WriteState(logPolicyStateKey, config.ToBytes()); err != nil {
log.Printf("Could not save log policy config to state store: %v", err)
}
return config
}
// noCORSTransport wraps a RoundTripper and forces the no-cors mode on requests,
// so that we can use it with non-CORS-aware servers.
type noCORSTransport struct {
http.RoundTripper
}
func (t *noCORSTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("js.fetch:mode", "no-cors")
resp, err := t.RoundTripper.RoundTrip(req)
if err == nil {
// In no-cors mode no response properties are returned. Populate just
// the status so that callers do not think this was an error.
resp.StatusCode = http.StatusOK
resp.Status = http.StatusText(http.StatusOK)
}
return resp, err
}
+2 -2
View File
@@ -90,7 +90,6 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts
// Configure the local Netstack and Dialer
wgstack.ProcessLocalIPs = true
wgstack.ProcessSubnets = true
sys.NetstackRouter.Set(true)
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
return true
@@ -106,7 +105,8 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts
// Dummy logid for the Tailscale backend
logid := logid.PublicID{}
tun.Start()
sys.NetstackRouter.Set(true)
sys.Tun.Get().Start()
server := ipnserver.New(logf, logid, sys.NetMon.Get())
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
+137
View File
@@ -4,6 +4,7 @@ package hp_ipn
import (
"errors"
"log"
"syscall/js"
)
@@ -40,6 +41,128 @@ func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
}, nil
}
// Options passed from the JS side to pass data to xterm.js.
type SSHXtermConfig struct {
// Number of rows in the PTY.
Rows int
// Number of columns in the PTY.
Cols int
// Fires when the PTY has output.
OnStdout func(data string)
// Fires when the PTY has an error.
OnStderr func(error string)
// Passes a function to the JS side to provide input.
OnStdin js.Value
// Fires when the PTY is opened.
OnConnect func()
// Fires when the PTY is closed.
OnDisconnect func()
}
// Parses the provided JS object to validate and extract SSHXtermConfig.
func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("SSHXtermConfig cannot be undefined or null")
}
rows := safeInt("Rows", obj)
cols := safeInt("Cols", obj)
if rows <= 0 || cols <= 0 {
return nil, errors.New("Rows and Cols must be positive integers")
}
config := &SSHXtermConfig{
Rows: rows,
Cols: cols,
}
onStdout := obj.Get("OnStdout")
if onStdout.IsUndefined() || onStdout.IsNull() {
return nil, errors.New("OnStdout must be a function")
}
if onStdout.Type() != js.TypeFunction {
return nil, errors.New("OnStdout must be a function")
}
config.OnStdout = func(data string) {
onStdout.Invoke(data)
}
onStderr := obj.Get("OnStderr")
if onStderr.IsUndefined() || onStderr.IsNull() {
return nil, errors.New("OnStderr must be a function")
}
if onStderr.Type() != js.TypeFunction {
return nil, errors.New("OnStderr must be a function")
}
config.OnStderr = func(error string) {
onStderr.Invoke(error)
}
onStdin := obj.Get("OnStdin")
if onStdin.IsUndefined() || onStdin.IsNull() {
return nil, errors.New("OnStdin must be a function")
}
if onStdin.Type() != js.TypeFunction {
return nil, errors.New("OnStdin must be a function")
}
config.OnStdin = onStdin
onConnect := obj.Get("OnConnect")
if onConnect.IsUndefined() || onConnect.IsNull() {
return nil, errors.New("OnConnect must be a function")
}
if onConnect.Type() != js.TypeFunction {
return nil, errors.New("OnConnect must be a function")
}
config.OnConnect = func() {
onConnect.Invoke()
}
onDisconnect := obj.Get("OnDisconnect")
if onDisconnect.IsUndefined() || onDisconnect.IsNull() {
return nil, errors.New("OnDisconnect must be a function")
}
if onDisconnect.Type() != js.TypeFunction {
return nil, errors.New("OnDisconnect must be a function")
}
config.OnDisconnect = func() {
onDisconnect.Invoke()
}
return config, nil
}
func (t *SSHXtermConfig) PassStdinHandler(fn func(string)) {
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 || args[0].Type() != js.TypeString {
return nil
}
fn(args[0].String())
return nil
})
log.Printf("Passing stdin handler to JS: %v", handler)
t.OnStdin.Invoke(handler)
}
// Retrieves a string value from a JS object safely.
func safeString(key string, obj js.Value) string {
if obj.IsUndefined() || obj.IsNull() {
@@ -53,3 +176,17 @@ 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
}
val := obj.Get(key)
if val.IsUndefined() || val.IsNull() {
return 0
}
return val.Int()
}
+184
View File
@@ -0,0 +1,184 @@
//go:build js && wasm
package hp_ipn
import (
"bytes"
"context"
"fmt"
"log"
"net"
"time"
"golang.org/x/crypto/ssh"
)
// Represents an SSH session over the Tailnet.
type SSHSession struct {
// Hostname on the Tailnet.
Hostname string
// 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
}
// Creates a new SSH session given a hostname and username.
func (i *TsWasmIpn) NewSSHSession(hostname, username string, termConfig *SSHXtermConfig) *SSHSession {
return &SSHSession{
Hostname: hostname,
Username: username,
TermConfig: termConfig,
Ipn: i,
}
}
func (s *SSHSession) ConnectAndRun() {
defer s.TermConfig.OnDisconnect()
// Default to a 5 second timeout for the connection AFTER dial.
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
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"))
if err != nil {
log.Printf("SSH dial error: %v", err)
s.writeError("Dial", err)
return
}
defer conn.Close()
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)
if err != nil {
s.writeError("SSH", err)
return
}
defer sshConn.Close()
sshClient := ssh.NewClient(sshConn, nil, nil)
defer sshClient.Close()
pty, err := sshClient.NewSession()
if err != nil {
s.writeError("SSH", err)
return
}
defer pty.Close()
s.Pty = pty
pty.Stdout = XtermPipe{s.TermConfig.OnStdout}
pty.Stderr = XtermPipe{s.TermConfig.OnStdout}
// TODO: Set Stdin func
stdin, err := pty.StdinPipe()
if err != nil {
s.writeError("SSH", err)
return
}
s.TermConfig.PassStdinHandler(func(input string) {
_, err := stdin.Write([]byte(input))
if err != nil {
s.writeError("SSH", err)
return
}
})
rows := s.TermConfig.Rows
if s.ResizeRows != 0 {
rows = s.ResizeRows
}
cols := s.TermConfig.Cols
if s.ResizeCols != 0 {
cols = s.ResizeCols
}
err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
if err != nil {
s.writeError("SSH", err)
return
}
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
}
}
// 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.
if s.Pty == nil {
s.ResizeRows = rows
s.ResizeCols = cols
return nil
}
return s.Pty.WindowChange(rows, cols)
}
// Closes the SSH session.
func (s *SSHSession) Close() error {
if s.Pty == nil {
return nil
}
return s.Pty.Close()
}
// 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)
s.TermConfig.OnStderr(o)
}
// io.Writer "emulator" to pass to the ssh module.
type XtermPipe struct {
// Function to call when data is written.
Send func(data string)
}
// Write implements the io.Writer interface for XtermPipe.
func (x XtermPipe) Write(data []byte) (int, error) {
// Tailscale's webSSH does this to fix issues in xterm.js
res := bytes.Replace(data, []byte("\n"), []byte("\n\r"), -1)
x.Send(string(res))
return len(data), nil
}