mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 04:55:31 +00:00
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:
parent
47f91dd732
commit
c06758c83b
@ -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>
|
||||
|
@ -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%);
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
export function hideSSHPeers() {
|
||||
const peersNode = document.getElementById("peers") as HTMLDivElement
|
||||
peersNode.innerHTML = ""
|
||||
const usernameNode = formNode.querySelector(".username") as HTMLInputElement
|
||||
formNode.onsubmit = (e) => {
|
||||
e.preventDefault()
|
||||
const hostname = selectNode.value
|
||||
ssh(hostname, usernameNode.value, ipn)
|
||||
}
|
||||
|
||||
function ssh(hostname: string, ipn: IPN) {
|
||||
noSSHNode.classList.add("hidden")
|
||||
formNode.classList.remove("hidden")
|
||||
}
|
||||
|
||||
export function hideSSHForm() {
|
||||
const formNode = document.getElementById("ssh-form") as HTMLDivElement
|
||||
formNode.classList.add("hidden")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -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,
|
||||
username: string,
|
||||
termConfig: {
|
||||
writeFn: (data: string) => void
|
||||
setReadFn: (readFn: (data: string) => void) => void
|
||||
rows: number
|
||||
cols: number
|
||||
onDone: () => void
|
||||
}
|
||||
): void
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user