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>
pull/5244/head
Mihai Parparita 2 years ago committed by Mihai Parparita
parent 47f91dd732
commit c06758c83b

@ -12,7 +12,27 @@
<div class="text-gray-600" id="state">Loading…</div> <div class="text-gray-600" id="state">Loading…</div>
</header> </header>
</div> </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> <script src="dist/index.js"></script>
</body> </body>
</html> </html>

@ -7,3 +7,69 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @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%);
}

@ -11,7 +11,7 @@ export async function showLoginURL(url: string) {
loginNode = document.createElement("div") loginNode = document.createElement("div")
loginNode.className = "flex flex-col items-center justify-items-center" loginNode.className = "flex flex-col items-center justify-items-center"
const linkNode = document.createElement("a") const linkNode = document.createElement("a")
linkNode.className = "text-blue-600 hover:underline" linkNode.className = "link"
linkNode.href = url linkNode.href = url
linkNode.target = "_blank" linkNode.target = "_blank"
loginNode.appendChild(linkNode) loginNode.appendChild(linkNode)
@ -49,7 +49,7 @@ export function showLogoutButton(ipn: IPN) {
} }
logoutButtonNode = document.createElement("button") logoutButtonNode = document.createElement("button")
logoutButtonNode.className = 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.textContent = "Logout"
logoutButtonNode.addEventListener( logoutButtonNode.addEventListener(
"click", "click",

@ -8,7 +8,7 @@ import {
showLogoutButton, showLogoutButton,
hideLogoutButton, hideLogoutButton,
} from "./login" } from "./login"
import { showSSHPeers, hideSSHPeers } from "./ssh" import { showSSHForm, hideSSHForm } from "./ssh"
import { IPNState } from "./wasm_js" import { IPNState } from "./wasm_js"
/** /**
@ -27,7 +27,7 @@ export function notifyState(ipn: IPN, state: IPNState) {
case IPNState.NeedsLogin: case IPNState.NeedsLogin:
stateLabel = "Needs Login" stateLabel = "Needs Login"
hideLogoutButton() hideLogoutButton()
hideSSHPeers() hideSSHForm()
ipn.login() ipn.login()
break break
case IPNState.NeedsMachineAuth: case IPNState.NeedsMachineAuth:
@ -36,7 +36,7 @@ export function notifyState(ipn: IPN, state: IPNState) {
case IPNState.Stopped: case IPNState.Stopped:
stateLabel = "Stopped" stateLabel = "Stopped"
hideLogoutButton() hideLogoutButton()
hideSSHPeers() hideSSHForm()
break break
case IPNState.Starting: case IPNState.Starting:
stateLabel = "Starting…" stateLabel = "Starting…"
@ -57,7 +57,7 @@ export function notifyNetMap(ipn: IPN, netMapStr: string) {
console.log("Received net map: " + JSON.stringify(netMap, null, 2)) 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) { export function notifyBrowseToURL(ipn: IPN, url: string) {

@ -4,43 +4,46 @@
import { Terminal } from "xterm" import { Terminal } from "xterm"
export function showSSHPeers(peers: IPNNetMapPeerNode[], ipn: IPN) { export function showSSHForm(peers: IPNNetMapPeerNode[], ipn: IPN) {
const peersNode = document.getElementById("peers") as HTMLDivElement const formNode = document.getElementById("ssh-form") as HTMLDivElement
peersNode.innerHTML = "" const noSSHNode = document.getElementById("no-ssh") as HTMLDivElement
const sshPeers = peers.filter((p) => p.tailscaleSSHEnabled) const sshPeers = peers.filter(
if (!sshPeers.length) { (p) => p.tailscaleSSHEnabled && p.online !== false
peersNode.textContent = "No machines have Tailscale SSH installed." )
if (sshPeers.length == 0) {
formNode.classList.add("hidden")
noSSHNode.classList.remove("hidden")
return return
} }
sshPeers.sort((a, b) => a.name.localeCompare(b.name))
for (const peer of sshPeers) { const selectNode = formNode.querySelector("select")!
const peerNode = document.createElement("div") selectNode.innerHTML = ""
peerNode.className = "flex justify-between p-0.5 hover:bg-gray-100" for (const p of sshPeers) {
const nameNode = document.createElement("div") const option = document.createElement("option")
nameNode.className = "font-mono" option.textContent = p.name.split(".")[0]
nameNode.textContent = peer.name option.value = p.name
peerNode.appendChild(nameNode) selectNode.appendChild(option)
}
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 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() { export function hideSSHForm() {
const peersNode = document.getElementById("peers") as HTMLDivElement const formNode = document.getElementById("ssh-form") as HTMLDivElement
peersNode.innerHTML = "" formNode.classList.add("hidden")
} }
function ssh(hostname: string, ipn: IPN) { function ssh(hostname: string, username: string, ipn: IPN) {
const termContainerNode = document.createElement("div") const termContainerNode = document.createElement("div")
termContainerNode.className = "p-3" termContainerNode.className = "p-3"
document.body.appendChild(termContainerNode) document.body.appendChild(termContainerNode)
@ -64,15 +67,14 @@ function ssh(hostname: string, ipn: IPN) {
term.focus() term.focus()
ipn.ssh( ipn.ssh(hostname, username, {
hostname, writeFn: (input) => term.write(input),
(input) => term.write(input), setReadFn: (hook) => (onDataHook = hook),
(hook) => (onDataHook = hook), rows: term.rows,
term.rows, cols: term.cols,
term.cols, onDone: () => {
() => {
term.dispose() term.dispose()
termContainerNode.remove() termContainerNode.remove()
} },
) })
} }

@ -17,11 +17,14 @@ declare global {
logout(): void logout(): void
ssh( ssh(
host: string, host: string,
writeFn: (data: string) => void, username: string,
setReadFn: (readFn: (data: string) => void) => void, termConfig: {
rows: number, writeFn: (data: string) => void
cols: number, setReadFn: (readFn: (data: string) => void) => void
onDone: () => void rows: number
cols: number
onDone: () => void
}
): void ): void
} }

@ -138,17 +138,14 @@ func newIPN(jsConfig js.Value) map[string]any {
return nil return nil
}), }),
"ssh": js.FuncOf(func(this js.Value, args []js.Value) interface{} { "ssh": js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) != 6 { if len(args) != 3 {
log.Printf("Usage: ssh(hostname, writeFn, readFn, rows, cols, onDone)") log.Printf("Usage: ssh(hostname, userName, termConfig)")
return nil return nil
} }
go jsIPN.ssh( go jsIPN.ssh(
args[0].String(), args[0].String(),
args[1], args[1].String(),
args[2], args[2])
args[3].Int(),
args[4].Int(),
args[5])
return nil return nil
}), }),
} }
@ -181,7 +178,7 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
if n.State != nil { if n.State != nil {
notifyState(*n.State) notifyState(*n.State)
} }
if nm := n.NetMap; nm != nil { if nm := n.NetMap; nm != nil && i.lb.State() == ipn.Running {
jsNetMap := jsNetMap{ jsNetMap := jsNetMap{
Self: jsNetMapSelfNode{ Self: jsNetMapSelfNode{
jsNetMapNode: jsNetMapNode{ jsNetMapNode: jsNetMapNode{
@ -193,9 +190,14 @@ func (i *jsIPN) run(jsCallbacks js.Value) {
MachineStatus: int(nm.MachineStatus), MachineStatus: int(nm.MachineStatus),
}, },
Peers: mapSlice(nm.Peers, func(p *tailcfg.Node) jsNetMapPeerNode { 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{ return jsNetMapPeerNode{
jsNetMapNode: jsNetMapNode{ jsNetMapNode: jsNetMapNode{
Name: p.Name, Name: name,
Addresses: mapSlice(p.Addresses, func(a netip.Prefix) string { return a.Addr().String() }), Addresses: mapSlice(p.Addresses, func(a netip.Prefix) string { return a.Addr().String() }),
MachineKey: p.Machine.String(), MachineKey: p.Machine.String(),
NodeKey: p.Key.String(), NodeKey: p.Key.String(),
@ -254,7 +256,13 @@ func (i *jsIPN) logout() {
go i.lb.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() defer onDone.Invoke()
write := func(s string) { 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{ config := &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: username,
} }
sshConn, _, _, err := ssh.NewClientConn(c, host, config) sshConn, _, _, err := ssh.NewClientConn(c, host, config)

Loading…
Cancel
Save