mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 00:08:14 +00:00
241 lines
5.2 KiB
Go
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
|
|
}
|