diff --git a/app/components/error-banner.tsx b/app/components/error-banner.tsx
index 7edf0b3..6ad50eb 100644
--- a/app/components/error-banner.tsx
+++ b/app/components/error-banner.tsx
@@ -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: (
-
- The server responded with a status code of{' '}
- {statusCode}, indicating a server-side issue.
- Please check the Headscale server status and try again later.
-
+ The server responded with a status code of{' '}
+ {statusCode}, indicating a server-side issue.
+ Please check the Headscale server status and try again later.
+
+ {error.data.data != null ? (
+ {JSON.stringify(error.data.data, null, 2)}
+ ) : (
+ {error.data.rawData}
+ )}
+
+ )}
+ >
),
};
}
diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts
index bd946ae..594b6c7 100644
--- a/app/routes/acls/acl-action.ts
+++ b/app/routes/acls/acl-action.ts
@@ -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,
+ );
+ }
}
}
diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts
index e8c7bea..3712d68 100644
--- a/app/routes/acls/acl-loader.ts
+++ b/app/routes/acls/acl-loader.ts
@@ -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;
}
}
diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx
index 94b62ee..d3c96db 100644
--- a/app/routes/acls/overview.tsx
+++ b/app/routes/acls/overview.tsx
@@ -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({
);
}
+
+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 (
+ file mode for ACLs, and the specified policy
+ file is missing.
+ database mode for ACLs by updating your Headscale
+ configuration. This will allow Headplane to manage the ACL policy
+ directly through the web interface.
+