Skip to content

Commit f5b1c12

Browse files
committed
ACL testing
1 parent d189d75 commit f5b1c12

File tree

10 files changed

+1340
-249
lines changed

10 files changed

+1340
-249
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
# 0.6.2 (Next)
2+
- Improved ACL editor error handling and feedback (via [#425](https://github.com/tale/headplane/pull/425)):
3+
- Added ACL testing support
4+
- Syntax errors show the specific line and character in the editor with highlighting for errors returned by
5+
server.
6+
- Auto-run ACL tests when save fails because of ACL test failure.
27
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
38
- Added support for Headscale 0.27.0 and 0.27.1
49
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).

app/routes/acls/acl-action.ts

Lines changed: 152 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -2,138 +2,174 @@ import { data } from 'react-router';
22
import { isDataWithApiError } from '~/server/headscale/api/error-client';
33
import { Capabilities } from '~/server/web/roles';
44
import type { Route } from './+types/overview';
5+
import {
6+
getApiErrorMessage,
7+
parseSyntaxError,
8+
parseTestResultsFromError,
9+
} from './utils/parsing';
10+
import {
11+
saveError,
12+
saveSuccess,
13+
testError,
14+
testSuccess,
15+
} from './utils/responses';
516

6-
// We only check capabilities here and assume it is writable
7-
// If it isn't, it'll gracefully error anyways, since this means some
8-
// fishy client manipulation is happening.
9-
export async function aclAction({ request, context }: Route.ActionArgs) {
10-
const session = await context.sessions.auth(request);
11-
const check = await context.sessions.check(
17+
async function handleTestPolicy(
18+
request: Request,
19+
context: Route.ActionArgs['context'],
20+
policyData: string,
21+
apiKey: string,
22+
) {
23+
const hasPermission = await context.sessions.check(
1224
request,
13-
Capabilities.write_policy,
25+
Capabilities.read_policy,
1426
);
15-
if (!check) {
16-
throw data('You do not have permission to write to the ACL policy', {
27+
if (!hasPermission) {
28+
throw data('You do not have permission to access the ACL policy', {
1729
status: 403,
1830
});
1931
}
2032

21-
// Try to write to the ACL policy via the API or via config file (TODO).
22-
const formData = await request.formData();
23-
const policyData = formData.get('policy')?.toString();
24-
if (!policyData) {
25-
throw data('Missing `policy` in the form data.', {
26-
status: 400,
33+
const api = context.hsApi.getRuntimeClient(apiKey);
34+
35+
try {
36+
return testSuccess(await api.testPolicy(policyData));
37+
} catch (error) {
38+
// Handle client-side errors (syntax errors, no tests found, etc.)
39+
if (error instanceof Error) {
40+
if (
41+
error.message.includes('No tests found') ||
42+
error.message.includes('Syntax Error')
43+
) {
44+
return testError(error.message);
45+
}
46+
}
47+
48+
if (!isDataWithApiError(error)) {
49+
// Unknown error - return generic message
50+
if (error instanceof Error) {
51+
return testError(`Error: ${error.message}`);
52+
}
53+
return testError('An unknown error occurred while testing the policy.');
54+
}
55+
56+
const { statusCode } = error.data;
57+
if (statusCode === 404 || statusCode === 501) {
58+
return testError(
59+
'ACL testing is not supported by your Headscale version. Please upgrade to a version that includes ACL testing support.',
60+
);
61+
}
62+
63+
const message = getApiErrorMessage(error.data.data);
64+
if (message) return testError(message);
65+
66+
return testError(`Server Error: Failed to test policy (${statusCode}).`);
67+
}
68+
}
69+
70+
async function handleSavePolicy(
71+
request: Request,
72+
context: Route.ActionArgs['context'],
73+
policyData: string,
74+
apiKey: string,
75+
) {
76+
const hasPermission = await context.sessions.check(
77+
request,
78+
Capabilities.write_policy,
79+
);
80+
if (!hasPermission) {
81+
throw data('You do not have permission to write to the ACL policy', {
82+
status: 403,
2783
});
2884
}
2985

30-
const api = context.hsApi.getRuntimeClient(session.api_key);
86+
const api = context.hsApi.getRuntimeClient(apiKey);
87+
3188
try {
3289
const { policy, updatedAt } = await api.setPolicy(policyData);
33-
return data({
34-
success: true,
35-
error: undefined,
36-
policy,
37-
updatedAt,
38-
});
90+
return saveSuccess(policy, updatedAt);
3991
} catch (error) {
40-
if (isDataWithApiError(error)) {
41-
const rawData = error.data.rawData;
42-
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
43-
if (rawData.includes('update is disabled')) {
44-
throw data('Policy is not writable', { status: 403 });
45-
}
92+
return handleSaveError(error, context, policyData);
93+
}
94+
}
4695

47-
const message =
48-
error.data.data != null &&
49-
'message' in error.data.data &&
50-
typeof error.data.data.message === 'string'
51-
? error.data.data.message
52-
: undefined;
96+
function handleSaveError(
97+
error: unknown,
98+
context: Route.ActionArgs['context'],
99+
policyData: string,
100+
) {
101+
if (!isDataWithApiError(error)) {
102+
if (error instanceof Error) {
103+
return saveError(`Error: ${error.message}`, undefined, 500);
104+
}
105+
return saveError(
106+
'Unknown Error: An unexpected error occurred.',
107+
undefined,
108+
500,
109+
);
110+
}
53111

54-
if (message == null) {
55-
throw error;
56-
}
112+
const { rawData, statusCode, data: errorData } = error.data;
57113

58-
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
59-
// we need to reference other error messages based on API version.
60-
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
61-
if (message.includes('parsing HuJSON:')) {
62-
const cutIndex = message.indexOf('parsing HuJSON:');
63-
const trimmed =
64-
cutIndex > -1
65-
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
66-
: message;
67-
68-
return data(
69-
{
70-
success: false,
71-
error: trimmed,
72-
policy: undefined,
73-
updatedAt: undefined,
74-
},
75-
400,
76-
);
77-
}
78-
79-
if (message.includes('parsing policy from bytes:')) {
80-
const cutIndex = message.indexOf('parsing policy from bytes:');
81-
const trimmed =
82-
cutIndex > -1
83-
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
84-
: message;
85-
86-
return data(
87-
{
88-
success: false,
89-
error: trimmed,
90-
policy: undefined,
91-
updatedAt: undefined,
92-
},
93-
400,
94-
);
95-
}
96-
} else {
97-
// Pre-0.27.0 error messages
98-
if (message.includes('parsing hujson')) {
99-
const cutIndex = message.indexOf('err: hujson:');
100-
const trimmed =
101-
cutIndex > -1
102-
? `Syntax error: ${message.slice(cutIndex + 12)}`
103-
: message;
104-
105-
return data(
106-
{
107-
success: false,
108-
error: trimmed,
109-
policy: undefined,
110-
updatedAt: undefined,
111-
},
112-
400,
113-
);
114-
}
115-
116-
if (message.includes('unmarshalling policy')) {
117-
const cutIndex = message.indexOf('err:');
118-
const trimmed =
119-
cutIndex > -1
120-
? `Syntax error: ${message.slice(cutIndex + 5)}`
121-
: message;
122-
123-
return data(
124-
{
125-
success: false,
126-
error: trimmed,
127-
policy: undefined,
128-
updatedAt: undefined,
129-
},
130-
400,
131-
);
132-
}
133-
}
134-
}
114+
// Gateway errors - Headscale unreachable
115+
if (statusCode >= 502 && statusCode <= 504) {
116+
return saveError(
117+
`Gateway Error: Headscale server is unavailable (${statusCode}).`,
118+
undefined,
119+
statusCode,
120+
);
121+
}
122+
123+
// Policy updates disabled in config
124+
if (rawData.includes('update is disabled')) {
125+
return saveError(
126+
'Policy Error: Policy updates are disabled in Headscale configuration.',
127+
undefined,
128+
403,
129+
);
130+
}
131+
132+
// Check for test failure results in error response
133+
const testResults = parseTestResultsFromError(errorData, policyData);
134+
if (testResults) {
135+
const failedCount = testResults.results.filter((r) => !r.passed).length;
136+
return saveError(
137+
`Test Failure: ${failedCount} test${failedCount !== 1 ? 's' : ''} failed`,
138+
testResults,
139+
statusCode,
140+
);
141+
}
142+
143+
// Try to extract meaningful error message
144+
const message = getApiErrorMessage(errorData);
145+
if (message) {
146+
const isModernVersion = context.hsApi.clientHelpers.isAtleast('0.27.0');
147+
const syntaxError = parseSyntaxError(message, isModernVersion);
148+
if (syntaxError) return saveError(syntaxError, undefined, statusCode);
149+
return saveError(`Policy Error: ${message}`, undefined, statusCode);
150+
}
151+
152+
return saveError(
153+
`Server Error: Failed to save policy (${statusCode}).`,
154+
undefined,
155+
statusCode,
156+
);
157+
}
158+
159+
export async function aclAction({ request, context }: Route.ActionArgs) {
160+
const session = await context.sessions.auth(request);
161+
const formData = await request.formData();
162+
163+
const actionType = formData.get('action')?.toString();
164+
const policyData = formData.get('policy')?.toString();
135165

136-
// Otherwise, this is a Headscale error that we can just propagate.
137-
throw error;
166+
if (!policyData) {
167+
throw data('Missing `policy` in the form data.', { status: 400 });
138168
}
169+
170+
if (actionType === 'test_policy') {
171+
return handleTestPolicy(request, context, policyData, session.api_key);
172+
}
173+
174+
return handleSavePolicy(request, context, policyData, session.api_key);
139175
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { FlaskConical } from 'lucide-react';
2+
import Button from '~/components/Button';
3+
4+
interface Props {
5+
isLoading: boolean;
6+
disabled: boolean;
7+
hasChanges: boolean;
8+
hasPolicy: boolean;
9+
onSave: () => void;
10+
onRunTests: () => void;
11+
onDiscard: () => void;
12+
}
13+
14+
export function ActionButtons({
15+
isLoading,
16+
disabled,
17+
hasChanges,
18+
hasPolicy,
19+
onSave,
20+
onRunTests,
21+
onDiscard,
22+
}: Props) {
23+
return (
24+
<div className="flex gap-2 flex-wrap">
25+
<Button
26+
isDisabled={disabled || isLoading || !hasPolicy || !hasChanges}
27+
onPress={onSave}
28+
variant="heavy"
29+
>
30+
Save
31+
</Button>
32+
<Button isDisabled={isLoading || !hasPolicy} onPress={onRunTests}>
33+
<span className="flex items-center gap-1.5">
34+
<FlaskConical className="w-4 h-4" />
35+
Run Tests
36+
</span>
37+
</Button>
38+
<Button
39+
isDisabled={disabled || isLoading || !hasChanges}
40+
onPress={onDiscard}
41+
>
42+
Discard Changes
43+
</Button>
44+
</div>
45+
);
46+
}

0 commit comments

Comments
 (0)