mirror of
https://github.com/tale/headplane.git
synced 2026-08-19 17:36:19 +00:00
fix: use descriptive error messages
This commit is contained in:
@@ -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<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) {
|
||||
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);
|
||||
|
||||
+72
-60
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user