diff --git a/.npmrc b/.npmrc index 72c3656..17f722e 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,2 @@ side-effects-cache = false -shamefully-hoist = true +public-hoist-pattern[]=hono diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx index 2b516ce..5894e9f 100644 --- a/app/components/Footer.tsx +++ b/app/components/Footer.tsx @@ -1,10 +1,6 @@ import Link from '~/components/Link'; import cn from '~/utils/cn'; -declare global { - const __VERSION__: string; -} - interface FooterProps { url: string; debug: boolean; diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 01a2f2c..c9b561a 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -10,12 +10,12 @@ import { import type { ReactNode } from 'react'; import { NavLink, useSubmit } from 'react-router'; import Menu from '~/components/Menu'; +import { AuthSession } from '~/server/web/sessions'; import cn from '~/utils/cn'; -import type { SessionData } from '~/utils/sessions.server'; interface Props { configAvailable: boolean; - user?: SessionData['user']; + user?: AuthSession['user']; } interface LinkProps { diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index 174332c..6aa97e6 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -1,34 +1,29 @@ import { XCircleFillIcon } from '@primer/octicons-react'; import { type LoaderFunctionArgs, redirect } from 'react-router'; import { Outlet, useLoaderData } from 'react-router'; +import type { LoadContext } from '~/server'; +import { ResponseError } from '~/server/headscale/api-client'; import cn from '~/utils/cn'; -import { HeadscaleError, healthcheck, pull } from '~/utils/headscale'; -import { destroySession, getSession } from '~/utils/sessions.server'; +import log from '~/utils/log'; import { useLiveData } from '~/utils/useLiveData'; -import log from '~server/utils/log'; -export async function loader({ request }: LoaderFunctionArgs) { - let healthy = false; - try { - healthy = await healthcheck(); - } catch (error) { - log.debug('APIC', 'Healthcheck failed %o', error); - } +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + const healthy = await context.client.healthcheck(); + const session = await context.sessions.auth(request); // We shouldn't session invalidate if Headscale is down if (healthy) { - // We can assert because shell ensures this is set - const session = await getSession(request.headers.get('Cookie')); - const apiKey = session.get('hsApiKey')!; - try { - await pull('v1/apikey', apiKey); + await context.client.get('/api/v1/apikey', session.get('api_key')!); } catch (error) { - if (error instanceof HeadscaleError) { - log.debug('APIC', 'API Key validation failed %o', error); + if (error instanceof ResponseError) { + log.debug('api', 'API Key validation failed %o', error); return redirect('/login', { headers: { - 'Set-Cookie': await destroySession(session), + 'Set-Cookie': await context.sessions.destroy(session), }, }); } diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index 493f26d..996ea0e 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -6,29 +6,36 @@ import { } from 'react-router'; import Footer from '~/components/Footer'; import Header from '~/components/Header'; -import { hs_getConfig } from '~/utils/config/loader'; -import { getSession } from '~/utils/sessions.server'; -import type { AppContext } from '~server/context/app'; -import { hp_getConfig } from '~server/context/global'; +import type { LoadContext } from '~/server'; // 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) { - const session = await getSession(request.headers.get('Cookie')); - if (!session.has('hsApiKey')) { +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + try { + const session = await context.sessions.auth(request); + if (!session.has('api_key')) { + // There is a session, but it's not valid + return redirect('/login', { + headers: { + 'Set-Cookie': await context.sessions.destroy(session), + }, + }); + } + + return { + config: context.hs.c, + url: context.config.headscale.public_url ?? context.config.headscale.url, + configAvailable: context.hs.readable(), + debug: context.config.debug, + user: session.get('user'), + }; + } catch { + // No session, so we can just return return redirect('/login'); } - - const context = hp_getConfig(); - const { mode, config } = hs_getConfig(); - - return { - config, - url: context.headscale.public_url ?? context.headscale.url, - configAvailable: mode !== 'no', - debug: context.debug, - user: session.get('user'), - }; } export default function Shell() { diff --git a/app/routes/acls/editor.tsx b/app/routes/acls/editor.tsx index afec805..cee760e 100644 --- a/app/routes/acls/editor.tsx +++ b/app/routes/acls/editor.tsx @@ -1,25 +1,31 @@ import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; -import { useFetcher, useLoaderData, useRevalidator } from 'react-router'; +import { + redirect, + useFetcher, + useLoaderData, + useRevalidator, +} from 'react-router'; import Button from '~/components/Button'; 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 type { LoadContext } from '~/server'; +import { ResponseError } from '~/server/headscale/api-client'; +import log from '~/utils/log'; import { send } from '~/utils/res'; -import { getSession } from '~/utils/sessions.server'; import toast from '~/utils/toast'; -import type { AppContext } from '~server/context/app'; -import log from '~server/utils/log'; import { Differ, Editor } from './components/cm.client'; import { ErrorView } from './components/error'; import { Unavailable } from './components/unavailable'; -export async function loader({ request }: LoaderFunctionArgs) { - const session = await getSession(request.headers.get('Cookie')); +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { + const session = await context.sessions.auth(request); // The way policy is handled in 0.23 of Headscale and later is verbose. // The 2 ACL policy modes are either the database one or file one @@ -45,31 +51,30 @@ export async function loader({ request }: LoaderFunctionArgs) { // We can do damage control by checking for write access and if we are not // able to PUT an ACL policy on the v1/policy route, we can already know // that the policy is at the very-least readonly or not available. - const { mode, config } = hs_getConfig(); let modeGuess = 'database'; // Assume database mode - if (mode !== 'no') { - modeGuess = config.policy?.mode ?? 'database'; + if (!context.hs.readable()) { + modeGuess = context.hs.c!.policy?.mode ?? 'database'; } // Attempt to load the policy, for both the frontend and for checking // if we are able to write to the policy for write access try { - const { policy } = await pull<{ policy: string }>( + const { policy } = await context.client.get<{ policy: string }>( 'v1/policy', - session.get('hsApiKey')!, + session.get('api_key')!, ); let write = false; // On file mode we already know it's readonly if (modeGuess === 'database' && policy.length > 0) { try { - await put('v1/policy', session.get('hsApiKey')!, { + await context.client.put('v1/policy', session.get('api_key')!, { policy: policy, }); write = true; } catch (error) { write = false; - log.debug('APIC', 'Failed to write to ACL policy with error %s', error); + log.debug('api', 'Failed to write to ACL policy with error %s', error); } } @@ -102,17 +107,17 @@ export async function loader({ request }: LoaderFunctionArgs) { } } -export async function action({ request }: ActionFunctionArgs) { - const session = await getSession(request.headers.get('Cookie')); - if (!session.has('hsApiKey')) { - return send({ success: false, error: null }, 401); - } +export async function action({ + request, + context, +}: ActionFunctionArgs) { + const session = await context.sessions.auth(request); try { const { acl } = (await request.json()) as { acl: string }; - const { policy } = await put<{ policy: string }>( + const { policy } = await context.client.put<{ policy: string }>( 'v1/policy', - session.get('hsApiKey')!, + session.get('api_key')!, { policy: acl, }, @@ -120,14 +125,14 @@ export async function action({ request }: ActionFunctionArgs) { return { success: true, policy, error: null }; } catch (error) { - log.debug('APIC', 'Failed to update ACL policy with error %s', error); + log.debug('api', 'Failed to update ACL policy with error %s', error); // @ts-ignore: TODO: Shut UP we know it's a string most of the time const text = JSON.parse(error.message); return send( { success: false, error: text.message }, { - status: error instanceof HeadscaleError ? error.status : 500, + status: error instanceof ResponseError ? error.status : 500, }, ); } diff --git a/app/routes/auth/login.tsx b/app/routes/auth/login.tsx index 130195a..6862da0 100644 --- a/app/routes/auth/login.tsx +++ b/app/routes/auth/login.tsx @@ -8,50 +8,42 @@ import Button from '~/components/Button'; import Card from '~/components/Card'; import Code from '~/components/Code'; import Input from '~/components/Input'; +import type { LoadContext } from '~/server'; import type { Key } from '~/types'; -import { pull } from '~/utils/headscale'; -import { commitSession, getSession } from '~/utils/sessions.server'; -import { hp_getConfig, hp_getSingleton } from '~server/context/global'; - -export async function loader({ request }: LoaderFunctionArgs) { - const session = await getSession(request.headers.get('Cookie')); - if (session.has('hsApiKey')) { - return redirect('/machines', { - headers: { - 'Set-Cookie': await commitSession(session), - }, - }); - } - - const context = hp_getConfig(); - const disableApiKeyLogin = context.oidc?.disable_api_key_login; - let oidc = false; +export async function loader({ + request, + context, +}: LoaderFunctionArgs) { try { - // Only set if OIDC is properly enabled anyways - hp_getSingleton('oidc_client'); - oidc = true; - - if (disableApiKeyLogin) { - return redirect('/oidc/start'); + const session = await context.sessions.auth(request); + if (session.has('api_key')) { + return redirect('/machines'); } } catch {} + const disableApiKeyLogin = context.config.oidc?.disable_api_key_login; + if (context.oidc && disableApiKeyLogin) { + return redirect('/oidc/start'); + } + return { - oidc, - apiKey: !disableApiKeyLogin, + oidc: context.oidc, + disableApiKeyLogin, }; } -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')); + const session = await context.sessions.getOrCreate(request); if (oidcStart) { - const context = hp_getConfig(); if (!context.oidc) { - throw new Error('An invalid OIDC configuration was provided'); + throw new Error('OIDC is not enabled'); } return redirect('/oidc/start'); @@ -61,17 +53,24 @@ export async function action({ request }: ActionFunctionArgs) { // Test the API key try { - const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey); + const apiKeys = await context.client.get<{ apiKeys: Key[] }>( + 'v1/apikey', + apiKey, + ); + const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix)); if (!key) { - throw new Error('Invalid API key'); + return { + error: 'Invalid API key', + }; } const expiry = new Date(key.expiration); const expiresIn = expiry.getTime() - Date.now(); const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24); - session.set('hsApiKey', apiKey); + session.set('state', 'auth'); + session.set('api_key', apiKey); session.set('user', { subject: 'unknown-non-oauth', name: key.prefix, @@ -80,7 +79,7 @@ export async function action({ request }: ActionFunctionArgs) { return redirect('/machines', { headers: { - 'Set-Cookie': await commitSession(session, { + 'Set-Cookie': await context.sessions.commit(session, { maxAge: expiresIn, }), }, @@ -100,7 +99,7 @@ export default function Page() {
Welcome to Headplane - {data.apiKey ? ( + {!data.disableApiKeyLogin ? (
Enter an API key to authenticate with Headplane. You can generate @@ -125,9 +124,9 @@ export default function Page() { ) : undefined} - {data.oidc === true ? ( + {data.oidc ? (
- {!data.apiKey ? ( + {data.disableApiKeyLogin ? ( Sign in with your authentication provider to continue. Your administrator has disabled API key login. @@ -137,7 +136,7 @@ export default function Page() {