diff --git a/cmd/tsconnect/index.html b/cmd/tsconnect/index.html index 05562071b..ba9a530f6 100644 --- a/cmd/tsconnect/index.html +++ b/cmd/tsconnect/index.html @@ -5,33 +5,38 @@ - -
+ +

Tailscale Connect

Loading…
- - diff --git a/cmd/tsconnect/package.json b/cmd/tsconnect/package.json index 770604799..7f09527ff 100644 --- a/cmd/tsconnect/package.json +++ b/cmd/tsconnect/package.json @@ -8,7 +8,8 @@ "qrcode": "^1.5.0", "tailwindcss": "^3.1.6", "typescript": "^4.7.4", - "xterm": "^4.18.0" + "xterm": "^4.18.0", + "xterm-addon-fit": "^0.5.0" }, "scripts": { "lint": "tsc --noEmit" diff --git a/cmd/tsconnect/src/index.css b/cmd/tsconnect/src/index.css index 24dd0cb64..c0ad0b037 100644 --- a/cmd/tsconnect/src/index.css +++ b/cmd/tsconnect/src/index.css @@ -73,3 +73,7 @@ background-color: currentColor; clip-path: polygon(100% 0%, 0 0%, 50% 100%); } + +body.ssh-active #ssh-form { + @apply hidden; +} diff --git a/cmd/tsconnect/src/index.ts b/cmd/tsconnect/src/index.ts index c9fac4170..8716a69bb 100644 --- a/cmd/tsconnect/src/index.ts +++ b/cmd/tsconnect/src/index.ts @@ -52,3 +52,7 @@ function handleGoPanic(err?: string) { } let panicNode: HTMLDivElement | undefined + +export function getContentNode(): HTMLDivElement { + return document.querySelector("#content") as HTMLDivElement +} diff --git a/cmd/tsconnect/src/login.ts b/cmd/tsconnect/src/login.ts index 137121333..5431cab44 100644 --- a/cmd/tsconnect/src/login.ts +++ b/cmd/tsconnect/src/login.ts @@ -3,6 +3,7 @@ // license that can be found in the LICENSE file. import * as qrcode from "qrcode" +import { getContentNode } from "./index" export async function showLoginURL(url: string) { if (loginNode) { @@ -30,7 +31,7 @@ export async function showLoginURL(url: string) { linkNode.appendChild(document.createTextNode(url)) - document.body.appendChild(loginNode) + getContentNode().appendChild(loginNode) } export function hideLoginURL() { diff --git a/cmd/tsconnect/src/notifier.ts b/cmd/tsconnect/src/notifier.ts index 00a7d52e4..a5ce3ffca 100644 --- a/cmd/tsconnect/src/notifier.ts +++ b/cmd/tsconnect/src/notifier.ts @@ -47,7 +47,7 @@ export function notifyState(ipn: IPN, state: IPNState) { showLogoutButton(ipn) break } - const stateNode = document.getElementById("state") as HTMLDivElement + const stateNode = document.querySelector("#state") as HTMLDivElement stateNode.textContent = stateLabel ?? "" } diff --git a/cmd/tsconnect/src/ssh.ts b/cmd/tsconnect/src/ssh.ts index e2415431e..1bdaf9307 100644 --- a/cmd/tsconnect/src/ssh.ts +++ b/cmd/tsconnect/src/ssh.ts @@ -3,10 +3,12 @@ // license that can be found in the LICENSE file. import { Terminal } from "xterm" +import { FitAddon } from "xterm-addon-fit" +import { getContentNode } from "./index" export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) { - const formNode = document.getElementById("ssh-form") as HTMLDivElement - const noSSHNode = document.getElementById("no-ssh") as HTMLDivElement + const formNode = document.querySelector("#ssh-form") as HTMLDivElement + const noSSHNode = document.querySelector("#no-ssh") as HTMLDivElement const sshPeers = peers.filter( (p) => p.tailscaleSSHEnabled && p.online !== false @@ -39,26 +41,23 @@ export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) { } export function hideSSHForm() { - const formNode = document.getElementById("ssh-form") as HTMLDivElement + const formNode = document.querySelector("#ssh-form") as HTMLDivElement formNode.classList.add("hidden") } function ssh(hostname: string, username: string, ipn: IPN) { + document.body.classList.add("ssh-active") const termContainerNode = document.createElement("div") - termContainerNode.className = "p-3" - document.body.appendChild(termContainerNode) + termContainerNode.className = "flex-grow bg-black p-2 overflow-hidden" + getContentNode().appendChild(termContainerNode) const term = new Terminal({ cursorBlink: true, }) + const fitAddon = new FitAddon() + term.loadAddon(fitAddon) term.open(termContainerNode) - - // Cancel wheel events from scrolling the page if the terminal has scrollback - termContainerNode.addEventListener("wheel", (e) => { - if (term.buffer.active.baseY > 0) { - e.preventDefault() - } - }) + fitAddon.fit() let onDataHook: ((data: string) => void) | undefined term.onData((e) => { @@ -67,14 +66,33 @@ function ssh(hostname: string, username: string, ipn: IPN) { term.focus() - ipn.ssh(hostname, username, { + const sshSession = ipn.ssh(hostname, username, { writeFn: (input) => term.write(input), setReadFn: (hook) => (onDataHook = hook), rows: term.rows, cols: term.cols, onDone: () => { + resizeObserver.disconnect() term.dispose() termContainerNode.remove() + document.body.classList.remove("ssh-active") + window.removeEventListener("beforeunload", beforeUnloadListener) }, }) + + // Make terminal and SSH session track the size of the containing DOM node. + const resizeObserver = new ResizeObserver((entries) => { + fitAddon.fit() + }) + resizeObserver.observe(termContainerNode) + term.onResize(({ rows, cols }) => { + sshSession.resize(rows, cols) + }) + + // Close the session if the user closes the window without an explicit + // exit. + const beforeUnloadListener = () => { + sshSession.close() + } + window.addEventListener("beforeunload", beforeUnloadListener) } diff --git a/cmd/tsconnect/src/wasm_js.ts b/cmd/tsconnect/src/wasm_js.ts index 32b9ff34b..4ef1b9d09 100644 --- a/cmd/tsconnect/src/wasm_js.ts +++ b/cmd/tsconnect/src/wasm_js.ts @@ -25,7 +25,12 @@ declare global { cols: number onDone: () => void } - ): void + ): IPNSSHSession + } + + interface IPNSSHSession { + resize(rows: number, cols: number): boolean + close(): boolean } interface IPNStateStorage { diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index e47e22a61..bcb777e67 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -142,11 +142,10 @@ func newIPN(jsConfig js.Value) map[string]any { log.Printf("Usage: ssh(hostname, userName, termConfig)") return nil } - go jsIPN.ssh( + return jsIPN.ssh( args[0].String(), args[1].String(), args[2]) - return nil }), } } @@ -256,13 +255,42 @@ func (i *jsIPN) logout() { go i.lb.Logout() } -func (i *jsIPN) ssh(host, username string, termConfig js.Value) { - writeFn := termConfig.Get("writeFn") - setReadFn := termConfig.Get("setReadFn") - rows := termConfig.Get("rows").Int() - cols := termConfig.Get("cols").Int() - onDone := termConfig.Get("onDone") +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) interface{} { + return jsSSHSession.Close() != nil + }), + "resize": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + 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 +} + +func (s *jsSSHSession) Run() { + writeFn := s.termConfig.Get("writeFn") + setReadFn := s.termConfig.Get("setReadFn") + rows := s.termConfig.Get("rows").Int() + cols := s.termConfig.Get("cols").Int() + onDone := s.termConfig.Get("onDone") defer onDone.Invoke() write := func(s string) { @@ -274,7 +302,7 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - c, err := i.dialer.UserDial(ctx, "tcp", net.JoinHostPort(host, "22")) + c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22")) if err != nil { writeError("Dial", err) return @@ -283,10 +311,10 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) { config := &ssh.ClientConfig{ HostKeyCallback: ssh.InsecureIgnoreHostKey(), - User: username, + User: s.username, } - sshConn, _, _, err := ssh.NewClientConn(c, host, config) + sshConn, _, _, err := ssh.NewClientConn(c, s.host, config) if err != nil { writeError("SSH Connection", err) return @@ -302,6 +330,7 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) { writeError("SSH Session", err) return } + s.session = session write("Session Established\r\n") defer session.Close() @@ -338,11 +367,19 @@ func (i *jsIPN) ssh(host, username string, termConfig js.Value) { err = session.Wait() if err != nil { - writeError("Exit", err) + writeError("Wait", err) return } } +func (s *jsSSHSession) Close() error { + return s.session.Close() +} + +func (s *jsSSHSession) Resize(rows, cols int) error { + return s.session.WindowChange(rows, cols) +} + type termWriter struct { f js.Value } diff --git a/cmd/tsconnect/yarn.lock b/cmd/tsconnect/yarn.lock index c7307bcbc..81c19436b 100644 --- a/cmd/tsconnect/yarn.lock +++ b/cmd/tsconnect/yarn.lock @@ -603,6 +603,11 @@ xtend@^4.0.2: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +xterm-addon-fit@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596" + integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ== + xterm@^4.18.0: version "4.18.0" resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.18.0.tgz#a1f6ab2c330c3918fb094ae5f4c2562987398ea1"