diff --git a/.gitignore b/.gitignore index b7add2d..0e2cf27 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ node_modules /build /test .env -public/hp_ssh.wasm -public/wasm_exec.js +app/hp_ssh.wasm +app/wasm_exec.js diff --git a/app/routes.ts b/app/routes.ts index 9a80c50..e2abb9d 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -10,6 +10,7 @@ export default [ route('/logout', 'routes/auth/logout.ts'), route('/oidc/callback', 'routes/auth/oidc-callback.ts'), route('/oidc/start', 'routes/auth/oidc-start.ts'), + route('/ssh', 'routes/ssh/console.tsx'), // All the main logged-in dashboard routes // Double nested to separate error propagations @@ -25,7 +26,6 @@ export default [ route('/users', 'routes/users/overview.tsx'), route('/acls', 'routes/acls/overview.tsx'), route('/dns', 'routes/dns/overview.tsx'), - route('/ssh', 'routes/ssh/overview.tsx'), ...prefix('/settings', [ index('routes/settings/overview.tsx'), diff --git a/app/routes/ssh/console.tsx b/app/routes/ssh/console.tsx new file mode 100644 index 0000000..bdbc51e --- /dev/null +++ b/app/routes/ssh/console.tsx @@ -0,0 +1,166 @@ +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 '~/wasm_exec'; + +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')!, + // ); + // // 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, + // }, + // }; +} + +function generateHostname(username: string) { + const adjective = faker.word.adjective({ + length: { + min: 3, + max: 6, + }, + }); + + const noun = faker.word.noun({ + length: { + min: 3, + max: 6, + }, + }); + + return `ssh-${adjective}-${noun}-${username}`; +} + +export default function Page() { + // const { pause } = useLiveData(); + const [ipn, setIpn] = useState(null); + // 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); + // }, + // }, + // ); + + // handle.Start(); + // }, + // ); + // }, []); + + return ( +
+ {ipn === null ? ( +
+ +
+ ) : ( +
+ {/*

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 new file mode 100644 index 0000000..4cf22e7 --- /dev/null +++ b/app/routes/ssh/hp_ssh.d.ts @@ -0,0 +1,48 @@ +declare function newIPN(config: NewIPNConfig): IPNHandle; +declare function TsWasmNet( + options: TsWasmNetOptions, + callbacks: TsWasmNetCallbacks, +): TsWasmNet; + +interface TsWasmNetOptions { + ControlURL: string; + PreAuthKey: string; + Hostname: string; +} + +interface TsWasmNetCallbacks { + NotifyState: (state: IPNState) => void; + NotifyNetMap: (netMapJson: string) => void; + NotifyBrowseToURL: (url: string) => void; + NotifyPanicRecover: (err: string) => void; +} + +interface TsWasmNet { + Start: () => void; +} + +type IPNState = + | 'NoState' + | 'InUseOtherUser' + | 'NeedsLogin' + | 'NeedsMachineAuth' + | 'Stopped' + | '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 SSHSession { + close(): boolean; + resize(rows: number, cols: number): boolean; +} diff --git a/app/routes/ssh/overview.tsx b/app/routes/ssh/overview.tsx deleted file mode 100644 index 3cab350..0000000 --- a/app/routes/ssh/overview.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { decode } from 'cborg'; -import { Loader2 } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; -import { LoaderFunctionArgs, data, useLoaderData } from 'react-router'; -import { ClientOnly } from 'remix-utils/client-only'; -import { - Command, - SSHConnectData, - SSHConnectFailedData, -} from '~/server/agent/dispatcher'; -import { useLiveData } from '~/utils/live-data'; -import toast from '~/utils/toast'; -import XTerm from './xterm.client'; - -export async function loader({ request }: LoaderFunctionArgs) { - 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); - } - - const baseUrl = new URL(request.url).origin; - const wsUrl = new URL('/_ssh_plexer', baseUrl); - wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl.searchParams.set('username', username); - wsUrl.searchParams.set('hostname', hostname); - wsUrl.searchParams.set('port', port.toString()); - - return { - socketUrl: wsUrl.toString(), - }; -} -type SessionStatus = 'loading' | 'connected' | 'error'; - -export default function Page() { - const { pause } = useLiveData(); - const { socketUrl } = useLoaderData(); - const [socket, setSocket] = useState(null); - const [status, setStatus] = useState('loading'); - const [sessionId, setSessionId] = useState(null); - - const queue = useRef>([]); - const validated = useRef(false); - - useEffect(() => { - // SSH connections should not use stale while revalidate logic. - pause(); - - const ws = new WebSocket(socketUrl); - ws.binaryType = 'arraybuffer'; - - ws.onopen = () => { - setSocket(ws); - setStatus('loading'); - }; - - // We need to wait for the WebSocket to open and respond with the - // connection ID. Without a session ID, we do not have a mux. - const messageHandler = (event: MessageEvent) => { - if (!(event.data instanceof ArrayBuffer)) { - toast('Invalid message received from server'); - return; - } - - const data = new Uint8Array(event.data); - const obj = decode(data) as Command; - - if (obj.op === 'ssh_conn_successful') { - const data = obj as SSHConnectData; - if (!validated.current) { - validated.current = true; - toast( - `SSH connection established with session ID: ${data.payload.sessionId}`, - ); - - setStatus('connected'); - setSessionId(data.payload.sessionId); - } - - return; - } - - if (obj.op === 'ssh_conn_failed') { - const data = obj as SSHConnectFailedData; - if (!validated.current) { - validated.current = true; - toast(`SSH connection failed: ${data.payload.reason}`); - setStatus('error'); - } - - return; - } - - if (obj.op === 'ssh_frame') { - queue.current.push(new Uint8Array(event.data)); - return; - } - }; - - ws.addEventListener('message', messageHandler); - - ws.onerror = (error) => { - setStatus('error'); - toast(`WebSocket error: ${error}`); - }; - - ws.onclose = () => { - if (status !== 'error') { - toast('SSH connection closed'); - } - - setSocket(null); - setStatus('error'); - }; - - return () => { - ws.removeEventListener('message', messageHandler); - ws.close(); - }; - }, [socketUrl]); - - if (socket === null || !sessionId || status === 'loading') { - return ( - - ); - } - - return ( -
-

Session ID: {sessionId}

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

- {queue.current.length} frames queued -

- )} - - -
- ); -} diff --git a/app/routes/ssh/wasm_exec.d.ts b/app/routes/ssh/wasm_exec.d.ts new file mode 100644 index 0000000..25b56fd --- /dev/null +++ b/app/routes/ssh/wasm_exec.d.ts @@ -0,0 +1,7 @@ +declare class Go { + importObject: WebAssembly.Imports; + run(instance: WebAssembly.Instance): Promise; + argv?: string[]; + env?: Record; + exit?: (code: number) => void; +} diff --git a/app/routes/ssh/xterm.client.tsx b/app/routes/ssh/xterm.client.tsx index 1fb8d99..daa9a7e 100644 --- a/app/routes/ssh/xterm.client.tsx +++ b/app/routes/ssh/xterm.client.tsx @@ -114,6 +114,9 @@ export default function XTerm({ ws, sessionId, queue }: XTermProps) { return; } + const size = event.data.byteLength; + console.log(`[WS] Received ${size} bytes at ${Date.now()}`); + const data = new Uint8Array(event.data); handleFrame(data); }; diff --git a/agent/cmd/hp_agent/hp_agent.go b/cmd/hp_agent/hp_agent.go similarity index 100% rename from agent/cmd/hp_agent/hp_agent.go rename to cmd/hp_agent/hp_agent.go diff --git a/cmd/hp_ssh/hp_ssh.go b/cmd/hp_ssh/hp_ssh.go index 0610b42..e243daa 100644 --- a/cmd/hp_ssh/hp_ssh.go +++ b/cmd/hp_ssh/hp_ssh.go @@ -3,6 +3,7 @@ package main import ( + "context" "log" "syscall/js" @@ -10,14 +11,39 @@ import ( ) func main() { - js.Global().Set("newIPN", js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 1 { - log.Fatal("Usage: newIPN(config)") + log.Printf("Loading WASM Headplane SSH module") + js.Global().Set("TsWasmNet", js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 2 { + log.Fatal("Usage: TsWasmNet(config, callbacks)") return nil } - return hp_ipn.NewIPN(args[0]) + options, err := hp_ipn.ParseTsWasmNetOptions(args[0]) + if err != nil { + log.Fatal("Error parsing options:", err) + return nil + } + + callbacks, err := hp_ipn.ParseTsWasmNetCallbacks(args[1]) + if err != nil { + log.Fatal("Error parsing callbacks:", err) + return nil + } + + ipn, err := hp_ipn.NewTsWasmIpn(options, callbacks) + if err != nil { + log.Fatal("Error creating TsWasmIpn:", err) + return nil + } + + return map[string]any{ + "Start": js.FuncOf(func(this js.Value, args []js.Value) any { + ipn.Start(context.Background()) + return nil + }), + } })) + log.Printf("WASM Headplane SSH module loaded successfully") <-make(chan bool) } diff --git a/agent/internal/config/config.go b/internal/config.go similarity index 100% rename from agent/internal/config/config.go rename to internal/config.go diff --git a/internal/hp_ipn/ipn.go b/internal/hp_ipn/ipn.go index 4e97ad9..4819705 100644 --- a/internal/hp_ipn/ipn.go +++ b/internal/hp_ipn/ipn.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "log" - "math/rand/v2" "net" "net/http" "net/netip" @@ -31,10 +30,10 @@ import ( "tailscale.com/safesocket" "tailscale.com/tailcfg" "tailscale.com/tsd" + "tailscale.com/types/logid" "tailscale.com/types/views" "tailscale.com/wgengine" "tailscale.com/wgengine/netstack" - "tailscale.com/words" ) // ControlURL defines the URL to be used for connection to Control. @@ -59,7 +58,7 @@ func NewIPN(jsConfig js.Value) map[string]any { if jsHostname := jsConfig.Get("hostname"); jsHostname.Type() == js.TypeString { hostname = jsHostname.String() } else { - hostname = generateHostname() + hostname = "blah" } lpc := getOrCreateLogPolicyConfig(store) @@ -126,7 +125,8 @@ func NewIPN(jsConfig js.Value) map[string]any { sys.NetstackRouter.Set(true) sys.Tun.Get().Start() - logid := lpc.PublicID + logid := logid.PublicID{} + srv := ipnserver.New(logf, logid, sys.NetMon.Get()) lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginDefault) if err != nil { @@ -595,34 +595,6 @@ func mapSliceView[T any, M any](a views.Slice[T], f func(T) M) []M { return n } -func filterSlice[T any](a []T, f func(T) bool) []T { - n := make([]T, 0, len(a)) - for _, e := range a { - if f(e) { - n = append(n, e) - } - } - return n -} - -func generateHostname() string { - tails := words.Tails() - scales := words.Scales() - if rand.IntN(2) == 0 { - // JavaScript - tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "j") }) - scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "s") }) - } else { - // WebAssembly - tails = filterSlice(tails, func(s string) bool { return strings.HasPrefix(s, "w") }) - scales = filterSlice(scales, func(s string) bool { return strings.HasPrefix(s, "a") }) - } - - tail := tails[rand.IntN(len(tails))] - scale := scales[rand.IntN(len(scales))] - return fmt.Sprintf("%s-%s", tail, scale) -} - const logPolicyStateKey = "log-policy" func getOrCreateLogPolicyConfig(state ipn.StateStore) *logpolicy.Config { diff --git a/internal/hp_ipn/ipnserver.go b/internal/hp_ipn/ipnserver.go new file mode 100644 index 0000000..4db9e55 --- /dev/null +++ b/internal/hp_ipn/ipnserver.go @@ -0,0 +1,172 @@ +//go:build js && wasm + +package hp_ipn + +import ( + "context" + "fmt" + "log" + "net" + "net/netip" + + "tailscale.com/control/controlclient" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/ipnserver" + "tailscale.com/ipn/store/mem" + "tailscale.com/net/netmon" + "tailscale.com/net/netns" + "tailscale.com/net/tsdial" + "tailscale.com/safesocket" + "tailscale.com/tsd" + "tailscale.com/types/logid" + "tailscale.com/wgengine" + "tailscale.com/wgengine/netstack" +) + +// Represents an in-state Tailscale backend that is WASM friendly. +// The bare minimum to have userspace Wireguard networking is a dialer, +// a server, and a backend. +type TsWasmIpn struct { + // The options used to initialize the TsWasmNet module. + options *TsWasmNetOptions + + // The Tailscale dialer, which is used to establish connections. + dialer *tsdial.Dialer + + // The Tailscale server, which handles incoming connections and requests. + server *ipnserver.Server + + // The Tailscale backend, which manages the local state and operations. + backend *ipnlocal.LocalBackend +} + +// NewTsWasmIpn initializes a new TsWasmIpn instance with the provided options. +// This intentionally does not initialize Logtail, as it is only available in +// the Tailscale SaaS and not on self-hosted instances. +func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*TsWasmIpn, error) { + logf := log.Printf // TODO: Update + + netns.SetEnabled(false) // netns is a separate process (not WASM friendly) + + // Base system (NewSystem() creates a bus automatically) + // We supply an in-memory store + sys := tsd.NewSystem() + bus := sys.Bus.Get() + sys.Set(new(mem.Store)) + + dialer := &tsdial.Dialer{Logf: logf} + netmon, err := netmon.New(bus, logf) + if err != nil { + return nil, err + } + + // Userspace Wireguard engine + engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ + Dialer: dialer, + NetMon: netmon, + SetSubsystem: sys.Set, + ControlKnobs: sys.ControlKnobs(), + HealthTracker: sys.HealthTracker(), + Metrics: sys.UserMetricsRegistry(), + }) + + sys.Set(engine) + if err != nil { + return nil, err + } + + tun := sys.Tun.Get() + msock := sys.MagicSock.Get() + dnsman := sys.DNSManager.Get() + proxymap := sys.ProxyMapper() + wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap) + + sys.Set(wgstack) + if err != nil { + return nil, err + } + + // 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 + } + + dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { + return wgstack.DialContextTCP(ctx, dst) + } + + dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { + return wgstack.DialContextUDP(ctx, dst) + } + + // Dummy logid for the Tailscale backend + logid := logid.PublicID{} + tun.Start() + + server := ipnserver.New(logf, logid, sys.NetMon.Get()) + flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral + + backend, err := ipnlocal.NewLocalBackend(logf, logid, sys, flags) + if err != nil { + return nil, err + } + + err = wgstack.Start(backend) + if err != nil { + return nil, err + } + + server.SetLocalBackend(backend) + registerNotifyCallback(callbacks, backend) + + return &TsWasmIpn{ + options: options, + dialer: dialer, + server: server, + backend: backend, + }, nil +} + +// Starts the WASM backend which will connect to the Tailscale tailnet and +// register an ephemeral node viewable in the Tailscale admin console. +func (t *TsWasmIpn) Start(ctx context.Context) error { + // Blank "socket" is a requirement for WASM + // This NEEDS to happen before the LocalBackend is started, + listener, err := safesocket.Listen("") + if err != nil { + return fmt.Errorf("failed to create safesocket listener: %w", err) + } + + // Start the server BEFORE the LocalBackend is started + go func() { + err := t.server.Run(ctx, listener) + if err != nil { + // TODO: Handle this dispatch using a chan + log.Printf("Failed to run Tailscale server: %v", err) + } + }() + + // Start the LocalBackend + err = t.backend.Start(ipn.Options{ + AuthKey: t.options.PreAuthKey, + UpdatePrefs: &ipn.Prefs{ + ControlURL: t.options.ControlURL, + Hostname: t.options.Hostname, + WantRunning: true, + RunWebClient: false, + LoggedOut: false, + }, + }) + + if err != nil { + return fmt.Errorf("failed to start Tailscale backend: %w", err) + } + + log.Printf("Tailscale backend started successfully with hostname: %s", t.options.Hostname) + return nil +} diff --git a/internal/hp_ipn/jsfunc.go b/internal/hp_ipn/jsfunc.go new file mode 100644 index 0000000..70bb838 --- /dev/null +++ b/internal/hp_ipn/jsfunc.go @@ -0,0 +1,100 @@ +//go:build js && wasm + +package hp_ipn + +import ( + "errors" + "fmt" + "syscall/js" + + "tailscale.com/ipn" + "tailscale.com/types/netmap" +) + +// Maps ipn.State values to their string representations for the frontend. +var BackendState = map[ipn.State]string{ + ipn.NoState: "NoState", + ipn.Stopped: "Stopped", + ipn.Starting: "Starting", + ipn.Running: "Running", + ipn.InUseOtherUser: "InUseOtherUser", + ipn.NeedsMachineAuth: "NeedsMachineAuth", + ipn.NeedsLogin: "NeedsLogin", +} + +// Represents the callbacks that the TsWasmNet module can invoke to register +// data retrieval and notifications on the frontend. +type TsWasmNetCallbacks struct { + // Changes in the backend state. + NotifyState func(ipn.State) + + // Updates to the backend's network map. + NotifyNetMap func(*netmap.NetworkMap) + + // If interactive login is required, this passes a login URL. + NotifyBrowseToURL func(string) + + // If the process panics, this function is called in go.recover. + NotifyPanicRecover func(string) +} + +// Parses a JavaScript object containing the necessary callbacks for the +// TsWasmNet module to properly interact with the frontend. +func ParseTsWasmNetCallbacks(obj js.Value) (*TsWasmNetCallbacks, error) { + if obj.IsUndefined() || obj.IsNull() { + return nil, errors.New("callbacks object is undefined or null") + } + + state, err := validateCallback("NotifyState", obj) + if err != nil { + return nil, fmt.Errorf("invalid callback NotifyState: %w", err) + } + + // TODO: This is complicated, as the NetworkMap is a complex type. + _, err = validateCallback("NotifyNetMap", obj) + if err != nil { + return nil, fmt.Errorf("invalid callback NotifyNetMap: %w", err) + } + + browseURL, err := validateCallback("NotifyBrowseToURL", obj) + if err != nil { + return nil, fmt.Errorf("invalid callback NotifyBrowseToURL: %w", err) + } + + panicRecover, err := validateCallback("NotifyPanicRecover", obj) + if err != nil { + return nil, fmt.Errorf("invalid callback NotifyPanicRecover: %w", err) + } + + return &TsWasmNetCallbacks{ + NotifyState: func(ipnState ipn.State) { + state.Invoke(BackendState[ipnState]) + }, + + NotifyNetMap: func(nm *netmap.NetworkMap) { + // TODO: This is complicated + }, + + NotifyBrowseToURL: func(url string) { + browseURL.Invoke(url) + }, + + NotifyPanicRecover: func(msg string) { + panicRecover.Invoke(msg) + }, + }, nil +} + +// Validates the specified key is a JS function and returns it. +func validateCallback(key string, obj js.Value) (*js.Value, error) { + val := obj.Get(key) + if val.IsUndefined() || val.IsNull() { + return nil, errors.New("callback is undefined or null") + } + + if val.Type() != js.TypeFunction { + return nil, errors.New("callback is not a function") + } + + return &val, nil +} diff --git a/internal/hp_ipn/jsmap.go b/internal/hp_ipn/jsmap.go new file mode 100644 index 0000000..f0ab34f --- /dev/null +++ b/internal/hp_ipn/jsmap.go @@ -0,0 +1,55 @@ +//go:build js && wasm + +package hp_ipn + +import ( + "errors" + "syscall/js" +) + +// Represents the options needed to initialize the TsWasmNet module. +type TsWasmNetOptions struct { + // Tailscale control URL, e.g., "https://controlplane.tailscale.com" + ControlURL string + + // Pre-authentication key for the Tailnet + PreAuthKey string + + // Optional hostname, autogenerated if not provided. + Hostname string +} + +// Parses the provided JS object to validate and extract the TsWasmNetOptions. +func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) { + if obj.IsUndefined() || obj.IsNull() { + return nil, errors.New("TsWasmNetOptions cannot be undefined or null") + } + + cUrl := safeString("ControlURL", obj) + preAuthKey := safeString("PreAuthKey", obj) + hostname := safeString("Hostname", obj) + + if cUrl == "" || preAuthKey == "" || hostname == "" { + return nil, errors.New("missing required fields in TsWasmNetOptions") + } + + return &TsWasmNetOptions{ + ControlURL: cUrl, + PreAuthKey: preAuthKey, + Hostname: hostname, + }, nil +} + +// Retrieves a string value from a JS object safely. +func safeString(key string, obj js.Value) string { + if obj.IsUndefined() || obj.IsNull() { + return "" + } + + val := obj.Get(key) + if val.IsUndefined() || val.IsNull() { + return "" + } + + return val.String() +} diff --git a/internal/hp_ipn/notify.go b/internal/hp_ipn/notify.go new file mode 100644 index 0000000..4c66de2 --- /dev/null +++ b/internal/hp_ipn/notify.go @@ -0,0 +1,95 @@ +//go:build js && wasm + +package hp_ipn + +import ( + "context" + "fmt" + "log" + + "tailscale.com/ipn" + "tailscale.com/ipn/ipnlocal" +) + +func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBackend) { + lb.SetNotifyCallback(func(n ipn.Notify) { + // Panics should be treated with care in a JS/wasm environment. + // If a panic occurs, notify the user and either automatically reload + // or give the option to reload. + + defer func() { + rec := recover() + if rec != nil { + callbacks.NotifyPanicRecover(fmt.Sprint(rec)) + } + }() + + if n.State != nil { + callbacks.NotifyState(*n.State) + + if *n.State == ipn.NeedsLogin { + // If the state is NeedsLogin, we need to force an interactive login. + go forceInteractiveLogin(lb) + } + } + + if n.BrowseToURL != nil { + callbacks.NotifyBrowseToURL(*n.BrowseToURL) + } + + log.Printf("NOTIFY: %+v", n) + + // 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) + // } + }) +} + +// To get auth to work, even with a pre-auth key, we need to +// force an interactive login on the NeedsLogin state. +func forceInteractiveLogin(lb *ipnlocal.LocalBackend) { + err := lb.StartLoginInteractive(context.Background()) + if err != nil { + fmt.Printf("Error starting interactive login: %v\n", err) + } +} diff --git a/agent/internal/hpagent/handler.go b/internal/hpagent/handler.go similarity index 79% rename from agent/internal/hpagent/handler.go rename to internal/hpagent/handler.go index 61667d6..7bb48e7 100644 --- a/agent/internal/hpagent/handler.go +++ b/internal/hpagent/handler.go @@ -49,38 +49,38 @@ func FollowMaster(agent *tsnet.TSAgent) { log.Debug("Received message from master: %s", msg) switch msg.Op { - case "ssh_conn": - var sshPayload sshutil.SSHConnectPayload - err = cbor.Unmarshal(msg.Payload, &sshPayload) - if err != nil { - log.Error("Unable to unmarshal SSH connect payload: %s", err) - continue - } - - sshutil.StartWebSSH(agent, sshPayload) + case "ssh_conn": + var sshPayload sshutil.SSHConnectPayload + err = cbor.Unmarshal(msg.Payload, &sshPayload) + if err != nil { + log.Error("Unable to unmarshal SSH connect payload: %s", err) continue + } - case "ssh_term": - var sshPayload sshutil.SSHClosePayload - err = cbor.Unmarshal(msg.Payload, &sshPayload) - if err != nil { - log.Error("Unable to unmarshal SSH close payload: %s", err) - continue - } + sshutil.StartWebSSH(agent, sshPayload) + continue - sshutil.CloseWebSSH(agent, sshPayload) + case "ssh_term": + var sshPayload sshutil.SSHClosePayload + err = cbor.Unmarshal(msg.Payload, &sshPayload) + if err != nil { + log.Error("Unable to unmarshal SSH close payload: %s", err) continue + } - case "ssh_resize": - var sshPayload sshutil.SSHResizePayload - err = cbor.Unmarshal(msg.Payload, &sshPayload) - if err != nil { - log.Error("Unable to unmarshal SSH resize payload: %s", err) - continue - } + sshutil.CloseWebSSH(agent, sshPayload) + continue - sshutil.ResizeWebSSH(agent, sshPayload) + case "ssh_resize": + var sshPayload sshutil.SSHResizePayload + err = cbor.Unmarshal(msg.Payload, &sshPayload) + if err != nil { + log.Error("Unable to unmarshal SSH resize payload: %s", err) continue + } + + sshutil.ResizeWebSSH(agent, sshPayload) + continue } } diff --git a/agent/internal/config/preflight.go b/internal/preflight.go similarity index 100% rename from agent/internal/config/preflight.go rename to internal/preflight.go diff --git a/agent/internal/sshutil/connect.go b/internal/sshutil/connect.go similarity index 100% rename from agent/internal/sshutil/connect.go rename to internal/sshutil/connect.go diff --git a/agent/internal/sshutil/encoder.go b/internal/sshutil/encoder.go similarity index 100% rename from agent/internal/sshutil/encoder.go rename to internal/sshutil/encoder.go diff --git a/internal/sshutil/framebatcher.go b/internal/sshutil/framebatcher.go new file mode 100644 index 0000000..6653452 --- /dev/null +++ b/internal/sshutil/framebatcher.go @@ -0,0 +1,61 @@ +package sshutil + +import ( + "io" + "sync" + "time" + + "github.com/tale/headplane/agent/internal/util" +) + +type FrameBatcher struct { + mu sync.Mutex + buffer []byte + writer io.Writer + timer *time.Timer + interval time.Duration + done chan struct{} +} + +func NewFrameBatcher(writer io.Writer, interval time.Duration) *FrameBatcher { + return &FrameBatcher{ + writer: writer, + interval: interval, + done: make(chan struct{}), + } +} + +func (b *FrameBatcher) QueueMsg(msg []byte) { + b.mu.Lock() + defer b.mu.Unlock() + + b.buffer = append(b.buffer, msg...) + if b.timer != nil { + b.timer.Stop() + } + + b.timer = time.AfterFunc(b.interval, b.flush) +} + +func (b *FrameBatcher) flush() { + log := util.GetLogger() + + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.buffer) == 0 { + return + } + + _, err := b.writer.Write(b.buffer) + if err != nil { + log.Error("Failed to write batched message: %v", err) + } + + b.buffer = nil +} + +func (b *FrameBatcher) Close() { + close(b.done) + b.flush() +} diff --git a/agent/internal/sshutil/handler.go b/internal/sshutil/handler.go similarity index 89% rename from agent/internal/sshutil/handler.go rename to internal/sshutil/handler.go index e9b64ff..63b90c9 100644 --- a/agent/internal/sshutil/handler.go +++ b/internal/sshutil/handler.go @@ -3,6 +3,7 @@ package sshutil import ( "io" "os" + "time" "github.com/tale/headplane/agent/internal/util" ) @@ -92,17 +93,19 @@ func dispatchSSHStdout(id string, stdout io.Reader, stderr io.Reader) { return } + batcher := NewFrameBatcher(fd, 10*time.Millisecond) // Roughly 60fps + go readerStreamRoutine(StreamRoutine{ SessionID: id, Reader: stdout, - Writer: fd, + Writer: batcher, ChannelType: ChannelTypeStdout, }) go readerStreamRoutine(StreamRoutine{ SessionID: id, Reader: stderr, - Writer: fd, + Writer: batcher, ChannelType: ChannelTypeStderr, }) } @@ -110,13 +113,13 @@ func dispatchSSHStdout(id string, stdout io.Reader, stderr io.Reader) { type StreamRoutine struct { SessionID string Reader io.Reader - Writer io.Writer + Writer *FrameBatcher ChannelType ChannelType } func readerStreamRoutine(routine StreamRoutine) { hpls1 := HPLSFrame1{} - buf := make([]byte, 1024) + buf := make([]byte, 16384) // 16 KiB buffer for { byteCount, err := routine.Reader.Read(buf) if err != nil { @@ -138,9 +141,12 @@ func readerStreamRoutine(routine StreamRoutine) { continue } - if _, err := routine.Writer.Write(frame); err != nil { - util.GetLogger().Error("Failed to write frame to writer: %v", err) - break - } + // if _, err := routine.Writer.Write(frame); err != nil { + // util.GetLogger().Error("Failed to write frame to writer: %v", err) + // break + // } + // + + routine.Writer.QueueMsg(frame) } } diff --git a/agent/internal/sshutil/sessions.go b/internal/sshutil/sessions.go similarity index 100% rename from agent/internal/sshutil/sessions.go rename to internal/sshutil/sessions.go diff --git a/agent/internal/tsnet/peers.go b/internal/tsnet/peers.go similarity index 100% rename from agent/internal/tsnet/peers.go rename to internal/tsnet/peers.go diff --git a/agent/internal/tsnet/server.go b/internal/tsnet/server.go similarity index 100% rename from agent/internal/tsnet/server.go rename to internal/tsnet/server.go diff --git a/agent/internal/util/logger.go b/internal/util/logger.go similarity index 100% rename from agent/internal/util/logger.go rename to internal/util/logger.go diff --git a/mise.toml b/mise.toml index f92272c..f69d05e 100644 --- a/mise.toml +++ b/mise.toml @@ -6,8 +6,8 @@ alias = ["gojs"] description = "Copies Go's wasm_exec.js to the public directory" run = [ - "echo $(go version) > public/wasm_exec.js", - "cat $(go env GOROOT)/lib/wasm/wasm_exec.js >> public/wasm_exec.js", + "echo // $(go version) > app/wasm_exec.js", + "cat $(go env GOROOT)/lib/wasm/wasm_exec.js >> app/wasm_exec.js", ] [tasks.build-go-wasm] @@ -15,7 +15,7 @@ alias = ["wasm"] depends = ["copy-wasm-shim"] description = "Builds the Go WebAssembly module for Tailscale SSH" env = { GOOS = "js", GOARCH = "wasm" } -run = "go build -o public/hp_ssh.wasm ./cmd/hp_ssh" +run = "go build -o app/hp_ssh.wasm ./cmd/hp_ssh" [tasks.generate-caddy-certs] alias = ["mkcert"] diff --git a/package.json b/package.json index a706153..80c079f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@faker-js/faker": "^9.8.0", "@fontsource-variable/inter": "^5.2.5", "@kubernetes/client-node": "^1.3.0", "@primer/octicons-react": "^19.15.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 629e533..7da93f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.1.0) + '@faker-js/faker': + specifier: ^9.8.0 + version: 9.8.0 '@fontsource-variable/inter': specifier: ^5.2.5 version: 5.2.5 @@ -611,6 +614,10 @@ packages: cpu: [x64] os: [win32] + '@faker-js/faker@9.8.0': + resolution: {integrity: sha512-U9wpuSrJC93jZBxx/Qq2wPjCuYISBueyVUGK7qqdmj7r/nxaxwW8AQDCLeRO7wZnjj94sh3p246cAYjUKuqgfg==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@fontsource-variable/inter@5.2.5': resolution: {integrity: sha512-TrWffUAFOnT8zroE9YmGybagoOgM/HjRqMQ8k9R0vVgXlnUh/vnpbGPAS/Caz1KIlOPnPGh6fvJbb7DHbFCncA==} @@ -3306,6 +3313,8 @@ snapshots: '@esbuild/win32-x64@0.25.5': optional: true + '@faker-js/faker@9.8.0': {} + '@fontsource-variable/inter@5.2.5': {} '@formatjs/ecma402-abstract@2.3.4':