summaryrefslogtreecommitdiffhomepage
path: root/cmd/tsconnect/src
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/tsconnect/src')
-rw-r--r--cmd/tsconnect/src/app/app.tsx294
-rw-r--r--cmd/tsconnect/src/app/go-panic-display.tsx40
-rw-r--r--cmd/tsconnect/src/app/header.tsx74
-rw-r--r--cmd/tsconnect/src/app/index.css148
-rw-r--r--cmd/tsconnect/src/app/index.ts72
-rw-r--r--cmd/tsconnect/src/app/ssh.tsx314
-rw-r--r--cmd/tsconnect/src/app/url-display.tsx62
-rw-r--r--cmd/tsconnect/src/lib/js-state-store.ts26
-rw-r--r--cmd/tsconnect/src/pkg/pkg.css16
-rw-r--r--cmd/tsconnect/src/pkg/pkg.ts80
-rw-r--r--cmd/tsconnect/src/types/esbuild.d.ts28
-rw-r--r--cmd/tsconnect/src/types/wasm_js.d.ts206
12 files changed, 680 insertions, 680 deletions
diff --git a/cmd/tsconnect/src/app/app.tsx b/cmd/tsconnect/src/app/app.tsx
index ee538eaea..c0aa7a5e8 100644
--- a/cmd/tsconnect/src/app/app.tsx
+++ b/cmd/tsconnect/src/app/app.tsx
@@ -1,147 +1,147 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-import { render, Component } from "preact"
-import { URLDisplay } from "./url-display"
-import { Header } from "./header"
-import { GoPanicDisplay } from "./go-panic-display"
-import { SSH } from "./ssh"
-
-type AppState = {
- ipn?: IPN
- ipnState: IPNState
- netMap?: IPNNetMap
- browseToURL?: string
- goPanicError?: string
-}
-
-class App extends Component<{}, AppState> {
- state: AppState = { ipnState: "NoState" }
- #goPanicTimeout?: number
-
- render() {
- const { ipn, ipnState, goPanicError, netMap, browseToURL } = this.state
-
- let goPanicDisplay
- if (goPanicError) {
- goPanicDisplay = (
- <GoPanicDisplay error={goPanicError} dismiss={this.clearGoPanic} />
- )
- }
-
- let urlDisplay
- if (browseToURL) {
- urlDisplay = <URLDisplay url={browseToURL} />
- }
-
- let machineAuthInstructions
- if (ipnState === "NeedsMachineAuth") {
- machineAuthInstructions = (
- <div class="container mx-auto px-4 text-center">
- An administrator needs to approve this device.
- </div>
- )
- }
-
- const lockedOut = netMap?.lockedOut
- let lockedOutInstructions
- if (lockedOut) {
- lockedOutInstructions = (
- <div class="container mx-auto px-4 text-center space-y-4">
- <p>This instance of Tailscale Connect needs to be signed, due to
- {" "}<a href="https://tailscale.com/kb/1226/tailnet-lock/" class="link">tailnet lock</a>{" "}
- being enabled on this domain.
- </p>
-
- <p>
- Run the following command on a device with a trusted tailnet lock key:
- <pre>tailscale lock sign {netMap.self.nodeKey}</pre>
- </p>
- </div>
- )
- }
-
- let ssh
- if (ipn && ipnState === "Running" && netMap && !lockedOut) {
- ssh = <SSH netMap={netMap} ipn={ipn} />
- }
-
- return (
- <>
- <Header state={ipnState} ipn={ipn} />
- {goPanicDisplay}
- <div class="flex-grow flex flex-col justify-center overflow-hidden">
- {urlDisplay}
- {machineAuthInstructions}
- {lockedOutInstructions}
- {ssh}
- </div>
- </>
- )
- }
-
- runWithIPN(ipn: IPN) {
- this.setState({ ipn }, () => {
- ipn.run({
- notifyState: this.handleIPNState,
- notifyNetMap: this.handleNetMap,
- notifyBrowseToURL: this.handleBrowseToURL,
- notifyPanicRecover: this.handleGoPanic,
- })
- })
- }
-
- handleIPNState = (state: IPNState) => {
- const { ipn } = this.state
- this.setState({ ipnState: state })
- if (state === "NeedsLogin") {
- ipn?.login()
- } else if (["Running", "NeedsMachineAuth"].includes(state)) {
- this.setState({ browseToURL: undefined })
- }
- }
-
- handleNetMap = (netMapStr: string) => {
- const netMap = JSON.parse(netMapStr) as IPNNetMap
- if (DEBUG) {
- console.log("Received net map: " + JSON.stringify(netMap, null, 2))
- }
- this.setState({ netMap })
- }
-
- handleBrowseToURL = (url: string) => {
- if (this.state.ipnState === "Running") {
- // Ignore URL requests if we're already running -- it's most likely an
- // SSH check mode trigger and we already linkify the displayed URL
- // in the terminal.
- return
- }
- this.setState({ browseToURL: url })
- }
-
- handleGoPanic = (error: string) => {
- if (DEBUG) {
- console.error("Go panic", error)
- }
- this.setState({ goPanicError: error })
- if (this.#goPanicTimeout) {
- window.clearTimeout(this.#goPanicTimeout)
- }
- this.#goPanicTimeout = window.setTimeout(this.clearGoPanic, 10000)
- }
-
- clearGoPanic = () => {
- window.clearTimeout(this.#goPanicTimeout)
- this.#goPanicTimeout = undefined
- this.setState({ goPanicError: undefined })
- }
-}
-
-export function renderApp(): Promise<App> {
- return new Promise((resolve) => {
- render(
- <App ref={(app) => (app ? resolve(app) : undefined)} />,
- document.body
- )
- })
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import { render, Component } from "preact"
+import { URLDisplay } from "./url-display"
+import { Header } from "./header"
+import { GoPanicDisplay } from "./go-panic-display"
+import { SSH } from "./ssh"
+
+type AppState = {
+ ipn?: IPN
+ ipnState: IPNState
+ netMap?: IPNNetMap
+ browseToURL?: string
+ goPanicError?: string
+}
+
+class App extends Component<{}, AppState> {
+ state: AppState = { ipnState: "NoState" }
+ #goPanicTimeout?: number
+
+ render() {
+ const { ipn, ipnState, goPanicError, netMap, browseToURL } = this.state
+
+ let goPanicDisplay
+ if (goPanicError) {
+ goPanicDisplay = (
+ <GoPanicDisplay error={goPanicError} dismiss={this.clearGoPanic} />
+ )
+ }
+
+ let urlDisplay
+ if (browseToURL) {
+ urlDisplay = <URLDisplay url={browseToURL} />
+ }
+
+ let machineAuthInstructions
+ if (ipnState === "NeedsMachineAuth") {
+ machineAuthInstructions = (
+ <div class="container mx-auto px-4 text-center">
+ An administrator needs to approve this device.
+ </div>
+ )
+ }
+
+ const lockedOut = netMap?.lockedOut
+ let lockedOutInstructions
+ if (lockedOut) {
+ lockedOutInstructions = (
+ <div class="container mx-auto px-4 text-center space-y-4">
+ <p>This instance of Tailscale Connect needs to be signed, due to
+ {" "}<a href="https://tailscale.com/kb/1226/tailnet-lock/" class="link">tailnet lock</a>{" "}
+ being enabled on this domain.
+ </p>
+
+ <p>
+ Run the following command on a device with a trusted tailnet lock key:
+ <pre>tailscale lock sign {netMap.self.nodeKey}</pre>
+ </p>
+ </div>
+ )
+ }
+
+ let ssh
+ if (ipn && ipnState === "Running" && netMap && !lockedOut) {
+ ssh = <SSH netMap={netMap} ipn={ipn} />
+ }
+
+ return (
+ <>
+ <Header state={ipnState} ipn={ipn} />
+ {goPanicDisplay}
+ <div class="flex-grow flex flex-col justify-center overflow-hidden">
+ {urlDisplay}
+ {machineAuthInstructions}
+ {lockedOutInstructions}
+ {ssh}
+ </div>
+ </>
+ )
+ }
+
+ runWithIPN(ipn: IPN) {
+ this.setState({ ipn }, () => {
+ ipn.run({
+ notifyState: this.handleIPNState,
+ notifyNetMap: this.handleNetMap,
+ notifyBrowseToURL: this.handleBrowseToURL,
+ notifyPanicRecover: this.handleGoPanic,
+ })
+ })
+ }
+
+ handleIPNState = (state: IPNState) => {
+ const { ipn } = this.state
+ this.setState({ ipnState: state })
+ if (state === "NeedsLogin") {
+ ipn?.login()
+ } else if (["Running", "NeedsMachineAuth"].includes(state)) {
+ this.setState({ browseToURL: undefined })
+ }
+ }
+
+ handleNetMap = (netMapStr: string) => {
+ const netMap = JSON.parse(netMapStr) as IPNNetMap
+ if (DEBUG) {
+ console.log("Received net map: " + JSON.stringify(netMap, null, 2))
+ }
+ this.setState({ netMap })
+ }
+
+ handleBrowseToURL = (url: string) => {
+ if (this.state.ipnState === "Running") {
+ // Ignore URL requests if we're already running -- it's most likely an
+ // SSH check mode trigger and we already linkify the displayed URL
+ // in the terminal.
+ return
+ }
+ this.setState({ browseToURL: url })
+ }
+
+ handleGoPanic = (error: string) => {
+ if (DEBUG) {
+ console.error("Go panic", error)
+ }
+ this.setState({ goPanicError: error })
+ if (this.#goPanicTimeout) {
+ window.clearTimeout(this.#goPanicTimeout)
+ }
+ this.#goPanicTimeout = window.setTimeout(this.clearGoPanic, 10000)
+ }
+
+ clearGoPanic = () => {
+ window.clearTimeout(this.#goPanicTimeout)
+ this.#goPanicTimeout = undefined
+ this.setState({ goPanicError: undefined })
+ }
+}
+
+export function renderApp(): Promise<App> {
+ return new Promise((resolve) => {
+ render(
+ <App ref={(app) => (app ? resolve(app) : undefined)} />,
+ document.body
+ )
+ })
+}
diff --git a/cmd/tsconnect/src/app/go-panic-display.tsx b/cmd/tsconnect/src/app/go-panic-display.tsx
index 5dd7095a2..aab35c4d5 100644
--- a/cmd/tsconnect/src/app/go-panic-display.tsx
+++ b/cmd/tsconnect/src/app/go-panic-display.tsx
@@ -1,20 +1,20 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-export function GoPanicDisplay({
- error,
- dismiss,
-}: {
- error: string
- dismiss: () => void
-}) {
- return (
- <div
- class="rounded bg-red-500 p-2 absolute top-2 right-2 text-white font-bold text-right cursor-pointer"
- onClick={dismiss}
- >
- Tailscale has encountered an error.
- <div class="text-sm font-normal">Click to reload</div>
- </div>
- )
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+export function GoPanicDisplay({
+ error,
+ dismiss,
+}: {
+ error: string
+ dismiss: () => void
+}) {
+ return (
+ <div
+ class="rounded bg-red-500 p-2 absolute top-2 right-2 text-white font-bold text-right cursor-pointer"
+ onClick={dismiss}
+ >
+ Tailscale has encountered an error.
+ <div class="text-sm font-normal">Click to reload</div>
+ </div>
+ )
+}
diff --git a/cmd/tsconnect/src/app/header.tsx b/cmd/tsconnect/src/app/header.tsx
index 099ff2f8c..8449f4563 100644
--- a/cmd/tsconnect/src/app/header.tsx
+++ b/cmd/tsconnect/src/app/header.tsx
@@ -1,37 +1,37 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-export function Header({ state, ipn }: { state: IPNState; ipn?: IPN }) {
- const stateText = STATE_LABELS[state]
-
- let logoutButton
- if (state === "Running") {
- logoutButton = (
- <button
- class="button bg-gray-500 border-gray-500 text-white hover:bg-gray-600 hover:border-gray-600 ml-2 font-bold"
- onClick={() => ipn?.logout()}
- >
- Logout
- </button>
- )
- }
- return (
- <div class="bg-gray-100 border-b border-gray-200 pt-4 pb-2">
- <header class="container mx-auto px-4 flex flex-row items-center">
- <h1 class="text-3xl font-bold grow">Tailscale Connect</h1>
- <div class="text-gray-600">{stateText}</div>
- {logoutButton}
- </header>
- </div>
- )
-}
-
-const STATE_LABELS = {
- NoState: "Initializing…",
- InUseOtherUser: "In-use by another user",
- NeedsLogin: "Needs login",
- NeedsMachineAuth: "Needs approval",
- Stopped: "Stopped",
- Starting: "Starting…",
- Running: "Running",
-} as const
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+export function Header({ state, ipn }: { state: IPNState; ipn?: IPN }) {
+ const stateText = STATE_LABELS[state]
+
+ let logoutButton
+ if (state === "Running") {
+ logoutButton = (
+ <button
+ class="button bg-gray-500 border-gray-500 text-white hover:bg-gray-600 hover:border-gray-600 ml-2 font-bold"
+ onClick={() => ipn?.logout()}
+ >
+ Logout
+ </button>
+ )
+ }
+ return (
+ <div class="bg-gray-100 border-b border-gray-200 pt-4 pb-2">
+ <header class="container mx-auto px-4 flex flex-row items-center">
+ <h1 class="text-3xl font-bold grow">Tailscale Connect</h1>
+ <div class="text-gray-600">{stateText}</div>
+ {logoutButton}
+ </header>
+ </div>
+ )
+}
+
+const STATE_LABELS = {
+ NoState: "Initializing…",
+ InUseOtherUser: "In-use by another user",
+ NeedsLogin: "Needs login",
+ NeedsMachineAuth: "Needs approval",
+ Stopped: "Stopped",
+ Starting: "Starting…",
+ Running: "Running",
+} as const
diff --git a/cmd/tsconnect/src/app/index.css b/cmd/tsconnect/src/app/index.css
index 751b313d9..848b83d12 100644
--- a/cmd/tsconnect/src/app/index.css
+++ b/cmd/tsconnect/src/app/index.css
@@ -1,74 +1,74 @@
-/* Copyright (c) Tailscale Inc & AUTHORS */
-/* SPDX-License-Identifier: BSD-3-Clause */
-
-@import "xterm/css/xterm.css";
-
-@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%);
-}
+/* Copyright (c) Tailscale Inc & AUTHORS */
+/* SPDX-License-Identifier: BSD-3-Clause */
+
+@import "xterm/css/xterm.css";
+
+@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/app/index.ts b/cmd/tsconnect/src/app/index.ts
index 24ca45439..1432188ae 100644
--- a/cmd/tsconnect/src/app/index.ts
+++ b/cmd/tsconnect/src/app/index.ts
@@ -1,36 +1,36 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-import "../wasm_exec"
-import wasmUrl from "./main.wasm"
-import { sessionStateStorage } from "../lib/js-state-store"
-import { renderApp } from "./app"
-
-async function main() {
- const app = await renderApp()
- const go = new Go()
- const wasmInstance = await WebAssembly.instantiateStreaming(
- fetch(`./dist/${wasmUrl}`),
- go.importObject
- )
- // The Go process should never exit, if it does then it's an unhandled panic.
- go.run(wasmInstance.instance).then(() =>
- app.handleGoPanic("Unexpected shutdown")
- )
-
- const params = new URLSearchParams(window.location.search)
- const authKey = params.get("authkey") ?? undefined
-
- const ipn = newIPN({
- // Persist IPN state in sessionStorage in development, so that we don't need
- // to re-authorize every time we reload the page.
- stateStorage: DEBUG ? sessionStateStorage : undefined,
- // authKey allows for an auth key to be
- // specified as a url param which automatically
- // authorizes the client for use.
- authKey: DEBUG ? authKey : undefined,
- })
- app.runWithIPN(ipn)
-}
-
-main()
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import "../wasm_exec"
+import wasmUrl from "./main.wasm"
+import { sessionStateStorage } from "../lib/js-state-store"
+import { renderApp } from "./app"
+
+async function main() {
+ const app = await renderApp()
+ const go = new Go()
+ const wasmInstance = await WebAssembly.instantiateStreaming(
+ fetch(`./dist/${wasmUrl}`),
+ go.importObject
+ )
+ // The Go process should never exit, if it does then it's an unhandled panic.
+ go.run(wasmInstance.instance).then(() =>
+ app.handleGoPanic("Unexpected shutdown")
+ )
+
+ const params = new URLSearchParams(window.location.search)
+ const authKey = params.get("authkey") ?? undefined
+
+ const ipn = newIPN({
+ // Persist IPN state in sessionStorage in development, so that we don't need
+ // to re-authorize every time we reload the page.
+ stateStorage: DEBUG ? sessionStateStorage : undefined,
+ // authKey allows for an auth key to be
+ // specified as a url param which automatically
+ // authorizes the client for use.
+ authKey: DEBUG ? authKey : undefined,
+ })
+ app.runWithIPN(ipn)
+}
+
+main()
diff --git a/cmd/tsconnect/src/app/ssh.tsx b/cmd/tsconnect/src/app/ssh.tsx
index df81745bd..1534fd5db 100644
--- a/cmd/tsconnect/src/app/ssh.tsx
+++ b/cmd/tsconnect/src/app/ssh.tsx
@@ -1,157 +1,157 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-import { useState, useCallback, useMemo, useEffect, useRef } from "preact/hooks"
-import { createPortal } from "preact/compat"
-import type { VNode } from "preact"
-import { runSSHSession, SSHSessionDef } from "../lib/ssh"
-
-export function SSH({ netMap, ipn }: { netMap: IPNNetMap; ipn: IPN }) {
- const [sshSessionDef, setSSHSessionDef] = useState<SSHFormSessionDef | null>(
- null
- )
- const clearSSHSessionDef = useCallback(() => setSSHSessionDef(null), [])
- if (sshSessionDef) {
- const sshSession = (
- <SSHSession def={sshSessionDef} ipn={ipn} onDone={clearSSHSessionDef} />
- )
- if (sshSessionDef.newWindow) {
- return <NewWindow close={clearSSHSessionDef}>{sshSession}</NewWindow>
- }
- return sshSession
- }
- const sshPeers = netMap.peers.filter(
- (p) => p.tailscaleSSHEnabled && p.online !== false
- )
-
- if (sshPeers.length == 0) {
- return <NoSSHPeers />
- }
-
- return <SSHForm sshPeers={sshPeers} onSubmit={setSSHSessionDef} />
-}
-
-type SSHFormSessionDef = SSHSessionDef & { newWindow?: boolean }
-
-function SSHSession({
- def,
- ipn,
- onDone,
-}: {
- def: SSHSessionDef
- ipn: IPN
- onDone: () => void
-}) {
- const ref = useRef<HTMLDivElement>(null)
- useEffect(() => {
- if (ref.current) {
- runSSHSession(ref.current, def, ipn, {
- onConnectionProgress: (p) => console.log("Connection progress", p),
- onConnected() {},
- onError: (err) => console.error(err),
- onDone,
- })
- }
- }, [ref])
-
- return <div class="flex-grow bg-black p-2 overflow-hidden" ref={ref} />
-}
-
-function NoSSHPeers() {
- return (
- <div class="container mx-auto px-4 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>
- )
-}
-
-function SSHForm({
- sshPeers,
- onSubmit,
-}: {
- sshPeers: IPNNetMapPeerNode[]
- onSubmit: (def: SSHFormSessionDef) => void
-}) {
- sshPeers = sshPeers.slice().sort((a, b) => a.name.localeCompare(b.name))
- const [username, setUsername] = useState("")
- const [hostname, setHostname] = useState(sshPeers[0].name)
- return (
- <form
- class="container mx-auto px-4 flex justify-center"
- onSubmit={(e) => {
- e.preventDefault()
- onSubmit({ username, hostname })
- }}
- >
- <input
- type="text"
- class="input username"
- placeholder="Username"
- onChange={(e) => setUsername(e.currentTarget.value)}
- />
- <div class="select-with-arrow mx-2">
- <select
- class="select"
- onChange={(e) => setHostname(e.currentTarget.value)}
- >
- {sshPeers.map((p) => (
- <option key={p.nodeKey}>{p.name.split(".")[0]}</option>
- ))}
- </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"
- onClick={(e) => {
- if (e.altKey) {
- e.preventDefault()
- e.stopPropagation()
- onSubmit({ username, hostname, newWindow: true })
- }
- }}
- />
- </form>
- )
-}
-
-const NewWindow = ({
- children,
- close,
-}: {
- children: VNode
- close: () => void
-}) => {
- const newWindow = useMemo(() => {
- const newWindow = window.open(undefined, undefined, "width=600,height=400")
- if (newWindow) {
- const containerNode = newWindow.document.createElement("div")
- containerNode.className = "h-screen flex flex-col overflow-hidden"
- newWindow.document.body.appendChild(containerNode)
-
- for (const linkNode of document.querySelectorAll(
- "head link[rel=stylesheet]"
- )) {
- const newLink = document.createElement("link")
- newLink.rel = "stylesheet"
- newLink.href = (linkNode as HTMLLinkElement).href
- newWindow.document.head.appendChild(newLink)
- }
- }
- return newWindow
- }, [])
- if (!newWindow) {
- console.error("Could not open window")
- return null
- }
- newWindow.onbeforeunload = () => {
- close()
- }
-
- useEffect(() => () => newWindow.close(), [])
- return createPortal(children, newWindow.document.body.lastChild as Element)
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import { useState, useCallback, useMemo, useEffect, useRef } from "preact/hooks"
+import { createPortal } from "preact/compat"
+import type { VNode } from "preact"
+import { runSSHSession, SSHSessionDef } from "../lib/ssh"
+
+export function SSH({ netMap, ipn }: { netMap: IPNNetMap; ipn: IPN }) {
+ const [sshSessionDef, setSSHSessionDef] = useState<SSHFormSessionDef | null>(
+ null
+ )
+ const clearSSHSessionDef = useCallback(() => setSSHSessionDef(null), [])
+ if (sshSessionDef) {
+ const sshSession = (
+ <SSHSession def={sshSessionDef} ipn={ipn} onDone={clearSSHSessionDef} />
+ )
+ if (sshSessionDef.newWindow) {
+ return <NewWindow close={clearSSHSessionDef}>{sshSession}</NewWindow>
+ }
+ return sshSession
+ }
+ const sshPeers = netMap.peers.filter(
+ (p) => p.tailscaleSSHEnabled && p.online !== false
+ )
+
+ if (sshPeers.length == 0) {
+ return <NoSSHPeers />
+ }
+
+ return <SSHForm sshPeers={sshPeers} onSubmit={setSSHSessionDef} />
+}
+
+type SSHFormSessionDef = SSHSessionDef & { newWindow?: boolean }
+
+function SSHSession({
+ def,
+ ipn,
+ onDone,
+}: {
+ def: SSHSessionDef
+ ipn: IPN
+ onDone: () => void
+}) {
+ const ref = useRef<HTMLDivElement>(null)
+ useEffect(() => {
+ if (ref.current) {
+ runSSHSession(ref.current, def, ipn, {
+ onConnectionProgress: (p) => console.log("Connection progress", p),
+ onConnected() {},
+ onError: (err) => console.error(err),
+ onDone,
+ })
+ }
+ }, [ref])
+
+ return <div class="flex-grow bg-black p-2 overflow-hidden" ref={ref} />
+}
+
+function NoSSHPeers() {
+ return (
+ <div class="container mx-auto px-4 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>
+ )
+}
+
+function SSHForm({
+ sshPeers,
+ onSubmit,
+}: {
+ sshPeers: IPNNetMapPeerNode[]
+ onSubmit: (def: SSHFormSessionDef) => void
+}) {
+ sshPeers = sshPeers.slice().sort((a, b) => a.name.localeCompare(b.name))
+ const [username, setUsername] = useState("")
+ const [hostname, setHostname] = useState(sshPeers[0].name)
+ return (
+ <form
+ class="container mx-auto px-4 flex justify-center"
+ onSubmit={(e) => {
+ e.preventDefault()
+ onSubmit({ username, hostname })
+ }}
+ >
+ <input
+ type="text"
+ class="input username"
+ placeholder="Username"
+ onChange={(e) => setUsername(e.currentTarget.value)}
+ />
+ <div class="select-with-arrow mx-2">
+ <select
+ class="select"
+ onChange={(e) => setHostname(e.currentTarget.value)}
+ >
+ {sshPeers.map((p) => (
+ <option key={p.nodeKey}>{p.name.split(".")[0]}</option>
+ ))}
+ </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"
+ onClick={(e) => {
+ if (e.altKey) {
+ e.preventDefault()
+ e.stopPropagation()
+ onSubmit({ username, hostname, newWindow: true })
+ }
+ }}
+ />
+ </form>
+ )
+}
+
+const NewWindow = ({
+ children,
+ close,
+}: {
+ children: VNode
+ close: () => void
+}) => {
+ const newWindow = useMemo(() => {
+ const newWindow = window.open(undefined, undefined, "width=600,height=400")
+ if (newWindow) {
+ const containerNode = newWindow.document.createElement("div")
+ containerNode.className = "h-screen flex flex-col overflow-hidden"
+ newWindow.document.body.appendChild(containerNode)
+
+ for (const linkNode of document.querySelectorAll(
+ "head link[rel=stylesheet]"
+ )) {
+ const newLink = document.createElement("link")
+ newLink.rel = "stylesheet"
+ newLink.href = (linkNode as HTMLLinkElement).href
+ newWindow.document.head.appendChild(newLink)
+ }
+ }
+ return newWindow
+ }, [])
+ if (!newWindow) {
+ console.error("Could not open window")
+ return null
+ }
+ newWindow.onbeforeunload = () => {
+ close()
+ }
+
+ useEffect(() => () => newWindow.close(), [])
+ return createPortal(children, newWindow.document.body.lastChild as Element)
+}
diff --git a/cmd/tsconnect/src/app/url-display.tsx b/cmd/tsconnect/src/app/url-display.tsx
index fc82c7fb9..c9b590181 100644
--- a/cmd/tsconnect/src/app/url-display.tsx
+++ b/cmd/tsconnect/src/app/url-display.tsx
@@ -1,31 +1,31 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-import { useState } from "preact/hooks"
-import * as qrcode from "qrcode"
-
-export function URLDisplay({ url }: { url: string }) {
- const [dataURL, setDataURL] = useState("")
- qrcode.toDataURL(url, { width: 512 }, (err, dataURL) => {
- if (err) {
- console.error("Error generating QR code", err)
- } else {
- setDataURL(dataURL)
- }
- })
-
- return (
- <div class="flex flex-col items-center justify-items-center">
- <a href={url} class="link" target="_blank">
- <img
- src={dataURL}
- class="mx-auto"
- width="256"
- height="256"
- alt="QR Code of URL"
- />
- {url}
- </a>
- </div>
- )
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+import { useState } from "preact/hooks"
+import * as qrcode from "qrcode"
+
+export function URLDisplay({ url }: { url: string }) {
+ const [dataURL, setDataURL] = useState("")
+ qrcode.toDataURL(url, { width: 512 }, (err, dataURL) => {
+ if (err) {
+ console.error("Error generating QR code", err)
+ } else {
+ setDataURL(dataURL)
+ }
+ })
+
+ return (
+ <div class="flex flex-col items-center justify-items-center">
+ <a href={url} class="link" target="_blank">
+ <img
+ src={dataURL}
+ class="mx-auto"
+ width="256"
+ height="256"
+ alt="QR Code of URL"
+ />
+ {url}
+ </a>
+ </div>
+ )
+}
diff --git a/cmd/tsconnect/src/lib/js-state-store.ts b/cmd/tsconnect/src/lib/js-state-store.ts
index e57dfd98e..7685e28a9 100644
--- a/cmd/tsconnect/src/lib/js-state-store.ts
+++ b/cmd/tsconnect/src/lib/js-state-store.ts
@@ -1,13 +1,13 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-/** @fileoverview Callbacks used by jsStateStore to persist IPN state. */
-
-export const sessionStateStorage: IPNStateStorage = {
- setState(id, value) {
- window.sessionStorage[`ipn-state-${id}`] = value
- },
- getState(id) {
- return window.sessionStorage[`ipn-state-${id}`] || ""
- },
-}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+/** @fileoverview Callbacks used by jsStateStore to persist IPN state. */
+
+export const sessionStateStorage: IPNStateStorage = {
+ setState(id, value) {
+ window.sessionStorage[`ipn-state-${id}`] = value
+ },
+ getState(id) {
+ return window.sessionStorage[`ipn-state-${id}`] || ""
+ },
+}
diff --git a/cmd/tsconnect/src/pkg/pkg.css b/cmd/tsconnect/src/pkg/pkg.css
index 76ea21f5b..60146d5b7 100644
--- a/cmd/tsconnect/src/pkg/pkg.css
+++ b/cmd/tsconnect/src/pkg/pkg.css
@@ -1,8 +1,8 @@
-/* Copyright (c) Tailscale Inc & AUTHORS */
-/* SPDX-License-Identifier: BSD-3-Clause */
-
-@import "xterm/css/xterm.css";
-
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
+/* Copyright (c) Tailscale Inc & AUTHORS */
+/* SPDX-License-Identifier: BSD-3-Clause */
+
+@import "xterm/css/xterm.css";
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/cmd/tsconnect/src/pkg/pkg.ts b/cmd/tsconnect/src/pkg/pkg.ts
index 4d535cb40..c0dcb5652 100644
--- a/cmd/tsconnect/src/pkg/pkg.ts
+++ b/cmd/tsconnect/src/pkg/pkg.ts
@@ -1,40 +1,40 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-// Type definitions need to be manually imported for dts-bundle-generator to
-// discover them.
-/// <reference path="../types/esbuild.d.ts" />
-/// <reference path="../types/wasm_js.d.ts" />
-
-import "../wasm_exec"
-import wasmURL from "./main.wasm"
-
-/**
- * Superset of the IPNConfig type, with additional configuration that is
- * needed for the package to function.
- */
-type IPNPackageConfig = IPNConfig & {
- // Auth key used to initialize the Tailscale client (required)
- authKey: string
- // URL of the main.wasm file that is included in the page, if it is not
- // accessible via a relative URL.
- wasmURL?: string
- // Function invoked if the Go process panics or unexpectedly exits.
- panicHandler: (err: string) => void
-}
-
-export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
- const go = new Go()
- const wasmInstance = await WebAssembly.instantiateStreaming(
- fetch(config.wasmURL ?? wasmURL),
- go.importObject
- )
- // The Go process should never exit, if it does then it's an unhandled panic.
- go.run(wasmInstance.instance).then(() =>
- config.panicHandler("Unexpected shutdown")
- )
-
- return newIPN(config)
-}
-
-export { runSSHSession } from "../lib/ssh"
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Type definitions need to be manually imported for dts-bundle-generator to
+// discover them.
+/// <reference path="../types/esbuild.d.ts" />
+/// <reference path="../types/wasm_js.d.ts" />
+
+import "../wasm_exec"
+import wasmURL from "./main.wasm"
+
+/**
+ * Superset of the IPNConfig type, with additional configuration that is
+ * needed for the package to function.
+ */
+type IPNPackageConfig = IPNConfig & {
+ // Auth key used to initialize the Tailscale client (required)
+ authKey: string
+ // URL of the main.wasm file that is included in the page, if it is not
+ // accessible via a relative URL.
+ wasmURL?: string
+ // Function invoked if the Go process panics or unexpectedly exits.
+ panicHandler: (err: string) => void
+}
+
+export async function createIPN(config: IPNPackageConfig): Promise<IPN> {
+ const go = new Go()
+ const wasmInstance = await WebAssembly.instantiateStreaming(
+ fetch(config.wasmURL ?? wasmURL),
+ go.importObject
+ )
+ // The Go process should never exit, if it does then it's an unhandled panic.
+ go.run(wasmInstance.instance).then(() =>
+ config.panicHandler("Unexpected shutdown")
+ )
+
+ return newIPN(config)
+}
+
+export { runSSHSession } from "../lib/ssh"
diff --git a/cmd/tsconnect/src/types/esbuild.d.ts b/cmd/tsconnect/src/types/esbuild.d.ts
index ef28f7b1c..7153b4244 100644
--- a/cmd/tsconnect/src/types/esbuild.d.ts
+++ b/cmd/tsconnect/src/types/esbuild.d.ts
@@ -1,14 +1,14 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-/**
- * @fileoverview Type definitions for types generated by the esbuild build
- * process.
- */
-
-declare module "*.wasm" {
- const path: string
- export default path
-}
-
-declare const DEBUG: boolean
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+/**
+ * @fileoverview Type definitions for types generated by the esbuild build
+ * process.
+ */
+
+declare module "*.wasm" {
+ const path: string
+ export default path
+}
+
+declare const DEBUG: boolean
diff --git a/cmd/tsconnect/src/types/wasm_js.d.ts b/cmd/tsconnect/src/types/wasm_js.d.ts
index 492197ccb..82822c508 100644
--- a/cmd/tsconnect/src/types/wasm_js.d.ts
+++ b/cmd/tsconnect/src/types/wasm_js.d.ts
@@ -1,103 +1,103 @@
-// Copyright (c) Tailscale Inc & AUTHORS
-// SPDX-License-Identifier: BSD-3-Clause
-
-/**
- * @fileoverview Type definitions for types exported by the wasm_js.go Go
- * module.
- */
-
-declare global {
- function newIPN(config: IPNConfig): IPN
-
- interface IPN {
- run(callbacks: IPNCallbacks): void
- login(): void
- logout(): void
- ssh(
- host: string,
- username: string,
- termConfig: {
- writeFn: (data: string) => void
- writeErrorFn: (err: string) => void
- setReadFn: (readFn: (data: string) => void) => void
- rows: number
- cols: number
- /** Defaults to 5 seconds */
- timeoutSeconds?: number
- onConnectionProgress: (message: string) => void
- onConnected: () => void
- onDone: () => void
- }
- ): IPNSSHSession
- fetch(url: string): Promise<{
- status: number
- statusText: string
- text: () => Promise<string>
- }>
- }
-
- interface IPNSSHSession {
- resize(rows: number, cols: number): boolean
- close(): boolean
- }
-
- interface IPNStateStorage {
- setState(id: string, value: string): void
- getState(id: string): string
- }
-
- type IPNConfig = {
- stateStorage?: IPNStateStorage
- authKey?: string
- controlURL?: string
- hostname?: string
- }
-
- type IPNCallbacks = {
- notifyState: (state: IPNState) => void
- notifyNetMap: (netMapStr: string) => void
- notifyBrowseToURL: (url: string) => void
- notifyPanicRecover: (err: string) => void
- }
-
- type IPNNetMap = {
- self: IPNNetMapSelfNode
- peers: IPNNetMapPeerNode[]
- lockedOut: boolean
- }
-
- type IPNNetMapNode = {
- name: string
- addresses: string[]
- machineKey: string
- nodeKey: string
- }
-
- type IPNNetMapSelfNode = IPNNetMapNode & {
- machineStatus: IPNMachineStatus
- }
-
- type IPNNetMapPeerNode = IPNNetMapNode & {
- online?: boolean
- tailscaleSSHEnabled: boolean
- }
-
- /** Mirrors values from ipn/backend.go */
- type IPNState =
- | "NoState"
- | "InUseOtherUser"
- | "NeedsLogin"
- | "NeedsMachineAuth"
- | "Stopped"
- | "Starting"
- | "Running"
-
- /** Mirrors values from MachineStatus in tailcfg.go */
- type IPNMachineStatus =
- | "MachineUnknown"
- | "MachineUnauthorized"
- | "MachineAuthorized"
- | "MachineInvalid"
-}
-
-export {}
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+/**
+ * @fileoverview Type definitions for types exported by the wasm_js.go Go
+ * module.
+ */
+
+declare global {
+ function newIPN(config: IPNConfig): IPN
+
+ interface IPN {
+ run(callbacks: IPNCallbacks): void
+ login(): void
+ logout(): void
+ ssh(
+ host: string,
+ username: string,
+ termConfig: {
+ writeFn: (data: string) => void
+ writeErrorFn: (err: string) => void
+ setReadFn: (readFn: (data: string) => void) => void
+ rows: number
+ cols: number
+ /** Defaults to 5 seconds */
+ timeoutSeconds?: number
+ onConnectionProgress: (message: string) => void
+ onConnected: () => void
+ onDone: () => void
+ }
+ ): IPNSSHSession
+ fetch(url: string): Promise<{
+ status: number
+ statusText: string
+ text: () => Promise<string>
+ }>
+ }
+
+ interface IPNSSHSession {
+ resize(rows: number, cols: number): boolean
+ close(): boolean
+ }
+
+ interface IPNStateStorage {
+ setState(id: string, value: string): void
+ getState(id: string): string
+ }
+
+ type IPNConfig = {
+ stateStorage?: IPNStateStorage
+ authKey?: string
+ controlURL?: string
+ hostname?: string
+ }
+
+ type IPNCallbacks = {
+ notifyState: (state: IPNState) => void
+ notifyNetMap: (netMapStr: string) => void
+ notifyBrowseToURL: (url: string) => void
+ notifyPanicRecover: (err: string) => void
+ }
+
+ type IPNNetMap = {
+ self: IPNNetMapSelfNode
+ peers: IPNNetMapPeerNode[]
+ lockedOut: boolean
+ }
+
+ type IPNNetMapNode = {
+ name: string
+ addresses: string[]
+ machineKey: string
+ nodeKey: string
+ }
+
+ type IPNNetMapSelfNode = IPNNetMapNode & {
+ machineStatus: IPNMachineStatus
+ }
+
+ type IPNNetMapPeerNode = IPNNetMapNode & {
+ online?: boolean
+ tailscaleSSHEnabled: boolean
+ }
+
+ /** Mirrors values from ipn/backend.go */
+ type IPNState =
+ | "NoState"
+ | "InUseOtherUser"
+ | "NeedsLogin"
+ | "NeedsMachineAuth"
+ | "Stopped"
+ | "Starting"
+ | "Running"
+
+ /** Mirrors values from MachineStatus in tailcfg.go */
+ type IPNMachineStatus =
+ | "MachineUnknown"
+ | "MachineUnauthorized"
+ | "MachineAuthorized"
+ | "MachineInvalid"
+}
+
+export {}