From f5436f5ee3711878960018f78050b8a24dc243d1 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Wed, 19 Feb 2025 18:09:42 -0500 Subject: [PATCH] feat: respect context in server --- app/entry.server.tsx | 23 ++++-- app/layouts/shell.tsx | 19 +++-- app/routes/acls/editor.tsx | 5 +- app/routes/auth/login.tsx | 31 +++++--- app/routes/auth/oidc-callback.ts | 16 +++-- app/routes/auth/oidc-start.ts | 16 +++-- app/routes/dns/dns-actions.ts | 3 +- app/routes/dns/overview.tsx | 2 +- app/routes/machines/action.tsx | 8 ++- app/routes/machines/machine.tsx | 2 +- app/routes/machines/overview.tsx | 21 ++++-- app/routes/settings/auth-keys.tsx | 16 +++-- app/routes/settings/local-agent.tsx | 30 ++++---- app/routes/users/components/manage-banner.tsx | 4 +- app/routes/users/overview.tsx | 16 +++-- app/utils/config/loader.ts | 4 +- app/utils/headscale.ts | 39 ++++++++-- app/utils/log.ts | 16 ++--- app/utils/oidc.ts | 5 +- app/utils/state.ts | 5 -- app/utils/ws-agent.ts | 71 ++++++++++--------- server/context/app.ts | 12 ++++ {app/utils => server}/context/loader.ts | 14 +--- {app/utils => server}/context/parser.ts | 4 +- server/{ => dev}/dev-handler.ts | 4 +- server/{dev.ts => dev/hot-server.ts} | 19 +++-- server/entry.ts | 38 +++++----- server/listener.ts | 37 +++++----- server/prod-handler.ts | 13 ++-- server/{ => utils}/log.ts | 28 ++++++-- server/utils/mutex.ts | 32 +++++++++ server/{ => utils}/ws.ts | 10 +-- 32 files changed, 359 insertions(+), 204 deletions(-) delete mode 100644 app/utils/state.ts create mode 100644 server/context/app.ts rename {app/utils => server}/context/loader.ts (93%) rename {app/utils => server}/context/parser.ts (95%) rename server/{ => dev}/dev-handler.ts (66%) rename server/{dev.ts => dev/hot-server.ts} (71%) rename server/{ => utils}/log.ts (61%) create mode 100644 server/utils/mutex.ts rename server/{ => utils}/ws.ts (89%) diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 3e12f45..a1863be 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,24 +1,39 @@ import { PassThrough } from 'node:stream'; - import { createReadableStreamFromReadable } from '@react-router/node'; import { isbot } from 'isbot'; import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; import type { AppLoadContext, EntryContext } from 'react-router'; import { ServerRouter } from 'react-router'; -import { hp_loadConfig } from '~/utils/context/loader'; +import { hs_loadConfig } from '~/utils/config/loader'; +import { hp_storeContext } from '~/utils/headscale'; +import { hp_loadLogger } from '~/utils/log'; +import { initSessionManager } from '~/utils/sessions.server'; +import type { AppContext } from '~server/context/app'; -hp_loadConfig(); export const streamTimeout = 5_000; +// TODO: checkOidc export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: AppContext, ) { + const { context } = loadContext; return new Promise((resolve, reject) => { + initSessionManager( + context.server.cookie_secret, + context.server.cookie_secure, + ); + + // This is a promise but we don't need to wait for it to finish + // before we start rendering the shell since it only loads once. + hs_loadConfig(context); + hp_storeContext(context); + hp_loadLogger(context.debug); + let shellRendered = false; const userAgent = request.headers.get('user-agent'); diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index b8c47f2..3181c8b 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -6,25 +6,34 @@ import { } from 'react-router'; import Footer from '~/components/Footer'; import Header from '~/components/Header'; +import { hs_getConfig } from '~/utils/config/loader'; +import { noContext } from '~/utils/log'; import { getSession } from '~/utils/sessions.server'; -import { hp_getConfig, hs_getConfig } from '~/utils/state'; +import type { AppContext } from '~server/context/app'; // This loads the bare minimum for the application to function // So we know that if context fails to load then well, oops? -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); if (!session.has('hsApiKey')) { return redirect('/login'); } - const context = hp_getConfig(); + if (!context) { + throw noContext(); + } + + const ctx = context.context; const { mode, config } = hs_getConfig(); return { config, - url: context.headscale.public_url ?? context.headscale.url, + url: ctx.headscale.public_url ?? ctx.headscale.url, configAvailable: mode !== 'no', - debug: context.debug, + debug: ctx.debug, user: session.get('user'), }; } diff --git a/app/routes/acls/editor.tsx b/app/routes/acls/editor.tsx index b1f3d13..2996eaa 100644 --- a/app/routes/acls/editor.tsx +++ b/app/routes/acls/editor.tsx @@ -7,17 +7,18 @@ import Link from '~/components/Link'; import Notice from '~/components/Notice'; import Spinner from '~/components/Spinner'; import Tabs from '~/components/Tabs'; +import { hs_getConfig } from '~/utils/config/loader'; import { HeadscaleError, pull, put } from '~/utils/headscale'; import log from '~/utils/log'; import { send } from '~/utils/res'; import { getSession } from '~/utils/sessions.server'; -import { hs_getConfig } from '~/utils/state'; import toast from '~/utils/toast'; +import type { AppContext } from '~server/context/app'; import { Differ, Editor } from './components/cm.client'; import { ErrorView } from './components/error'; import { Unavailable } from './components/unavailable'; -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ request }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); // The way policy is handled in 0.23 of Headscale and later is verbose. diff --git a/app/routes/auth/login.tsx b/app/routes/auth/login.tsx index 1272df3..107fd48 100644 --- a/app/routes/auth/login.tsx +++ b/app/routes/auth/login.tsx @@ -10,10 +10,14 @@ import Code from '~/components/Code'; import Input from '~/components/Input'; import type { Key } from '~/types'; import { pull } from '~/utils/headscale'; +import { noContext } from '~/utils/log'; import { commitSession, getSession } from '~/utils/sessions.server'; -import { hp_getConfig } from '~/utils/state'; +import type { AppContext } from '~server/context/app'; -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); if (session.has('hsApiKey')) { return redirect('/machines', { @@ -23,28 +27,37 @@ export async function loader({ request }: LoaderFunctionArgs) { }); } - const context = hp_getConfig(); + if (!context) { + throw noContext(); + } // Only set if OIDC is properly enabled anyways - if (context.oidc?.disable_api_key_login) { + const ctx = context.context; + if (ctx.oidc?.disable_api_key_login) { return redirect('/oidc/start'); } return { - oidc: context.oidc?.issuer, - apiKey: !context.oidc?.disable_api_key_login, + oidc: ctx.oidc?.issuer, + apiKey: !ctx.oidc?.disable_api_key_login, }; } -export async function action({ request }: ActionFunctionArgs) { +export async function action({ + request, + context, +}: ActionFunctionArgs) { const formData = await request.formData(); const oidcStart = formData.get('oidc-start'); const session = await getSession(request.headers.get('Cookie')); if (oidcStart) { - const context = hp_getConfig(); + if (!context) { + throw noContext(); + } - if (!context.oidc) { + const ctx = context.context; + if (!ctx.oidc) { throw new Error('An invalid OIDC configuration was provided'); } diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 6150518..204c5da 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -1,10 +1,14 @@ import { type LoaderFunctionArgs, redirect } from 'react-router'; -import { finishAuthFlow, formatError, getRedirectUri } from '~/utils/oidc'; +import { noContext } from '~/utils/log'; +import { finishAuthFlow, formatError } from '~/utils/oidc'; import { send } from '~/utils/res'; import { commitSession, getSession } from '~/utils/sessions.server'; -import { hp_getConfig } from '~/utils/state'; +import type { AppContext } from '~server/context/app'; -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { // Check if we have 0 query parameters const url = new URL(request.url); if (url.searchParams.toString().length === 0) { @@ -16,7 +20,11 @@ export async function loader({ request }: LoaderFunctionArgs) { return redirect('/machines'); } - const { oidc } = hp_getConfig(); + if (!context) { + throw noContext(); + } + + const { oidc } = context.context; if (!oidc) { throw new Error('An invalid OIDC configuration was provided'); } diff --git a/app/routes/auth/oidc-start.ts b/app/routes/auth/oidc-start.ts index bcbb337..ba66fd5 100644 --- a/app/routes/auth/oidc-start.ts +++ b/app/routes/auth/oidc-start.ts @@ -1,17 +1,23 @@ import { type LoaderFunctionArgs, redirect } from 'react-router'; +import { noContext } from '~/utils/log'; import { beginAuthFlow, getRedirectUri } from '~/utils/oidc'; import { commitSession, getSession } from '~/utils/sessions.server'; -import { hp_getConfig } from '~/utils/state'; +import type { AppContext } from '~server/context/app'; -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); if (session.has('hsApiKey')) { return redirect('/machines'); } - // This is a hold-over from the old code - // TODO: Rewrite checkOIDC in the context loader - const { oidc } = hp_getConfig(); + if (!context) { + throw noContext(); + } + + const { oidc } = context.context; if (!oidc) { throw new Error('An invalid OIDC configuration was provided'); } diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts index 98dfb44..2286cfa 100644 --- a/app/routes/dns/dns-actions.ts +++ b/app/routes/dns/dns-actions.ts @@ -1,7 +1,6 @@ import { ActionFunctionArgs, data } from 'react-router'; -import { hs_patchConfig } from '~/utils/config/loader'; +import { hs_getConfig, hs_patchConfig } from '~/utils/config/loader'; import { auth } from '~/utils/sessions.server'; -import { hs_getConfig } from '~/utils/state'; export async function dnsAction({ request }: ActionFunctionArgs) { const session = await auth(request); diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index 2048980..86fc605 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from 'react-router'; import { useLoaderData } from 'react-router'; import Code from '~/components/Code'; import Notice from '~/components/Notice'; -import { hs_getConfig } from '~/utils/state'; +import { hs_getConfig } from '~/utils/config/loader'; import ManageDomains from './components/manage-domains'; import ManageNS from './components/manage-ns'; import ManageRecords from './components/manage-records'; diff --git a/app/routes/machines/action.tsx b/app/routes/machines/action.tsx index 21012b9..6d4d888 100644 --- a/app/routes/machines/action.tsx +++ b/app/routes/machines/action.tsx @@ -109,9 +109,13 @@ export async function menuAction(request: ActionFunctionArgs['request']) { const to = String(data.get('to')); try { - await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!); + await post(`v1/node/${id}/user`, session.get('hsApiKey')!, { + user: to, + }); + return { message: `Moved node ${id} to ${to}` }; - } catch { + } catch (error) { + console.error(error); return send( { message: `Failed to move node ${id} to ${to}` }, { diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 34f5216..e52920a 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -11,9 +11,9 @@ import StatusCircle from '~/components/StatusCircle'; import Tooltip from '~/components/Tooltip'; import type { Machine, Route, User } from '~/types'; import cn from '~/utils/cn'; +import { hs_getConfig } from '~/utils/config/loader'; import { pull } from '~/utils/headscale'; import { getSession } from '~/utils/sessions.server'; -import { hs_getConfig } from '~/utils/state'; import { menuAction } from './action'; import MenuOptions from './components/menu'; import Routes from './dialogs/routes'; diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 3357ccf..fea23b0 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -12,12 +12,17 @@ import { getSession } from '~/utils/sessions.server'; import { initAgentSocket, queryAgent } from '~/utils/ws-agent'; import Tooltip from '~/components/Tooltip'; -import { hp_getConfig, hs_getConfig } from '~/utils/state'; +import { hs_getConfig } from '~/utils/config/loader'; +import { noContext } from '~/utils/log'; +import { AppContext } from '~server/context/app'; import { menuAction } from './action'; import MachineRow from './components/machine'; import NewMachine from './dialogs/new'; -export async function loader({ request, context: lC }: LoaderFunctionArgs) { +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); const [machines, routes, users] = await Promise.all([ pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!), @@ -25,10 +30,14 @@ export async function loader({ request, context: lC }: LoaderFunctionArgs) { pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), ]); - initAgentSocket(lC); + if (!context) { + throw noContext(); + } + + initAgentSocket(context); const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey)); - const context = hp_getConfig(); + const ctx = context.context; const { mode, config } = hs_getConfig(); let magic: string | undefined; @@ -45,8 +54,8 @@ export async function loader({ request, context: lC }: LoaderFunctionArgs) { users: users.users, magic, stats, - server: context.headscale.url, - publicServer: context.headscale.public_url, + server: ctx.headscale.url, + publicServer: ctx.headscale.public_url, }; } diff --git a/app/routes/settings/auth-keys.tsx b/app/routes/settings/auth-keys.tsx index 9340c52..b9fabc6 100644 --- a/app/routes/settings/auth-keys.tsx +++ b/app/routes/settings/auth-keys.tsx @@ -7,9 +7,10 @@ import Select from '~/components/Select'; import TableList from '~/components/TableList'; import type { PreAuthKey, User } from '~/types'; import { post, pull } from '~/utils/headscale'; +import { noContext } from '~/utils/log'; import { send } from '~/utils/res'; import { getSession } from '~/utils/sessions.server'; -import { hp_getConfig } from '~/utils/state'; +import type { AppContext } from '~server/context/app'; import AuthKeyRow from './components/key'; import AddPreAuthKey from './dialogs/new'; @@ -90,14 +91,21 @@ export async function action({ request }: ActionFunctionArgs) { } } -export async function loader({ request }: LoaderFunctionArgs) { - const context = hp_getConfig(); +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); const users = await pull<{ users: User[] }>( 'v1/user', session.get('hsApiKey')!, ); + if (!context) { + throw noContext(); + } + + const ctx = context.context; const preAuthKeys = await Promise.all( users.users.map((user) => { const qp = new URLSearchParams(); @@ -113,7 +121,7 @@ export async function loader({ request }: LoaderFunctionArgs) { return { keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys), users: users.users, - server: context.headscale.public_url ?? context.headscale.url, + server: ctx.headscale.public_url ?? ctx.headscale.url, }; } diff --git a/app/routes/settings/local-agent.tsx b/app/routes/settings/local-agent.tsx index 972d0a3..940dac5 100644 --- a/app/routes/settings/local-agent.tsx +++ b/app/routes/settings/local-agent.tsx @@ -1,22 +1,23 @@ import { useMemo } from 'react'; -import { useLoaderData, type LoaderFunctionArgs } from 'react-router'; -import { getSession, commitSession } from '~/utils/sessions.server' -import { queryAgent } from '~/utils/ws-agent' -import AgentManagement from './components/agent/manage' +import { type LoaderFunctionArgs, useLoaderData } from 'react-router'; +import { commitSession, getSession } from '~/utils/sessions.server'; +import { queryAgent } from '~/utils/ws-agent'; +import AgentManagement from './components/agent/manage'; export async function loader({ request, context }: LoaderFunctionArgs) { const { ws, wsAuthKey } = context; const session = await getSession(request.headers.get('Cookie')); const onboarding = session.get('agent_onboarding') ?? false; - const nodeKey = 'nodekey:542dad28354eb8d51e240aada7adf0222ba3ecc74af0bbd56123f03eefdb391b' + const nodeKey = + 'nodekey:542dad28354eb8d51e240aada7adf0222ba3ecc74af0bbd56123f03eefdb391b'; const stats = await queryAgent([nodeKey]); return { configured: wsAuthKey !== undefined, onboarding, - stats: stats[nodeKey] - } + stats: stats?.[nodeKey], + }; } export default function Page() { @@ -24,21 +25,18 @@ export default function Page() { // Whether we show the onboarding or management UI const management = useMemo(() => { - return data.configured && (data.onboarding === false) + return data.configured && data.onboarding === false; }, [data.configured, data.onboarding]); return (
{management ? ( - + ) : ( -
-

Local Agent Coming Soon

-
+
+

Local Agent Coming Soon

+
)}
- ) + ); } diff --git a/app/routes/users/components/manage-banner.tsx b/app/routes/users/components/manage-banner.tsx index aab9f0e..a3fd827 100644 --- a/app/routes/users/components/manage-banner.tsx +++ b/app/routes/users/components/manage-banner.tsx @@ -1,11 +1,11 @@ import { Building2, House, Key } from 'lucide-react'; import Card from '~/components/Card'; import Link from '~/components/Link'; -import { HeadplaneConfig } from '~/utils/state'; +import type { AppContext } from '~server/context/app'; import CreateUser from '../dialogs/create-user'; interface Props { - oidc?: NonNullable; + oidc?: NonNullable; } export default function ManageBanner({ oidc }: Props) { diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index 0bd2f67..5c72dba 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -14,14 +14,22 @@ import cn from '~/utils/cn'; import { pull } from '~/utils/headscale'; import { getSession } from '~/utils/sessions.server'; -import { hp_getConfig, hs_getConfig } from '~/utils/state'; +import { hs_getConfig } from '~/utils/config/loader'; +import { noContext } from '~/utils/log'; +import type { AppContext } from '~server/context/app'; import ManageBanner from './components/manage-banner'; import DeleteUser from './dialogs/delete-user'; import RenameUser from './dialogs/rename-user'; import { userAction } from './user-actions'; -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); + if (!context) { + throw noContext(); + } const [machines, apiUsers] = await Promise.all([ pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!), @@ -33,7 +41,7 @@ export async function loader({ request }: LoaderFunctionArgs) { machines: machines.nodes.filter((machine) => machine.user.id === user.id), })); - const context = hp_getConfig(); + const ctx = context.context; const { mode, config } = hs_getConfig(); let magic: string | undefined; @@ -44,7 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } return { - oidc: context.oidc, + oidc: ctx.oidc, magic, users, }; diff --git a/app/utils/config/loader.ts b/app/utils/config/loader.ts index fb533bd..70c84e0 100644 --- a/app/utils/config/loader.ts +++ b/app/utils/config/loader.ts @@ -1,8 +1,8 @@ import { constants, access, readFile, writeFile } from 'node:fs/promises'; import { Document, parseDocument } from 'yaml'; -import { HeadplaneConfig } from '~/utils/context/parser'; import log from '~/utils/log'; import mutex from '~/utils/mutex'; +import type { HeadplaneConfig } from '~server/context/parser'; import { HeadscaleConfig, validateConfig } from './parser'; let runtimeYaml: Document | undefined = undefined; @@ -13,7 +13,7 @@ let runtimeStrict = true; const runtimeLock = mutex(); -type ConfigModes = +export type ConfigModes = | { mode: 'rw' | 'ro'; config: HeadscaleConfig; diff --git a/app/utils/headscale.ts b/app/utils/headscale.ts index ca9f74f..f7cfb9e 100644 --- a/app/utils/headscale.ts +++ b/app/utils/headscale.ts @@ -1,6 +1,7 @@ -import log from '~/utils/log'; -import { hp_getConfig } from '~/utils/state'; +import log, { noContext } from '~/utils/log'; +import { AppContext } from '~server/context/app'; +type Context = AppContext['context']; export class HeadscaleError extends Error { status: number; @@ -20,8 +21,20 @@ export class FatalError extends Error { } } +let context: Context | undefined = undefined; +export function hp_storeContext(ctx: Context) { + if (context) { + return; + } + + context = ctx; +} + export async function healthcheck() { - const context = hp_getConfig(); + if (!context) { + throw noContext(); + } + const prefix = context.headscale.url; log.debug('APIC', 'GET /health'); @@ -37,11 +50,14 @@ export async function healthcheck() { } export async function pull(url: string, key: string) { + if (!context) { + throw noContext(); + } + if (!key || key === 'undefined' || key.length === 0) { throw new Error('Missing API key, could this be a cookie setting issue?'); } - const context = hp_getConfig(); const prefix = context.headscale.url; log.debug('APIC', 'GET %s', `${prefix}/api/${url}`); @@ -65,11 +81,14 @@ export async function pull(url: string, key: string) { } export async function post(url: string, key: string, body?: unknown) { + if (!context) { + throw noContext(); + } + if (!key || key === 'undefined' || key.length === 0) { throw new Error('Missing API key, could this be a cookie setting issue?'); } - const context = hp_getConfig(); const prefix = context.headscale.url; log.debug('APIC', 'POST %s', `${prefix}/api/${url}`); @@ -95,11 +114,14 @@ export async function post(url: string, key: string, body?: unknown) { } export async function put(url: string, key: string, body?: unknown) { + if (!context) { + throw noContext(); + } + if (!key || key === 'undefined' || key.length === 0) { throw new Error('Missing API key, could this be a cookie setting issue?'); } - const context = hp_getConfig(); const prefix = context.headscale.url; log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`); @@ -125,11 +147,14 @@ export async function put(url: string, key: string, body?: unknown) { } export async function del(url: string, key: string) { + if (!context) { + throw noContext(); + } + if (!key || key === 'undefined' || key.length === 0) { throw new Error('Missing API key, could this be a cookie setting issue?'); } - const context = hp_getConfig(); const prefix = context.headscale.url; log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`); diff --git a/app/utils/log.ts b/app/utils/log.ts index 70b568f..79019f6 100644 --- a/app/utils/log.ts +++ b/app/utils/log.ts @@ -3,16 +3,6 @@ export function hp_loadLogger(debug: boolean) { log.debug = (category: string, message: string, ...args: unknown[]) => { defaultLog('DEBG', category, message, ...args); }; - - log.info('CFGX', 'Debug logging enabled'); - log.info( - 'CFGX', - 'This is very verbose and should only be used for debugging purposes', - ); - log.info( - 'CFGX', - 'If you run this in production, your storage WILL fill up quickly', - ); } } @@ -43,4 +33,10 @@ function defaultLog( console.log(`${date} (${level}) [${category}] ${message}`, ...args); } +export function noContext() { + return new Error( + 'Context is not loaded. This is most likely a configuration error with your reverse proxy.', + ); +} + export default log; diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts index f228aad..7fce546 100644 --- a/app/utils/oidc.ts +++ b/app/utils/oidc.ts @@ -1,13 +1,12 @@ import * as client from 'openid-client'; import log from '~/utils/log'; -import { HeadplaneConfig } from '~/utils/state'; +import type { AppContext } from '~server/context/app'; +type OidcConfig = NonNullable; declare global { const __PREFIX__: string; } -type OidcConfig = NonNullable; - // We try our best to infer the callback URI of our Headplane instance // By default it is always //oidc/callback // (This can ALWAYS be overridden through the OidcConfig) diff --git a/app/utils/state.ts b/app/utils/state.ts deleted file mode 100644 index 2033f36..0000000 --- a/app/utils/state.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { hp_getConfig } from '~/utils/context/loader'; -export { hs_getConfig } from '~/utils/config/loader'; - -export type { HeadplaneConfig } from '~/utils/context/parser'; -export type { HeadscaleConfig } from '~/utils/config/parser'; diff --git a/app/utils/ws-agent.ts b/app/utils/ws-agent.ts index 55ac591..bd40609 100644 --- a/app/utils/ws-agent.ts +++ b/app/utils/ws-agent.ts @@ -2,8 +2,8 @@ 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 type { HostInfo } from '~/types'; import log from './log'; // Essentially a HashMap which invalidates entries after a certain time. @@ -68,7 +68,7 @@ class TimedCache { this.writeLock = true; const data = Array.from(this._cache.entries()).map(([key, value]) => { - return { key, value, expires: this._timeCache.get(key) } + return { key, value, expires: this._timeCache.get(key) }; }); await writeFile(this.filepath, JSON.stringify(data), 'utf-8'); @@ -85,10 +85,11 @@ export async function initAgentCache(defaultTTL: number, filepath: string) { } 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; @@ -97,21 +98,24 @@ export function initAgentSocket(context: LoaderFunctionArgs['context']) { // 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; + return; + // biome-ignore lint: bruh if (!cache) { log.error('CACH', 'Cache not initialized'); return; } const cached: Record = {}; - await Promise.all(nodes.map(async node => { - const cachedData = await cache?.get(node); - if (cachedData) { - cached[node] = cachedData; - } - })) + 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]); + const uncached = nodes.filter((node) => !cached[node]); // No need to query the agent if we have all the data cached if (uncached.length === 0) { @@ -124,29 +128,32 @@ export async function queryAgent(nodes: string[]) { return cached; } - agentSocket.send(JSON.stringify({ NodeIDs: uncached })); - const returnData = await new Promise | 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) { + agentSocket?.send(JSON.stringify({ NodeIDs: uncached })); + // biome-ignore lint: bruh + const returnData = await new Promise | void>( + (resolve, reject) => { + const timeout = setTimeout(() => { + agentSocket?.removeAllListeners('message'); resolve(); - } + }, 3000); - agentSocket?.removeAllListeners('message'); - resolve(data); - }); - }); - - if (returnData) { - for await (const [node, info] of Object.entries(returnData)) { - await cache.set(node, info); - } - } + 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; } diff --git a/server/context/app.ts b/server/context/app.ts new file mode 100644 index 0000000..c06e79b --- /dev/null +++ b/server/context/app.ts @@ -0,0 +1,12 @@ +import { hp_getConfig } from './loader'; +import { HeadplaneConfig } from './parser'; + +export interface AppContext { + context: HeadplaneConfig; +} + +export default function appContext() { + return { + context: hp_getConfig(), + }; +} diff --git a/app/utils/context/loader.ts b/server/context/loader.ts similarity index 93% rename from app/utils/context/loader.ts rename to server/context/loader.ts index 5e2e869..f1e0426 100644 --- a/app/utils/context/loader.ts +++ b/server/context/loader.ts @@ -1,11 +1,9 @@ import { constants, access, readFile } from 'node:fs/promises'; import { type } from 'arktype'; import { parseDocument } from 'yaml'; -import { hs_loadConfig } from '~/utils/config/loader'; -import log, { hp_loadLogger } from '~/utils/log'; -import mutex from '~/utils/mutex'; import { testOidc } from '~/utils/oidc'; -import { initSessionManager } from '~/utils/sessions.server'; +import log, { hpServer_loadLogger } from '~server/utils/log'; +import mutex from '~server/utils/mutex'; import { HeadplaneConfig, coalesceConfig, validateConfig } from './parser'; const envBool = type('string | undefined').pipe((v) => { @@ -67,7 +65,7 @@ export async function hp_loadConfig() { } // Load our debug based logger before ANYTHING - hp_loadLogger(envs.HEADPLANE_DEBUG_LOG); + hpServer_loadLogger(envs.HEADPLANE_DEBUG_LOG); if (envs.HEADPLANE_CONFIG_PATH) { path = envs.HEADPLANE_CONFIG_PATH; @@ -104,17 +102,11 @@ export async function hp_loadConfig() { process.exit(1); } - if (config.headscale.config_path) { - await hs_loadConfig(config); - } - if (config.oidc?.strict_validation) { testOidc(config.oidc); } runtimeConfig = config; - - initSessionManager(config.server.cookie_secret, config.server.cookie_secure); runtimeLock.release(); } diff --git a/app/utils/context/parser.ts b/server/context/parser.ts similarity index 95% rename from app/utils/context/parser.ts rename to server/context/parser.ts index 211b712..d238853 100644 --- a/app/utils/context/parser.ts +++ b/server/context/parser.ts @@ -1,9 +1,7 @@ import { type } from 'arktype'; -import log from '~/utils/log'; +import log from '~server/utils/log'; -// TODO: ALLOW HEADSCALE CONFIG TO OVERRIDE HEADPLANE CONFIG MAYBE FOR OIDC? export type HeadplaneConfig = typeof headplaneConfig.infer; - const stringToBool = type('string | boolean').pipe((v) => Boolean(v)); const serverConfig = type({ host: 'string.ip', diff --git a/server/dev-handler.ts b/server/dev/dev-handler.ts similarity index 66% rename from server/dev-handler.ts rename to server/dev/dev-handler.ts index 11be281..3a689a9 100644 --- a/server/dev-handler.ts +++ b/server/dev/dev-handler.ts @@ -1,7 +1,7 @@ -import { createRequestHandler } from 'react-router' +import { createRequestHandler } from 'react-router'; export default createRequestHandler( // @ts-expect-error: React Router Vite plugin () => import('virtual:react-router/server-build'), - 'development' + 'development', ); diff --git a/server/dev.ts b/server/dev/hot-server.ts similarity index 71% rename from server/dev.ts rename to server/dev/hot-server.ts index 49b7e35..fd72668 100644 --- a/server/dev.ts +++ b/server/dev/hot-server.ts @@ -1,23 +1,23 @@ -import log from '~server/log'; -import { createServer, type ViteDevServer } from 'vite'; -import { type createRequestHandler } from 'react-router'; +import { type ViteDevServer, createServer } from 'vite'; +import log from '~server/utils/log'; +// TODO: Remove env.NODE_ENV let server: ViteDevServer | undefined; export async function loadDevtools() { - log.info('DEVX', 'Starting Vite Development server') + log.info('DEVX', 'Starting Vite Development server'); process.env.NODE_ENV = 'development'; // This is loading the ROOT vite.config.ts server = await createServer({ server: { middlewareMode: true, - } + }, }); // We can't just do ssrLoadModule for virtual:react-router/server-build // because for hot reload to work server side it needs to be imported // using builtin import in its own file. - const handler = await server.ssrLoadModule('./server/dev-handler.ts'); + const handler = await server.ssrLoadModule('./server/dev/dev-handler.ts'); return { server, handler: handler.default, @@ -26,11 +26,11 @@ export async function loadDevtools() { export async function stacksafeTry( devtools: { - server: ViteDevServer, - handler: any, // import() is dynamic + server: ViteDevServer; + handler: (req: Request, context: unknown) => Promise; }, req: Request, - context: unknown + context: unknown, ) { try { const result = await devtools.handler(req, context); @@ -38,7 +38,6 @@ export async function stacksafeTry( } catch (error) { log.error('DEVX', 'Error in request handler', error); if (typeof error === 'object' && error instanceof Error) { - console.log('got error'); devtools.server.ssrFixStacktrace(error); } diff --git a/server/entry.ts b/server/entry.ts index 909a284..baac614 100644 --- a/server/entry.ts +++ b/server/entry.ts @@ -1,8 +1,9 @@ +// import { initWebsocket } from '~server/ws'; +import { constants, access } from 'node:fs/promises'; import { createServer } from 'node:http'; +import { hp_getConfig, hp_loadConfig } from '~server/context/loader'; import { listener } from '~server/listener'; -import { initWebsocket } from '~server/ws'; -import { access, constants } from 'node:fs/promises'; -import log from '~server/log'; +import log from '~server/utils/log'; log.info('SRVX', 'Running Node.js %s', process.versions.node); @@ -15,21 +16,26 @@ try { process.exit(1); } +await hp_loadConfig(); const server = createServer(listener); -const port = process.env.PORT || 3000; -const host = process.env.HOST || '0.0.0.0'; -const ws = initWebsocket(); -if (ws) { - server.on('upgrade', (req, socket, head) => { - ws.handleUpgrade(req, socket, head, (ws) => { - ws.emit('connection', ws, req); - }); - }); -} +// const ws = initWebsocket(); +// if (ws) { +// server.on('upgrade', (req, socket, head) => { +// ws.handleUpgrade(req, socket, head, (ws) => { +// ws.emit('connection', ws, req); +// }); +// }); +// } -server.listen(Number(port), host, () => { - log.info('SRVX', 'Running on %s:%s', host, port); +const context = hp_getConfig(); +server.listen(context.server.port, context.server.host, () => { + log.info( + 'SRVX', + 'Running on %s:%s', + context.server.host, + context.server.port, + ); }); if (import.meta.hot) { @@ -41,5 +47,3 @@ if (import.meta.hot) { server.close(); }); } - -// export const app = listener; diff --git a/server/listener.ts b/server/listener.ts index ae5fe1a..f047673 100644 --- a/server/listener.ts +++ b/server/listener.ts @@ -1,17 +1,13 @@ -import { type RequestListener } from 'node:http'; -import { resolve, join } from 'node:path' -import { createServer } from 'vite' -import { createRequestHandler } from 'react-router' -import { access, constants } from 'node:fs/promises'; import { createReadStream, existsSync, statSync } from 'node:fs'; +import { type RequestListener } from 'node:http'; +import { join, resolve } from 'node:path'; import { createReadableStreamFromReadable, writeReadableStreamToWritable, } from '@react-router/node'; -import mime from 'mime/lite' - -import { loadDevtools, stacksafeTry } from '~server/dev'; -import { appContext } from '~server/ws'; +import mime from 'mime/lite'; +import appContext from '~server/context/app'; +import { loadDevtools, stacksafeTry } from '~server/dev/hot-server'; import prodBuild from '~server/prod-handler'; declare global { @@ -19,13 +15,9 @@ declare global { const __hp_prefix: string; } -const devtools = import.meta.env.DEV - ? await loadDevtools() - : undefined; +const devtools = import.meta.env.DEV ? await loadDevtools() : undefined; -const prodHandler = import.meta.env.PROD - ? await prodBuild() - : undefined; +const prodHandler = import.meta.env.PROD ? await prodBuild() : undefined; const buildPath = process.env.BUILD_PATH ?? './build'; const baseDir = resolve(join(buildPath, 'client')); @@ -113,15 +105,22 @@ export const listener: RequestListener = async (req, res) => { // If we have a body, we set the duplex and load it ...(req.method !== 'GET' && req.method !== 'HEAD' ? { - body: createReadableStreamFromReadable(req), - duplex: 'half', - } : {}), + body: createReadableStreamFromReadable(req), + duplex: 'half', + } + : {}), }); const response = devtools ? await stacksafeTry(devtools, frameworkReq, appContext()) : await prodHandler?.(frameworkReq, appContext()); + if (!response) { + res.writeHead(404); + res.end(); + return; + } + res.statusCode = response.status; res.statusMessage = response.statusText; @@ -134,4 +133,4 @@ export const listener: RequestListener = async (req, res) => { } res.end(); -} +}; diff --git a/server/prod-handler.ts b/server/prod-handler.ts index 27a2eaf..5be71f9 100644 --- a/server/prod-handler.ts +++ b/server/prod-handler.ts @@ -1,9 +1,9 @@ -import { createRequestHandler } from 'react-router' -import { access, constants } from 'node:fs/promises'; +import { constants, access } from 'node:fs/promises'; import { join, resolve } from 'node:path'; -import log from '~server/log'; +import { createRequestHandler } from 'react-router'; +import log from '~server/utils/log'; -export default async function() { +export default async function () { const buildPath = process.env.BUILD_PATH ?? './build'; const server = resolve(join(buildPath, 'server')); @@ -12,7 +12,10 @@ export default async function() { log.info('SRVX', 'Using build directory %s', resolve(buildPath)); } catch (error) { log.error('SRVX', 'No build found. Please refer to the documentation'); - log.error('SRVX', 'https://github.com/tale/headplane/blob/main/docs/integration/Native.md'); + log.error( + 'SRVX', + 'https://github.com/tale/headplane/blob/main/docs/integration/Native.md', + ); console.error(error); process.exit(1); } diff --git a/server/log.ts b/server/utils/log.ts similarity index 61% rename from server/log.ts rename to server/utils/log.ts index 91ff7dc..7aae0b2 100644 --- a/server/log.ts +++ b/server/utils/log.ts @@ -1,4 +1,22 @@ -export default { +export function hpServer_loadLogger(debug: boolean) { + if (debug) { + log.debug = (category: string, message: string, ...args: unknown[]) => { + defaultLog('DEBG', category, message, ...args); + }; + + log.info('CFGX', 'Debug logging enabled'); + log.info( + 'CFGX', + 'This is very verbose and should only be used for debugging purposes', + ); + log.info( + 'CFGX', + 'If you run this in production, your storage COULD fill up quickly', + ); + } +} + +const log = { info: (category: string, message: string, ...args: unknown[]) => { defaultLog('INFO', category, message, ...args); }, @@ -11,11 +29,7 @@ export default { defaultLog('ERRO', category, message, ...args); }, - debug: (category: string, message: string, ...args: unknown[]) => { - if (process.env.DEBUG === 'true') { - defaultLog('DEBG', category, message, ...args); - } - }, + debug: (category: string, message: string, ...args: unknown[]) => {}, }; function defaultLog( @@ -27,3 +41,5 @@ function defaultLog( const date = new Date().toISOString(); console.log(`${date} (${level}) [${category}] ${message}`, ...args); } + +export default log; diff --git a/server/utils/mutex.ts b/server/utils/mutex.ts new file mode 100644 index 0000000..e8b3ea0 --- /dev/null +++ b/server/utils/mutex.ts @@ -0,0 +1,32 @@ +class Mutex { + private locked = false; + private queue: (() => void)[] = []; + + constructor(locked: boolean) { + this.locked = locked; + } + + acquire() { + return new Promise((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); +} diff --git a/server/ws.ts b/server/utils/ws.ts similarity index 89% rename from server/ws.ts rename to server/utils/ws.ts index 7f6bc3d..b01f71c 100644 --- a/server/ws.ts +++ b/server/utils/ws.ts @@ -1,5 +1,5 @@ -import WebSocket, { WebSocketServer } from 'ws' -import log from '~server/log' +import WebSocket, { WebSocketServer } from 'ws'; +import log from '~server/utils/log'; const server = new WebSocketServer({ noServer: true }); export function initWebsocket() { @@ -13,6 +13,7 @@ export function initWebsocket() { log.info('CACH', 'Initializing agent WebSocket'); server.on('connection', (ws, req) => { + // biome-ignore lint: this file is not USED const auth = req.headers['authorization']; if (auth !== `Bearer ${key}`) { log.warn('CACH', 'Invalid agent WebSocket connection'); @@ -20,7 +21,6 @@ export function initWebsocket() { return; } - const nodeID = req.headers['x-headplane-ts-node-id']; if (!nodeID) { log.warn('CACH', 'Invalid agent WebSocket connection'); @@ -46,7 +46,7 @@ export function initWebsocket() { log.error('CACH', 'Closing agent WebSocket connection'); log.error('CACH', 'Agent WebSocket error: %s', error); ws.close(1011, 'ERR_INTERNAL_ERROR'); - }) + }); }); return server; @@ -56,5 +56,5 @@ export function appContext() { return { ws: server, wsAuthKey: process.env.LOCAL_AGENT_AUTHKEY, - } + }; }