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
+29 -24
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,
},
);
}
+36 -37
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
+11 -4
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),
},
});
}
+25 -32
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) {
throw new Error('OIDC is not enabled');
}
hp_getSingleton('oidc_client');
} catch {
return send({ error: 'OIDC is not enabled' }, { status: 400 });
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
// 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) {
+28 -23
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) {
throw new Error('OIDC is not enabled');
}
hp_getSingleton('oidc_client');
} catch {
return send({ error: 'OIDC is not enabled' }, { status: 400 });
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
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);
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,
);
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),
},
});
}
+38 -67
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,
+8 -8
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
+42 -26
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')!, {
user: to,
});
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')!, {
tags,
});
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,
});
+25 -18
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);
}
+25 -22
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);
}
+20 -26
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',
+15 -18
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,
};
+35 -24
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,
});
}
+4 -10
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: {