From 7b4966be028d7a3f1bf322d66290a2bb0955ac99 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 13 Dec 2025 16:33:51 -0500 Subject: [PATCH] feat: track headscale acl response changes --- app/components/error-banner.tsx | 27 ++-- app/routes/acls/acl-action.ts | 143 +++++++++++++-------- app/routes/acls/acl-loader.ts | 23 +--- app/routes/acls/overview.tsx | 58 ++++++++- app/routes/auth/login/action.ts | 13 +- app/server/headscale/api/error-client.ts | 27 ++++ app/server/headscale/api/response-error.ts | 21 --- 7 files changed, 204 insertions(+), 108 deletions(-) delete mode 100644 app/server/headscale/api/response-error.ts 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: ( - - There was an error communicating with the Headscale API. -
- The server responded with a status code of{' '} - {statusCode}, indicating a server-side issue. - Please check the Headscale server status and try again later. -
+ <> + + There was an error communicating with the Headscale API. +
+ 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 || error.data.rawData != null) && ( +
+									{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 ( +
+ +
+ ACL Policy Unavailable + +
+ + The ACL policy is currently unavailable because the policy file does + not exist on the server. This usually indicates that Headscale is + running in file mode for ACLs, and the specified policy + file is missing. + +
+ + + In order to resolve this issue, there are two possible actions you + can take: + +
    +
  • + Create the ACL policy file at the specified path in your Headscale + configuration. +
  • +
  • + Alternatively, you can switch Headscale to use{' '} + database mode for ACLs by updating your Headscale + configuration. This will allow Headplane to manage the ACL policy + directly through the web interface. +
  • +
+
+
+ ); + } + + throw error; +} diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts index d622183..25c3a83 100644 --- a/app/routes/auth/login/action.ts +++ b/app/routes/auth/login/action.ts @@ -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, diff --git a/app/server/headscale/api/error-client.ts b/app/server/headscale/api/error-client.ts index 7c6a9ff..8088871 100644 --- a/app/server/headscale/api/error-client.ts +++ b/app/server/headscale/api/error-client.ts @@ -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) + ); +} diff --git a/app/server/headscale/api/response-error.ts b/app/server/headscale/api/response-error.ts deleted file mode 100644 index 4802572..0000000 --- a/app/server/headscale/api/response-error.ts +++ /dev/null @@ -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; - - 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 {} - } -}