Files
2025-10-02 13:52:19 -04:00

241 lines
5.2 KiB
Go

//go:build js && wasm
package hp_ipn
import (
"context"
"fmt"
"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
// 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
}
// 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()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.TermConfig.Timeout)*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
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{
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
})
if err != nil {
s.writeError("SSH", err)
return
}
stdin, err := pty.StdinPipe()
if err != nil {
s.writeError("SSH", err)
return
}
s.wireStdinHandler(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(XtermPipe{s.TermConfig.OnStdout}, stdout)
go io.Copy(XtermPipe{s.TermConfig.OnStderr}, 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
}
}
// 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(cols, rows)
}
// Closes the SSH session.
func (s *SSHSession) Close() error {
if s.stdinHandler != nil {
s.stdinHandler.Release()
s.stdinHandler = nil
}
if s.Pty != nil {
err := s.Pty.Close()
if err != nil {
return err
}
}
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)
}
// io.Writer "emulator" to pass to the ssh module.
type XtermPipe struct {
// Function to call when data is written.
Send func(data js.Value)
}
// 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)
return len(data), nil
}