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 <mihai@tailscale.com>
This commit is contained in:
Mihai Parparita 2022-07-29 11:37:17 -07:00 committed by Mihai Parparita
parent 47f91dd732
commit c06758c83b
7 changed files with 159 additions and 59 deletions

View File

@ -12,7 +12,27 @@
<div class="text-gray-600" id="state">Loading…</div>
</header>
</div>
<div id="peers" class="container mx-auto px-4"></div>
<form
id="ssh-form"
class="container mx-auto px-4 hidden flex justify-center"
>
<input type="text" class="input username" placeholder="Username" />
<div class="select-with-arrow mx-2">
<select class="select"></select>
</div>
<input
type="submit"
class="button bg-green-500 border-green-500 text-white hover:bg-green-600 hover:border-green-600"
value="SSH"
/>
</form>
<div id="no-ssh" class="container mx-auto px-4 hidden text-center">
None of your machines have
<a href="https://tailscale.com/kb/1193/tailscale-ssh/" class="link"
>Tailscale SSH</a
>
enabled. Give it a try!
</div>
<script src="dist/index.js"></script>
</body>
</html>

View File

@ -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%);
}

View File

@ -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",

View File

@ -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) {

View File

@ -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)
peersNode.appendChild(peerNode)
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)
}
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()
}
)
},
})
}

View File

@ -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
}

View File

@ -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)