diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 44525ff..6cf8ab2 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -11,7 +11,7 @@ import { Form } from 'react-router'; import { cn } from '~/utils/cn'; import type { HeadplaneContext } from '~/utils/config/headplane'; -import type { SessionData } from '~/utils/sessions'; +import type { SessionData } from '~/utils/sessions.server'; import Menu from './Menu'; import TabLink from './TabLink'; diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index e0c29cf..f6546b4 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -9,7 +9,7 @@ import Link from '~/components/Link'; import { cn } from '~/utils/cn'; import { loadContext } from '~/utils/config/headplane'; import { HeadscaleError, pull } from '~/utils/headscale'; -import { destroySession, getSession } from '~/utils/sessions'; +import { destroySession, getSession } from '~/utils/sessions.server'; export async function loader({ request }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); diff --git a/app/routes/acls/editor.tsx b/app/routes/acls/editor.tsx index fb35763..ccd7358 100644 --- a/app/routes/acls/editor.tsx +++ b/app/routes/acls/editor.tsx @@ -21,7 +21,7 @@ import { cn } from '~/utils/cn'; import { loadContext } from '~/utils/config/headplane'; import { loadConfig } from '~/utils/config/headscale'; import { HeadscaleError, pull, put } from '~/utils/headscale'; -import { getSession } from '~/utils/sessions'; +import { getSession } from '~/utils/sessions.server'; import { send } from '~/utils/res'; import log from '~/utils/log'; diff --git a/app/routes/auth/login.tsx b/app/routes/auth/login.tsx index 5cec9e0..6b4d17a 100644 --- a/app/routes/auth/login.tsx +++ b/app/routes/auth/login.tsx @@ -10,7 +10,7 @@ import type { Key } from '~/types'; import { loadContext } from '~/utils/config/headplane'; import { pull } from '~/utils/headscale'; import { startOidc } from '~/utils/oidc'; -import { commitSession, getSession } from '~/utils/sessions'; +import { commitSession, getSession } from '~/utils/sessions.server'; export async function loader({ request }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')); diff --git a/app/routes/auth/logout.ts b/app/routes/auth/logout.ts index 68403a6..595e8cf 100644 --- a/app/routes/auth/logout.ts +++ b/app/routes/auth/logout.ts @@ -1,5 +1,5 @@ import { type ActionFunctionArgs, redirect } from 'react-router'; -import { destroySession, getSession } from '~/utils/sessions'; +import { destroySession, getSession } from '~/utils/sessions.server'; export async function loader() { return redirect('/machines'); diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index b2df033..1523cea 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -1,11 +1,11 @@ import type { ActionFunctionArgs } from 'react-router'; -import { json, useLoaderData } from 'react-router'; +import { data, useLoaderData } from 'react-router'; import Code from '~/components/Code'; import Notice from '~/components/Notice'; import { loadContext } from '~/utils/config/headplane'; import { loadConfig, patchConfig } from '~/utils/config/headscale'; -import { getSession } from '~/utils/sessions'; +import { getSession } from '~/utils/sessions.server'; import { useLiveData } from '~/utils/useLiveData'; import DNS from './components/dns'; diff --git a/app/routes/machines/action.tsx b/app/routes/machines/action.tsx index 6472eba..dea6745 100644 --- a/app/routes/machines/action.tsx +++ b/app/routes/machines/action.tsx @@ -1,6 +1,6 @@ import type { ActionFunctionArgs } from 'react-router'; import { del, post } from '~/utils/headscale'; -import { getSession } from '~/utils/sessions'; +import { getSession } from '~/utils/sessions.server'; import { send } from '~/utils/res'; import log from '~/utils/log'; diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index ce0cba1..d25d046 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -20,7 +20,7 @@ import { cn } from '~/utils/cn'; import { loadContext } from '~/utils/config/headplane'; import { loadConfig } from '~/utils/config/headscale'; import { pull } from '~/utils/headscale'; -import { getSession } from '~/utils/sessions'; +import { getSession } from '~/utils/sessions.server'; import { useLiveData } from '~/utils/useLiveData'; import Link from '~/components/Link'; diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index c4d6785..cf922e5 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -9,7 +9,7 @@ import { cn } from '~/utils/cn'; import { loadContext } from '~/utils/config/headplane'; import { loadConfig } from '~/utils/config/headscale'; import { pull } from '~/utils/headscale'; -import { getSession } from '~/utils/sessions'; +import { getSession } from '~/utils/sessions.server'; import { useLiveData } from '~/utils/useLiveData'; import type { Machine, Route, User } from '~/types'; diff --git a/app/routes/settings/auth-keys.tsx b/app/routes/settings/auth-keys.tsx index 5363cd1..d143052 100644 --- a/app/routes/settings/auth-keys.tsx +++ b/app/routes/settings/auth-keys.tsx @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs, ActionFunctionArgs } from 'react-router'; import { useLoaderData } from 'react-router'; import { useLiveData } from '~/utils/useLiveData'; -import { getSession } from '~/utils/sessions'; +import { getSession } from '~/utils/sessions.server'; import { Link as RemixLink } from 'react-router'; import type { PreAuthKey, User } from '~/types'; import { pull, post } from '~/utils/headscale'; diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index dd2cee4..3cf8e24 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -14,7 +14,7 @@ import { cn } from '~/utils/cn'; import { loadContext } from '~/utils/config/headplane'; import { loadConfig } from '~/utils/config/headscale'; import { del, post, pull } from '~/utils/headscale'; -import { getSession } from '~/utils/sessions'; +import { getSession } from '~/utils/sessions.server'; import { useLiveData } from '~/utils/useLiveData'; import { send } from '~/utils/res'; diff --git a/app/utils/config/headplane.ts b/app/utils/config/headplane.ts index 3d2a0fc..9779b35 100644 --- a/app/utils/config/headplane.ts +++ b/app/utils/config/headplane.ts @@ -8,10 +8,11 @@ import { resolve } from 'node:path'; import { parse } from 'yaml'; -import { type IntegrationFactory, loadIntegration } from '~/integration'; -import { type HeadscaleConfig, loadConfig } from '~/utils/config/headscale'; +import { IntegrationFactory, loadIntegration } from '~/integration'; +import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'; import { testOidc } from '~/utils/oidc'; import log from '~/utils/log'; +import { initSessionManager } from '~/utils/sessions.server'; export interface HeadplaneContext { debug: boolean; @@ -36,12 +37,25 @@ export interface HeadplaneContext { } let context: HeadplaneContext | undefined; +let loadLock = false; export async function loadContext(): Promise { if (context) { return context; } + if (loadLock) { + return new Promise((resolve) => { + const interval = setInterval(() => { + if (context) { + clearInterval(interval); + resolve(context); + } + }, 100); + }); + } + + loadLock = true; const envFile = process.env.LOAD_ENV_FILE === 'true'; if (envFile) { log.info('CTXT', 'Loading environment variables from .env'); @@ -68,7 +82,7 @@ export async function loadContext(): Promise { headscaleUrl = headscaleUrl ?? config.server_url; if (!headscalePublicUrl) { // Fallback to the config value if the env var is not set - headscalePublicUrl = config.public_url; + headscalePublicUrl = config.server_url; } } @@ -81,6 +95,9 @@ export async function loadContext(): Promise { throw new Error('COOKIE_SECRET not set'); } + // Initialize Session Management + initSessionManager(); + context = { debug, headscaleUrl, @@ -107,6 +124,7 @@ export async function loadContext(): Promise { ); log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable'); + loadLock = false; return context; } @@ -235,7 +253,7 @@ async function checkOidc(config?: HeadscaleConfig) { return; } - if (config.oidc.only_start_if_oidc_is_available) { + if (config?.oidc?.only_start_if_oidc_is_available) { log.debug('CTXT', 'Validating OIDC configuration from headscale config'); const result = await testOidc(issuer, client, secret); if (!result) { diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts index 081415b..f90f08e 100644 --- a/app/utils/oidc.ts +++ b/app/utils/oidc.ts @@ -16,7 +16,7 @@ import { } from 'oauth4webapi'; import { post } from '~/utils/headscale'; -import { commitSession, getSession } from '~/utils/sessions'; +import { commitSession, getSession } from '~/utils/sessions.server'; import log from '~/utils/log'; import type { HeadplaneContext } from './config/headplane'; diff --git a/app/utils/sessions.server.ts b/app/utils/sessions.server.ts new file mode 100644 index 0000000..3c5e8b8 --- /dev/null +++ b/app/utils/sessions.server.ts @@ -0,0 +1,62 @@ +import { Session, SessionStorage, createCookieSessionStorage } from 'react-router'; + +export type SessionData = { + hsApiKey: string; + authState: string; + authNonce: string; + authVerifier: string; + user: { + name: string; + email?: string; + }; +}; + +type SessionFlashData = { + error: string; +}; + +type SessionStore = SessionStorage; + +// TODO: Add args to this function to allow custom domain/config +let sessionStorage: SessionStore | null = null; +export function initSessionManager() { + if (sessionStorage) { + throw new Error('Session manager already initialized'); + } + + sessionStorage = createCookieSessionStorage({ + cookie: { + name: 'hp_sess', + httpOnly: true, + maxAge: 60 * 60 * 24, // 24 hours + path: '/', + sameSite: 'lax', + secrets: [process.env.COOKIE_SECRET!], + secure: process.env.COOKIE_SECURE !== 'false', + }, + }); +} + +export function getSession(cookie: string | null) { + if (!sessionStorage) { + throw new Error('Session manager not initialized'); + } + + return sessionStorage.getSession(cookie); +} + +export function destroySession(session: Session) { + if (!sessionStorage) { + throw new Error('Session manager not initialized'); + } + + return sessionStorage.destroySession(session); +} + +export function commitSession(session: Session, opts?: { maxAge?: number }) { + if (!sessionStorage) { + throw new Error('Session manager not initialized'); + } + + return sessionStorage.commitSession(session, opts); +} diff --git a/app/utils/sessions.ts b/app/utils/sessions.ts deleted file mode 100644 index 328ce0e..0000000 --- a/app/utils/sessions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createCookieSessionStorage } from 'react-router'; // Or cloudflare/deno - -export type SessionData = { - hsApiKey: string; - authState: string; - authNonce: string; - authVerifier: string; - user: { - name: string; - email?: string; - }; -}; - -type SessionFlashData = { - error: string; -}; - -export const { getSession, commitSession, destroySession } = - createCookieSessionStorage({ - cookie: { - name: 'hp_sess', - httpOnly: true, - maxAge: 60 * 60 * 24, // 24 hours - path: '/', - sameSite: 'lax', - secrets: [process.env.COOKIE_SECRET!], - secure: process.env.COOKIE_SECURE !== 'false', - }, - });