chore: reorganize go code

This commit is contained in:
Aarnav Tale
2025-06-08 08:59:33 -04:00
parent 7a6ad8d2d5
commit 0f9bf73b82
28 changed files with 796 additions and 220 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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'),
+166
View File
@@ -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<LoadContext>) {
// 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<TsWasmNet | null>(null);
// const { PreAuthKey, ControlURL, Hostname, ssh } =
// useLoaderData<typeof loader>();
// 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 (
<div className="w-screen h-screen bg-headplane-900">
{ipn === null ? (
<div className="mx-auto h-screen flex items-center justify-center">
<Loader2 className="animate-spin size-10" />
</div>
) : (
<div className="flex flex-col h-screen">
{/* <h1>Session ID: {sessionId}</h1>
{queue.current.length > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{queue.current.length} frames queued
</p>
)} */}
{/* <XTerm ipn={ipn} /> */}
<div className="flex-1 overflow-auto">
{/* Render your terminal component here */}
{/* <Terminal ipn={ipn} /> */}
</div>
</div>
)}
</div>
);
}
+48
View File
@@ -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;
}
-145
View File
@@ -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<typeof loader>();
const [socket, setSocket] = useState<WebSocket | null>(null);
const [status, setStatus] = useState<SessionStatus>('loading');
const [sessionId, setSessionId] = useState<string | null>(null);
const queue = useRef<Array<Uint8Array>>([]);
const validated = useRef<boolean>(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 (
<Loader2 className="animate-spin text-gray-500 dark:text-gray-400 w-6 h-6 mx-auto mt-4" />
);
}
return (
<div className="flex flex-col h-full">
<h1>Session ID: {sessionId}</h1>
{queue.current.length > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{queue.current.length} frames queued
</p>
)}
<XTerm ws={socket} sessionId={sessionId} queue={queue.current} />
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
declare class Go {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
}
+3
View File
@@ -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);
};
+30 -4
View File
@@ -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)
}
+4 -32
View File
@@ -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 {
+172
View File
@@ -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
}
+100
View File
@@ -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
}
+55
View File
@@ -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()
}
+95
View File
@@ -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)
}
}
@@ -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
}
}
+61
View File
@@ -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()
}
@@ -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)
}
}
+3 -3
View File
@@ -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"]
+1
View File
@@ -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",
+9
View File
@@ -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':