From c06758c83b23de3a45d0b441221fedc0a73a3f4e Mon Sep 17 00:00:00 2001 From: Mihai Parparita Date: Fri, 29 Jul 2022 11:37:17 -0700 Subject: [PATCH] cmd/tsconnect: allow SSH username to be specified Redoes the UI to be a form, with a username field and a host drop-down. Fixes #5139 Signed-off-by: Mihai Parparita --- cmd/tsconnect/index.html | 22 ++++++++++- cmd/tsconnect/src/index.css | 66 +++++++++++++++++++++++++++++++ cmd/tsconnect/src/login.ts | 4 +- cmd/tsconnect/src/notifier.ts | 8 ++-- cmd/tsconnect/src/ssh.ts | 74 ++++++++++++++++++----------------- cmd/tsconnect/src/wasm_js.ts | 13 +++--- cmd/tsconnect/wasm/wasm_js.go | 29 +++++++++----- 7 files changed, 158 insertions(+), 58 deletions(-) diff --git a/cmd/tsconnect/index.html b/cmd/tsconnect/index.html index ddcebc248..05562071b 100644 --- a/cmd/tsconnect/index.html +++ b/cmd/tsconnect/index.html @@ -12,7 +12,27 @@
Loading…
-
+ + diff --git a/cmd/tsconnect/src/index.css b/cmd/tsconnect/src/index.css index a1852f034..24dd0cb64 100644 --- a/cmd/tsconnect/src/index.css +++ b/cmd/tsconnect/src/index.css @@ -7,3 +7,69 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.link { + @apply text-blue-600; +} + +.link:hover { + @apply underline; +} + +.button { + @apply font-medium py-1 px-2 rounded-md border border-transparent text-center cursor-pointer; + transition-property: background-color, border-color, color, box-shadow; + transition-duration: 120ms; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); + min-width: 80px; +} +.button:focus { + @apply outline-none ring; +} +.button:disabled { + @apply pointer-events-none select-none; +} + +.input { + @apply appearance-none leading-tight rounded-md bg-white border border-gray-300 hover:border-gray-400 transition-colors px-3; + height: 2.375rem; +} + +.input::placeholder { + @apply text-gray-400; +} + +.input:disabled { + @apply border-gray-200; + @apply bg-gray-50; + @apply cursor-not-allowed; +} + +.input:focus { + @apply outline-none ring border-transparent; +} + +.select { + @apply appearance-none py-2 px-3 leading-tight rounded-md bg-white border border-gray-300; +} + +.select-with-arrow { + @apply relative; +} + +.select-with-arrow .select { + width: 100%; +} + +.select-with-arrow::after { + @apply absolute; + content: ""; + top: 50%; + right: 0.5rem; + transform: translate(-0.3em, -0.15em); + width: 0.6em; + height: 0.4em; + opacity: 0.6; + background-color: currentColor; + clip-path: polygon(100% 0%, 0 0%, 50% 100%); +} diff --git a/cmd/tsconnect/src/login.ts b/cmd/tsconnect/src/login.ts index 3825c7a75..137121333 100644 --- a/cmd/tsconnect/src/login.ts +++ b/cmd/tsconnect/src/login.ts @@ -11,7 +11,7 @@ export async function showLoginURL(url: string) { loginNode = document.createElement("div") loginNode.className = "flex flex-col items-center justify-items-center" const linkNode = document.createElement("a") - linkNode.className = "text-blue-600 hover:underline" + linkNode.className = "link" linkNode.href = url linkNode.target = "_blank" loginNode.appendChild(linkNode) @@ -49,7 +49,7 @@ export function showLogoutButton(ipn: IPN) { } logoutButtonNode = document.createElement("button") logoutButtonNode.className = - "py-1 px-2 rounded bg-gray-500 border-gray-500 text-white hover:bg-gray-600 hover:border-gray-600 ml-2 font-bold" + "button bg-gray-500 border-gray-500 text-white hover:bg-gray-600 hover:border-gray-600 ml-2 font-bold" logoutButtonNode.textContent = "Logout" logoutButtonNode.addEventListener( "click", diff --git a/cmd/tsconnect/src/notifier.ts b/cmd/tsconnect/src/notifier.ts index fe215c35d..00a7d52e4 100644 --- a/cmd/tsconnect/src/notifier.ts +++ b/cmd/tsconnect/src/notifier.ts @@ -8,7 +8,7 @@ import { showLogoutButton, hideLogoutButton, } from "./login" -import { showSSHPeers, hideSSHPeers } from "./ssh" +import { showSSHForm, hideSSHForm } from "./ssh" import { IPNState } from "./wasm_js" /** @@ -27,7 +27,7 @@ export function notifyState(ipn: IPN, state: IPNState) { case IPNState.NeedsLogin: stateLabel = "Needs Login" hideLogoutButton() - hideSSHPeers() + hideSSHForm() ipn.login() break case IPNState.NeedsMachineAuth: @@ -36,7 +36,7 @@ export function notifyState(ipn: IPN, state: IPNState) { case IPNState.Stopped: stateLabel = "Stopped" hideLogoutButton() - hideSSHPeers() + hideSSHForm() break case IPNState.Starting: stateLabel = "Starting…" @@ -57,7 +57,7 @@ export function notifyNetMap(ipn: IPN, netMapStr: string) { console.log("Received net map: " + JSON.stringify(netMap, null, 2)) } - showSSHPeers(netMap.peers, ipn) + showSSHForm(netMap.peers, ipn) } export function notifyBrowseToURL(ipn: IPN, url: string) { diff --git a/cmd/tsconnect/src/ssh.ts b/cmd/tsconnect/src/ssh.ts index b908c13a3..e2415431e 100644 --- a/cmd/tsconnect/src/ssh.ts +++ b/cmd/tsconnect/src/ssh.ts @@ -4,43 +4,46 @@ import { Terminal } from "xterm" -export function showSSHPeers(peers: IPNNetMapPeerNode[], ipn: IPN) { - const peersNode = document.getElementById("peers") as HTMLDivElement - peersNode.innerHTML = "" +export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) { + const formNode = document.getElementById("ssh-form") as HTMLDivElement + const noSSHNode = document.getElementById("no-ssh") as HTMLDivElement - const sshPeers = peers.filter((p) => p.tailscaleSSHEnabled) - if (!sshPeers.length) { - peersNode.textContent = "No machines have Tailscale SSH installed." + const sshPeers = peers.filter( + (p) => p.tailscaleSSHEnabled && p.online !== false + ) + if (sshPeers.length == 0) { + formNode.classList.add("hidden") + noSSHNode.classList.remove("hidden") return } + sshPeers.sort((a, b) => a.name.localeCompare(b.name)) - for (const peer of sshPeers) { - const peerNode = document.createElement("div") - peerNode.className = "flex justify-between p-0.5 hover:bg-gray-100" - const nameNode = document.createElement("div") - nameNode.className = "font-mono" - nameNode.textContent = peer.name - peerNode.appendChild(nameNode) - - const sshButtonNode = document.createElement("button") - sshButtonNode.className = - "py-1 px-2 rounded bg-green-500 border-green-500 text-white hover:bg-green-600 hover:border-green-600" - sshButtonNode.addEventListener("click", function () { - ssh(peer.name, ipn) - }) - sshButtonNode.textContent = "SSH" - peerNode.appendChild(sshButtonNode) + const selectNode = formNode.querySelector("select")! + selectNode.innerHTML = "" + for (const p of sshPeers) { + const option = document.createElement("option") + option.textContent = p.name.split(".")[0] + option.value = p.name + selectNode.appendChild(option) + } - peersNode.appendChild(peerNode) + const usernameNode = formNode.querySelector(".username") as HTMLInputElement + formNode.onsubmit = (e) => { + e.preventDefault() + const hostname = selectNode.value + ssh(hostname, usernameNode.value, ipn) } + + noSSHNode.classList.add("hidden") + formNode.classList.remove("hidden") } -export function hideSSHPeers() { - const peersNode = document.getElementById("peers") as HTMLDivElement - peersNode.innerHTML = "" +export function hideSSHForm() { + const formNode = document.getElementById("ssh-form") as HTMLDivElement + formNode.classList.add("hidden") } -function ssh(hostname: string, ipn: IPN) { +function ssh(hostname: string, username: string, ipn: IPN) { const termContainerNode = document.createElement("div") termContainerNode.className = "p-3" document.body.appendChild(termContainerNode) @@ -64,15 +67,14 @@ function ssh(hostname: string, ipn: IPN) { term.focus() - ipn.ssh( - hostname, - (input) => term.write(input), - (hook) => (onDataHook = hook), - term.rows, - term.cols, - () => { + ipn.ssh(hostname, username, { + writeFn: (input) => term.write(input), + setReadFn: (hook) => (onDataHook = hook), + rows: term.rows, + cols: term.cols, + onDone: () => { term.dispose() termContainerNode.remove() - } - ) + }, + }) } diff --git a/cmd/tsconnect/src/wasm_js.ts b/cmd/tsconnect/src/wasm_js.ts index 924c1cc22..32b9ff34b 100644 --- a/cmd/tsconnect/src/wasm_js.ts +++ b/cmd/tsconnect/src/wasm_js.ts @@ -17,11 +17,14 @@ declare global { logout(): void ssh( host: string, - writeFn: (data: string) => void, - setReadFn: (readFn: (data: string) => void) => void, - rows: number, - cols: number, - onDone: () => void + username: string, + termConfig: { + writeFn: (data: string) => void + setReadFn: (readFn: (data: string) => void) => void + rows: number + cols: number + onDone: () => void + } ): void } diff --git a/cmd/tsconnect/wasm/wasm_js.go b/cmd/tsconnect/wasm/wasm_js.go index 7559f431e..e47e22a61 100644 --- a/cmd/tsconnect/wasm/wasm_js.go +++ b/cmd/tsconnect/wasm/wasm_js.go @@ -138,17 +138,14 @@ func newIPN(jsConfig js.Value) map[string]any { return nil }), "ssh": js.FuncOf(func(this js.Value, args []js.Value) interface{} { - if len(args) != 6 { - log.Printf("Usage: ssh(hostname, writeFn, readFn, rows, cols, onDone)") + if len(args) != 3 { + log.Printf("Usage: ssh(hostname, userName, termConfig)") return nil } go jsIPN.ssh( args[0].String(), - args[1], - args[2], - args[3].Int(), - args[4].Int(), - args[5]) + args[1].String(), + args[2]) return nil }), } @@ -181,7 +178,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) { if n.State != nil { notifyState(*n.State) } - if nm := n.NetMap; nm != nil { + if nm := n.NetMap; nm != nil && i.lb.State() == ipn.Running { jsNetMap := jsNetMap{ Self: jsNetMapSelfNode{ jsNetMapNode: jsNetMapNode{ @@ -193,9 +190,14 @@ func (i *jsIPN) run(jsCallbacks js.Value) { MachineStatus: int(nm.MachineStatus), }, Peers: mapSlice(nm.Peers, func(p *tailcfg.Node) jsNetMapPeerNode { + name := p.Name + if name == "" { + // In practice this should only happen for Hello. + name = p.Hostinfo.Hostname() + } return jsNetMapPeerNode{ jsNetMapNode: jsNetMapNode{ - Name: p.Name, + Name: name, Addresses: mapSlice(p.Addresses, func(a netip.Prefix) string { return a.Addr().String() }), MachineKey: p.Machine.String(), NodeKey: p.Key.String(), @@ -254,7 +256,13 @@ func (i *jsIPN) logout() { go i.lb.Logout() } -func (i *jsIPN) ssh(host string, writeFn js.Value, setReadFn js.Value, rows, cols int, onDone js.Value) { +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") + defer onDone.Invoke() write := func(s string) { @@ -275,6 +283,7 @@ func (i *jsIPN) ssh(host string, writeFn js.Value, setReadFn js.Value, rows, col config := &ssh.ClientConfig{ HostKeyCallback: ssh.InsecureIgnoreHostKey(), + User: username, } sshConn, _, _, err := ssh.NewClientConn(c, host, config)