feat: reimplement websocket to use hono

This commit is contained in:
Aarnav Tale
2025-03-24 10:52:29 -04:00
parent c066b3064d
commit 9a1051b9af
13 changed files with 337 additions and 351 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
import log from '~/utils/log';
import { hp_getConfig } from '~server/context/global';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import type { HeadplaneConfig } from '~server/context/parser';
import { Integration } from './abstract';
// import dockerIntegration from './docker';
// import kubernetesIntegration from './kubernetes';
-32
View File
@@ -1,32 +0,0 @@
class Mutex {
private locked = false;
private queue: (() => void)[] = [];
constructor(locked: boolean) {
this.locked = locked;
}
acquire() {
return new Promise<void>((resolve) => {
if (!this.locked) {
this.locked = true;
resolve();
} else {
this.queue.push(resolve);
}
});
}
release() {
if (this.queue.length > 0) {
const next = this.queue.shift();
next?.();
} else {
this.locked = false;
}
}
}
export default function mutex(locked = false) {
return new Mutex(locked);
}
+1 -129
View File
@@ -1,11 +1,6 @@
import { readFile } from 'node:fs/promises';
import * as client from 'openid-client';
import { Configuration } from 'openid-client';
import { hp_getSingleton, hp_setSingleton } from '~server/context/global';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
import log from '~/utils/log';
// We try our best to infer the callback URI of our Headplane instance
// By default it is always /<base_path>/oidc/callback
@@ -35,72 +30,6 @@ export function getRedirectUri(req: Request) {
return url.href;
}
let oidcSecret: string | undefined = undefined;
export function getOidcSecret() {
return oidcSecret;
}
async function resolveClientSecret(oidc: OidcConfig) {
if (!oidc.client_secret && !oidc.client_secret_path) {
return;
}
if (oidc.client_secret_path) {
// We need to interpolate environment variables into the path
// Path formatting can be like ${ENV_NAME}/path/to/secret
let path = oidc.client_secret_path;
const matches = path.match(/\${(.*?)}/g);
if (matches) {
for (const match of matches) {
const env = match.slice(2, -1);
const value = process.env[env];
if (!value) {
log.error('CFGX', 'Environment variable %s is not set', env);
return;
}
log.debug('CFGX', 'Interpolating %s with %s', match, value);
path = path.replace(match, value);
}
}
try {
log.debug('CFGX', 'Reading client secret from %s', path);
const secret = await readFile(path, 'utf-8');
if (secret.trim().length === 0) {
log.error('CFGX', 'Empty OIDC client secret');
return;
}
oidcSecret = secret;
} catch (error) {
log.error('CFGX', 'Failed to read client secret from %s', path);
log.error('CFGX', 'Error: %s', error);
log.debug('CFGX', 'Error details: %o', error);
}
}
if (oidc.client_secret) {
oidcSecret = oidc.client_secret;
}
}
function clientAuthMethod(
method: string,
): (secret: string) => client.ClientAuth {
switch (method) {
case 'client_secret_post':
return client.ClientSecretPost;
case 'client_secret_basic':
return client.ClientSecretBasic;
case 'client_secret_jwt':
return client.ClientSecretJwt;
default:
throw new Error('Invalid client authentication method');
}
}
export async function beginAuthFlow(
config: Configuration,
redirect_uri: string,
@@ -243,60 +172,3 @@ export function formatError(error: unknown) {
},
};
}
export async function testOidc(oidc: OidcConfig) {
await resolveClientSecret(oidc);
if (!oidcSecret) {
log.debug(
'OIDC',
'Cannot validate OIDC configuration without a client secret',
);
return false;
}
log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer);
const secret = await resolveClientSecret(oidc);
const config = await client.discovery(
new URL(oidc.issuer),
oidc.client_id,
oidc.client_secret,
clientAuthMethod(oidc.token_endpoint_auth_method)(oidcSecret),
);
const meta = config.serverMetadata();
if (meta.authorization_endpoint === undefined) {
return false;
}
log.debug('OIDC', 'Authorization endpoint: %s', meta.authorization_endpoint);
log.debug('OIDC', 'Token endpoint: %s', meta.token_endpoint);
if (meta.response_types_supported) {
if (meta.response_types_supported.includes('code') === false) {
log.error('OIDC', 'OIDC server does not support code flow');
return false;
}
} else {
log.warn('OIDC', 'OIDC server does not advertise response_types_supported');
}
if (meta.token_endpoint_auth_methods_supported) {
if (
meta.token_endpoint_auth_methods_supported.includes(
oidc.token_endpoint_auth_method,
) === false
) {
log.error(
'OIDC',
'OIDC server does not support %s',
oidc.token_endpoint_auth_method,
);
return false;
}
}
log.debug('OIDC', 'OIDC configuration is valid');
hp_setSingleton('oidc_client', config);
return true;
}
-158
View File
@@ -1,158 +0,0 @@
// Handlers for the Local Agent on the server side
import { readFile, writeFile } from 'node:fs/promises';
import { setTimeout as pSetTimeout } from 'node:timers/promises';
import type { LoaderFunctionArgs } from 'react-router';
import { WebSocket } from 'ws';
import type { HostInfo } from '~/types';
import log from '~server/utils/log';
// Essentially a HashMap which invalidates entries after a certain time.
// It also is capable of syncing as a compressed file to disk.
class TimedCache<K, V> {
private _cache = new Map<K, V>();
private _timeCache = new Map<K, number>();
private defaultTTL: number;
private filepath: string;
private writeLock = false;
constructor(defaultTTL: number, filepath: string) {
this.defaultTTL = defaultTTL;
this.filepath = filepath;
}
async set(key: K, value: V, ttl: number = this.defaultTTL) {
this._cache.set(key, value);
this._timeCache.set(key, Date.now() + ttl);
await this.syncToFile();
}
async get(key: K) {
const entry = this._cache.get(key);
if (!entry) {
return;
}
const expires = this._timeCache.get(key);
if (!expires || expires < Date.now()) {
this._cache.delete(key);
this._timeCache.delete(key);
await this.syncToFile();
return;
}
return entry;
}
async loadFromFile() {
try {
const data = await readFile(this.filepath, 'utf-8');
const cache = JSON.parse(data);
for (const { key, value, expires } of cache) {
this._cache.set(key, value);
this._timeCache.set(key, expires);
}
} catch (e) {
if (e.code === 'ENOENT') {
log.debug('CACH', 'Cache file not found, creating new cache');
return;
}
log.error('CACH', 'Failed to load cache from file', e);
}
}
private async syncToFile() {
while (this.writeLock) {
await pSetTimeout(100);
}
this.writeLock = true;
const data = Array.from(this._cache.entries()).map(([key, value]) => {
return { key, value, expires: this._timeCache.get(key) };
});
await writeFile(this.filepath, JSON.stringify(data), 'utf-8');
await this.loadFromFile();
this.writeLock = false;
}
}
let cache: TimedCache<string, HostInfo> | undefined;
export async function initAgentCache(defaultTTL: number, filepath: string) {
cache = new TimedCache(defaultTTL, filepath);
await pSetTimeout(500);
await cache.loadFromFile();
}
let agentSocket: WebSocket | undefined;
// TODO: Actually type this?
export function initAgentSocket(context: LoaderFunctionArgs['context']) {
if (!context.ws) {
return;
}
const client = context.ws.clients.values().next().value;
agentSocket = client;
}
// Check the cache and then attempt the websocket query
// If we aren't connected to an agent, then debug log and return the cache
export async function queryAgent(nodes: string[]) {
return;
if (!cache) {
log.error('CACH', 'Cache not initialized');
return;
}
const cached: Record<string, HostInfo> = {};
await Promise.all(
nodes.map(async (node) => {
const cachedData = await cache?.get(node);
if (cachedData) {
cached[node] = cachedData;
}
}),
);
const uncached = nodes.filter((node) => !cached[node]);
// No need to query the agent if we have all the data cached
if (uncached.length === 0) {
return cached;
}
// We don't have an agent socket, so we can't query the agent
// and we just return the cached values available instead
if (!agentSocket) {
return cached;
}
agentSocket?.send(JSON.stringify({ NodeIDs: uncached }));
// biome-ignore lint: bruh
const returnData = await new Promise<Record<string, HostInfo> | void>(
(resolve, reject) => {
const timeout = setTimeout(() => {
agentSocket?.removeAllListeners('message');
resolve();
}, 3000);
agentSocket?.on('message', async (message: string) => {
const data = JSON.parse(message.toString());
if (Object.keys(data).length === 0) {
resolve();
}
agentSocket?.removeAllListeners('message');
resolve(data);
});
},
);
// if (returnData) {
// for await (const [node, info] of Object.entries(returnData)) {
// await cache?.set(node, info);
// }
// }
return returnData ? { ...cached, ...returnData } : cached;
}