Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 27 additions & 14 deletions app/components/ToastProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
import { ToastData } from '~/utils/toast';

interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
interface ToastProps extends AriaToastProps<ToastData> {
state: ToastState<ToastData>;
}

function Toast({ state, ...props }: ToastProps) {
Expand All @@ -22,26 +23,38 @@ function Toast({ state, ...props }: ToastProps) {
ref,
);

const { content, type } = props.toast.content;
const isError = type === 'error';
const isSuccess = type === 'success';

return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
'shadow-lg dark:shadow-md rounded-xl py-3',
'max-w-[50vw] whitespace-pre-wrap break-words',
!isError &&
!isSuccess &&
'bg-headplane-900 dark:bg-headplane-950 text-white',
isError && 'bg-red-100 dark:bg-red-900 text-red-900 dark:text-red-100',
isSuccess &&
'bg-green-100 dark:bg-green-900 text-green-900 dark:text-green-100',
)}
ref={ref}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
<div {...contentProps} className="flex flex-col gap-2 flex-1">
<div {...titleProps}>{content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
'bg-transparent hover:bg-black/10',
!isError &&
!isSuccess &&
'hover:bg-headplane-700 dark:hover:bg-headplane-800',
)}
label="Close"
>
<X className="p-1" />
</IconButton>
Expand All @@ -50,7 +63,7 @@ function Toast({ state, ...props }: ToastProps) {
}

interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
state: ToastState<ToastData>;
}

function ToastRegion({ state, ...props }: ToastRegionProps) {
Expand All @@ -60,18 +73,18 @@ function ToastRegion({ state, ...props }: ToastRegionProps) {
return (
<div
{...regionProps}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4', 'z-50')}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
<Toast key={toast.key} state={state} toast={toast} />
))}
</div>
);
}

export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
queue: ToastQueue<ToastData>;
}

export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
Expand Down
231 changes: 170 additions & 61 deletions app/routes/acls/acl-action.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isApiError } from '~/server/headscale/api/error-client';
// import ResponseError from '~/server/headscale/api/response-error'; // Unused
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';

// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
interface DataWithResponseInit {
data: {
data?: {
message?: string;
};
rawData?: string;
};
init: {
status?: number;
statusText?: string;
};
}

function isDataWithResponseInit(error: unknown): error is DataWithResponseInit {
return (
typeof error === 'object' &&
error !== null &&
'data' in error &&
'init' in error
);
}

export async function aclAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
Expand Down Expand Up @@ -36,73 +59,159 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
policy,
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
throw data('Policy is not writable', { status: 403 });
}
// biome-ignore lint/suspicious/noExplicitAny: Error handling needs to catch all types
} catch (error: unknown) {
console.error('ACL Action Error:', error);

// Handle data() throw objects (DataWithResponseInit) which aren't instanceof Response
// but have the structure: { data: { ... }, init: { status: 502, ... } }
if (isDataWithResponseInit(error)) {
const statusCode = error.init.status || 500;
const statusText = error.init.statusText || 'Error';

// 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;

return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
// The internal error from Headscale
const internalData = error.data.data;
let message = internalData?.message;

if (!message) {
// Fallback to raw data or stringified object
message = error.data?.rawData || JSON.stringify(error.data);
}

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;

return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
// Clean up common prefixes if present
if (typeof message === 'string') {
if (message.includes('setting policy:')) {
message = message.replace('setting policy:', '').trim();
}
if (message.includes('parsing policy:')) {
message = message.replace('parsing policy:', '').trim();
}
}

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: `${message}\n\nStatus: ${statusCode} ${statusText}`,
policy: undefined,
updatedAt: undefined,
},
// We return 200 or 400 to the UI so it renders the page with the error
// instead of triggering an ErrorBoundary
400,
);
}

// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof Response) {
try {
const payload = await error.json();
console.error('ACL Action Payload:', payload);

if (isApiError(payload)) {
let message =
(payload.data?.message as string) ||
payload.rawData ||
'Unknown error';

if (typeof message === 'object') {
message = JSON.stringify(message);
}

// 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
return data(
{
success: false,
error: 'Policy is not writable (File mode enabled)',
policy: undefined,
updatedAt: undefined,
},
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;

return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}

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;

return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}

if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
}

// Return the raw error if no specific match
return data(
{
success: false,
error: message,
policy: undefined,
updatedAt: undefined,
},
payload.statusCode,
);
}
} catch (e) {
console.error('Failed to parse error response:', e);
}
}

// Otherwise, this is a Headscale error that we can just propagate.
throw error;
// Otherwise, catch generic errors and return them to the UI
// instead of throwing (which triggers ErrorBoundary).
return data(
{
success: false,
error: error instanceof Error ? error.message : String(error),
policy: undefined,
updatedAt: undefined,
},
500,
);
}
}
29 changes: 18 additions & 11 deletions app/routes/acls/acl-loader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { data } from 'react-router';
import { isApiError } from '~/server/headscale/api/error-client';
import ResponseError from '~/server/headscale/api/response-error';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
Expand Down Expand Up @@ -39,18 +40,24 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
} 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
flags.writable = true;
}
if (error instanceof Response) {
const payload = await error.json();
if (isApiError(payload)) {
const message =
(payload.data?.message as string) ||
payload.rawData ||
'Unknown error';

return flags;
// 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
flags.writable = true;
return flags;
}
}
}

// Otherwise, this is a Headscale error that we can just propagate.
Expand Down
Loading