feat: integrate hostinfo into ui

This commit is contained in:
Aarnav Tale
2025-01-08 14:33:16 +05:30
parent 7d4da73141
commit e33504016b
9 changed files with 564 additions and 56 deletions
+24
View File
@@ -13,6 +13,7 @@ import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale';
import { testOidc } from '~/utils/oidc';
import log from '~/utils/log';
import { initSessionManager } from '~/utils/sessions.server';
import { initAgentCache } from '~/utils/ws-agent';
export interface HeadplaneContext {
debug: boolean;
@@ -21,6 +22,12 @@ export interface HeadplaneContext {
cookieSecret: string;
integration: IntegrationFactory | undefined;
cache: {
enabled: boolean;
path: string;
defaultTTL: number;
}
config: {
read: boolean;
write: boolean;
@@ -98,6 +105,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
// Initialize Session Management
initSessionManager();
const cacheEnabled = process.env.AGENT_CACHE_DISABLED !== 'true';
const cachePath = process.env.AGENT_CACHE_PATH ?? '/etc/headplane/agent.cache';
const cacheTTL = 300 * 1000; // 5 minutes
// Load agent cache
if (cacheEnabled) {
log.info('CTXT', 'Initializing Agent Cache');
log.debug('CTXT', 'Cache Path: %s', cachePath);
log.debug('CTXT', 'Cache TTL: %d', cacheTTL);
await initAgentCache(cacheTTL, cachePath);
}
context = {
debug,
headscaleUrl,
@@ -105,6 +124,11 @@ export async function loadContext(): Promise<HeadplaneContext> {
cookieSecret,
integration: await loadIntegration(),
config: contextData,
cache: {
enabled: cacheEnabled,
path: cachePath,
defaultTTL: cacheTTL,
},
oidc: await checkOidc(config),
};
+36
View File
@@ -0,0 +1,36 @@
import type { HostInfo } from '~/utils/types';
export function getTSVersion(host: HostInfo) {
const { IPNVersion } = host;
if (!IPNVersion) {
return 'Unknown';
}
// IPNVersion is <Semver>-<something>-<something>
return IPNVersion.split('-')[0];
}
export function getOSInfo(host: HostInfo) {
const { OS, OSVersion } = host;
// OS follows runtime.GOOS but uses iOS and macOS instead of darwin
const formattedOS = formatOS(OS);
// Trim in case OSVersion is empty
return `${formattedOS} ${OSVersion}`.trim();
}
function formatOS(os?: string) {
switch (os) {
case 'macOS':
case 'iOS':
return os;
case 'windows':
return 'Windows';
case 'linux':
return 'Linux';
case undefined:
return 'Unknown';
default:
return os;
}
}
+126 -2
View File
@@ -1,4 +1,5 @@
import { redirect } from 'react-router';
import * as client from 'openid-client';
import {
authorizationCodeGrantRequest,
calculatePKCECodeChallenge,
@@ -21,8 +22,130 @@ import { commitSession, getSession } from '~/utils/sessions.server';
import log from '~/utils/log';
import type { HeadplaneContext } from './config/headplane';
import { z } from 'zod';
type OidcConfig = NonNullable<HeadplaneContext['oidc']>;
const oidcConfigSchema = z.object({
issuer: z.string(),
clientId: z.string(),
clientSecret: z.string(),
tokenEndpointAuthMethod: z
.enum(['client_secret_post', 'client_secret_basic'])
.default('client_secret_basic'),
idTokenSigningAlg: z
.enum([
'RS256',
'RS384',
'RS512',
'ES256',
'ES384',
'ES512',
'PS256',
'PS384',
'PS512',
])
.default('RS256'),
idTokenEncryptionAlg: z
.enum(['RSA1_5', 'RSA-OAEP', 'RSA-OAEP-256'])
.default('RSA-OAEP'),
idTokenEncryptionEnc: z
.enum([
'A128CBC-HS256',
'A192CBC-HS384',
'A256CBC-HS512',
'A128GCM',
'A192GCM',
'A256GCM',
])
.default('A256GCM'),
});
declare global {
const __PREFIX__: string;
}
export type OidcConfig = z.infer<typeof oidcConfigSchema>;
// We try our best to infer the callback URI of our Headplane instance
// By default it is always /<base_path>/oidc/callback
export function getRedirectUri(req: Request) {
const base = __PREFIX__ ?? '/admin'; // Fallback
const url = new URL(`${base}/oidc/callback`, req.url);
let host = req.headers.get('Host');
if (!host) {
host = req.headers.get('X-Forwarded-Host');
}
if (!host) {
log.error('OIDC', 'Unable to find a host header');
log.error('OIDC', 'Ensure either Host or X-Forwarded-Host is set');
throw new Error('Could not determine reverse proxy host');
}
const proto = req.headers.get('X-Forwarded-Proto');
if (!proto) {
log.warn('OIDC', 'No X-Forwarded-Proto header found');
log.warn('OIDC', 'Assuming your Headplane instance runs behind HTTP');
}
url.protocol = proto ?? 'http:';
url.host = host;
return url.href;
}
export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
const config = await client.discovery(
oidc.issuer,
oidc.clientId,
oidc.clientSecret,
);
let codeVerifier: string, codeChallenge: string;
codeVerifier = client.randomPKCECodeVerifier();
codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
let params: Record<string, string> = {
redirect_uri,
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
}
// PKCE is backwards compatible with non-PKCE servers
// so if we don't support it, just set our nonce
if (!config.serverMetadata().supportsPKCE()) {
params.nonce = client.randomNonce();
}
const url = client.buildAuthorizationUrl(config, params);
return {
url: url.href,
codeVerifier,
nonce: params.nonce,
};
}
interface FlowOptions {
redirect_uri: string;
codeVerifier: string;
nonce?: string;
}
export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
const config = await client.discovery(
oidc.issuer,
oidc.clientId,
oidc.clientSecret,
);
let subject: string, accessToken: string;
const tokens = await client.authorizationCodeGrant(config, new URL(options.redirect_uri), {
pkceCodeVerifier: options.codeVerifier,
expectedNonce: options.nonce,
idTokenExpected: true
})
console.log(tokens);
}
export async function startOidc(oidc: OidcConfig, req: Request) {
const session = await getSession(req.headers.get('Cookie'));
@@ -35,12 +158,13 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
});
}
// TODO: Properly validate the method is a valid type
const method = oidc.method as ClientAuthenticationMethod;
const issuerUrl = new URL(oidc.issuer);
const oidcClient = {
client_id: oidc.client,
token_endpoint_auth_method: method
token_endpoint_auth_method: method,
} satisfies Client;
const response = await discoveryRequest(issuerUrl);
+139
View File
@@ -0,0 +1,139 @@
// 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 type { HostInfo } from '~/types';
import { WebSocket } from 'ws';
import { log } from './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;
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.error('CACH', 'Failed to load cache from file', e);
return;
}
// log.debug('CACH', 'Cache file not found, creating new cache');
}
}
private async syncToFile() {
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');
}
}
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;
export function initAgentSocket(context: LoaderFunctionArgs['context']) {
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[]) {
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 }));
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
}
-47
View File
@@ -1,47 +0,0 @@
// This is a "side-effect" but we want a lifecycle cache map of
// peer statuses to prevent unnecessary fetches to the agent.
import type { LoaderFunctionArgs } from 'react-router';
type Context = LoaderFunctionArgs['context'];
const cache: { [nodeID: string]: unknown } = {};
export async function queryWS(context: Context, nodeIDs: string[]) {
const ws = context.ws;
const firstClient = ws.clients.values().next().value;
if (!firstClient) {
return cache;
}
const cached = nodeIDs.map((nodeID) => {
const cached = cache[nodeID];
if (cached) {
return cached;
}
});
// We only need to query the nodes that are not cached
const uncached = nodeIDs.filter((nodeID) => !cached.includes(nodeID));
if (uncached.length === 0) {
return cache;
}
firstClient.send(JSON.stringify({ NodeIDs: uncached }));
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
resolve();
}, 3000);
firstClient.on('message', (message: string) => {
const data = JSON.parse(message.toString());
if (Object.keys(data).length === 0) {
resolve();
}
for (const [nodeID, status] of Object.entries(data)) {
cache[nodeID] = status;
}
});
});
return cache;
}