diff --git a/app/components/Error.tsx b/app/components/Error.tsx
index 5a6e4f2..c41f456 100644
--- a/app/components/Error.tsx
+++ b/app/components/Error.tsx
@@ -8,7 +8,7 @@ interface Props {
type?: 'full' | 'embedded';
}
-function getMessage(error: Error | unknown): {
+export function getErrorMessage(error: Error | unknown): {
title: string;
message: string;
} {
@@ -45,10 +45,7 @@ function getMessage(error: Error | unknown): {
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
- return {
- title: 'Errors',
- message: rootError.errors.map((error) => error.message).join('\n'),
- };
+ throw new Error('Unhandled AggregateError');
}
return {
@@ -60,7 +57,7 @@ function getMessage(error: Error | unknown): {
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
- const { title, message } = getMessage(error);
+ const { title, message } = getErrorMessage(error);
return (
-
- Headplane is entirely free to use. If you find it useful, consider{' '}
-
+
- donating
- {' '}
- to support development.{' '}
-
-
- Version: {__VERSION__}
- {' — '}
- Connecting to{' '}
-
- {/* Connecting to {url} */}
- {debug && ' (Debug mode enabled)'}
-
+
+ Headplane is free. Please consider{' '}
+
+ donating
+ {' '}
+ to support development.{' '}
+
+
+ Version: {__VERSION__}
+ {' — '}
+ Connecting to{' '}
+
+ {debug && ' (Debug mode enabled)'}
+
+
+ {!healthy ? (
+
+
+
Headscale is unreachable
+
+ ) : undefined}
+
);
}
diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx
index 7e3b54b..ca53a7e 100644
--- a/app/layouts/dashboard.tsx
+++ b/app/layouts/dashboard.tsx
@@ -1,7 +1,14 @@
import { XCircleFillIcon } from '@primer/octicons-react';
-import { type LoaderFunctionArgs, redirect } from 'react-router';
+import { ServerCrash } from 'lucide-react';
+import {
+ type LoaderFunctionArgs,
+ isRouteErrorResponse,
+ redirect,
+ useRouteError,
+} from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
-import { ErrorPopup } from '~/components/Error';
+import Card from '~/components/Card';
+import { ErrorPopup, getErrorMessage } from '~/components/Error';
import type { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
@@ -37,29 +44,8 @@ export async function loader({
}
export default function Layout() {
- const { healthy } = useLoaderData();
-
return (
<>
- {!healthy ? (
-
-
-
- Headscale is unreachable
-
-
- ) : undefined}
diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx
index ddb1e7e..7fb7a17 100644
--- a/app/layouts/shell.tsx
+++ b/app/layouts/shell.tsx
@@ -116,6 +116,7 @@ export async function loader({
),
},
onboarding: request.url.endsWith('/onboarding'),
+ healthy: await context.client.healthcheck(),
};
} catch {
// No session, so we can just return
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx
index 249a881..c12ab8e 100644
--- a/app/routes/machines/overview.tsx
+++ b/app/routes/machines/overview.tsx
@@ -164,7 +164,3 @@ export default function Page() {
>
);
}
-
-export function ErrorBoundary() {
- return ;
-}
diff --git a/app/server/headscale/api-client.ts b/app/server/headscale/api-client.ts
index 3de5638..7536652 100644
--- a/app/server/headscale/api-client.ts
+++ b/app/server/headscale/api-client.ts
@@ -1,8 +1,132 @@
import { readFile } from 'node:fs/promises';
+import { data } from 'react-router';
import { Agent, Dispatcher, request } from 'undici';
+import { errors } from 'undici';
import log from '~/utils/log';
import ResponseError from './api-error';
+function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
+ const keys = Object.keys(error as Record);
+ 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);
@@ -37,21 +161,34 @@ export class ApiClient {
const method = options?.method ?? 'GET';
log.debug('api', '%s %s', method, url);
- return await request(new URL(url, this.base), {
- dispatcher: this.agent,
- headers: {
- ...options?.headers,
- Accept: 'application/json',
- 'User-Agent': `Headplane/${__VERSION__}`,
- },
- body: options?.body,
- method,
- });
+ 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);
+ }
}
async healthcheck() {
try {
- const res = await this.defaultFetch('/health');
+ 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);
diff --git a/app/server/web/oidc.ts b/app/server/web/oidc.ts
index 6e12bda..c8cbcb6 100644
--- a/app/server/web/oidc.ts
+++ b/app/server/web/oidc.ts
@@ -75,76 +75,88 @@ export async function createOidcClient(
}
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
- const oidc = await client.discovery(
- new URL(config.issuer),
- config.client_id,
- secret,
- clientAuthMethod(config.token_endpoint_auth_method)(secret),
- );
-
- const metadata = oidc.serverMetadata();
- if (!metadata.authorization_endpoint) {
- log.error(
- 'config',
- 'Issuer discovery did not return `authorization_endpoint`',
+ try {
+ const oidc = await client.discovery(
+ new URL(config.issuer),
+ config.client_id,
+ secret,
+ clientAuthMethod(config.token_endpoint_auth_method)(secret),
);
- log.error('config', 'OIDC server does not support authorization code flow');
- return;
- }
- if (!metadata.token_endpoint) {
- log.error('config', 'Issuer discovery did not return `token_endpoint`');
- log.error('config', 'OIDC server does not support token exchange');
- return;
- }
-
- // If this field is missing, assume the server supports all response types
- // and that we can continue safely.
- if (metadata.response_types_supported) {
- if (!metadata.response_types_supported.includes('code')) {
+ const metadata = oidc.serverMetadata();
+ if (!metadata.authorization_endpoint) {
log.error(
'config',
- 'Issuer discovery `response_types_supported` does not include `code`',
- );
- log.error('config', 'OIDC server does not support code flow');
- return;
- }
- }
-
- if (metadata.token_endpoint_auth_methods_supported) {
- if (
- !metadata.token_endpoint_auth_methods_supported.includes(
- config.token_endpoint_auth_method,
- )
- ) {
- log.error(
- 'config',
- 'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
- config.token_endpoint_auth_method,
+ 'Issuer discovery did not return `authorization_endpoint`',
);
log.error(
'config',
- 'OIDC server does not support %s',
- config.token_endpoint_auth_method,
+ 'OIDC server does not support authorization code flow',
);
return;
}
- }
- if (!metadata.userinfo_endpoint) {
- log.error('config', 'Issuer discovery did not return `userinfo_endpoint`');
- log.error('config', 'OIDC server does not support userinfo endpoint');
- return;
- }
+ if (!metadata.token_endpoint) {
+ log.error('config', 'Issuer discovery did not return `token_endpoint`');
+ log.error('config', 'OIDC server does not support token exchange');
+ return;
+ }
- log.debug('config', 'OIDC client created successfully');
- log.info('config', 'Using %s as the OIDC issuer', config.issuer);
- log.debug(
- 'config',
- 'Authorization endpoint: %s',
- metadata.authorization_endpoint,
- );
- log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
- log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
- return oidc;
+ // If this field is missing, assume the server supports all response types
+ // and that we can continue safely.
+ if (metadata.response_types_supported) {
+ if (!metadata.response_types_supported.includes('code')) {
+ log.error(
+ 'config',
+ 'Issuer discovery `response_types_supported` does not include `code`',
+ );
+ log.error('config', 'OIDC server does not support code flow');
+ return;
+ }
+ }
+
+ if (metadata.token_endpoint_auth_methods_supported) {
+ if (
+ !metadata.token_endpoint_auth_methods_supported.includes(
+ config.token_endpoint_auth_method,
+ )
+ ) {
+ log.error(
+ 'config',
+ 'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
+ config.token_endpoint_auth_method,
+ );
+ log.error(
+ 'config',
+ 'OIDC server does not support %s',
+ config.token_endpoint_auth_method,
+ );
+ return;
+ }
+ }
+
+ if (!metadata.userinfo_endpoint) {
+ log.error(
+ 'config',
+ 'Issuer discovery did not return `userinfo_endpoint`',
+ );
+ log.error('config', 'OIDC server does not support userinfo endpoint');
+ return;
+ }
+
+ log.debug('config', 'OIDC client created successfully');
+ log.info('config', 'Using %s as the OIDC issuer', config.issuer);
+ log.debug(
+ 'config',
+ 'Authorization endpoint: %s',
+ metadata.authorization_endpoint,
+ );
+ log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
+ log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
+ return oidc;
+ } catch (error) {
+ log.error('config', 'Failed to discover OIDC issuer');
+ log.error('config', 'Error: %s', error);
+ log.debug('config', 'Error details: %o', error);
+ }
}