Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# 0.6.2 (Next)
- Improved ACL editor error handling and feedback (via [#425](https://github.com/tale/headplane/pull/425)):
- Added ACL testing support
- Syntax errors show the specific line and character in the editor with highlighting for errors returned by
server.
- Auto-run ACL tests when save fails because of ACL test failure.
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
- Added support for Headscale 0.27.0 and 0.27.1
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
Expand Down
268 changes: 152 additions & 116 deletions app/routes/acls/acl-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,138 +2,174 @@ import { data } from 'react-router';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import {
getApiErrorMessage,
parseSyntaxError,
parseTestResultsFromError,
} from './utils/parsing';
import {
saveError,
saveSuccess,
testError,
testSuccess,
} from './utils/responses';

// 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.
export async function aclAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
async function handleTestPolicy(
request: Request,
context: Route.ActionArgs['context'],
policyData: string,
apiKey: string,
) {
const hasPermission = await context.sessions.check(
request,
Capabilities.write_policy,
Capabilities.read_policy,
);
if (!check) {
throw data('You do not have permission to write to the ACL policy', {
if (!hasPermission) {
throw data('You do not have permission to access the ACL policy', {
status: 403,
});
}

// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data('Missing `policy` in the form data.', {
status: 400,
const api = context.hsApi.getRuntimeClient(apiKey);

try {
return testSuccess(await api.testPolicy(policyData));
} catch (error) {
// Handle client-side errors (syntax errors, no tests found, etc.)
if (error instanceof Error) {
if (
error.message.includes('No tests found') ||
error.message.includes('Syntax Error')
) {
return testError(error.message);
}
}

if (!isDataWithApiError(error)) {
// Unknown error - return generic message
if (error instanceof Error) {
return testError(`Error: ${error.message}`);
}
return testError('An unknown error occurred while testing the policy.');
}

const { statusCode } = error.data;
if (statusCode === 404 || statusCode === 501) {
return testError(
'ACL testing is not supported by your Headscale version. Please upgrade to a version that includes ACL testing support.',
);
}

const message = getApiErrorMessage(error.data.data);
if (message) return testError(message);

return testError(`Server Error: Failed to test policy (${statusCode}).`);
}
}

async function handleSavePolicy(
request: Request,
context: Route.ActionArgs['context'],
policyData: string,
apiKey: string,
) {
const hasPermission = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!hasPermission) {
throw data('You do not have permission to write to the ACL policy', {
status: 403,
});
}

const api = context.hsApi.getRuntimeClient(session.api_key);
const api = context.hsApi.getRuntimeClient(apiKey);

try {
const { policy, updatedAt } = await api.setPolicy(policyData);
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
return saveSuccess(policy, updatedAt);
} catch (error) {
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 });
}
return handleSaveError(error, context, policyData);
}
}

const message =
error.data.data != null &&
'message' in error.data.data &&
typeof error.data.data.message === 'string'
? error.data.data.message
: undefined;
function handleSaveError(
error: unknown,
context: Route.ActionArgs['context'],
policyData: string,
) {
if (!isDataWithApiError(error)) {
if (error instanceof Error) {
return saveError(`Error: ${error.message}`, undefined, 500);
}
return saveError(
'Unknown Error: An unexpected error occurred.',
undefined,
500,
);
}

if (message == null) {
throw error;
}
const { rawData, statusCode, data: errorData } = error.data;

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

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,
);
}
}
}
// Gateway errors - Headscale unreachable
if (statusCode >= 502 && statusCode <= 504) {
return saveError(
`Gateway Error: Headscale server is unavailable (${statusCode}).`,
undefined,
statusCode,
);
}

// Policy updates disabled in config
if (rawData.includes('update is disabled')) {
return saveError(
'Policy Error: Policy updates are disabled in Headscale configuration.',
undefined,
403,
);
}

// Check for test failure results in error response
const testResults = parseTestResultsFromError(errorData, policyData);
if (testResults) {
const failedCount = testResults.results.filter((r) => !r.passed).length;
return saveError(
`Test Failure: ${failedCount} test${failedCount !== 1 ? 's' : ''} failed`,
testResults,
statusCode,
);
}

// Try to extract meaningful error message
const message = getApiErrorMessage(errorData);
if (message) {
const isModernVersion = context.hsApi.clientHelpers.isAtleast('0.27.0');
const syntaxError = parseSyntaxError(message, isModernVersion);
if (syntaxError) return saveError(syntaxError, undefined, statusCode);
return saveError(`Policy Error: ${message}`, undefined, statusCode);
}

return saveError(
`Server Error: Failed to save policy (${statusCode}).`,
undefined,
statusCode,
);
}

export async function aclAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const formData = await request.formData();

const actionType = formData.get('action')?.toString();
const policyData = formData.get('policy')?.toString();

// Otherwise, this is a Headscale error that we can just propagate.
throw error;
if (!policyData) {
throw data('Missing `policy` in the form data.', { status: 400 });
}

if (actionType === 'test_policy') {
return handleTestPolicy(request, context, policyData, session.api_key);
}

return handleSavePolicy(request, context, policyData, session.api_key);
}
46 changes: 46 additions & 0 deletions app/routes/acls/components/action-buttons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { FlaskConical } from 'lucide-react';
import Button from '~/components/Button';

interface Props {
isLoading: boolean;
disabled: boolean;
hasChanges: boolean;
hasPolicy: boolean;
onSave: () => void;
onRunTests: () => void;
onDiscard: () => void;
}

export function ActionButtons({
isLoading,
disabled,
hasChanges,
hasPolicy,
onSave,
onRunTests,
onDiscard,
}: Props) {
return (
<div className="flex gap-2 flex-wrap">
<Button
isDisabled={disabled || isLoading || !hasPolicy || !hasChanges}
onPress={onSave}
variant="heavy"
>
Save
</Button>
<Button isDisabled={isLoading || !hasPolicy} onPress={onRunTests}>
<span className="flex items-center gap-1.5">
<FlaskConical className="w-4 h-4" />
Run Tests
</span>
</Button>
<Button
isDisabled={disabled || isLoading || !hasChanges}
onPress={onDiscard}
>
Discard Changes
</Button>
</div>
);
}
Loading