fix: use descriptive error messages
This commit is contained in:
parent
494efe0493
commit
9e0450b15b
@ -8,7 +8,7 @@ interface Props {
|
|||||||
type?: 'full' | 'embedded';
|
type?: 'full' | 'embedded';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMessage(error: Error | unknown): {
|
export function getErrorMessage(error: Error | unknown): {
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
} {
|
} {
|
||||||
@ -45,10 +45,7 @@ function getMessage(error: Error | unknown): {
|
|||||||
|
|
||||||
// If we are aggregate, concat into a single message
|
// If we are aggregate, concat into a single message
|
||||||
if (rootError instanceof AggregateError) {
|
if (rootError instanceof AggregateError) {
|
||||||
return {
|
throw new Error('Unhandled AggregateError');
|
||||||
title: 'Errors',
|
|
||||||
message: rootError.errors.map((error) => error.message).join('\n'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -60,7 +57,7 @@ function getMessage(error: Error | unknown): {
|
|||||||
export function ErrorPopup({ type = 'full' }: Props) {
|
export function ErrorPopup({ type = 'full' }: Props) {
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
const routing = isRouteErrorResponse(error);
|
const routing = isRouteErrorResponse(error);
|
||||||
const { title, message } = getMessage(error);
|
const { title, message } = getErrorMessage(error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,49 +1,71 @@
|
|||||||
|
import { CircleX } from 'lucide-react';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
url: string;
|
url: string;
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
|
healthy: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Footer({ url, debug }: FooterProps) {
|
export default function Footer({ url, debug, healthy }: FooterProps) {
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed bottom-0 left-0 z-40 w-full h-14',
|
'fixed w-full bottom-0 left-0 z-40 h-12',
|
||||||
'flex flex-col justify-center gap-1 shadow-inner',
|
'flex items-center justify-center',
|
||||||
'bg-headplane-100 dark:bg-headplane-950',
|
'bg-headplane-50 dark:bg-headplane-950',
|
||||||
'text-headplane-800 dark:text-headplane-200',
|
|
||||||
'dark:border-t dark:border-headplane-800',
|
'dark:border-t dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="container text-xs">
|
<div
|
||||||
Headplane is entirely free to use. If you find it useful, consider{' '}
|
className={cn(
|
||||||
<Link
|
'grid grid-rows-1 items-center container mx-auto',
|
||||||
to="https://github.com/sponsors/tale"
|
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
|
||||||
name="Aarnav's GitHub Sponsors"
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
|
||||||
>
|
>
|
||||||
donating
|
<p>
|
||||||
</Link>{' '}
|
Headplane is free. Please consider{' '}
|
||||||
to support development.{' '}
|
<Link
|
||||||
</p>
|
to="https://github.com/sponsors/tale"
|
||||||
<p className="container text-xs opacity-75">
|
name="Aarnav's GitHub Sponsors"
|
||||||
Version: {__VERSION__}
|
>
|
||||||
{' — '}
|
donating
|
||||||
Connecting to{' '}
|
</Link>{' '}
|
||||||
<button
|
to support development.{' '}
|
||||||
type="button"
|
</p>
|
||||||
tabIndex={0} // Allows keyboard focus
|
<p className="opacity-75">
|
||||||
className={cn(
|
Version: {__VERSION__}
|
||||||
'blur-sm hover:blur-none focus:blur-none transition',
|
{' — '}
|
||||||
'focus:outline-none focus:ring-2 rounded-sm',
|
Connecting to{' '}
|
||||||
)}
|
<button
|
||||||
>
|
type="button"
|
||||||
{url}
|
tabIndex={0} // Allows keyboard focus
|
||||||
</button>
|
className={cn(
|
||||||
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
|
'blur-sm hover:blur-none focus:blur-none transition',
|
||||||
{debug && ' (Debug mode enabled)'}
|
'focus:outline-none focus:ring-2 rounded-sm',
|
||||||
</p>
|
)}
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</button>
|
||||||
|
{debug && ' (Debug mode enabled)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!healthy ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
|
||||||
|
'bg-red-500 text-white font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CircleX size={16} strokeWidth={3} />
|
||||||
|
<p className="text-nowrap">Headscale is unreachable</p>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import { XCircleFillIcon } from '@primer/octicons-react';
|
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 { 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 type { LoadContext } from '~/server';
|
||||||
import ResponseError from '~/server/headscale/api-error';
|
import ResponseError from '~/server/headscale/api-error';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
@ -37,29 +44,8 @@ export async function loader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { healthy } = useLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!healthy ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'fixed bottom-0 right-0 z-50 w-fit h-14',
|
|
||||||
'flex flex-col justify-center gap-1',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
|
|
||||||
'border rounded-lg text-white bg-red-500',
|
|
||||||
'border-red-600 dark:border-red-400 shadow-sm',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<XCircleFillIcon className="w-4 h-4 text-white" />
|
|
||||||
Headscale is unreachable
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -116,6 +116,7 @@ export async function loader({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
onboarding: request.url.endsWith('/onboarding'),
|
onboarding: request.url.endsWith('/onboarding'),
|
||||||
|
healthy: await context.client.healthcheck(),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// No session, so we can just return
|
// No session, so we can just return
|
||||||
|
|||||||
@ -164,7 +164,3 @@ export default function Page() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
|
||||||
return <ErrorPopup type="embedded" />;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,8 +1,132 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { data } from 'react-router';
|
||||||
import { Agent, Dispatcher, request } from 'undici';
|
import { Agent, Dispatcher, request } from 'undici';
|
||||||
|
import { errors } from 'undici';
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import ResponseError from './api-error';
|
import ResponseError from './api-error';
|
||||||
|
|
||||||
|
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||||
|
const keys = Object.keys(error as Record<string, unknown>);
|
||||||
|
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) {
|
export async function createApiClient(base: string, certPath?: string) {
|
||||||
if (!certPath) {
|
if (!certPath) {
|
||||||
return new ApiClient(new Agent(), base);
|
return new ApiClient(new Agent(), base);
|
||||||
@ -37,21 +161,34 @@ export class ApiClient {
|
|||||||
const method = options?.method ?? 'GET';
|
const method = options?.method ?? 'GET';
|
||||||
log.debug('api', '%s %s', method, url);
|
log.debug('api', '%s %s', method, url);
|
||||||
|
|
||||||
return await request(new URL(url, this.base), {
|
try {
|
||||||
dispatcher: this.agent,
|
const res = await request(new URL(url, this.base), {
|
||||||
headers: {
|
dispatcher: this.agent,
|
||||||
...options?.headers,
|
headers: {
|
||||||
Accept: 'application/json',
|
...options?.headers,
|
||||||
'User-Agent': `Headplane/${__VERSION__}`,
|
Accept: 'application/json',
|
||||||
},
|
'User-Agent': `Headplane/${__VERSION__}`,
|
||||||
body: options?.body,
|
},
|
||||||
method,
|
body: options?.body,
|
||||||
});
|
method,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw friendlyError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async healthcheck() {
|
async healthcheck() {
|
||||||
try {
|
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;
|
return res.statusCode === 200;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.debug('api', 'Healthcheck failed %o', error);
|
log.debug('api', 'Healthcheck failed %o', error);
|
||||||
|
|||||||
@ -75,76 +75,88 @@ export async function createOidcClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
||||||
const oidc = await client.discovery(
|
try {
|
||||||
new URL(config.issuer),
|
const oidc = await client.discovery(
|
||||||
config.client_id,
|
new URL(config.issuer),
|
||||||
secret,
|
config.client_id,
|
||||||
clientAuthMethod(config.token_endpoint_auth_method)(secret),
|
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`',
|
|
||||||
);
|
);
|
||||||
log.error('config', 'OIDC server does not support authorization code flow');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metadata.token_endpoint) {
|
const metadata = oidc.serverMetadata();
|
||||||
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
if (!metadata.authorization_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')) {
|
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
'Issuer discovery `response_types_supported` does not include `code`',
|
'Issuer discovery did not return `authorization_endpoint`',
|
||||||
);
|
|
||||||
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(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
'OIDC server does not support %s',
|
'OIDC server does not support authorization code flow',
|
||||||
config.token_endpoint_auth_method,
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!metadata.userinfo_endpoint) {
|
if (!metadata.token_endpoint) {
|
||||||
log.error('config', 'Issuer discovery did not return `userinfo_endpoint`');
|
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
||||||
log.error('config', 'OIDC server does not support userinfo endpoint');
|
log.error('config', 'OIDC server does not support token exchange');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('config', 'OIDC client created successfully');
|
// If this field is missing, assume the server supports all response types
|
||||||
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
|
// and that we can continue safely.
|
||||||
log.debug(
|
if (metadata.response_types_supported) {
|
||||||
'config',
|
if (!metadata.response_types_supported.includes('code')) {
|
||||||
'Authorization endpoint: %s',
|
log.error(
|
||||||
metadata.authorization_endpoint,
|
'config',
|
||||||
);
|
'Issuer discovery `response_types_supported` does not include `code`',
|
||||||
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
|
);
|
||||||
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
|
log.error('config', 'OIDC server does not support code flow');
|
||||||
return oidc;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user