feat: fix acl api logic to work correctly

This commit is contained in:
Aarnav Tale
2025-11-04 23:16:49 -05:00
parent c84e9ca4a8
commit 444b2325fb
37 changed files with 772 additions and 1218 deletions
+12 -17
View File
@@ -1,40 +1,35 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { Capabilities } from '~/server/web/roles';
import { data400, data403 } from '~/utils/res';
import type { Route } from './+types/overview';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function aclAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data403('You do not have permission to write to the ACL policy');
throw data('You do not have permission to write to the ACL policy', {
status: 403,
});
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data400('Missing `policy` in the form data.');
throw data('Missing `policy` in the form data.', {
status: 400,
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.api_key, {
policy: policyData,
});
const { policy, updatedAt } = await api.setPolicy(policyData);
return data({
success: true,
error: undefined,
@@ -50,7 +45,7 @@ export async function aclAction({
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
throw data('Policy is not writable', { status: 403 });
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
+8 -13
View File
@@ -1,8 +1,7 @@
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { Capabilities } from '~/server/web/roles';
import { data403 } from '~/utils/res';
import type { Route } from './+types/overview';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
@@ -11,15 +10,13 @@ import { data403 } from '~/utils/res';
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function aclLoader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data403('You do not have permission to read the ACL policy.');
throw data('You do not have permission to read the ACL policy.', {
status: 403,
});
}
const flags = {
@@ -30,11 +27,9 @@ export async function aclLoader({
};
// Try to load the ACL policy from the API.
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.api_key);
const { policy, updatedAt } = await api.getPolicy();
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
+8 -8
View File
@@ -38,14 +38,14 @@ export function Editor(props: EditorProps) {
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
editable={!props.isDisabled}
extensions={[shopify.jsonc()]} // Allow editing unless disabled
height="100%" // Use readOnly if disabled
onChange={(value) => props.onChange(value)}
readOnly={props.isDisabled}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
onChange={(value) => props.onChange(value)}
value={props.value}
/>
)}
</ClientOnly>
@@ -92,14 +92,14 @@ export function Differ(props: DifferProps) {
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
extensions={[shopify.jsonc()]}
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
extensions={[shopify.jsonc()]}
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
+1 -1
View File
@@ -23,12 +23,12 @@ export default function Fallback({ acl }: Props) {
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
readOnly
value={acl}
/>
</div>
+13 -24
View File
@@ -1,34 +1,23 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import { useFetcher, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export const loader = aclLoader;
export const action = aclAction;
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { access, writable, policy },
}: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
@@ -75,15 +64,15 @@ export default function Page() {
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
to="https://tailscale.com/kb/1018/acls"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
to="https://headscale.net/stable/ref/acls/"
>
Headscale docs
</Link>
@@ -91,14 +80,14 @@ export default function Page() {
</p>
{fetcher.data?.error !== undefined ? (
<Notice
variant="error"
title={fetcher.data.error.split(':')[0] ?? 'Error'}
variant="error"
>
{fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'}
</Notice>
) : undefined}
<Tabs label="ACL Editor" className="mb-4">
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
@@ -110,8 +99,8 @@ export default function Page() {
>
<Editor
isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy}
value={codePolicy}
/>
</Tabs.Item>
<Tabs.Item
@@ -145,7 +134,6 @@ export default function Page() {
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
@@ -158,6 +146,7 @@ export default function Page() {
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
variant="heavy"
>
Save
</Button>
+5 -11
View File
@@ -1,13 +1,9 @@
import { ActionFunctionArgs, data, redirect } from 'react-router';
import { LoadContext } from '~/server';
import { data, redirect } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { Key } from '~/types';
import log from '~/utils/log';
import type { Route } from './+types/page';
export async function loginAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function loginAction({ request, context }: Route.LoaderArgs) {
const formData = await request.formData();
const apiKey = formData.has('api_key')
? String(formData.get('api_key'))
@@ -31,11 +27,9 @@ export async function loginAction({
throw data('Received an empty `api_key`', { status: 400 });
}
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const apiKeys = await api.getApiKeys();
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
+1 -3
View File
@@ -50,9 +50,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
};
}
export async function action(request: Route.ActionArgs) {
return loginAction(request);
}
export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { isOidcEnabled, isCookieSecureEnabled, urlState } = loaderData;
+205 -225
View File
@@ -1,11 +1,8 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function dnsAction({ request, context }: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
@@ -19,6 +16,9 @@ export async function dnsAction({
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient('fake-api-key');
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
@@ -26,226 +26,206 @@ export async function dnsAction({
}
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData, context);
case 'add_ns':
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData, context);
case 'override_dns':
return overrideDns(formData, context);
case 'rename_tailnet': {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Tailnet renamed successfully' };
}
case 'toggle_magic': {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Magic DNS state updated successfully' };
}
case 'remove_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver removed successfully' };
}
case 'add_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver added successfully' };
}
case 'remove_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain removed successfully' };
}
case 'add_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain added successfully' };
}
case 'remove_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: '',
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record removed successfully' };
}
case 'add_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record added successfully' };
}
case 'override_dns': {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'DNS override updated successfully' };
}
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(context.client);
}
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();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
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();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
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 domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
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 domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
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();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: '',
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(context.client);
}
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();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(context.client);
}
async function overrideDns(formData: FormData, context: LoadContext) {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
await context.integration?.onConfigChange(context.client);
}
+66 -89
View File
@@ -1,12 +1,8 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import { PreAuthKey } from '~/types';
import type { Route } from './+types/overview';
export async function authKeysAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
@@ -20,7 +16,7 @@ export async function authKeysAction({
}
const formData = await request.formData();
const apiKey = session.api_key;
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
@@ -29,90 +25,71 @@ export async function authKeysAction({
}
switch (action) {
case 'add_preauthkey':
return await addPreAuthKey(formData, apiKey, context);
case 'expire_preauthkey':
return await expirePreAuthKey(formData, apiKey, context);
case 'add_preauthkey': {
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
const expiry = formData.get('expiry')?.toString();
if (!expiry) {
return data('Missing `expiry` in the form data.', {
status: 400,
});
}
const reusable = formData.get('reusable')?.toString();
if (!reusable) {
return data('Missing `reusable` in the form data.', {
status: 400,
});
}
const ephemeral = formData.get('ephemeral')?.toString();
if (!ephemeral) {
return data('Missing `ephemeral` in the form data.', {
status: 400,
});
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]);
const date = new Date();
date.setDate(date.getDate() + day);
await api.createPreAuthKey(
user,
ephemeral === 'on',
reusable === 'on',
date,
[], // TODO
);
return data('Pre-auth key created');
}
case 'expire_preauthkey': {
const key = formData.get('key')?.toString();
if (!key) {
return data('Missing `key` in the form data.', {
status: 400,
});
}
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
await api.expirePreAuthKey(user, key);
return data('Pre-auth key expired');
}
default:
return data('Invalid action', {
status: 400,
});
}
}
async function addPreAuthKey(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
const expiry = formData.get('expiry')?.toString();
if (!expiry) {
return data('Missing `expiry` in the form data.', {
status: 400,
});
}
const reusable = formData.get('reusable')?.toString();
if (!reusable) {
return data('Missing `reusable` in the form data.', {
status: 400,
});
}
const ephemeral = formData.get('ephemeral')?.toString();
if (!ephemeral) {
return data('Missing `ephemeral` in the form data.', {
status: 400,
});
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]);
const date = new Date();
date.setDate(date.getDate() + day);
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
apiKey,
{
user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
},
);
return data('Pre-auth key created');
}
async function expirePreAuthKey(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const key = formData.get('key')?.toString();
if (!key) {
return data('Missing `key` in the form data.', {
status: 400,
});
}
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
await context.client.post('v1/preauthkey/expire', apiKey, { user, key });
return data('Pre-auth key expired');
}
@@ -1,5 +1,4 @@
import { Key, useState } from 'react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import NumberInput from '~/components/NumberInput';
@@ -22,36 +21,38 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Generate auth key</Dialog.Title>
<input type="hidden" name="action_id" value="add_preauthkey" />
<input type="hidden" name="user_id" value={userId?.toString()} />
<input name="action_id" type="hidden" value="add_preauthkey" />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
className="mb-2"
description="This is the user machines will belong to when they authenticate."
isRequired
label="User"
name="user"
placeholder="Select a user"
description="This is the user machines will belong to when they authenticate."
className="mb-2"
onSelectionChange={(value) => {
setUserId(value);
}}
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
<NumberInput
isRequired
name="expiry"
label="Key Expiration"
description="Set this key to expire after a certain number of days."
minValue={1}
maxValue={365_000} // 1000 years
defaultValue={90}
description="Set this key to expire after a certain number of days."
formatOptions={{
style: 'unit',
unit: 'day',
unitDisplay: 'short',
}}
isRequired
label="Key Expiration"
maxValue={365_000} // 1000 years
minValue={1}
name="expiry"
/>
<div className="flex justify-between items-center gap-2 mt-6">
<div>
@@ -61,15 +62,15 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
</Dialog.Text>
</div>
<Switch
defaultSelected={reusable}
label="Reusable"
name="reusable"
defaultSelected={reusable}
onChange={() => {
setReusable(!reusable);
}}
/>
</div>
<input type="hidden" name="reusable" value={reusable.toString()} />
<input name="reusable" type="hidden" value={reusable.toString()} />
<div className="flex justify-between items-center gap-2 mt-6">
<div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
@@ -77,23 +78,23 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
Devices authenticated with this key will be automatically removed
once they go offline.{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
to="https://tailscale.com/kb/1111/ephemeral-nodes"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
defaultSelected={ephemeral}
label="Ephemeral"
name="ephemeral"
defaultSelected={ephemeral}
onChange={() => {
setEphemeral(!ephemeral);
}}
/>
</div>
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
<input name="ephemeral" type="hidden" value={ephemeral.toString()} />
</Dialog.Panel>
</Dialog>
);
@@ -12,9 +12,9 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire auth key?</Dialog.Title>
<input type="hidden" name="action_id" value="expire_preauthkey" />
<input type="hidden" name="user_id" value={user.id} />
<input type="hidden" name="key" value={authKey.key} />
<input name="action_id" type="hidden" value="expire_preauthkey" />
<input name="user_id" type="hidden" value={user.id} />
<input name="key" type="hidden" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately prevent it from
being used to authenticate new devices. This action cannot be undone.
+14 -25
View File
@@ -1,41 +1,29 @@
import { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { PreAuthKey, User } from '~/types';
import log from '~/utils/log';
import type { Route } from './+types/overview';
import { authKeysAction } from './actions';
import AuthKeyRow from './auth-key-row';
import AddAuthKey from './dialogs/add-auth-key';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.api_key,
);
const api = context.hsApi.getRuntimeClient(session.api_key);
const users = await api.getUsers();
const preAuthKeys = await Promise.all(
users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => {
const qp = new URLSearchParams();
qp.set('user', user.id);
try {
const { preAuthKeys } = await context.client.get<{
preAuthKeys: PreAuthKey[];
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
const preAuthKeys = await api.getPreAuthKeys(user.id);
return {
success: true,
user,
@@ -47,7 +35,7 @@ export async function loader({
success: false,
user,
error,
preAuthKeys: [] as PreAuthKey[],
preAuthKeys: [],
};
}
}),
@@ -79,13 +67,12 @@ export async function loader({
};
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return authKeysAction(request);
}
export const action = authKeysAction;
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
export default function Page() {
const { keys, missing, users, url, access } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { keys, missing, users, url, access },
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState('__headplane_all');
const [status, setStatus] = useState<Status>('active');
const isDisabled =
@@ -198,7 +185,9 @@ export default function Page() {
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
)),
]}
</Select>
+142 -165
View File
@@ -1,11 +1,11 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function restrictionAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
}: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.configure_iam,
@@ -31,29 +31,162 @@ export async function restrictionAction({
});
}
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient('fake-api-key');
switch (action) {
case 'add_domain': {
return addDomain(formData, context);
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data('Domain added successfully.');
}
case 'remove_domain': {
return removeDomain(formData, context);
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data('Domain removed successfully.');
}
case 'add_group': {
return addGroup(formData, context);
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(api);
return data('Group added successfully.');
}
case 'remove_group': {
return removeGroup(formData, context);
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(api);
return data('Group removed successfully.');
}
case 'add_user': {
return addUser(formData, context);
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(api);
return data('User added successfully.');
}
case 'remove_user': {
return removeUser(formData, context);
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(api);
return data('User removed successfully.');
}
default: {
@@ -63,159 +196,3 @@ export async function restrictionAction({
}
}
}
async function addDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain added successfully.');
}
async function removeDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain removed successfully.');
}
async function addUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User added successfully.');
}
async function removeUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User removed successfully.');
}
async function addGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group added successfully.');
}
async function removeGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group removed successfully.');
}
@@ -39,19 +39,19 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
Add this domain to a list of allowed email domains that can
authenticate with Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_domain" />
<input name="action_id" type="hidden" value="add_domain" />
<Input
isRequired
label="Domain"
description={
domain.trim().length > 0
? `Matches users with <user>@${domain.trim()}`
: 'Enter a domain to match users with their email addresses.'
}
placeholder="example.com"
isInvalid={domain.trim().length === 0 || isInvalid}
isRequired
label="Domain"
name="domain"
onChange={setDomain}
isInvalid={domain.trim().length === 0 || isInvalid}
placeholder="example.com"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
@@ -30,15 +30,15 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
Add this group to a list of allowed groups that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_group" />
<input name="action_id" type="hidden" value="add_group" />
<Input
description="The group to allow for OIDC authentication."
isInvalid={group.trim().length === 0 || isInvalid}
isRequired
label="Group"
description="The group to allow for OIDC authentication."
placeholder="admin"
name="group"
onChange={setGroup}
isInvalid={group.trim().length === 0 || isInvalid}
placeholder="admin"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
@@ -30,15 +30,15 @@ export default function AddUser({ users, isDisabled }: AddUserProps) {
Add this user to a list of allowed users that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_user" />
<input name="action_id" type="hidden" value="add_user" />
<Input
description="The user to allow for OIDC authentication."
isInvalid={user.trim().length === 0 || isInvalid}
isRequired
label="User"
description="The user to allow for OIDC authentication."
placeholder="john_doe"
name="user"
onChange={setUser}
isInvalid={user.trim().length === 0 || isInvalid}
placeholder="john_doe"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
+13 -23
View File
@@ -1,24 +1,15 @@
import {
ActionFunctionArgs,
LoaderFunctionArgs,
Link as RemixLink,
data,
useLoaderData,
} from 'react-router';
import { data, Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { restrictionAction } from './actions';
import AddDomain from './dialogs/add-domain';
import AddGroup from './dialogs/add-group';
import AddUser from './dialogs/add-user';
import RestrictionTable from './table';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
throw data('You do not have permission to view IAM settings.', {
@@ -43,19 +34,18 @@ export async function loader({
};
}
export async function action(request: ActionFunctionArgs) {
return restrictionAction(request);
}
export const action = restrictionAction;
export default function Page() {
const { access, writable, settings } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { access, writable, settings },
}: Route.ComponentProps) {
const isDisabled = writable ? !access : true;
return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3">
<p className="mb-4 text-md">
<RemixLink to="/settings" className="font-medium">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Authentication Restrictions
@@ -85,33 +75,33 @@ export default function Page() {
used to limit access to your Tailnet to only certain users or groups
and Headplane will also respect these settings when authenticating.{' '}
<Link
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RestrictionTable
isDisabled={isDisabled}
type="domain"
values={settings.domains}
isDisabled={isDisabled}
>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="group"
values={settings.groups}
isDisabled={isDisabled}
>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="user"
values={settings.users}
isDisabled={isDisabled}
>
<AddUser users={settings.users} isDisabled={isDisabled} />
<AddUser isDisabled={isDisabled} users={settings.users} />
</RestrictionTable>
</div>
);
+4 -4
View File
@@ -40,18 +40,18 @@ export default function RestrictionTable({
)}
<Form method="POST">
<input
type="hidden"
name="action_id"
type="hidden"
value={`remove_${type}`}
/>
<input type="hidden" name={type} value={value} />
<input name={type} type="hidden" value={value} />
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
@@ -1,17 +1,16 @@
import { Building2, House, Key } from 'lucide-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import type { HeadplaneConfig } from '~/server/config/schema';
import CreateUser from '../dialogs/create-user';
interface ManageBannerProps {
oidc?: NonNullable<HeadplaneConfig['oidc']>;
oidc?: { issuer: string };
isDisabled?: boolean;
}
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
{oidc ? (
@@ -26,14 +25,14 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
{oidc ? (
<>
Users are managed through your{' '}
<Link to={oidc.issuer} name="OIDC Provider">
<Link name="OIDC Provider" to={oidc.issuer}>
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
@@ -43,8 +42,8 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
Users are not managed externally. Using OpenID Connect can
create a better experience when using Headscale.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
@@ -61,7 +60,7 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
: 'You can add, remove, and rename users here.'}
</p>
<div className="flex items-center gap-2 mt-4">
<CreateUser isOidc={oidc !== undefined} isDisabled={isDisabled} />
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
</div>
</div>
</div>
+4 -4
View File
@@ -22,40 +22,40 @@ export default function UserMenu({ user }: MenuProps) {
<>
{modal === 'rename' && (
<Rename
user={user}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'delete' && (
<Delete
user={user}
isOpen={modal === 'delete'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'reassign' && (
<Reassign
user={user}
isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
+7 -5
View File
@@ -18,22 +18,24 @@ export default function UserRow({ user, role }: UserRowProps) {
return (
<tr
key={user.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={user.id}
>
<td className="pl-0.5 py-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
src={user.profilePicUrl}
alt={user.name || user.displayName}
className="w-10 h-10 rounded-full"
src={user.profilePicUrl}
/>
) : (
<CircleUser className="w-10 h-10" />
)}
<div className="ml-4">
<p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p>
<p className={cn('font-semibold leading-snug')}>
{user.name || user.displayName}
</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
@@ -43,8 +45,8 @@ export default function UserRow({ user, role }: UserRowProps) {
</td>
<td className="pl-0.5 py-2">
<p
suppressHydrationWarning
className="text-sm text-headplane-600 dark:text-headplane-300"
suppressHydrationWarning
>
{new Date(user.createdAt).toLocaleDateString()}
</p>
@@ -56,7 +58,7 @@ export default function UserRow({ user, role }: UserRowProps) {
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
<StatusCircle className="w-4 h-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
</p>
+12 -12
View File
@@ -24,15 +24,14 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
</>
) : undefined}
</Dialog.Text>
<input type="hidden" name="action_id" value="create_user" />
<input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4">
<Input
isRequired
name="username"
type="text"
label="Username"
name="username"
placeholder="my-new-user"
validationBehavior="native"
type="text"
validate={(value) => {
if (value.trim().length === 0) {
return 'Username is required';
@@ -44,19 +43,20 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
return true;
}}
/>
<Input
name="display_name"
type="text"
label="Display Name"
placeholder="John Doe"
validationBehavior="native"
/>
<Input
name="email"
type="email"
label="Display Name"
name="display_name"
placeholder="John Doe"
type="text"
validationBehavior="native"
/>
<Input
label="Email"
name="email"
placeholder="name@example.com"
type="email"
validationBehavior="native"
/>
</div>
+2 -2
View File
@@ -32,8 +32,8 @@ export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
)}
</Dialog.Text>
)}
<input type="hidden" name="action_id" value="delete_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
</Dialog.Panel>
</Dialog>
);
+10 -8
View File
@@ -21,14 +21,16 @@ export default function ReassignUser({
<Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
>
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
<Dialog.Title>
Change role for {user.name || user.displayName}?
</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '}
<Link
to="https://tailscale.com/kb/1138/user-roles"
name="Tailscale User Roles documentation"
to="https://tailscale.com/kb/1138/user-roles"
>
Learn More
</Link>
@@ -37,21 +39,21 @@ export default function ReassignUser({
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input type="hidden" name="action_id" value="reassign_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<RadioGroup
isRequired
name="new_role"
label="Role"
className="gap-4"
defaultValue={user.headplaneRole}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== 'owner')
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} value={role} label={name}>
<RadioGroup.Radio key={role} label={name} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
+9 -9
View File
@@ -15,18 +15,18 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
<Dialog.Panel>
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
Enter a new username for {user.name || user.displayName}. Changing a username will not
update any ACL policies that may refer to this user by their old
username.
Enter a new username for {user.name || user.displayName}. Changing a
username will not update any ACL policies that may refer to this user
by their old username.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="rename_user" />
<input name="user_id" type="hidden" value={user.id} />
<Input
isRequired
name="new_name"
label="Username"
placeholder="my-new-name"
defaultValue={user.name}
isRequired
label="Username"
name="new_name"
placeholder="my-new-name"
/>
</Dialog.Panel>
</Dialog>
+3 -6
View File
@@ -1,12 +1,9 @@
import { eq } from 'drizzle-orm';
import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server';
import { redirect } from 'react-router';
import { users } from '~/server/db/schema';
import type { Route } from './+types/onboarding-skip';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const { user } = await context.sessions.auth(request);
await context.db
+8 -13
View File
@@ -1,23 +1,20 @@
import { Icon } from '@iconify/react';
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
import { NavLink } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Link from '~/components/Link';
import Options from '~/components/Options';
import StatusCircle from '~/components/StatusCircle';
import { LoadContext } from '~/server';
import { Machine } from '~/types';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import log from '~/utils/log';
import toast from '~/utils/toast';
import type { Route } from './+types/onboarding';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
@@ -48,13 +45,10 @@ export async function loader({
break;
}
const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined;
try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.api_key,
);
const nodes = await api.getNodes();
const node = nodes.find((n) => {
if (n.user.provider !== 'oidc') {
return false;
@@ -87,8 +81,9 @@ export async function loader({
};
}
export default function Page() {
const { user, osValue, firstMachine } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { user, osValue, firstMachine },
}: Route.ComponentProps) {
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
+19 -25
View File
@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import type { Route } from './+types/overview';
import ManageBanner from './components/manage-banner';
import UserRow from './components/user-row';
import { userAction } from './user-actions';
@@ -13,10 +11,7 @@ interface UserMachine extends User {
machines: Machine[];
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
@@ -31,14 +26,12 @@ export async function loader({
Capabilities.write_users,
);
const [machines, apiUsers] = await Promise.all([
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const users = apiUsers.users.map((user) => ({
const users = apiUsers.map((user) => ({
...user,
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
machines: nodes.filter((node) => node.user.id === user.id),
}));
const roles = await Promise.all(
@@ -77,28 +70,29 @@ export async function loader({
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc,
oidc: context.config.oidc
? {
issuer: context.config.oidc.issuer,
}
: undefined,
roles,
magic,
users,
};
}
export async function action(data: ActionFunctionArgs) {
return userAction(data);
}
export const action = userAction;
export default function Page() {
const data = useLoaderData<typeof loader>();
const [users, setUsers] = useState<UserMachine[]>(data.users);
export default function Page({ loaderData }: Route.ComponentProps) {
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
// This useEffect is entirely for the purpose of updating the users when the
// drag and drop changes the machines between users. It's pretty hacky, but
// the idea is to treat data.users as the source of truth and update the
// local state when it changes.
useEffect(() => {
setUsers(data.users);
}, [data.users]);
setUsers(loaderData.users);
}, [loaderData.users]);
return (
<>
@@ -106,7 +100,7 @@ export default function Page() {
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
</p>
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
@@ -127,7 +121,7 @@ export default function Page() {
.map((user) => (
<UserRow
key={user.id}
role={data.roles[users.indexOf(user)]}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
+99 -124
View File
@@ -1,140 +1,115 @@
import { ActionFunctionArgs, data } from 'react-router';
import type { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities, Roles } from '~/server/web/roles';
import { User } from '~/types';
import { data400, data403 } from '~/utils/res';
import type { Route } from './+types/overview';
export async function userAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function userAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) {
throw data403('You do not have permission to update users');
throw data('You do not have permission to update users', {
status: 403,
});
}
const apiKey = session.api_key;
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data400('Missing `action_id` in the form data.');
throw data('Missing `action_id` in the form data.', {
status: 404,
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
switch (action) {
case 'create_user':
return createUser(formData, apiKey, context);
case 'delete_user':
return deleteUser(formData, apiKey, context);
case 'rename_user':
return renameUser(formData, apiKey, context);
case 'reassign_user':
return reassignUser(formData, apiKey, context);
case 'create_user': {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
throw data('Missing `username` in the form data.', {
status: 400,
});
}
await api.createUser(name, email, displayName);
return { message: 'User created successfully' };
}
case 'delete_user': {
const userId = formData.get('user_id')?.toString();
if (!userId) {
throw data('Missing `user_id` in the form data.', {
status: 400,
});
}
await api.deleteUser(userId);
return { message: 'User deleted successfully' };
}
case 'rename_user': {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 });
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data('Users managed by OIDC cannot be renamed', {
status: 403,
});
}
await api.renameUser(userId, newName);
return { message: 'User renamed successfully' };
}
case 'reassign_user': {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
throw data('Missing `user_id` or `new_role` in the form data.', {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
throw data('Specified user is not an OIDC user', {
status: 400,
});
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop();
if (!subject) {
throw data(
'Malformed `providerId` for the specified user. Cannot find subject.',
{ status: 400 },
);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
throw data('Failed to reassign user role.', { status: 500 });
}
return { message: 'User reassigned successfully' };
}
default:
throw data400('Invalid `action_id` provided.');
throw data('Invalid `action_id` provided.', {
status: 400,
});
}
}
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();
if (!name) {
throw data400('Missing `username` in the form data.');
}
await context.client.post('v1/user', apiKey, {
name,
displayName,
email,
});
}
async function deleteUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
if (!userId) {
throw data400('Missing `user_id` in the form data.');
}
await context.client.delete(`v1/user/${userId}`, apiKey);
}
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);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data400(`No user found with id: ${userId}`);
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data403('Users managed by OIDC cannot be renamed');
}
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
async function reassignUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
throw data400('Missing `user_id` or `new_role` in the form data.');
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
throw data400('Specified user is not an OIDC user');
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop();
if (!subject) {
throw data400(
'Malformed `providerId` for the specified user. Cannot find subject.',
);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
return data({ success: false }, 403);
}
return data({ success: true });
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ApiClient } from '~/server/headscale/api-client';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
export abstract class Integration<T> {
protected context: NonNullable<T>;
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
}
abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(client: ApiClient): Promise<void> | void;
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
abstract get name(): string;
}
+5 -5
View File
@@ -1,7 +1,7 @@
import { constants, access } from 'node:fs/promises';
import { access, constants } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici';
import { ApiClient } from '~/server/headscale/api-client';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
@@ -193,7 +193,7 @@ export default class DockerIntegration extends Integration<T> {
return this.client !== undefined && this.containerId !== undefined;
}
async onConfigChange(client: ApiClient) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.client) {
return;
}
@@ -233,14 +233,14 @@ export default class DockerIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
const status = await client.isHealthy();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
} catch {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
+6 -6
View File
@@ -1,12 +1,12 @@
import { readFile, readdir } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import { ApiClient } from '~/server/headscale/api-client';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
@@ -202,7 +202,7 @@ export default class KubernetesIntegration extends Integration<T> {
}
}
async onConfigChange(client: ApiClient) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
@@ -220,14 +220,14 @@ export default class KubernetesIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
const status = await client.isHealthy();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
} catch {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
+35 -28
View File
@@ -1,11 +1,11 @@
import { readFile, readdir } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { ApiClient } from '~/server/headscale/api-client';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
@@ -68,30 +68,37 @@ export default class ProcIntegration extends Integration<T> {
pids.join(', '),
);
log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID');
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
for (const pid of pids) {
const pidStatusPath = join('/proc', pid.toString(), 'status');
try {
log.debug('config', 'Reading %s', pidStatusPath);
const pidData = await readFile(pidStatusPath, 'utf8');
const ppidResult = pidData.match(ppidRegex);
log.debug(
'config',
'Checking if any of them have Parent PID = 1, assuming thats the correct PID',
);
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
for (const pid of pids) {
const pidStatusPath = join('/proc', pid.toString(), 'status');
try {
log.debug('config', 'Reading %s', pidStatusPath);
const pidData = await readFile(pidStatusPath, 'utf8');
const ppidResult = pidData.match(ppidRegex);
if (ppidResult !== null) {
const potentialPPid = Number.parseInt(ppidResult[1], 10);
if (potentialPPid === 1) {
this.pid = pid;
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid);
return true;
}
}
} catch (error) {
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
}
}
if (ppidResult !== null) {
const potentialPPid = Number.parseInt(ppidResult[1], 10);
if (potentialPPid === 1) {
this.pid = pid;
log.info(
'config',
'Found potential Headscale process with PID: %d based on Parent PID = 1',
this.pid,
);
return true;
}
}
} catch (error) {
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
}
}
return false;
}
return false;
}
if (pids.length === 0) {
log.error('config', 'Could not find Headscale process');
@@ -107,7 +114,7 @@ export default class ProcIntegration extends Integration<T> {
}
}
async onConfigChange(client: ApiClient) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
@@ -125,7 +132,7 @@ export default class ProcIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
const status = await client.isHealthy();
if (status === false) {
log.error('config', 'Headscale is not running');
return;
@@ -133,7 +140,7 @@ export default class ProcIntegration extends Integration<T> {
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
} catch {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
-296
View File
@@ -1,296 +0,0 @@
import { readFile } from 'node:fs/promises';
import { data } from 'react-router';
import { Agent, Dispatcher, errors, request } from 'undici';
import log from '~/utils/log';
import ResponseError from './api/response-error';
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
const keys = Object.keys(error as Record<string, unknown>);
return (
typeof error === 'object' &&
error !== null &&
keys.includes('code') &&
keys.includes('errno')
);
}
function friendlyError(givenError: unknown) {
let error: unknown = givenError;
if (error instanceof AggregateError) {
error = error.errors[0];
}
switch (true) {
case error instanceof errors.BodyTimeoutError:
case error instanceof errors.ConnectTimeoutError:
case error instanceof errors.HeadersTimeoutError:
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
case error instanceof errors.SocketError:
case error instanceof errors.SecureProxyConnectionError:
case error instanceof errors.ClientClosedError:
case error instanceof errors.ClientDestroyedError:
case error instanceof errors.RequestAbortedError:
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
case error instanceof errors.InvalidArgumentError:
case error instanceof errors.InvalidReturnValueError:
case error instanceof errors.NotSupportedError:
return data('Unable to make a request (this is most likely a bug)', {
statusText: 'Internal Server Error',
status: 500,
});
case error instanceof errors.HeadersOverflowError:
case error instanceof errors.RequestContentLengthMismatchError:
case error instanceof errors.ResponseContentLengthMismatchError:
case error instanceof errors.ResponseExceededMaxSizeError:
return data('The Headscale API returned a malformed response', {
statusText: 'Bad Gateway',
status: 502,
});
case isNodeNetworkError(error):
if (error.code === 'ECONNREFUSED') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENOTFOUND') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EAI_AGAIN') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ETIMEDOUT') {
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
}
if (error.code === 'ECONNRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EPIPE') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETUNREACH') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
default:
return data((error as Error).message ?? 'An unknown error occurred', {
statusText: 'Internal Server Error',
status: 500,
});
}
}
export async function createApiClient(base: string, certPath?: string) {
if (!certPath) {
return new ApiClient(new Agent(), base);
}
try {
log.debug('config', 'Loading certificate from %s', certPath);
const data = await readFile(certPath, 'utf8');
log.info('config', 'Using certificate from %s', certPath);
return new ApiClient(new Agent({ connect: { ca: data.trim() } }), base);
} catch (error) {
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
log.debug('config', 'Error Details: %o', error);
return new ApiClient(new Agent(), base);
}
}
/**
* @deprecated Use the new RuntimeApiClient instead.
*/
export class ApiClient {
private agent: Agent;
private base: string;
constructor(agent: Agent, base: string) {
this.agent = agent;
this.base = base;
}
async defaultFetch(
url: string,
options?: Partial<Dispatcher.RequestOptions>,
) {
const method = options?.method ?? 'GET';
log.debug('api', '%s %s', method, url);
try {
const res = await request(new URL(url, this.base), {
dispatcher: this.agent,
headers: {
...options?.headers,
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
return res;
} catch (error: unknown) {
throw friendlyError(error);
}
}
/**
* @deprecated Use the new RuntimeApiClient instead.
*/
async healthcheck() {
try {
const res = await request(new URL('/health', this.base), {
dispatcher: this.agent,
headers: {
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
});
return res.statusCode === 200;
} catch (error) {
log.debug('api', 'Healthcheck failed %o', error);
return false;
}
}
/**
* @deprecated Use the new RuntimeApiClient instead.
*/
async get<T = unknown>(url: string, key: string) {
const res = await this.defaultFetch(`/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'GET %s failed with status %d', url, res.statusCode);
throw new ResponseError(
res.statusCode,
await res.body.text(),
`GET ${url}`,
);
}
return res.body.json() as Promise<T>;
}
/**
* @deprecated Use the new RuntimeApiClient instead.
*/
async post<T = unknown>(url: string, key: string, body?: unknown) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'POST %s failed with status %d', url, res.statusCode);
throw new ResponseError(
res.statusCode,
await res.body.text(),
`POST ${url}`,
);
}
return res.body.json() as Promise<T>;
}
/**
* @deprecated Use the new RuntimeApiClient instead.
*/
async put<T = unknown>(url: string, key: string, body?: unknown) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'PUT %s failed with status %d', url, res.statusCode);
throw new ResponseError(
res.statusCode,
await res.body.text(),
`PUT ${url}`,
);
}
return res.body.json() as Promise<T>;
}
/**
* @deprecated Use the new RuntimeApiClient instead.
*/
async delete<T = unknown>(url: string, key: string) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'DELETE %s failed with status %d', url, res.statusCode);
throw new ResponseError(
res.statusCode,
await res.body.text(),
`DELETE ${url}`,
);
}
return res.body.json() as Promise<T>;
}
}
+17 -17
View File
@@ -4,38 +4,38 @@ export interface PolicyEndpoints {
/**
* Retrieves the current ACL policy from the Headscale instance.
*
* @returns The ACL policy as a string.
* @returns The ACL policy as a string and the date it was last updated.
*/
getPolicy(): Promise<string>;
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
/**
* Sets the ACL policy for the Headscale instance.
*
* @param policy The ACL policy as a string.
* @returns The expiration date of the new policy.
* @returns The updated ACL policy as a string and the date it was last updated.
*/
setPolicy(policy: string): Promise<Date>;
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
}
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
getPolicy: async () => {
const { policy } = await client.apiFetch<{ policy: string }>(
'GET',
'v1/policy',
apiKey,
);
const { policy, updatedAt } = await client.apiFetch<{
policy: string;
updatedAt: string;
}>('GET', 'v1/policy', apiKey);
return policy;
return {
policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
};
},
setPolicy: async (policy) => {
const { updatedAt } = await client.apiFetch<{ updatedAt: string }>(
'PUT',
'v1/policy',
apiKey,
{ policy },
);
const { policy: newPolicy, updatedAt } = await client.apiFetch<{
policy: string;
updatedAt: string;
}>('PUT', 'v1/policy', apiKey, { policy });
return new Date(updatedAt);
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
}));
+1 -1
View File
@@ -1,4 +1,4 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises';
import { access, constants, readFile, writeFile } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import log from '~/utils/log';
-6
View File
@@ -7,7 +7,6 @@ import { loadIntegration } from './config/integration';
import { loadConfig } from './config/loader';
import { createDbClient } from './db/client.server';
import { createHeadscaleInterface } from './headscale/api';
import { createApiClient } from './headscale/api-client';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent';
import { configureOidcAuth } from './web/oidc';
@@ -73,11 +72,6 @@ const appLoadContext = {
config.headscale.tls_cert_path,
),
client: await createApiClient(
config.headscale.url,
config.headscale.tls_cert_path,
),
agents,
integration: await loadIntegration(config.integration),
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,