feat: reach an initial working stage

This commit is contained in:
Aarnav Tale 2025-03-22 01:36:27 -04:00
parent 8db323b63f
commit 34cfee7cff
34 changed files with 1078 additions and 684 deletions

2
.npmrc
View File

@ -1,2 +1,2 @@
side-effects-cache = false
shamefully-hoist = true
public-hoist-pattern[]=hono

View File

@ -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;

View File

@ -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 {

View File

@ -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<LoadContext>) {
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),
},
});
}

View File

@ -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')) {
return redirect('/login');
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
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),
},
});
}
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,
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');
}
}
export default function Shell() {

View File

@ -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<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
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<AppContext>) {
// 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<AppContext>) {
}
}
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<LoadContext>) {
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,
},
);
}

View File

@ -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<LoadContext>) {
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<LoadContext>) {
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() {
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{data.apiKey ? (
{!data.disableApiKeyLogin ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
@ -125,9 +124,9 @@ export default function Page() {
</Button>
</Form>
) : undefined}
{data.oidc === true ? (
{data.oidc ? (
<Form method="POST">
{!data.apiKey ? (
{data.disableApiKeyLogin ? (
<Card.Text className="mb-6">
Sign in with your authentication provider to continue. Your
administrator has disabled API key login.
@ -137,7 +136,7 @@ export default function Page() {
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={data.apiKey ? 'light' : 'heavy'}
variant={data.disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On

View File

@ -1,15 +1,22 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import { destroySession, getSession } from '~/utils/sessions.server';
import type { LoadContext } from '~/server';
export async function loader() {
return redirect('/machines');
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
}
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
'Set-Cookie': await context.sessions.destroy(session),
},
});
}

View File

@ -1,68 +1,61 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
import { commitSession, getSession } from '~/utils/sessions.server';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
export async function loader({ request }: LoaderFunctionArgs) {
const { oidc } = hp_getConfig();
try {
if (!oidc) {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
hp_getSingleton('oidc_client');
} catch {
return send({ error: 'OIDC is not enabled' }, { status: 400 });
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/machines');
return redirect('/login');
}
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines');
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
}
const codeVerifier = session.get('oidc_code_verif');
const state = session.get('oidc_state');
const nonce = session.get('oidc_nonce');
const redirectUri = session.get('oidc_redirect_uri');
if (!codeVerifier || !state || !nonce || !redirectUri) {
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirectUri);
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
codeVerifier,
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try {
const user = await finishAuthFlow(oidc, flowOptions);
session.set('user', user);
session.unset('oidc_code_verif');
session.unset('oidc_state');
session.unset('oidc_nonce');
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
session.set('hsApiKey', oidc.headscale_api_key);
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
'Set-Cookie': await context.sessions.commit(userSession),
},
});
} catch (error) {

View File

@ -1,37 +1,42 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { send } from '~/utils/res';
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')) {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
return redirect('/machines');
}
const { oidc } = hp_getConfig();
try {
if (!oidc) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
hp_getSingleton('oidc_client');
} catch {
return send({ error: 'OIDC is not enabled' }, { status: 400 });
}
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_method,
);
const redirectUri = oidc.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(oidc, redirectUri);
session.set('oidc_code_verif', data.codeVerifier);
session.set('oidc_state', data.state);
session.set('oidc_nonce', data.nonce);
session.set('oidc_redirect_uri', redirectUri);
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
'Set-Cookie': await context.sessions.commit(session),
},
});
}

View File

@ -1,16 +1,12 @@
import { ActionFunctionArgs, data } from 'react-router';
import { hs_getConfig, hs_patchConfig } from '~/utils/config/loader';
import { LoadContext } from '~/server';
import { hp_getIntegration } from '~/utils/integration/loader';
import { auth } from '~/utils/sessions.server';
export async function dnsAction({ request }: ActionFunctionArgs) {
const session = await auth(request);
if (!session) {
return data({ success: false }, 401);
}
const { mode } = hs_getConfig();
if (mode !== 'rw') {
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
@ -22,33 +18,33 @@ export async function dnsAction({ request }: ActionFunctionArgs) {
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData);
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData);
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData);
return removeNs(formData, context);
case 'add_ns':
return addNs(formData);
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData);
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData);
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData);
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData);
return addRecord(formData, context);
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData) {
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
@ -58,13 +54,13 @@ async function renameTailnet(formData: FormData) {
await hp_getIntegration()?.onConfigChange();
}
async function toggleMagic(formData: FormData) {
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
@ -74,7 +70,8 @@ async function toggleMagic(formData: FormData) {
await hp_getIntegration()?.onConfigChange();
}
async function removeNs(formData: FormData) {
async function removeNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
@ -82,15 +79,10 @@ async function removeNs(formData: FormData) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
@ -100,7 +92,7 @@ async function removeNs(formData: FormData) {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await hs_patchConfig([
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
@ -111,7 +103,8 @@ async function removeNs(formData: FormData) {
await hp_getIntegration()?.onConfigChange();
}
async function addNs(formData: FormData) {
async function addNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
@ -119,16 +112,11 @@ async function addNs(formData: FormData) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
@ -139,7 +127,7 @@ async function addNs(formData: FormData) {
const servers = splits[splitName] ?? [];
servers.push(ns);
await hs_patchConfig([
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
@ -150,20 +138,15 @@ async function addNs(formData: FormData) {
await hp_getIntegration()?.onConfigChange();
}
async function removeDomain(formData: FormData) {
async function removeDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
@ -173,21 +156,17 @@ async function removeDomain(formData: FormData) {
await hp_getIntegration()?.onConfigChange();
}
async function addDomain(formData: FormData) {
async function addDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const domains = config.dns.search_domains;
domains.push(domain);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
@ -197,7 +176,8 @@ async function addDomain(formData: FormData) {
await hp_getIntegration()?.onConfigChange();
}
async function removeRecord(formData: FormData) {
async function removeRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
@ -205,16 +185,11 @@ async function removeRecord(formData: FormData) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
@ -224,7 +199,8 @@ async function removeRecord(formData: FormData) {
await hp_getIntegration()?.onConfigChange();
}
async function addRecord(formData: FormData) {
async function addRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
@ -233,15 +209,10 @@ async function addRecord(formData: FormData) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,

View File

@ -1,8 +1,8 @@
import type { ActionFunctionArgs } from 'react-router';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import { hs_getConfig } from '~/utils/config/loader';
import type { LoadContext } from '~/server';
import ManageDomains from './components/manage-domains';
import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records';
@ -11,12 +11,12 @@ import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
// We do not want to expose every config value
export async function loader() {
const { config, mode } = hs_getConfig();
if (mode === 'no') {
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
if (!context.hs.readable()) {
throw new Error('No configuration is available');
}
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
@ -29,7 +29,7 @@ export async function loader() {
return {
...dns,
mode,
writable: context.hs.writable(),
};
}
@ -46,11 +46,11 @@ export default function Page() {
}
allNs.global = data.nameservers;
const isDisabled = data.mode !== 'rw';
const isDisabled = data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.mode === 'rw' ? undefined : (
{data.writable ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration

View File

@ -1,20 +1,14 @@
import type { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import type { LoadContext } from '~/server';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import log from '~server/utils/log';
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
// TODO: Turn this into the same thing as dns-actions like machine-actions!!!
export async function menuAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send(
@ -30,12 +24,18 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!);
await context.client.delete(
`/api/v1/node/${id}`,
session.get('api_key')!,
);
return { message: 'Machine removed' };
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
await context.client.post(
`/api/v1/node/${id}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
}
@ -50,8 +50,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
}
const name = String(data.get('name'));
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
await context.client.post(
`/api/v1/node/${id}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
}
@ -69,7 +71,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
await context.client.post(
`/api/v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
return { message: 'Route updated' };
}
@ -89,7 +94,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
await Promise.all(
routes.map(async (route) => {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
await context.client.post(
`/api/v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
}),
);
@ -109,9 +117,13 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const to = String(data.get('to'));
try {
await post(`v1/node/${id}/user`, session.get('hsApiKey')!, {
await context.client.post(
`v1/node/${id}/user`,
session.get('api_key')!,
{
user: to,
});
},
);
return { message: `Moved node ${id} to ${to}` };
} catch (error) {
@ -134,9 +146,13 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
.filter((tag) => tag.trim() !== '') ?? [];
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
await context.client.post(
`v1/node/${id}/tags`,
session.get('api_key')!,
{
tags,
});
},
);
return { message: 'Tags updated' };
} catch (error) {
@ -178,7 +194,7 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`;
await post(url, session.get('hsApiKey')!, {
await context.client.post(url, session.get('api_key')!, {
user,
key,
});

View File

@ -9,35 +9,40 @@ import Chip from '~/components/Chip';
import Link from '~/components/Link';
import StatusCircle from '~/components/StatusCircle';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
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 { hp_getSingleton, hp_getSingletonUnsafe } from '~server/context/global';
import { menuAction } from './action';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
export async function loader({
request,
params,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!params.id) {
throw new Error('No machine ID provided');
}
const { mode, config } = hs_getConfig();
let magic: string | undefined;
if (mode !== 'no') {
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const [machine, routes, users] = await Promise.all([
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
context.client.get<{ node: Machine }>(
`v1/node/${params.id}`,
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
return {
@ -45,13 +50,15 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
machine.node.id,
),
// TODO: Fix agent
agent: false,
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
// machine.node.id,
// ),
};
}
export async function action({ request }: ActionFunctionArgs) {
export async function action(request: ActionFunctionArgs) {
return menuAction(request);
}

View File

@ -1,38 +1,39 @@
import { InfoIcon } from '@primer/octicons-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import Tooltip from '~/components/Tooltip';
import { hs_getConfig } from '~/utils/config/loader';
import useAgent from '~/utils/useAgent';
import { hp_getConfig, hp_getSingletonUnsafe } from '~server/context/global';
import { menuAction } from './action';
import MachineRow from './components/machine';
import NewMachine from './dialogs/new';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
const context = hp_getConfig();
const { mode, config } = hs_getConfig();
let magic: string | undefined;
if (mode !== 'no') {
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
@ -41,13 +42,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
routes: routes.routes,
users: users.users,
magic,
server: context.headscale.url,
publicServer: context.headscale.public_url,
agents: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()],
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
// TODO: Fix this LOL
agents: ['test'],
// agents: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()],
};
}
export async function action({ request }: ActionFunctionArgs) {
export async function action(request: ActionFunctionArgs) {
return menuAction(request);
}

View File

@ -5,30 +5,30 @@ import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import type { PreAuthKey, User } from '~/types';
import { post, pull } from '~/utils/headscale';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import { hp_getConfig } from '~server/context/global';
import AuthKeyRow from './components/key';
import AddPreAuthKey from './dialogs/new';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const users = await pull<{ users: User[] }>(
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const users = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('hsApiKey')!,
session.get('api_key')!,
);
const context = hp_getConfig();
const preAuthKeys = await Promise.all(
users.users.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return pull<{ preAuthKeys: PreAuthKey[] }>(
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!,
session.get('api_key')!,
);
}),
);
@ -36,21 +36,15 @@ 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: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
// Expiring a pre-auth key
@ -67,9 +61,9 @@ export async function action({ request }: ActionFunctionArgs) {
);
}
await post<{ preAuthKey: PreAuthKey }>(
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
session.get('api_key')!,
{
user: user,
key: key,
@ -101,9 +95,9 @@ export async function action({ request }: ActionFunctionArgs) {
const date = new Date();
date.setDate(date.getDate() + day);
const key = await post<{ preAuthKey: PreAuthKey }>(
const key = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('hsApiKey')!,
session.get('api_key')!,
{
user: user,
ephemeral: ephemeral === 'on',

View File

@ -4,29 +4,29 @@ import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useSubmit } from 'react-router';
import { ClientOnly } from 'remix-utils/client-only';
import Attribute from '~/components/Attribute';
import Card from '~/components/Card';
import { ErrorPopup } from '~/components/Error';
import StatusCircle from '~/components/StatusCircle';
import type { LoadContext } from '~/server';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { hs_getConfig } from '~/utils/config/loader';
import type { AppContext } from '~server/context/app';
import { hp_getConfig } from '~server/context/global';
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<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
const users = apiUsers.users.map((user) => ({
@ -34,18 +34,15 @@ export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
}));
const { oidc } = hp_getConfig();
const { mode, config } = hs_getConfig();
let magic: string | undefined;
if (mode !== 'no') {
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
return {
oidc,
oidc: context.config.oidc,
magic,
users,
};

View File

@ -1,12 +1,12 @@
import { ActionFunctionArgs, data } from 'react-router';
import { del, post } from '~/utils/headscale';
import { auth } from '~/utils/sessions.server';
import type { LoadContext } from '~/server';
export async function userAction({ request }: ActionFunctionArgs) {
const session = await auth(request);
if (!session) {
return data({ success: false }, 401);
}
export async function userAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const apiKey = session.get('api_key')!;
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
@ -14,26 +14,25 @@ export async function userAction({ request }: ActionFunctionArgs) {
return data({ success: false }, 400);
}
const apiKey = session.get('hsApiKey');
if (!apiKey) {
return data({ success: false }, 401);
}
switch (action) {
case 'create_user':
return createUser(formData, apiKey);
return createUser(formData, apiKey, context);
case 'delete_user':
return deleteUser(formData, apiKey);
return deleteUser(formData, apiKey, context);
case 'rename_user':
return renameUser(formData, apiKey);
return renameUser(formData, apiKey, context);
case 'change_owner':
return changeOwner(formData, apiKey);
return changeOwner(formData, apiKey, context);
default:
return data({ success: false }, 400);
}
}
async function createUser(formData: FormData, apiKey: string) {
async function createUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
@ -42,40 +41,52 @@ async function createUser(formData: FormData, apiKey: string) {
return data({ success: false }, 400);
}
await post('v1/user', apiKey, {
await context.client.post('v1/user', apiKey, {
name,
displayName,
email,
});
}
async function deleteUser(formData: FormData, apiKey: string) {
async function deleteUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
if (!userId) {
return data({ success: false }, 400);
}
await del(`v1/user/${userId}`, apiKey);
await context.client.delete(`v1/user/${userId}`, apiKey);
}
async function renameUser(formData: FormData, apiKey: string) {
async function renameUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
await post(`v1/user/${userId}/rename/${newName}`, apiKey);
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
async function changeOwner(formData: FormData, apiKey: string) {
async function changeOwner(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const nodeId = formData.get('node_id')?.toString();
if (!userId || !nodeId) {
return data({ success: false }, 400);
}
await post(`v1/node/${nodeId}/user`, apiKey, {
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
user: userId,
});
}

View File

@ -1,14 +1,8 @@
import { healthcheck } from '~/utils/headscale';
import log from '~server/utils/log';
export async function loader() {
let healthy = false;
try {
healthy = await healthcheck();
} catch (error) {
log.debug('APIC', 'Healthcheck failed %o', error);
}
import { LoaderFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
status: healthy ? 200 : 500,
headers: {

View File

@ -13,7 +13,8 @@ server
│ ├── schema.ts: Defines the schema for the Headplane configuration.
├── headscale/
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
│ ├── config.ts: Loads the Headscale configuration (if available).
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
├── web/
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
│ ├── sessions.ts: Initializes the session store and methods to manage it.

View File

@ -1,7 +1,7 @@
import { constants, access, readFile } from 'node:fs/promises';
import { env, exit } from 'node:process';
import { type } from 'arktype';
import dotenv, { configDotenv } from 'dotenv';
import { configDotenv } from 'dotenv';
import { parseDocument } from 'yaml';
import log from '~/utils/log';
import { EnvOverrides, envVariables } from './env';
@ -17,7 +17,7 @@ import {
// TODO: Potential for file watching on the configuration
// But this may not be necessary as a use-case anyways
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
log.debug('config', 'Loading configuration file: %', path);
log.debug('config', 'Loading configuration file: %s', path);
const valid = await validateConfigPath(path);
if (!valid) {
exit(1);
@ -117,7 +117,7 @@ export function validateConfig(config: unknown) {
log.debug('config', 'Validating Headplane configuration');
const result = headplaneConfig(config);
if (result instanceof type.errors) {
log.error('config', 'Error parsing Headplane configuration:');
log.error('config', 'Error validating Headplane configuration:');
for (const [number, error] of result.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`);
}

View File

@ -55,7 +55,6 @@ class ApiClient {
return await request(new URL(url, this.base), {
dispatcher: this.agent,
throwOnError: false,
headers: {
...options?.headers,
Accept: 'application/json',

View File

@ -0,0 +1,211 @@
import { constants, access, readFile } from 'node:fs/promises';
import { type } from 'arktype';
import { parseDocument } from 'yaml';
import log from '~/utils/log';
import { headscaleConfig } from './config-schema';
interface ConfigModeAvailable {
access: 'rw' | 'ro';
// TODO: More attributes
}
interface ConfigModeUnavailable {
access: 'no';
}
interface PatchConfig {
path: string;
value: unknown;
}
// We need a class for the config because we need to be able to
// support retrieving it via a getter but also be able to
// patch it and to query it for its mode
class HeadscaleConfig {
private config?: typeof headscaleConfig.infer;
private access: 'rw' | 'ro' | 'no';
constructor(
access: 'rw' | 'ro' | 'no',
config?: typeof headscaleConfig.infer,
) {
this.access = access;
}
readable() {
return this.access !== 'no';
}
writable() {
return this.access === 'rw';
}
get c() {
return this.config;
}
// TODO: Implement patching
async patch(patches: PatchConfig[]) {
return;
}
}
export async function loadHeadscaleConfig(path?: string, strict = true) {
if (!path) {
log.debug('config', 'No Headscale configuration file was provided');
return new HeadscaleConfig('no');
}
log.debug('config', 'Loading Headscale configuration file: %s', path);
const { r, w } = await validateConfigPath(path);
if (!r) {
return new HeadscaleConfig('no');
}
const data = await loadConfigFile(path);
if (!data) {
return new HeadscaleConfig('no');
}
if (!strict) {
return new HeadscaleConfig(w ? 'rw' : 'ro', augmentUnstrictConfig(data));
}
const config = validateConfig(data);
if (!config) {
return new HeadscaleConfig('no');
}
return new HeadscaleConfig(w ? 'rw' : 'ro', config);
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info(
'config',
'Found a valid Headscale configuration file at %s',
path,
);
} catch (error) {
log.error(
'config',
'Unable to read a Headscale configuration file at %s',
path,
);
log.error('config', '%s', error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn(
'config',
'Headscale configuration file at %s is not writable',
path,
);
return { w: false, r: true };
}
}
async function loadConfigFile(path: string): Promise<unknown> {
log.debug('config', 'Reading Headscale configuration file at %s', path);
try {
const data = await readFile(path, 'utf8');
const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) {
log.error(
'config',
'Cannot parse Headscale configuration file at %s',
path,
);
for (const error of configYaml.errors) {
log.error('config', ` - ${error.toString()}`);
}
return false;
}
return configYaml.toJSON() as unknown;
} catch (e) {
log.error(
'config',
'Error reading Headscale configuration file at %s',
path,
);
log.error('config', '%s', e);
return false;
}
}
export function validateConfig(config: unknown) {
log.debug('config', 'Validating Headscale configuration');
const result = headscaleConfig(config);
if (result instanceof type.errors) {
log.error('config', 'Error validating Headscale configuration:');
for (const [number, error] of result.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`);
}
return;
}
return result;
}
// If config_strict is false, we set the defaults and disable
// the schema checking for the values that are not present
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
log.debug('config', 'Augmenting Headscale configuration in non-strict mode');
const config = {
...loaded,
tls_letsencrypt_cache_dir:
loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
tls_letsencrypt_challenge_type:
loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false,
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
unix_socket_permission: loaded.unix_socket_permission ?? '0770',
log: loaded.log ?? {
level: 'info',
format: 'text',
},
logtail: loaded.logtail ?? {
enabled: false,
},
prefixes: loaded.prefixes ?? {
allocation: 'sequential',
v4: '',
v6: '',
},
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: 'headscale.net',
},
};
log.warn('config', 'Headscale configuration was loaded in non-strict mode');
log.warn('config', 'This is very dangerous and comes with a few caveats:');
log.warn('config', ' - Headplane could very easily crash');
log.warn('config', ' - Headplane could break your Headscale installation');
log.warn(
'config',
' - The UI could throw random errors/show incorrect data',
);
return config as typeof headscaleConfig.infer;
}

View File

@ -0,0 +1,227 @@
import { type } from 'arktype';
const goBool = type('boolean | "true" | "false"').pipe((v) => {
if (v === 'true') return true;
if (v === 'false') return false;
return v;
});
const goDuration = type('0 | string').pipe((v) => {
return v.toString();
});
const databaseConfig = type({
type: '"sqlite" | "sqlite3"',
sqlite: {
path: 'string',
write_head_log: goBool.default(true),
wal_autocheckpoint: 'number = 1000',
},
})
.or({
type: '"postgres"',
postgres: {
host: 'string',
port: 'number | ""',
name: 'string',
user: 'string',
pass: 'string',
max_open_conns: 'number = 10',
max_idle_conns: 'number = 10',
conn_max_idle_time_secs: 'number = 3600',
ssl: goBool.default(false),
},
})
.merge({
debug: goBool.default(false),
'gorm?': {
prepare_stmt: goBool.default(true),
parameterized_queries: goBool.default(true),
skip_err_record_not_found: goBool.default(true),
slow_threshold: 'number = 1000',
},
});
// Not as strict parsing because we just need the values
// to be slightly truthy enough to safely modify them
export type HeadscaleConfig = typeof headscaleConfig.infer;
export const headscaleConfig = type({
server_url: 'string',
listen_addr: 'string',
'metrics_listen_addr?': 'string',
grpc_listen_addr: 'string = ":50433"',
grpc_allow_insecure: goBool.default(false),
noise: {
private_key_path: 'string',
},
prefixes: {
v4: 'string',
v6: 'string',
allocation: '"sequential" | "random" = "sequential"',
},
derp: {
server: {
enabled: goBool.default(true),
region_id: 'number?',
region_code: 'string?',
region_name: 'string?',
stun_listen_addr: 'string?',
private_key_path: 'string?',
ipv4: 'string?',
ipv6: 'string?',
automatically_add_embedded_derp_region: goBool.default(true),
},
urls: 'string[]?',
paths: 'string[]?',
auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default('24h'),
},
disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default('30m'),
database: databaseConfig,
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
acme_email: 'string = ""',
tls_letsencrypt_hostname: 'string = ""',
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
tls_letsencrypt_listen: 'string = ":http"',
'tls_cert_path?': 'string',
'tls_key_path?': 'string',
log: type({
format: 'string = "text"',
level: 'string = "info"',
}).default(() => ({ format: 'text', level: 'info' })),
'policy?': {
mode: '"database" | "file" = "file"',
path: 'string?',
},
dns: {
magic_dns: goBool.default(true),
base_domain: 'string = "headscale.net"',
nameservers: type({
global: type('string[]').default(() => []),
split: type('Record<string, string[]>').default(() => ({})),
}).default(() => ({ global: [], split: {} })),
search_domains: type('string[]').default(() => []),
extra_records: type({
name: 'string',
value: 'string',
type: 'string | "A"',
})
.array()
.default(() => []),
},
unix_socket: 'string?',
unix_socket_permission: 'string = "0770"',
'oidc?': {
only_start_if_oidc_is_available: goBool.default(false),
issuer: 'string',
client_id: 'string',
client_secret: 'string?',
client_secret_path: 'string?',
expiry: goDuration.default('180d'),
use_expiry_from_token: goBool.default(false),
scope: type('string[]').default(() => ['openid', 'email', 'profile']),
extra_params: 'Record<string, string>?',
allowed_domains: 'string[]?',
allowed_groups: 'string[]?',
allowed_users: 'string[]?',
'pkce?': {
enabled: goBool.default(false),
method: 'string = "S256"',
},
map_legacy_users: goBool.default(false),
},
'logtail?': {
enabled: goBool.default(false),
},
randomize_client_port: goBool.default(false),
});
// export function validateConfig(config: unknown, strict: boolean) {
// log.debug('CFGX', 'Validating Headscale configuration...');
// const out = strict
// ? headscaleConfig(config)
// : headscaleConfig(augmentUnstrictConfig(config as HeadscaleConfig));
// if (out instanceof type.errors) {
// log.error('CFGX', 'Error parsing Headscale configuration:');
// for (const [number, error] of out.entries()) {
// log.error('CFGX', ` (${number}): ${error.toString()}`);
// }
// log.error('CFGX', '');
// log.error('CFGX', 'Resolve these issues and try again.');
// log.error('CFGX', 'Headplane will operate without the config');
// log.error('CFGX', '');
// return;
// }
// log.debug('CFGX', 'Headscale configuration is valid.');
// return out;
// }
// // If config_strict is false, we set the defaults and disable
// // the schema checking for the values that are not present
// function augmentUnstrictConfig(
// loaded: Partial<HeadscaleConfig>,
// ): HeadscaleConfig {
// log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode');
// const config = {
// ...loaded,
// tls_letsencrypt_cache_dir:
// loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
// tls_letsencrypt_challenge_type:
// loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
// grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
// grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
// randomize_client_port: loaded.randomize_client_port ?? false,
// unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
// unix_socket_permission: loaded.unix_socket_permission ?? '0770',
// log: loaded.log ?? {
// level: 'info',
// format: 'text',
// },
// logtail: loaded.logtail ?? {
// enabled: false,
// },
// prefixes: loaded.prefixes ?? {
// allocation: 'sequential',
// v4: '',
// v6: '',
// },
// dns: loaded.dns ?? {
// nameservers: {
// global: [],
// split: {},
// },
// search_domains: [],
// extra_records: [],
// magic_dns: false,
// base_domain: 'headscale.net',
// },
// };
// log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode');
// log.warn('CFGX', 'By using this mode you forfeit GitHub issue support');
// log.warn('CFGX', 'This is very dangerous and comes with a few caveats:');
// log.warn('CFGX', ' Headplane could very easily crash');
// log.warn('CFGX', ' Headplane could break your Headscale installation');
// log.warn('CFGX', ' The UI could throw random errors/show incorrect data');
// log.warn('CFGX', '');
// return config as HeadscaleConfig;
// }

View File

@ -4,9 +4,15 @@ import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadConfig } from './config/loader';
import { createApiClient } from './headscale/api-client';
import { exampleMiddleware } from './middleware';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { createOidcClient } from './web/oidc';
import { createSessionStorage } from './web/sessions';
declare global {
const __PREFIX__: string;
const __VERSION__: string;
}
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// exists for the lifetime of the process, making it appropriate.
@ -25,9 +31,13 @@ const config = await loadConfig(
export type LoadContext = typeof appLoadContext;
const appLoadContext = {
config,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
),
// TODO: Better cookie options in config
sessionizer: createSessionStorage({
sessions: createSessionStorage({
name: '_hp_session',
maxAge: 60 * 60 * 24, // 24 hours
secure: config.server.cookie_secure,
@ -38,6 +48,8 @@ const appLoadContext = {
config.headscale.url,
config.headscale.tls_cert_path,
),
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
};
declare module 'react-router' {
@ -46,16 +58,14 @@ declare module 'react-router' {
export default await createHonoServer({
useWebSocket: true,
overrideGlobalObjects: true,
// overrideGlobalObjects: true,
getLoadContext(c, { build, mode }) {
// This is the place where we can handle reverse proxy translation
return appLoadContext;
},
configure(server) {
server.use('*', exampleMiddleware());
},
configure(server) {},
listeningListener(info) {
console.log(`Server is listening on http://localhost:${info.port}`);
},

View File

@ -1,8 +0,0 @@
import { createMiddleware } from 'hono/factory';
export function exampleMiddleware() {
return createMiddleware(async (c, next) => {
console.log('accept-language', c.req.header('accept-language'));
return next();
});
}

150
app/server/web/oidc.ts Normal file
View File

@ -0,0 +1,150 @@
import { readFile } from 'node:fs/promises';
import * as client from 'openid-client';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config/schema';
async function loadClientSecret(path: string) {
// We need to interpolate environment variables into the path
// Path formatting can be like ${ENV_NAME}/path/to/secret
const matches = path.match(/\${(.*?)}/g);
let resolvedPath = path;
if (matches) {
for (const match of matches) {
const env = match.slice(2, -1);
const value = process.env[env];
if (!value) {
log.error('config', 'Environment variable %s is not set', env);
return;
}
log.debug('config', 'Interpolating %s with %s', match, value);
resolvedPath = resolvedPath.replace(match, value);
}
}
try {
log.debug('config', 'Reading client secret from %s', resolvedPath);
const secret = await readFile(resolvedPath, 'utf-8');
if (secret.trim().length === 0) {
log.error('config', 'Empty OIDC client secret');
return;
}
return secret;
} catch (error) {
log.error('config', 'Failed to read client secret from %s', path);
log.error('config', 'Error: %s', error);
log.debug('config', 'Error details: %o', error);
}
}
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');
}
}
// Loads and configures an OIDC client to support OIDC authentication.
// This runs under the assumption the OIDC configuration exists and is valid.
// If it is invalid, Headplane automatically disables it.
//
// TODO: Support custom endpoints instead of relying on OIDC discovery.
// This will enable us to support servers like GitHub that do not support
// nor advertise a .well-known endpoint.
export async function createOidcClient(
config: NonNullable<HeadplaneConfig['oidc']>,
) {
// const secret = await loadClientSecret(oidc);
const secret = config.client_secret_path
? await loadClientSecret(config.client_secret_path)
: config.client_secret;
if (!secret) {
log.error('config', 'Missing an OIDC client secret');
return;
}
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
const oidc = await client.discovery(
new URL(config.issuer),
config.client_id,
secret,
clientAuthMethod(config.token_endpoint_auth_method)(secret),
);
const metadata = oidc.serverMetadata();
if (!metadata.authorization_endpoint) {
log.error(
'config',
'Issuer discovery did not return `authorization_endpoint`',
);
log.error('config', 'OIDC server does not support authorization code flow');
return;
}
if (!metadata.token_endpoint) {
log.error('config', 'Issuer discovery did not return `token_endpoint`');
log.error('config', 'OIDC server does not support token exchange');
return;
}
// If this field is missing, assume the server supports all response types
// and that we can continue safely.
if (metadata.response_types_supported) {
if (!metadata.response_types_supported.includes('code')) {
log.error(
'config',
'Issuer discovery `response_types_supported` does not include `code`',
);
log.error('config', 'OIDC server does not support code flow');
return;
}
}
if (metadata.token_endpoint_auth_methods_supported) {
if (
!metadata.token_endpoint_auth_methods_supported.includes(
config.token_endpoint_auth_method,
)
) {
log.error(
'config',
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
config.token_endpoint_auth_method,
);
log.error(
'config',
'OIDC server does not support %s',
config.token_endpoint_auth_method,
);
return;
}
}
if (!metadata.userinfo_endpoint) {
log.error('config', 'Issuer discovery did not return `userinfo_endpoint`');
log.error('config', 'OIDC server does not support userinfo endpoint');
return;
}
log.debug('config', 'OIDC client created successfully');
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
log.debug(
'config',
'Authorization endpoint: %s',
metadata.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
return oidc;
}

View File

@ -1,4 +1,5 @@
import {
CookieSerializeOptions,
Session,
SessionStorage,
createCookieSessionStorage,
@ -16,7 +17,7 @@ export interface AuthSession {
};
}
interface OidcFlowSession {
export interface OidcFlowSession {
state: 'flow';
oidc: {
state: string;
@ -52,27 +53,36 @@ class Sessionizer {
});
}
// This throws on the assumption that auth is already checked correctly
// on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error.
async auth(request: Request) {
const cookie = request.headers.get('cookie');
const session = await this.storage.getSession(cookie);
const type = session.get('state');
if (!type) {
return false;
throw new Error('Session state not found');
}
if (type !== 'auth') {
return false;
throw new Error('Session is not authenticated');
}
return session as Session<AuthSession>;
return session as Session<AuthSession, Error>;
}
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
return this.storage.getSession(request.headers.get('cookie')) as Promise<
Session<T, Error>
>;
}
destroy(session: Session) {
return this.storage.destroySession(session);
}
commit(session: Session) {
return this.storage.commitSession(session);
commit(session: Session, options?: CookieSerializeOptions) {
return this.storage.commitSession(session, options);
}
}

View File

@ -22,24 +22,29 @@ export type ConfigModes =
config: undefined;
};
export function hs_getConfig(): ConfigModes {
if (runtimeMode === 'no') {
return {
mode: 'no',
config: undefined,
};
}
// export function hs_getConfig(): ConfigModes {
// return {
// mode: 'no',
// config: undefined,
// };
runtimeLock.acquire();
// We can assert if mode is not 'no'
const config = runtimeConfig!;
runtimeLock.release();
// if (runtimeMode === 'no') {
// return {
// mode: 'no',
// config: undefined,
// };
// }
return {
mode: runtimeMode,
config: config,
};
}
// runtimeLock.acquire();
// // We can assert if mode is not 'no'
// const config = runtimeConfig!;
// runtimeLock.release();
// return {
// mode: runtimeMode,
// config: config,
// };
// }
export async function hs_loadConfig(path?: string, strict?: boolean) {
if (runtimeConfig !== undefined) {
@ -199,6 +204,6 @@ export async function hs_patchConfig(patches: PatchConfig[]) {
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
// TODO: Replace this into the new singleton system
const context = hp_getConfig();
hs_loadConfig(context.headscale.config_path, context.headscale.config_strict);
hp_getIntegration();
// const context = hp_getConfig();
// hs_loadConfig(context.headscale.config_path, context.headscale.config_strict);
// hp_getIntegration();

View File

@ -1,149 +0,0 @@
import { request } from 'undici';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
import log from '~server/utils/log';
export class HeadscaleError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'HeadscaleError';
this.status = status;
}
}
export class FatalError extends Error {
constructor() {
super(
'The Headscale server is not accessible or the supplied API key is invalid',
);
this.name = 'FatalError';
}
}
export async function healthcheck() {
log.debug('APIC', 'GET /health');
const health = new URL('health', hp_getConfig().headscale.url);
const response = await request(health.toString(), {
dispatcher: hp_getSingleton('api_agent'),
headers: {
Accept: 'application/json',
},
});
// Intentionally not catching
return response.statusCode === 200;
}
export async function pull<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`);
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.statusCode >= 400) {
log.debug(
'APIC',
'GET %s failed with status %d',
`${prefix}/api/${url}`,
response.statusCode,
);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.body.json() as Promise<T>;
}
export async function post<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`);
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.statusCode >= 400) {
log.debug(
'APIC',
'POST %s failed with status %d',
`${prefix}/api/${url}`,
response.statusCode,
);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.body.json() as Promise<T>;
}
export async function put<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`);
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.statusCode >= 400) {
log.debug(
'APIC',
'PUT %s failed with status %d',
`${prefix}/api/${url}`,
response.statusCode,
);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.body.json() as Promise<T>;
}
export async function del<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`);
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.statusCode >= 400) {
log.debug(
'APIC',
'DELETE %s failed with status %d',
`${prefix}/api/${url}`,
response.statusCode,
);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.body.json() as Promise<T>;
}

View File

@ -2,11 +2,11 @@ import { hp_getConfig } from '~server/context/global';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { Integration } from './abstract';
import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
// import dockerIntegration from './docker';
// import kubernetesIntegration from './kubernetes';
// import procIntegration from './proc';
let runtimeIntegration: Integration<unknown> | undefined = undefined;
const runtimeIntegration: Integration<unknown> | undefined = undefined;
export function hp_getIntegration() {
return runtimeIntegration;
@ -15,24 +15,22 @@ export function hp_getIntegration() {
export async function hp_loadIntegration(
context: HeadplaneConfig['integration'],
) {
const integration = getIntegration(context);
if (!integration) {
return;
}
try {
const res = await integration.isAvailable();
if (!res) {
log.error('INTG', 'Integration %s is not available', integration);
return;
}
} catch (error) {
log.error('INTG', 'Failed to load integration %s: %s', integration, error);
log.debug('INTG', 'Loading error: %o', error);
return;
}
runtimeIntegration = integration;
// const integration = getIntegration(context);
// if (!integration) {
// return;
// }
// try {
// const res = await integration.isAvailable();
// if (!res) {
// log.error('INTG', 'Integration %s is not available', integration);
// return;
// }
// } catch (error) {
// log.error('INTG', 'Failed to load integration %s: %s', integration, error);
// log.debug('INTG', 'Loading error: %o', error);
// return;
// }
// runtimeIntegration = integration;
}
function getIntegration(integration: HeadplaneConfig['integration']) {
@ -50,23 +48,23 @@ function getIntegration(integration: HeadplaneConfig['integration']) {
return;
}
if (docker?.enabled) {
log.info('INTG', 'Using Docker integration');
return new dockerIntegration(integration?.docker);
}
// if (docker?.enabled) {
// log.info('INTG', 'Using Docker integration');
// return new dockerIntegration(integration?.docker);
// }
if (k8s?.enabled) {
log.info('INTG', 'Using Kubernetes integration');
return new kubernetesIntegration(integration?.kubernetes);
}
// if (k8s?.enabled) {
// log.info('INTG', 'Using Kubernetes integration');
// return new kubernetesIntegration(integration?.kubernetes);
// }
if (proc?.enabled) {
log.info('INTG', 'Using Proc integration');
return new procIntegration(integration?.proc);
}
// if (proc?.enabled) {
// log.info('INTG', 'Using Proc integration');
// return new procIntegration(integration?.proc);
// }
}
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
// TODO: Switch this to the new singleton system
const context = hp_getConfig();
hp_loadIntegration(context.integration);
// hp_loadIntegration(context.integration);

View File

@ -1,13 +1,11 @@
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']>;
declare global {
const __PREFIX__: string;
}
// We try our best to infer the callback URI of our Headplane instance
// By default it is always /<base_path>/oidc/callback
@ -103,8 +101,11 @@ function clientAuthMethod(
}
}
export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
const config = hp_getSingleton('oidc_client');
export async function beginAuthFlow(
config: Configuration,
redirect_uri: string,
token_endpoint_auth_method: string,
) {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
@ -113,7 +114,7 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
token_endpoint_auth_method: oidc.token_endpoint_auth_method,
token_endpoint_auth_method,
state: client.randomState(),
};
@ -134,18 +135,20 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
interface FlowOptions {
redirect_uri: string;
codeVerifier: string;
code_verifier: string;
state: string;
nonce?: string;
}
export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
const config = hp_getSingleton('oidc_client');
export async function finishAuthFlow(
config: Configuration,
options: FlowOptions,
) {
const tokens = await client.authorizationCodeGrant(
config,
new URL(options.redirect_uri),
{
pkceCodeVerifier: options.codeVerifier,
pkceCodeVerifier: options.code_verifier,
expectedNonce: options.nonce,
expectedState: options.state,
idTokenExpected: true,

View File

@ -1,63 +0,0 @@
import { Session, createCookieSessionStorage } from 'react-router';
import { hp_getConfig } from '~server/context/global';
export type SessionData = {
hsApiKey: string;
oidc_state: string;
oidc_code_verif: string;
oidc_nonce: string;
oidc_redirect_uri: string;
agent_onboarding: boolean;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
};
type SessionFlashData = {
error: string;
};
// TODO: Domain config in cookies
// TODO: Move this to the singleton system
const context = hp_getConfig();
const sessionStorage = createCookieSessionStorage<
SessionData,
SessionFlashData
>({
cookie: {
name: 'hp_sess',
httpOnly: true,
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
sameSite: 'lax',
secrets: [context.server.cookie_secret],
secure: context.server.cookie_secure,
},
});
export function getSession(cookie: string | null) {
return sessionStorage.getSession(cookie);
}
export type ServerSession = Session<SessionData, SessionFlashData>;
export async function auth(request: Request) {
const cookie = request.headers.get('Cookie');
const session = await sessionStorage.getSession(cookie);
if (!session.has('hsApiKey')) {
return false;
}
return session;
}
export function destroySession(session: Session) {
return sessionStorage.destroySession(session);
}
export function commitSession(session: Session, opts?: { maxAge?: number }) {
return sessionStorage.commitSession(session, opts);
}

View File

@ -19,7 +19,7 @@ if (!version) {
}
export default defineConfig({
base: `${prefix}/`,
// base: `${prefix}/`,
plugins: [reactRouterHonoServer(), reactRouter(), tsconfigPaths()],
css: {
postcss: {