mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +00:00
feat: track headscale acl response changes
This commit is contained in:
@@ -18,15 +18,26 @@ export function getErrorMessage(error: Error | unknown): {
|
|||||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||||
if (statusCode >= 500) {
|
if (statusCode >= 500) {
|
||||||
return {
|
return {
|
||||||
title: 'Cannot connect to Headscale API',
|
title: 'Headscale API Error',
|
||||||
jsxMessage: (
|
jsxMessage: (
|
||||||
<Card.Text>
|
<>
|
||||||
There was an error communicating with the Headscale API.
|
<Card.Text>
|
||||||
<br />
|
There was an error communicating with the Headscale API.
|
||||||
The server responded with a status code of{' '}
|
<br />
|
||||||
<strong>{statusCode}</strong>, indicating a server-side issue.
|
The server responded with a status code of{' '}
|
||||||
Please check the Headscale server status and try again later.
|
<strong>{statusCode}</strong>, indicating a server-side issue.
|
||||||
</Card.Text>
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { data } from 'react-router';
|
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 { Capabilities } from '~/server/web/roles';
|
||||||
import type { Route } from './+types/overview';
|
import type { Route } from './+types/overview';
|
||||||
|
|
||||||
@@ -37,68 +37,101 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// This means Headscale returned a protobuf error to us
|
if (isDataWithApiError(error)) {
|
||||||
// It also means we 100% know this is in database mode
|
const rawData = error.data.rawData;
|
||||||
if (error instanceof ResponseError && error.responseObject?.message) {
|
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
|
||||||
const message = error.responseObject.message as string;
|
if (rawData.includes('update is disabled')) {
|
||||||
// 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
|
|
||||||
throw data('Policy is not writable', { status: 403 });
|
throw data('Policy is not writable', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
|
const message =
|
||||||
if (message.includes('parsing hujson')) {
|
error.data.data != null &&
|
||||||
// This means the policy was invalid, return a 400
|
'message' in error.data.data &&
|
||||||
// with the actual error message from Headscale
|
typeof error.data.data.message === 'string'
|
||||||
const cutIndex = message.indexOf('err: hujson:');
|
? error.data.data.message
|
||||||
const trimmed =
|
: undefined;
|
||||||
cutIndex > -1
|
|
||||||
? `Syntax error: ${message.slice(cutIndex + 12)}`
|
|
||||||
: message;
|
|
||||||
|
|
||||||
return data(
|
if (message == null) {
|
||||||
{
|
throw error;
|
||||||
success: false,
|
|
||||||
error: trimmed,
|
|
||||||
policy: undefined,
|
|
||||||
updatedAt: undefined,
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.includes('unmarshalling policy')) {
|
console.log('rawData', message);
|
||||||
// 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;
|
|
||||||
|
|
||||||
return data(
|
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||||
{
|
// we need to reference other error messages based on API version.
|
||||||
success: false,
|
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
|
||||||
error: trimmed,
|
if (message.includes('parsing HuJSON:')) {
|
||||||
policy: undefined,
|
const cutIndex = message.indexOf('parsing HuJSON:');
|
||||||
updatedAt: undefined,
|
const trimmed =
|
||||||
},
|
cutIndex > -1
|
||||||
400,
|
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
|
||||||
);
|
: message;
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('empty policy')) {
|
return data(
|
||||||
return data(
|
{
|
||||||
{
|
success: false,
|
||||||
success: false,
|
error: trimmed,
|
||||||
error: 'Policy error: Supplied policy was empty',
|
policy: undefined,
|
||||||
policy: undefined,
|
updatedAt: undefined,
|
||||||
updatedAt: undefined,
|
},
|
||||||
},
|
400,
|
||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { data } from 'react-router';
|
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 { Capabilities } from '~/server/web/roles';
|
||||||
import type { Route } from './+types/overview';
|
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);
|
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||||
try {
|
try {
|
||||||
const { policy, updatedAt } = await api.getPolicy();
|
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.writable = updatedAt !== null;
|
||||||
flags.policy = policy;
|
flags.policy = policy;
|
||||||
return flags;
|
return flags;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// This means Headscale returned a protobuf error to us
|
if (isDataWithApiError(error)) {
|
||||||
// It also means we 100% know this is in database mode
|
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
|
||||||
if (error instanceof ResponseError && error.responseObject?.message) {
|
if (error.data.rawData.includes('acl policy not found')) {
|
||||||
const message = error.responseObject.message as string;
|
flags.policy = '';
|
||||||
// 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
|
|
||||||
flags.writable = true;
|
flags.writable = true;
|
||||||
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
return flags;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, this is a Headscale error that we can just propagate.
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from 'react';
|
||||||
import { useFetcher, useRevalidator } from 'react-router';
|
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
|
import Card from '~/components/Card';
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import Notice from '~/components/Notice';
|
import Notice from '~/components/Notice';
|
||||||
import Tabs from '~/components/Tabs';
|
import Tabs from '~/components/Tabs';
|
||||||
|
import { isApiError } from '~/server/headscale/api/error-client';
|
||||||
import toast from '~/utils/toast';
|
import toast from '~/utils/toast';
|
||||||
import type { Route } from './+types/overview';
|
import type { Route } from './+types/overview';
|
||||||
import { aclAction } from './acl-action';
|
import { aclAction } from './acl-action';
|
||||||
@@ -164,3 +172,49 @@ export default function Page({
|
|||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { data, redirect } from 'react-router';
|
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 log from '~/utils/log';
|
||||||
import type { Route } from './+types/page';
|
import type { Route } from './+types/page';
|
||||||
|
|
||||||
@@ -74,12 +74,15 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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?
|
// TODO: What in gods name is wrong with the headscale API?
|
||||||
if (
|
if (
|
||||||
error.status === 401 ||
|
apiError.statusCode === 401 ||
|
||||||
error.status === 403 ||
|
apiError.statusCode === 403 ||
|
||||||
(error.status === 500 && error.response.trim() === 'Unauthorized')
|
(apiError.statusCode === 500 &&
|
||||||
|
apiError.rawData.trim() === 'Unauthorized')
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -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 {
|
export function isDataUnauthorizedError(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
error != null &&
|
error != null &&
|
||||||
@@ -55,3 +62,23 @@ export function isDataUnauthorizedError(error: unknown): boolean {
|
|||||||
error.data.statusCode === 401
|
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 {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user