diff --git a/app/routes/ssh/console.tsx b/app/routes/ssh/console.tsx index bdbc51e..010bdbc 100644 --- a/app/routes/ssh/console.tsx +++ b/app/routes/ssh/console.tsx @@ -1,82 +1,95 @@ import { faker } from '@faker-js/faker'; -import { useState } from 'react'; -import { LoaderFunctionArgs } from 'react-router'; -import { LoadContext } from '~/server'; - import { Loader2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { + LoaderFunctionArgs, + ShouldRevalidateFunction, + data, + useLoaderData, +} from 'react-router'; +import { LoadContext } from '~/server'; +import { PreAuthKey, User } from '~/types'; +import { useLiveData } from '~/utils/live-data'; +import XTerm from './xterm.client'; + +import wasm from '~/hp_ssh.wasm?url'; import '~/wasm_exec'; +export const shouldRevalidate: ShouldRevalidateFunction = () => { + return false; +}; + export async function loader({ request, context, }: LoaderFunctionArgs) { - // const session = await context.sessions.auth(request); - // const user = session.get('user'); - // if (!user) { - // throw data('Unauthorized', 401); - // } - // if (user.subject === 'unknown-non-oauth') { - // throw data('Only OAuth users are allowed to use WebSSH', 403); - // } - // const { users } = await context.client.get<{ users: User[] }>( - // 'v1/user', - // session.get('api_key')!, + const session = await context.sessions.auth(request); + const user = session.get('user'); + if (!user) { + throw data('Unauthorized', 401); + } + if (user.subject === 'unknown-non-oauth') { + throw data('Only OAuth users are allowed to use WebSSH', 403); + } + const { users } = await context.client.get<{ users: User[] }>( + 'v1/user', + session.get('api_key')!, + ); + // MARK: This assumes that a user has authenticated with Headscale first + // Since the only way to enforce permissions via ACLs is to generate a + // pre-authkey which REQUIRES a user ID, meaning the user has to have + // authenticated with Headscale first. + const lookup = users.find((u) => { + const subject = u.providerId?.split('/').pop(); + if (!subject) { + return false; + } + return subject === user.subject; + }); + if (!lookup) { + throw data( + `User with subject ${user.subject} not found within Headscale`, + 404, + ); + } + const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>( + 'v1/preauthkey', + session.get('api_key')!, + { + user: lookup.id, + reusable: false, + ephemeral: true, + expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour + }, + ); + // TODO: Enable config to enforce generate_authkeys capability + // For now, any user is capable of WebSSH connections + // const check = await context.sessions.check( + // request, + // Capabilities.generate_authkeys, // ); - // // MARK: This assumes that a user has authenticated with Headscale first - // // Since the only way to enforce permissions via ACLs is to generate a - // // pre-authkey which REQUIRES a user ID, meaning the user has to have - // // authenticated with Headscale first. - // const lookup = users.find((u) => { - // const subject = u.providerId?.split('/').pop(); - // if (!subject) { - // return false; - // } - // return subject === user.subject; - // }); - // if (!lookup) { - // throw data( - // `User with subject ${user.subject} not found within Headscale`, - // 404, - // ); - // } - // const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>( - // 'v1/preauthkey', - // session.get('api_key')!, - // { - // user: lookup.id, - // reusable: false, - // ephemeral: true, - // expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour - // }, - // ); - // // TODO: Enable config to enforce generate_authkeys capability - // // For now, any user is capable of WebSSH connections - // // const check = await context.sessions.check( - // // request, - // // Capabilities.generate_authkeys, - // // ); - // const qp = new URL(request.url).searchParams; - // const username = qp.get('username') || undefined; - // const hostname = qp.get('hostname') || undefined; - // const port = qp.get('port') - // ? Number.parseInt(qp.get('port')!, 10) - // : undefined; - // if (!username || !hostname || !port) { - // throw data('Missing required parameters: username, hostname, port', 400); - // } - // // TODO: Verify the Host headers to ensure CORS friendly - // // TODO: Check if the URL actually resolves correctly - // // TODO: Keep track of hostname since ephemeral nodes are broken atm - // return { - // PreAuthKey: preAuthKey.key, - // ControlURL: - // context.config.headscale.public_url ?? context.config.headscale.url, - // Hostname: generateHostname(username), - // ssh: { - // username, - // hostname, - // }, - // }; + const qp = new URL(request.url).searchParams; + const username = qp.get('username') || undefined; + const hostname = qp.get('hostname') || undefined; + const port = qp.get('port') + ? Number.parseInt(qp.get('port')!, 10) + : undefined; + if (!username || !hostname || !port) { + throw data('Missing required parameters: username, hostname, port', 400); + } + // TODO: Verify the Host headers to ensure CORS friendly + // TODO: Check if the URL actually resolves correctly + // TODO: Keep track of hostname since ephemeral nodes are broken atm + return { + PreAuthKey: preAuthKey.key, + ControlURL: + context.config.headscale.public_url ?? context.config.headscale.url, + Hostname: generateHostname(username), + ssh: { + username, + hostname, + }, + }; } function generateHostname(username: string) { @@ -98,46 +111,46 @@ function generateHostname(username: string) { } export default function Page() { - // const { pause } = useLiveData(); + const { pause } = useLiveData(); const [ipn, setIpn] = useState(null); - // const { PreAuthKey, ControlURL, Hostname, ssh } = - // useLoaderData(); + const { PreAuthKey, ControlURL, Hostname, ssh } = + useLoaderData(); - // useEffect(() => { - // pause(); - // const go = new Go(); // Go is defined by wasm_exec.js - // WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then( - // (value) => { - // go.run(value.instance); - // const handle = TsWasmNet( - // { - // PreAuthKey, - // ControlURL, - // Hostname, - // }, - // { - // NotifyState: (state) => { - // console.log('State changed:', state); - // if (state === 'Running') { - // setIpn(handle); - // } - // }, - // NotifyNetMap: (netmap) => { - // console.log('NetMap updated:', netmap); - // }, - // NotifyBrowseToURL: (url) => { - // console.log('Browse to URL:', url); - // }, - // NotifyPanicRecover: (message) => { - // console.error('Panic recover:', message); - // }, - // }, - // ); + useEffect(() => { + pause(); + const go = new Go(); // Go is defined by wasm_exec.js + WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then( + (value) => { + go.run(value.instance); + const handle = TsWasmNet( + { + PreAuthKey, + ControlURL, + Hostname, + }, + { + NotifyState: (state) => { + console.log('State changed:', state); + if (state === 'Running') { + setIpn(handle); + } + }, + NotifyNetMap: (netmap) => { + console.log('NetMap updated:', netmap); + }, + NotifyBrowseToURL: (url) => { + console.log('Browse to URL:', url); + }, + NotifyPanicRecover: (message) => { + console.error('Panic recover:', message); + }, + }, + ); - // handle.Start(); - // }, - // ); - // }, []); + handle.Start(); + }, + ); + }, []); return (
@@ -147,18 +160,7 @@ export default function Page() {
) : (
- {/*

Session ID: {sessionId}

- {queue.current.length > 0 && ( -

- {queue.current.length} frames queued -

- )} */} - - {/* */} -
- {/* Render your terminal component here */} - {/* */} -
+
)} diff --git a/app/routes/ssh/hp_ssh.d.ts b/app/routes/ssh/hp_ssh.d.ts index 4cf22e7..46b7df9 100644 --- a/app/routes/ssh/hp_ssh.d.ts +++ b/app/routes/ssh/hp_ssh.d.ts @@ -1,4 +1,3 @@ -declare function newIPN(config: NewIPNConfig): IPNHandle; declare function TsWasmNet( options: TsWasmNetOptions, callbacks: TsWasmNetCallbacks, @@ -19,6 +18,11 @@ interface TsWasmNetCallbacks { interface TsWasmNet { Start: () => void; + OpenSSH: ( + hostname: string, + username: string, + options: XtermConfig, + ) => SSHSession; } type IPNState = @@ -30,19 +34,31 @@ type IPNState = | 'Starting' | 'Running'; -interface SSHTerminalConfig { - writeFn: (data: string) => void; - writeErrorFn: (error: string) => void; - setReadFn: (cb: (input: string) => void) => void; - rows: number; - cols: number; - timeoutSeconds?: number; - onConnectionProgress: (message: string) => void; - onConnected: () => void; - onDone: () => void; +interface XtermConfig { + Rows: number; + Cols: number; + + OnStdout: (data: string) => void; + OnStderr: (data: string) => void; + OnStdin: (func: (input: string) => void) => void; + + OnConnect: () => void; + OnDisconnect: () => void; } +// interface SSHTerminalConfig { +// writeFn: (data: string) => void; +// writeErrorFn: (error: string) => void; +// setReadFn: (cb: (input: string) => void) => void; +// rows: number; +// cols: number; +// timeoutSeconds?: number; +// onConnectionProgress: (message: string) => void; +// onConnected: () => void; +// onDone: () => void; +// } + interface SSHSession { - close(): boolean; - resize(rows: number, cols: number): boolean; + Close(): boolean; + Resize(rows: number, cols: number): boolean; } diff --git a/app/routes/ssh/xterm.client.tsx b/app/routes/ssh/xterm.client.tsx index daa9a7e..d726fed 100644 --- a/app/routes/ssh/xterm.client.tsx +++ b/app/routes/ssh/xterm.client.tsx @@ -4,36 +4,30 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebglAddon } from '@xterm/addon-webgl'; import * as xterm from '@xterm/xterm'; -import { useEffect, useRef, useState } from 'react'; import '@xterm/xterm/css/xterm.css'; -import { decode, encode } from 'cborg'; -import type { - SSHDataCommand, - SSHFrameData, - SSHResizeCommand, -} from '~/server/agent/dispatcher'; +import { useEffect, useRef, useState } from 'react'; import cn from '~/utils/cn'; import { useLiveData } from '~/utils/live-data'; interface XTermProps { - ws: WebSocket; - sessionId: string; - queue: Array; + ipn: TsWasmNet; + username: string; + hostname: string; } const RED = new TextEncoder().encode('\x1b[31m'); const RESET = new TextEncoder().encode('\x1b[0m'); -export default function XTerm({ ws, sessionId, queue }: XTermProps) { +export default function XTerm({ ipn, username, hostname }: XTermProps) { const { pause } = useLiveData(); const container = useRef(null); const term = useRef(null); const [isResizing, setIsResizing] = useState(false); + const inputRef = useRef<((input: string) => void) | null>(null); useEffect(() => { pause(); - const terminal = new xterm.Terminal({ allowProposedApi: true, cursorBlink: true, @@ -49,9 +43,13 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) { terminal.loadAddon(new Unicode11Addon()); terminal.loadAddon(new ClipboardAddon()); - terminal.loadAddon(new WebLinksAddon()); - terminal.unicode.activeVersion = '11'; + terminal.loadAddon( + new WebLinksAddon((event, uri) => { + event.view?.open(uri, '_blank', 'noopener noreferrer'); + }), + ); + terminal.unicode.activeVersion = '11'; const gl = new WebglAddon(); terminal.loadAddon(gl); @@ -67,97 +65,61 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) { terminal.focus(); term.current = terminal; - const handleFrame = (data: Uint8Array) => { - try { - const frame: SSHFrameData = decode(data); - if (frame.op !== 'ssh_frame') { - console.warn('Received unexpected frame type:', frame.op); - return; + let ro: ResizeObserver | null = null; + let onUnload: ((e: Event) => void) | null = null; + + const session = ipn.OpenSSH(hostname, username, { + Rows: terminal.rows, + Cols: terminal.cols, + + OnStdout: (data) => terminal.write(data), + OnStderr: (data) => { + terminal.write(data); + console.log('SSH stderr:', data); + }, + OnStdin: (func) => { + inputRef.current = func; + }, + OnConnect: () => { + console.log('SSH session connected'); + }, + + OnDisconnect: () => { + ro?.disconnect(); + terminal.dispose(); + if (onUnload) { + parent.removeEventListener('unload', onUnload); } - // If this is stderr, color it red - if (frame.payload.channel === 2) { - terminal.write( - new Uint8Array([...RED, ...frame.payload.frame, ...RESET]), - ); - } else { - terminal.write(frame.payload.frame); - } - } catch (err) { - console.error('Failed to decode CBOR frame:', err); + console.log('SSH session disconnected'); + term.current = null; + }, + }); + + const parent = container.current?.ownerDocument.defaultView ?? window; + ro = new parent.ResizeObserver(() => { + if (term.current) { + setIsResizing(true); + fit.fit(); + setTimeout(() => setIsResizing(false), 100); } - }; + }); - for (const buffer of queue) { - handleFrame(buffer); + if (container.current) { + ro.observe(container.current); } - terminal.onData((input) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send( - encode({ - op: 'ssh_data', - payload: { - sessionId, - data: new TextEncoder().encode(input), - }, - } satisfies SSHDataCommand), - ); - } else { - console.warn('WebSocket is not open, cannot send data'); - } + terminal.onResize(({ cols, rows }) => { + session.Resize(rows, cols); }); - const onMessage = (event: MessageEvent) => { - if (!(event.data instanceof ArrayBuffer)) { - console.warn('Received non-binary message from WebSocket'); - return; - } - - const size = event.data.byteLength; - console.log(`[WS] Received ${size} bytes at ${Date.now()}`); - - const data = new Uint8Array(event.data); - handleFrame(data); - }; - - ws.addEventListener('message', onMessage); - const ro = new ResizeObserver(() => { - const before = { - cols: terminal.cols, - rows: terminal.rows, - }; - - fit.fit(); - if (before.cols !== terminal.cols || before.rows !== terminal.rows) { - console.log( - `Resized terminal to ${terminal.cols} cols and ${terminal.rows} rows`, - ); - ws.send( - encode({ - op: 'ssh_resize', - payload: { - sessionId, - width: terminal.cols, - height: terminal.rows, - }, - } satisfies SSHResizeCommand), - ); - - setIsResizing(true); - setTimeout(() => { - setIsResizing(false); - }, 1000); - } + terminal.onData((data) => { + inputRef.current?.(data); }); - ro.observe(container.current!); - return () => { - ws.removeEventListener('message', onMessage); - term.current?.dispose(); - ro.disconnect(); - }; - }, [ws, queue]); + onUnload = (_) => session.Close(); + parent.addEventListener('unload', onUnload); + }, []); return (
diff --git a/cmd/hp_ssh/hp_ssh.go b/cmd/hp_ssh/hp_ssh.go index e243daa..bee7ce1 100644 --- a/cmd/hp_ssh/hp_ssh.go +++ b/cmd/hp_ssh/hp_ssh.go @@ -41,6 +41,65 @@ func main() { ipn.Start(context.Background()) return nil }), + "OpenSSH": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 3 { + log.Fatal("Usage: OpenSSH(host, user, options)") + return nil + } + + hostname := args[0] + if hostname.IsNull() || hostname.IsUndefined() { + log.Fatal("Hostname must be a non-null, non-undefined string") + return nil + } + + if hostname.Type() != js.TypeString { + log.Fatal("Hostname must be a string") + return nil + } + + username := args[1] + if username.IsNull() || username.IsUndefined() { + log.Fatal("Username must be a non-null, non-undefined string") + return nil + } + + if username.Type() != js.TypeString { + log.Fatal("Username must be a string") + return nil + } + + sshOptions, err := hp_ipn.ParseSSHXtermConfig(args[2]) + if err != nil { + log.Fatal("Error parsing SSH options:", err) + return nil + } + + session := ipn.NewSSHSession(hostname.String(), username.String(), sshOptions) + go session.ConnectAndRun() + + return map[string]any{ + "Close": js.FuncOf(func(this js.Value, args []js.Value) any { + return session.Close() != nil + }), + + "Resize": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 2 { + log.Fatal("Usage: Resize(cols, rows)") + return nil + } + + rows := args[0].Int() + cols := args[1].Int() + if cols <= 0 || rows <= 0 { + log.Fatal("Columns and rows must be positive integers") + return nil + } + + return session.Resize(cols, rows) == nil + }), + } + }), } })) diff --git a/internal/hp_ipn/ipn.go b/internal/hp_ipn/ipn.go deleted file mode 100644 index 4819705..0000000 --- a/internal/hp_ipn/ipn.go +++ /dev/null @@ -1,633 +0,0 @@ -//go:build js && wasm - -package hp_ipn - -import ( - "bytes" - "context" - "encoding/hex" - "errors" - "fmt" - "log" - "net" - "net/http" - "net/netip" - "strings" - "syscall/js" - "time" - - "golang.org/x/crypto/ssh" - "tailscale.com/control/controlclient" - "tailscale.com/ipn" - "tailscale.com/ipn/ipnlocal" - "tailscale.com/ipn/ipnserver" - "tailscale.com/ipn/store/mem" - "tailscale.com/logpolicy" - "tailscale.com/logtail" - "tailscale.com/net/netmon" - "tailscale.com/net/netns" - "tailscale.com/net/tsdial" - "tailscale.com/safesocket" - "tailscale.com/tailcfg" - "tailscale.com/tsd" - "tailscale.com/types/logid" - "tailscale.com/types/views" - "tailscale.com/wgengine" - "tailscale.com/wgengine/netstack" -) - -// ControlURL defines the URL to be used for connection to Control. -var ControlURL = ipn.DefaultControlURL - -func NewIPN(jsConfig js.Value) map[string]any { - netns.SetEnabled(false) - - store := &mem.Store{} - - controlURL := ControlURL - if jsControlURL := jsConfig.Get("controlURL"); jsControlURL.Type() == js.TypeString { - controlURL = jsControlURL.String() - } - - var authKey string - if jsAuthKey := jsConfig.Get("authKey"); jsAuthKey.Type() == js.TypeString { - authKey = jsAuthKey.String() - } - - var hostname string - if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString { - hostname = jsHostname.String() - } else { - hostname = "blah" - } - - lpc := getOrCreateLogPolicyConfig(store) - c := logtail.Config{ - Collection: lpc.Collection, - PrivateID: lpc.PrivateID, - - // Compressed requests set HTTP headers that are not supported by the - // no-cors fetching mode: - CompressLogs: false, - - HTTPC: &http.Client{Transport: &noCORSTransport{http.DefaultTransport}}, - } - - logtail := logtail.NewLogger(c, log.Printf) - logf := logtail.Logf - - // Instead of new(tsd.System), we use tsd.NewSystem() to ensure that - // the bus is initialized correctly and that the system is set up - sys := tsd.NewSystem() - sys.Set(store) - - dialer := &tsdial.Dialer{Logf: logf} - netmon, err := netmon.New(sys.Bus.Get(), logf) - - if err != nil { - log.Fatalf("netmon.New: %v", err) - return nil - } - - eng, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ - Dialer: dialer, - SetSubsystem: sys.Set, - ControlKnobs: sys.ControlKnobs(), - HealthTracker: sys.HealthTracker(), - NetMon: netmon, - Metrics: sys.UserMetricsRegistry(), - }) - - if err != nil { - log.Fatal(err) - } - - sys.Set(eng) - - ns, err := netstack.Create(logf, sys.Tun.Get(), eng, sys.MagicSock.Get(), dialer, sys.DNSManager.Get(), sys.ProxyMapper()) - if err != nil { - log.Fatalf("netstack.Create: %v", err) - } - sys.Set(ns) - ns.ProcessLocalIPs = true - ns.ProcessSubnets = true - - dialer.UseNetstackForIP = func(ip netip.Addr) bool { - return true - } - dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { - return ns.DialContextTCP(ctx, dst) - } - dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { - return ns.DialContextUDP(ctx, dst) - } - - sys.NetstackRouter.Set(true) - sys.Tun.Get().Start() - - logid := logid.PublicID{} - - srv := ipnserver.New(logf, logid, sys.NetMon.Get()) - lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginDefault) - if err != nil { - log.Fatalf("ipnlocal.NewLocalBackend: %v", err) - } - if err := ns.Start(lb); err != nil { - log.Fatalf("failed to start netstack: %v", err) - } - - srv.SetLocalBackend(lb) - - log.Printf("Using control URL and auth key: %q, %q", controlURL, authKey) - jsIPN := &jsIPN{ - dialer: dialer, - srv: srv, - lb: lb, - controlURL: controlURL, - authKey: authKey, - hostname: hostname, - } - - ipnO := ipn.Options{ - AuthKey: authKey, - UpdatePrefs: &ipn.Prefs{ - ControlURL: controlURL, - Hostname: hostname, - WantRunning: true, - LoggedOut: false, - }, - } - - state := lb.State() - log.Printf("State: %+v", state) - - lb.SetNotifyCallback(func(n ipn.Notify) { - log.Printf("NOTIFY: %+v", n) - - st := n.State - if st != nil { - log.Printf("State changed: %s", *st) - if *st == ipn.NeedsLogin { - go func() { - if err := lb.StartLoginInteractive(context.Background()); err != nil { - log.Printf("StartLoginInteractive failed: %v", err) - } else { - log.Printf("Started login interactive") - } - }() - } - } - }) - - // Start safesocket listener before lb.Start - ln, err := safesocket.Listen("") - if err != nil { - log.Fatalf("safesocket.Listen: %v", err) - } - - // Start server loop before backend Start - go func() { - if err := srv.Run(context.Background(), ln); err != nil && !errors.Is(err, context.Canceled) { - log.Fatalf("ipnserver.Run exited: %v", err) - } - }() - - // Now start the backend (this can trigger login) - log.Printf("Starting local backend with options: %+v", ipnO) - if err := lb.Start(ipnO); err != nil { - log.Fatalf("Failed to start local backend: %v", err) - } - - return map[string]any{ - // "run": js.FuncOf(func(this js.Value, args []js.Value) any { - // if len(args) != 1 { - // log.Fatal(`Usage: run({ - // notifyState(state: int): void, - // notifyNetMap(netMap: object): void, - // notifyBrowseToURL(url: string): void, - // notifyPanicRecover(err: string): void, - // })`) - // return nil - // } - // jsIPN.run(args[0]) - // return nil - // }), - "ssh": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 3 { - log.Printf("Usage: ssh(hostname, userName, termConfig)") - return nil - } - return jsIPN.ssh( - args[0].String(), - args[1].String(), - args[2]) - }), - "login": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 0 { - log.Printf("Usage: login()") - return nil - } - jsIPN.login() - return nil - }), - } -} - -func (i *jsIPN) login() { - go i.lb.StartLoginInteractive(context.Background()) -} - -type jsIPN struct { - dialer *tsdial.Dialer - srv *ipnserver.Server - lb *ipnlocal.LocalBackend - controlURL string - authKey string - hostname string -} - -var jsIPNState = map[ipn.State]string{ - ipn.NoState: "NoState", - ipn.InUseOtherUser: "InUseOtherUser", - ipn.NeedsLogin: "NeedsLogin", - ipn.NeedsMachineAuth: "NeedsMachineAuth", - ipn.Stopped: "Stopped", - ipn.Starting: "Starting", - ipn.Running: "Running", -} - -var jsMachineStatus = map[tailcfg.MachineStatus]string{ - tailcfg.MachineUnknown: "MachineUnknown", - tailcfg.MachineUnauthorized: "MachineUnauthorized", - tailcfg.MachineAuthorized: "MachineAuthorized", - tailcfg.MachineInvalid: "MachineInvalid", -} - -func (i *jsIPN) Run(jsCallbacks js.Value) { - notifyState := func(state ipn.State) { - jsCallbacks.Call("notifyState", jsIPNState[state]) - } - - notifyState(ipn.NoState) - - i.lb.SetNotifyCallback(func(n ipn.Notify) { - // Panics in the notify callback are likely due to be due to bugs in - // this bridging module (as opposed to actual bugs in Tailscale) and - // thus may be recoverable. Let the UI know, and allow the user to - // choose if they want to reload the page. - defer func() { - if r := recover(); r != nil { - fmt.Println("Panic recovered:", r) - jsCallbacks.Call("notifyPanicRecover", fmt.Sprint(r)) - } - }() - log.Printf("NOTIFY: %+v", n) - - if n.Health != nil { - log.Printf("NOTIFY HEALTH: %+v", n.Health) - for warning := range n.Health.Warnings { - log.Printf("Health warning: %s", warning) - } - } - - if n.State != nil { - notifyState(*n.State) - } - - log.Printf("NOTIFY NETMAP: %+v", n.NetMap) - - // if nm := n.NetMap; nm != nil { - // jsNetMap := jsNetMap{ - // Self: jsNetMapSelfNode{ - // jsNetMapNode: jsNetMapNode{ - // Name: nm.Name, - // Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }), - // NodeKey: nm.NodeKey.String(), - // MachineKey: nm.MachineKey.String(), - // }, - // MachineStatus: jsMachineStatus[nm.GetMachineStatus()], - // }, - // Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode { - // name := p.Name() - // if name == "" { - // // In practice this should only happen for Hello. - // name = p.Hostinfo().Hostname() - // } - // addrs := make([]string, p.Addresses().Len()) - // for i, ap := range p.Addresses().All() { - // addrs[i] = ap.Addr().String() - // } - // return jsNetMapPeerNode{ - // jsNetMapNode: jsNetMapNode{ - // Name: name, - // Addresses: addrs, - // MachineKey: p.Machine().String(), - // NodeKey: p.Key().String(), - // }, - // Online: p.Online().Clone(), - // TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(), - // } - // }), - // LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0, - // } - // if jsonNetMap, err := json.Marshal(jsNetMap); err == nil { - // jsCallbacks.Call("notifyNetMap", string(jsonNetMap)) - // } else { - // log.Printf("Could not generate JSON netmap: %v", err) - // } - // } - // if n.BrowseToURL != nil { - // jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL) - // } - }) - - go func() { - o := ipn.Options{ - UpdatePrefs: &ipn.Prefs{ - ControlURL: i.controlURL, - RouteAll: false, - WantRunning: true, - Hostname: i.hostname, - }, - AuthKey: i.authKey, - } - - log.Printf("Fucking options: %+v", o) - err := i.lb.Start(o) - if err != nil { - log.Printf("Start error: %v", err) - } - }() - - // go func() { - // ln, err := safesocket.Listen("") - // if err != nil { - // log.Fatalf("safesocket.Listen: %v", err) - // } - - // err = i.srv.Run(context.Background(), ln) - // log.Fatalf("ipnserver.Run exited: %v", err) - // }() -} - -func (i *jsIPN) ssh(host, username string, termConfig js.Value) map[string]any { - jsSSHSession := &jsSSHSession{ - jsIPN: i, - host: host, - username: username, - termConfig: termConfig, - } - - go jsSSHSession.Run() - - return map[string]any{ - "close": js.FuncOf(func(this js.Value, args []js.Value) any { - return jsSSHSession.Close() != nil - }), - "resize": js.FuncOf(func(this js.Value, args []js.Value) any { - rows := args[0].Int() - cols := args[1].Int() - return jsSSHSession.Resize(rows, cols) != nil - }), - } -} - -type jsSSHSession struct { - jsIPN *jsIPN - host string - username string - termConfig js.Value - session *ssh.Session - - pendingResizeRows int - pendingResizeCols int -} - -func (s *jsSSHSession) Run() { - writeFn := s.termConfig.Get("writeFn") - writeErrorFn := s.termConfig.Get("writeErrorFn") - setReadFn := s.termConfig.Get("setReadFn") - rows := s.termConfig.Get("rows").Int() - cols := s.termConfig.Get("cols").Int() - timeoutSeconds := 5.0 - if jsTimeoutSeconds := s.termConfig.Get("timeoutSeconds"); jsTimeoutSeconds.Type() == js.TypeNumber { - timeoutSeconds = jsTimeoutSeconds.Float() - } - onConnectionProgress := s.termConfig.Get("onConnectionProgress") - onConnected := s.termConfig.Get("onConnected") - onDone := s.termConfig.Get("onDone") - defer onDone.Invoke() - - writeError := func(label string, err error) { - writeErrorFn.Invoke(fmt.Sprintf("%s Error: %v\r\n", label, err)) - } - reportProgress := func(message string) { - onConnectionProgress.Invoke(message) - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds*float64(time.Second))) - defer cancel() - reportProgress(fmt.Sprintf("Connecting to %s…", strings.Split(s.host, ".")[0])) - c, err := s.jsIPN.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.host, "22")) - if err != nil { - writeError("Dial", err) - return - } - defer c.Close() - - config := &ssh.ClientConfig{ - HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { - // Host keys are not used with Tailscale SSH, but we can use this - // callback to know that the connection has been established. - reportProgress("SSH connection established…") - return nil - }, - User: s.username, - } - - reportProgress("Starting SSH client…") - sshConn, _, _, err := ssh.NewClientConn(c, s.host, config) - if err != nil { - writeError("SSH Connection", err) - return - } - defer sshConn.Close() - - sshClient := ssh.NewClient(sshConn, nil, nil) - defer sshClient.Close() - - session, err := sshClient.NewSession() - if err != nil { - writeError("SSH Session", err) - return - } - s.session = session - defer session.Close() - - stdin, err := session.StdinPipe() - if err != nil { - writeError("SSH Stdin", err) - return - } - - session.Stdout = termWriter{writeFn} - session.Stderr = termWriter{writeFn} - - setReadFn.Invoke(js.FuncOf(func(this js.Value, args []js.Value) any { - input := args[0].String() - _, err := stdin.Write([]byte(input)) - if err != nil { - writeError("Write Input", err) - } - return nil - })) - - // We might have gotten a resize notification since we started opening the - // session, pick up the latest size. - if s.pendingResizeRows != 0 { - rows = s.pendingResizeRows - } - if s.pendingResizeCols != 0 { - cols = s.pendingResizeCols - } - err = session.RequestPty("xterm", rows, cols, ssh.TerminalModes{}) - - if err != nil { - writeError("Pseudo Terminal", err) - return - } - - err = session.Shell() - if err != nil { - writeError("Shell", err) - return - } - - onConnected.Invoke() - err = session.Wait() - if err != nil { - writeError("Wait", err) - return - } -} - -func (s *jsSSHSession) Close() error { - if s.session == nil { - // We never had a chance to open the session, ignore the close request. - return nil - } - return s.session.Close() -} - -func (s *jsSSHSession) Resize(rows, cols int) error { - if s.session == nil { - s.pendingResizeRows = rows - s.pendingResizeCols = cols - return nil - } - return s.session.WindowChange(rows, cols) -} - -type termWriter struct { - f js.Value -} - -func (w termWriter) Write(p []byte) (n int, err error) { - r := bytes.Replace(p, []byte("\n"), []byte("\n\r"), -1) - w.f.Invoke(string(r)) - return len(p), nil -} - -type jsNetMap struct { - Self jsNetMapSelfNode `json:"self"` - Peers []jsNetMapPeerNode `json:"peers"` - LockedOut bool `json:"lockedOut"` -} - -type jsNetMapNode struct { - Name string `json:"name"` - Addresses []string `json:"addresses"` - MachineKey string `json:"machineKey"` - NodeKey string `json:"nodeKey"` -} - -type jsNetMapSelfNode struct { - jsNetMapNode - MachineStatus string `json:"machineStatus"` -} - -type jsNetMapPeerNode struct { - jsNetMapNode - Online *bool `json:"online,omitempty"` - TailscaleSSHEnabled bool `json:"tailscaleSSHEnabled"` -} - -type jsStateStore struct { - jsStateStorage js.Value -} - -func (s *jsStateStore) ReadState(id ipn.StateKey) ([]byte, error) { - jsValue := s.jsStateStorage.Call("getState", string(id)) - if jsValue.String() == "" { - return nil, ipn.ErrStateNotExist - } - return hex.DecodeString(jsValue.String()) -} - -func (s *jsStateStore) WriteState(id ipn.StateKey, bs []byte) error { - s.jsStateStorage.Call("setState", string(id), hex.EncodeToString(bs)) - return nil -} - -func mapSlice[T any, M any](a []T, f func(T) M) []M { - n := make([]M, len(a)) - for i, e := range a { - n[i] = f(e) - } - return n -} - -func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M { - n := make([]M, a.Len()) - for i, v := range a.All() { - n[i] = f(v) - } - return n -} - -const logPolicyStateKey = "log-policy" - -func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config { - if configBytes, err := state.ReadState(logPolicyStateKey); err == nil { - if config, err := logpolicy.ConfigFromBytes(configBytes); err == nil { - return config - } else { - log.Printf("Could not parse log policy config: %v", err) - } - } else if err != ipn.ErrStateNotExist { - log.Printf("Could not get log policy config from state store: %v", err) - } - config := logpolicy.NewConfig(logtail.CollectionNode) - if err := state.WriteState(logPolicyStateKey, config.ToBytes()); err != nil { - log.Printf("Could not save log policy config to state store: %v", err) - } - return config -} - -// noCORSTransport wraps a RoundTripper and forces the no-cors mode on requests, -// so that we can use it with non-CORS-aware servers. -type noCORSTransport struct { - http.RoundTripper -} - -func (t *noCORSTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("js.fetch:mode", "no-cors") - resp, err := t.RoundTripper.RoundTrip(req) - if err == nil { - // In no-cors mode no response properties are returned. Populate just - // the status so that callers do not think this was an error. - resp.StatusCode = http.StatusOK - resp.Status = http.StatusText(http.StatusOK) - } - return resp, err -} diff --git a/internal/hp_ipn/ipnserver.go b/internal/hp_ipn/ipnserver.go index 4db9e55..d77f290 100644 --- a/internal/hp_ipn/ipnserver.go +++ b/internal/hp_ipn/ipnserver.go @@ -90,7 +90,6 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts // Configure the local Netstack and Dialer wgstack.ProcessLocalIPs = true wgstack.ProcessSubnets = true - sys.NetstackRouter.Set(true) dialer.UseNetstackForIP = func(ip netip.Addr) bool { return true @@ -106,7 +105,8 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts // Dummy logid for the Tailscale backend logid := logid.PublicID{} - tun.Start() + sys.NetstackRouter.Set(true) + sys.Tun.Get().Start() server := ipnserver.New(logf, logid, sys.NetMon.Get()) flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral diff --git a/internal/hp_ipn/jsmap.go b/internal/hp_ipn/jsmap.go index f0ab34f..bc47a7c 100644 --- a/internal/hp_ipn/jsmap.go +++ b/internal/hp_ipn/jsmap.go @@ -4,6 +4,7 @@ package hp_ipn import ( "errors" + "log" "syscall/js" ) @@ -40,6 +41,128 @@ func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) { }, nil } +// Options passed from the JS side to pass data to xterm.js. +type SSHXtermConfig struct { + // Number of rows in the PTY. + Rows int + + // Number of columns in the PTY. + Cols int + + // Fires when the PTY has output. + OnStdout func(data string) + + // Fires when the PTY has an error. + OnStderr func(error string) + + // Passes a function to the JS side to provide input. + OnStdin js.Value + + // Fires when the PTY is opened. + OnConnect func() + + // Fires when the PTY is closed. + OnDisconnect func() +} + +// Parses the provided JS object to validate and extract SSHXtermConfig. +func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) { + if obj.IsUndefined() || obj.IsNull() { + return nil, errors.New("SSHXtermConfig cannot be undefined or null") + } + + rows := safeInt("Rows", obj) + cols := safeInt("Cols", obj) + + if rows <= 0 || cols <= 0 { + return nil, errors.New("Rows and Cols must be positive integers") + } + + config := &SSHXtermConfig{ + Rows: rows, + Cols: cols, + } + + onStdout := obj.Get("OnStdout") + if onStdout.IsUndefined() || onStdout.IsNull() { + return nil, errors.New("OnStdout must be a function") + } + + if onStdout.Type() != js.TypeFunction { + return nil, errors.New("OnStdout must be a function") + } + + config.OnStdout = func(data string) { + onStdout.Invoke(data) + } + + onStderr := obj.Get("OnStderr") + if onStderr.IsUndefined() || onStderr.IsNull() { + return nil, errors.New("OnStderr must be a function") + } + + if onStderr.Type() != js.TypeFunction { + return nil, errors.New("OnStderr must be a function") + } + + config.OnStderr = func(error string) { + onStderr.Invoke(error) + } + + onStdin := obj.Get("OnStdin") + if onStdin.IsUndefined() || onStdin.IsNull() { + return nil, errors.New("OnStdin must be a function") + } + + if onStdin.Type() != js.TypeFunction { + return nil, errors.New("OnStdin must be a function") + } + + config.OnStdin = onStdin + + onConnect := obj.Get("OnConnect") + if onConnect.IsUndefined() || onConnect.IsNull() { + return nil, errors.New("OnConnect must be a function") + } + + if onConnect.Type() != js.TypeFunction { + return nil, errors.New("OnConnect must be a function") + } + + config.OnConnect = func() { + onConnect.Invoke() + } + + onDisconnect := obj.Get("OnDisconnect") + if onDisconnect.IsUndefined() || onDisconnect.IsNull() { + return nil, errors.New("OnDisconnect must be a function") + } + + if onDisconnect.Type() != js.TypeFunction { + return nil, errors.New("OnDisconnect must be a function") + } + + config.OnDisconnect = func() { + onDisconnect.Invoke() + } + + return config, nil +} + +func (t *SSHXtermConfig) PassStdinHandler(fn func(string)) { + handler := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 || args[0].Type() != js.TypeString { + return nil + } + + fn(args[0].String()) + return nil + }) + + log.Printf("Passing stdin handler to JS: %v", handler) + t.OnStdin.Invoke(handler) +} + // Retrieves a string value from a JS object safely. func safeString(key string, obj js.Value) string { if obj.IsUndefined() || obj.IsNull() { @@ -53,3 +176,17 @@ func safeString(key string, obj js.Value) string { return val.String() } + +// Retrieves an integer value from a JS object safely. +func safeInt(key string, obj js.Value) int { + if obj.IsUndefined() || obj.IsNull() { + return 0 + } + + val := obj.Get(key) + if val.IsUndefined() || val.IsNull() { + return 0 + } + + return val.Int() +} diff --git a/internal/hp_ipn/ssh.go b/internal/hp_ipn/ssh.go new file mode 100644 index 0000000..8db62b2 --- /dev/null +++ b/internal/hp_ipn/ssh.go @@ -0,0 +1,184 @@ +//go:build js && wasm + +package hp_ipn + +import ( + "bytes" + "context" + "fmt" + "log" + "net" + "time" + + "golang.org/x/crypto/ssh" +) + +// Represents an SSH session over the Tailnet. +type SSHSession struct { + // Hostname on the Tailnet. + Hostname string + + // Username for the SSH connection. + Username string + + // Xterm configuration for the SSH session. + TermConfig *SSHXtermConfig + + // Handle to the current IPN connection. + Ipn *TsWasmIpn + + // Handle to the current SSH session. + Pty *ssh.Session + + // Tracks resize notifications for rows. + ResizeRows int + + // Tracks resize notifications for columns. + ResizeCols int +} + +// Creates a new SSH session given a hostname and username. +func (i *TsWasmIpn) NewSSHSession(hostname, username string, termConfig *SSHXtermConfig) *SSHSession { + return &SSHSession{ + Hostname: hostname, + Username: username, + TermConfig: termConfig, + Ipn: i, + } +} + +func (s *SSHSession) ConnectAndRun() { + defer s.TermConfig.OnDisconnect() + + // Default to a 5 second timeout for the connection AFTER dial. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // TODO: Log here + log.Printf("Attempting SSH dial to host: %s", net.JoinHostPort(s.Hostname, "22")) + conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.Hostname, "22")) + if err != nil { + log.Printf("SSH dial error: %v", err) + s.writeError("Dial", err) + return + } + + defer conn.Close() + sshConf := &ssh.ClientConfig{ + User: s.Username, + HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + // Tailscale SSH doesn't use host keys + // TODO: Log that the connection was established + return nil + }, + } + + // TODO: LOG: Starting SSH Client + sshConn, _, _, err := ssh.NewClientConn(conn, s.Hostname, sshConf) + if err != nil { + s.writeError("SSH", err) + return + } + + defer sshConn.Close() + sshClient := ssh.NewClient(sshConn, nil, nil) + defer sshClient.Close() + + pty, err := sshClient.NewSession() + if err != nil { + s.writeError("SSH", err) + return + } + + defer pty.Close() + s.Pty = pty + + pty.Stdout = XtermPipe{s.TermConfig.OnStdout} + pty.Stderr = XtermPipe{s.TermConfig.OnStdout} + + // TODO: Set Stdin func + stdin, err := pty.StdinPipe() + if err != nil { + s.writeError("SSH", err) + return + } + + s.TermConfig.PassStdinHandler(func(input string) { + _, err := stdin.Write([]byte(input)) + if err != nil { + s.writeError("SSH", err) + return + } + }) + + rows := s.TermConfig.Rows + if s.ResizeRows != 0 { + rows = s.ResizeRows + } + + cols := s.TermConfig.Cols + if s.ResizeCols != 0 { + cols = s.ResizeCols + } + + err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{}) + if err != nil { + s.writeError("SSH", err) + return + } + + err = pty.Shell() + if err != nil { + s.writeError("SSH", err) + return + } + + s.TermConfig.OnConnect() + err = pty.Wait() + if err != nil { + s.writeError("SSH", err) + return + } +} + +// Resize resizes the terminal for the SSH session. +// TODO: This does NOT work correctly from Xterm.js +func (s *SSHSession) Resize(rows, cols int) error { + // Used to handle resizes while still connecting. + if s.Pty == nil { + s.ResizeRows = rows + s.ResizeCols = cols + return nil + } + + return s.Pty.WindowChange(rows, cols) +} + +// Closes the SSH session. +func (s *SSHSession) Close() error { + if s.Pty == nil { + return nil + } + + return s.Pty.Close() +} + +// Quick easy formatter for writing errors to the terminal. +func (s *SSHSession) writeError(label string, err error) { + o := fmt.Sprintf("%s error: %v\r\n", label, err) + s.TermConfig.OnStderr(o) +} + +// io.Writer "emulator" to pass to the ssh module. +type XtermPipe struct { + // Function to call when data is written. + Send func(data string) +} + +// Write implements the io.Writer interface for XtermPipe. +func (x XtermPipe) Write(data []byte) (int, error) { + // Tailscale's webSSH does this to fix issues in xterm.js + res := bytes.Replace(data, []byte("\n"), []byte("\n\r"), -1) + x.Send(string(res)) + return len(data), nil +}