diff options
Diffstat (limited to 'cmd/tsconnect/src')
| -rw-r--r-- | cmd/tsconnect/src/app/app.tsx | 294 | ||||
| -rw-r--r-- | cmd/tsconnect/src/app/go-panic-display.tsx | 40 | ||||
| -rw-r--r-- | cmd/tsconnect/src/app/header.tsx | 74 | ||||
| -rw-r--r-- | cmd/tsconnect/src/app/index.css | 148 | ||||
| -rw-r--r-- | cmd/tsconnect/src/app/index.ts | 72 | ||||
| -rw-r--r-- | cmd/tsconnect/src/app/ssh.tsx | 314 | ||||
| -rw-r--r-- | cmd/tsconnect/src/app/url-display.tsx | 62 | ||||
| -rw-r--r-- | cmd/tsconnect/src/lib/js-state-store.ts | 26 | ||||
| -rw-r--r-- | cmd/tsconnect/src/pkg/pkg.css | 16 | ||||
| -rw-r--r-- | cmd/tsconnect/src/pkg/pkg.ts | 80 | ||||
| -rw-r--r-- | cmd/tsconnect/src/types/esbuild.d.ts | 28 | ||||
| -rw-r--r-- | cmd/tsconnect/src/types/wasm_js.d.ts | 206 |
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 {}
|
