feat: track headscale acl response changes

This commit is contained in:
Aarnav Tale
2025-12-13 16:33:51 -05:00
parent 3d07b941b4
commit 7b4966be02
7 changed files with 204 additions and 108 deletions
+19 -8
View File
@@ -18,15 +18,26 @@ export function getErrorMessage(error: Error | unknown): {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Cannot connect to Headscale API',
title: 'Headscale API Error',
jsxMessage: (
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
+88 -55
View File
@@ -1,5 +1,5 @@
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
@@ -37,68 +37,101 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
if (isDataWithApiError(error)) {
const rawData = error.data.rawData;
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
if (rawData.includes('update is disabled')) {
throw data('Policy is not writable', { status: 403 });
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
const message =
error.data.data != null &&
'message' in error.data.data &&
typeof error.data.data.message === 'string'
? error.data.data.message
: undefined;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
if (message == null) {
throw error;
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
console.log('rawData', message);
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
if (message.includes('parsing HuJSON:')) {
const cutIndex = message.indexOf('parsing HuJSON:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
: message;
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('parsing policy from bytes:')) {
const cutIndex = message.indexOf('parsing policy from bytes:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes('parsing hujson')) {
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
}
+6 -17
View File
@@ -1,5 +1,5 @@
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
@@ -30,30 +30,19 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
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.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
if (isDataWithApiError(error)) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes('acl policy not found')) {
flags.policy = '';
flags.writable = true;
return flags;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+56 -2
View File
@@ -1,11 +1,19 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFetcher, useRevalidator } from 'react-router';
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import { isApiError } from '~/server/headscale/api/error-client';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
@@ -164,3 +172,49 @@ export default function Page({
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes('reading policy from path') &&
error.data.rawData.includes('no such file or directory')
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does
not exist on the server. This usually indicates that Headscale is
running in <Code>file</Code> mode for ACLs, and the specified policy
file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you
can take:
</Card.Text>
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale
configuration.
</li>
<li>
Alternatively, you can switch Headscale to use{' '}
<Code>database</Code> mode for ACLs by updating your Headscale
configuration. This will allow Headplane to manage the ACL policy
directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
throw error;
}
+8 -5
View File
@@ -1,5 +1,5 @@
import { data, redirect } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import log from '~/utils/log';
import type { Route } from './+types/page';
@@ -74,12 +74,15 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
},
});
} catch (error) {
if (error instanceof ResponseError) {
// Check if this is a React Router DataWithResponseInit wrapping a Headscale API error
if (isDataWithApiError(error)) {
const apiError = error.data;
// TODO: What in gods name is wrong with the headscale API?
if (
error.status === 401 ||
error.status === 403 ||
(error.status === 500 && error.response.trim() === 'Unauthorized')
apiError.statusCode === 401 ||
apiError.statusCode === 403 ||
(apiError.statusCode === 500 &&
apiError.rawData.trim() === 'Unauthorized')
) {
return {
success: false,
+27
View File
@@ -44,6 +44,13 @@ export function isConnectionError(
);
}
/**
* Type guard to check if an error is a DataUnauthorizedError.
* This checks if the error has a `data` property with a `statusCode` of 401.
*
* @param error - The error to check.
* @returns True if the error is a DataUnauthorizedError, false otherwise.
*/
export function isDataUnauthorizedError(error: unknown): boolean {
return (
error != null &&
@@ -55,3 +62,23 @@ export function isDataUnauthorizedError(error: unknown): boolean {
error.data.statusCode === 401
);
}
/**
* Type guard to check if an error is a DataWithResponseInit wrapping a
* HeadscaleAPIError. This is used in loaders/actions to handle errors thrown by
* `data()` before they reach the ErrorBoundary.
*
* @param error - The error to check.
* @returns True if the error is a DataWithResponseInit containing a
* HeadscaleAPIError, false otherwise.
*/
export function isDataWithApiError(
error: unknown,
): error is { data: HeadscaleAPIError } {
return (
error != null &&
typeof error === 'object' &&
'data' in error &&
isApiError(error.data)
);
}
@@ -1,21 +0,0 @@
// Represents an error that occurred during a response
// Thrown when status codes are >= 400
export default class ResponseError extends Error {
status: number;
response: string;
requestUrl: string;
responseObject?: Record<string, unknown>;
constructor(status: number, response: string, requestUrl: string) {
super(`${requestUrl}: status ${status} - ${response}`);
this.name = 'ResponseError';
this.status = status;
this.response = response;
this.requestUrl = requestUrl;
try {
// Try to parse the response as JSON to get a response object
this.responseObject = JSON.parse(response);
} catch {}
}
}