feat(ssh): bump restty and follow upstream tailscale ssh

This commit is contained in:
Aarnav Tale
2026-08-27 14:59:47 -07:00
parent b9aa99c45e
commit 29afac60f6
31 changed files with 1653 additions and 1814 deletions
-134
View File
@@ -1,134 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"context"
"fmt"
"log"
"net"
"net/netip"
"tailscale.com/control/controlclient"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
"tailscale.com/tsd"
"tailscale.com/types/logid"
"tailscale.com/wgengine"
"tailscale.com/wgengine/netstack"
)
type TsWasmIpn struct {
options *IPNConfig
dialer *tsdial.Dialer
server *ipnserver.Server
backend *ipnlocal.LocalBackend
}
func NewTsWasmIpn(options *IPNConfig, callbacks *IPNCallbacks) (*TsWasmIpn, error) {
logf := log.Printf
netns.SetEnabled(false)
sys := tsd.NewSystem()
sys.Set(new(mem.Store))
dialer := &tsdial.Dialer{Logf: logf}
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(),
HealthTracker: sys.HealthTracker(),
Metrics: sys.UserMetricsRegistry(),
EventBus: sys.Bus.Get(),
})
if err != nil {
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)
if err != nil {
return nil, fmt.Errorf("failed to create netstack: %w", err)
}
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)
}
logID := logid.PublicID{}
sys.NetstackRouter.Set(true)
sys.Tun.Get().Start()
server := ipnserver.New(logf, logID, sys.NetMon.Get())
backend, err := ipnlocal.NewLocalBackend(logf, logID, sys, controlclient.LoginEphemeral)
if err != nil {
return nil, fmt.Errorf("failed to create local backend: %w", err)
}
if err := wgstack.Start(backend); err != nil {
return nil, fmt.Errorf("failed to start netstack: %w", err)
}
server.SetLocalBackend(backend)
registerNotifyCallback(callbacks, backend)
return &TsWasmIpn{
options: options,
dialer: dialer,
server: server,
backend: backend,
}, nil
}
func (t *TsWasmIpn) Start(ctx context.Context) error {
listener, err := safesocket.Listen("")
if err != nil {
return fmt.Errorf("failed to create safesocket listener: %w", err)
}
go func() {
if err := t.server.Run(ctx, listener); err != nil {
log.Printf("Tailscale server exited: %v", err)
}
}()
err = t.backend.Start(ipn.Options{
AuthKey: t.options.PreAuthKey,
UpdatePrefs: &ipn.Prefs{
ControlURL: t.options.ControlURL,
Hostname: t.options.Hostname,
WantRunning: true,
RunWebClient: false,
LoggedOut: false,
},
})
if err != nil {
return fmt.Errorf("failed to start Tailscale backend: %w", err)
}
return nil
}
-43
View File
@@ -1,43 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"syscall/js"
"tailscale.com/ipn"
)
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",
ipn.Starting: "Starting",
ipn.Running: "Running",
ipn.InUseOtherUser: "InUseOtherUser",
ipn.NeedsMachineAuth: "NeedsMachineAuth",
ipn.NeedsLogin: "NeedsLogin",
}
-118
View File
@@ -1,118 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"errors"
"syscall/js"
)
type IPNConfig struct {
ControlURL string
PreAuthKey string
Hostname string
}
func ParseIPNConfig(obj js.Value) (*IPNConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("config cannot be undefined or null")
}
controlURL := safeString("controlURL", obj)
preAuthKey := safeString("preAuthKey", obj)
hostname := safeString("hostname", obj)
if controlURL == "" || preAuthKey == "" || hostname == "" {
return nil, errors.New("missing required fields: controlURL, preAuthKey, hostname")
}
return &IPNConfig{
ControlURL: controlURL,
PreAuthKey: preAuthKey,
Hostname: hostname,
}, nil
}
type TunnelConfig struct {
IPAddress string
Username string
Timeout int
OnData func(data string)
OnConnect func()
OnDisconnect func()
}
func ParseTunnelConfig(obj js.Value) (*TunnelConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
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)
if timeout <= 0 {
timeout = 30
}
config := &TunnelConfig{
IPAddress: ipAddress,
Username: username,
Timeout: timeout,
}
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.OnData = func(data string) {
onData.Invoke(data)
}
onConnect := obj.Get("onConnect")
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 {
return nil, errors.New("`onDisconnect` is required and must be a function")
}
config.OnDisconnect = func() {
onDisconnect.Invoke()
}
return config, nil
}
func safeString(key string, obj js.Value) string {
if obj.IsUndefined() || obj.IsNull() {
return ""
}
val := obj.Get(key)
if val.IsUndefined() || val.IsNull() {
return ""
}
return val.String()
}
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()
}
-41
View File
@@ -1,41 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"context"
"fmt"
"log"
"sync"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
)
func registerNotifyCallback(callbacks *IPNCallbacks, lb *ipnlocal.LocalBackend) {
var readyOnce sync.Once
lb.SetNotifyCallback(func(n ipn.Notify) {
defer func() {
if rec := recover(); rec != nil {
callbacks.OnError(fmt.Sprint(rec))
}
}()
if n.State != nil {
if *n.State == ipn.Running {
readyOnce.Do(callbacks.OnReady)
}
if *n.State == ipn.NeedsLogin {
go forceInteractiveLogin(lb)
}
}
})
}
func forceInteractiveLogin(lb *ipnlocal.LocalBackend) {
if err := lb.StartLoginInteractive(context.Background()); err != nil {
log.Printf("Error starting interactive login: %v", err)
}
}
-186
View File
@@ -1,186 +0,0 @@
//go:build js && wasm
package hp_ipn
import (
"context"
"fmt"
"io"
"log"
"net"
"time"
"golang.org/x/crypto/ssh"
)
type SSHSession struct {
IPAddress string
Username string
Config *TunnelConfig
Ipn *TsWasmIpn
Pty *ssh.Session
stdin io.Writer
resizeCols int
resizeRows int
cancel context.CancelFunc
}
func (i *TsWasmIpn) NewSSHSession(config *TunnelConfig) *SSHSession {
return &SSHSession{
IPAddress: config.IPAddress,
Username: config.Username,
Config: config,
Ipn: i,
}
}
func (s *SSHSession) ConnectAndRun() {
defer s.Config.OnDisconnect()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.Config.Timeout)*time.Second)
s.cancel = cancel
defer cancel()
conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.IPAddress, "22"))
if err != nil {
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 {
return nil
},
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, s.IPAddress, sshConf)
if err != nil {
s.writeError("SSH", err)
return
}
defer sshConn.Close()
conn.SetReadDeadline(time.Time{})
sshClient := ssh.NewClient(sshConn, chans, reqs)
defer sshClient.Close()
pty, err := sshClient.NewSession()
if err != nil {
s.writeError("SSH", err)
return
}
defer pty.Close()
s.Pty = pty
rows := 24
if s.resizeRows != 0 {
rows = s.resizeRows
}
cols := 80
if s.resizeCols != 0 {
cols = s.resizeCols
}
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
}
stdin, err := pty.StdinPipe()
if err != nil {
s.writeError("SSH", err)
return
}
s.stdin = stdin
stdout, err := pty.StdoutPipe()
if err != nil {
s.writeError("SSH", err)
return
}
stderr, err := pty.StderrPipe()
if err != nil {
s.writeError("SSH", err)
return
}
go io.Copy(DataPipe{s.Config.OnData}, stdout)
go io.Copy(DataPipe{s.Config.OnData}, stderr)
err = pty.Shell()
if err != nil {
s.writeError("SSH", err)
return
}
s.Config.OnConnect()
if err := pty.Wait(); err != nil {
log.Printf("SSH session ended: %v", err)
}
}
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.resizeCols = cols
s.resizeRows = rows
return nil
}
return s.Pty.WindowChange(rows, cols)
}
func (s *SSHSession) Close() error {
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
if s.Pty != nil {
return s.Pty.Close()
}
return nil
}
func (s *SSHSession) writeError(label string, err error) {
s.Config.OnData(fmt.Sprintf("%s error: %v\r\n", label, err))
}
type DataPipe struct {
Send func(data string)
}
func (p DataPipe) Write(data []byte) (int, error) {
p.Send(string(data))
return len(data), nil
}