//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() 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 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(cols, rows) } // 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 }