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…
-
-
- None of your machines have
-
Tailscale SSH
- enabled. Give it a try!
+
+
+
+
+
+
+
+ None of your machines have
+
Tailscale SSH
+ enabled. Give it a try!
+
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"