+
+ );
+}
diff --git a/app/routes/acls/hooks/use-acl-editor.ts b/app/routes/acls/hooks/use-acl-editor.ts
new file mode 100644
index 00000000..eed6f0f4
--- /dev/null
+++ b/app/routes/acls/hooks/use-acl-editor.ts
@@ -0,0 +1,174 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useFetcher, useRevalidator } from 'react-router';
+import type { TestACLResponse } from '~/server/headscale/api/endpoints/policy';
+import toast from '~/utils/toast';
+import type { aclAction } from '../acl-action';
+import {
+ parseSyntaxErrorLocation,
+ type SyntaxErrorLocation,
+} from '../utils/parsing';
+
+interface EditorState {
+ testResults: TestACLResponse | null;
+ testError: string | null;
+ saveError: string | undefined;
+ syntaxError: SyntaxErrorLocation | null;
+ pendingTestAfterSaveError: boolean;
+}
+
+export function useACLEditor(initialPolicy: string) {
+ const [codePolicy, setCodePolicy] = useState(initialPolicy);
+ const [state, setState] = useState({
+ testResults: null,
+ testError: null,
+ saveError: undefined,
+ syntaxError: null,
+ pendingTestAfterSaveError: false,
+ });
+
+ const fetcher = useFetcher();
+ const { revalidate } = useRevalidator();
+
+ // Track policy for auto-test trigger
+ const codePolicyRef = useRef(codePolicy);
+ codePolicyRef.current = codePolicy;
+
+ // Track which fetcher response we've already processed to avoid re-processing
+ const processedDataRef = useRef(null);
+
+ // Sync with loader data when it changes
+ useEffect(() => {
+ if (initialPolicy !== codePolicy) {
+ setCodePolicy(initialPolicy);
+ }
+ }, [initialPolicy]);
+
+ // Reset state when policy changes
+ useEffect(() => {
+ setState({
+ testResults: null,
+ testError: null,
+ saveError: undefined,
+ syntaxError: null,
+ pendingTestAfterSaveError: false,
+ });
+ }, [codePolicy]);
+
+ const runTests = useCallback(() => {
+ const formData = new FormData();
+ formData.append('action', 'test_policy');
+ formData.append('policy', codePolicyRef.current);
+ fetcher.submit(formData, { method: 'POST' });
+ }, [fetcher]);
+
+ // Auto-run tests after save error when fetcher becomes idle
+ useEffect(() => {
+ if (state.pendingTestAfterSaveError && fetcher.state === 'idle') {
+ setState((s) => ({ ...s, pendingTestAfterSaveError: false }));
+ runTests();
+ }
+ }, [state.pendingTestAfterSaveError, fetcher.state, runTests]);
+
+ // Handle fetcher responses
+ useEffect(() => {
+ if (!fetcher.data) return;
+
+ // Skip if we've already processed this exact response
+ if (processedDataRef.current === fetcher.data) return;
+ processedDataRef.current = fetcher.data;
+
+ const data = fetcher.data;
+
+ // Test policy response
+ if ('action' in data && data.action === 'test_policy') {
+ if (data.success && data.testResults) {
+ const results = data.testResults;
+ setState((s) => {
+ // Only show toast if this wasn't triggered by a save error
+ if (!s.saveError) {
+ toast(
+ results.allPassed ? 'All tests passed!' : 'Some tests failed',
+ );
+ }
+ return {
+ ...s,
+ testResults: results,
+ testError: null,
+ syntaxError: null,
+ };
+ });
+ } else if (!data.success && data.error) {
+ const error = data.error;
+ // Try to parse syntax error location
+ const syntaxError =
+ parseSyntaxErrorLocation(error, codePolicyRef.current) ?? null;
+ setState((s) => ({
+ ...s,
+ testError: error,
+ testResults: null,
+ syntaxError,
+ }));
+ }
+ return;
+ }
+
+ // Save policy response
+ if ('action' in data && data.action === 'save_policy') {
+ if (data.success) {
+ toast('Updated policy');
+ revalidate();
+ setState({
+ testResults: null,
+ testError: null,
+ saveError: undefined,
+ syntaxError: null,
+ pendingTestAfterSaveError: false,
+ });
+ return;
+ }
+
+ // Save failed - show error and schedule auto-test
+ const error = data.error;
+ const results = data.testResults;
+ // Try to parse syntax error location
+ const syntaxError = error
+ ? (parseSyntaxErrorLocation(error, codePolicyRef.current) ?? null)
+ : null;
+ setState((s) => ({
+ ...s,
+ saveError: error,
+ testResults: results ?? null,
+ syntaxError,
+ // Schedule test run if no results from server and no syntax error
+ pendingTestAfterSaveError: !results && !syntaxError,
+ }));
+ toast('Save failed');
+ }
+ }, [fetcher.data, revalidate]);
+
+ const save = () => {
+ const formData = new FormData();
+ formData.append('policy', codePolicy);
+ fetcher.submit(formData, { method: 'PATCH' });
+ };
+
+ const clearTestResults = () => {
+ setState((s) => ({
+ ...s,
+ testResults: null,
+ saveError: undefined,
+ syntaxError: null,
+ }));
+ };
+
+ return {
+ codePolicy,
+ setCodePolicy,
+ isLoading: fetcher.state !== 'idle',
+ hasChanges: codePolicy !== initialPolicy,
+ ...state,
+ save,
+ runTests,
+ clearTestResults,
+ };
+}
diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx
index d3c96db0..70ee68a8 100644
--- a/app/routes/acls/overview.tsx
+++ b/app/routes/acls/overview.tsx
@@ -1,24 +1,18 @@
-import {
- AlertCircle,
- Construction,
- Eye,
- FlaskConical,
- Pencil,
-} from 'lucide-react';
-import { useEffect, useState } from 'react';
-import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
-import Button from '~/components/Button';
+import { AlertCircle, Eye, Pencil } from 'lucide-react';
+import { isRouteErrorResponse } from 'react-router';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import { isApiError } from '~/server/headscale/api/error-client';
-import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
+import { ActionButtons } from './components/action-buttons';
import { Differ, Editor } from './components/cm.client';
+import { TestResults } from './components/test-results';
+import { useACLEditor } from './hooks/use-acl-editor';
export const loader = aclLoader;
export const action = aclAction;
@@ -26,39 +20,20 @@ export const action = aclAction;
export default function Page({
loaderData: { access, writable, policy },
}: Route.ComponentProps) {
- const [codePolicy, setCodePolicy] = useState(policy);
- const fetcher = useFetcher();
- const { revalidate } = useRevalidator();
- const disabled = !access || !writable; // Disable if no permission or not writable
-
- useEffect(() => {
- // Update the codePolicy when the loader data changes
- if (policy !== codePolicy) {
- setCodePolicy(policy);
- }
- }, [policy]);
-
- useEffect(() => {
- if (!fetcher.data) {
- // No data yet, return
- return;
- }
-
- if (fetcher.data.success === true) {
- toast('Updated policy');
- revalidate();
- }
- }, [fetcher.data]);
+ const editor = useACLEditor(policy);
+ const disabled = !access || !writable;
return (
- {!access ? (
+ {!access && (
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
- ) : !writable ? (
+ )}
+
+ {access && !writable && (
The ACL policy mode is most likely set to file in your
Headscale configuration. This means that the ACL file cannot be edited
@@ -66,8 +41,10 @@ export default function Page({
set policy.mode to database in your
Headscale configuration.
- ) : undefined}
+ )}
+
Access Control List (ACL)
+
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
@@ -86,15 +63,19 @@ export default function Page({
.
- Previewing rules is not available yet. This feature is still in
- development and is pretty complicated to implement. Hopefully I
- will be able to get to it soon.
-
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
- if (
+ const isPolicyFileMissing =
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes('reading policy from path') &&
- error.data.rawData.includes('no such file or directory')
- ) {
- return (
-
-
-
- ACL Policy Unavailable
-
-
-
- The ACL policy is currently unavailable because the policy file does
- not exist on the server. This usually indicates that Headscale is
- running in file mode for ACLs, and the specified policy
- file is missing.
-
-
-
-
- In order to resolve this issue, there are two possible actions you
- can take:
-
-
-
- Create the ACL policy file at the specified path in your Headscale
- configuration.
-
-
- Alternatively, you can switch Headscale to use{' '}
- database mode for ACLs by updating your Headscale
- configuration. This will allow Headplane to manage the ACL policy
- directly through the web interface.
-
-
-
-
- );
+ error.data.rawData.includes('no such file or directory');
+
+ if (!isPolicyFileMissing) {
+ throw error;
}
- throw error;
+ return (
+
+
+
+ ACL Policy Unavailable
+
+
+
+ The ACL policy is currently unavailable because the policy file does
+ not exist on the server. This usually indicates that Headscale is
+ running in file mode for ACLs, and the specified policy
+ file is missing.
+
+
+
+
+ In order to resolve this issue, there are two possible actions you can
+ take:
+
+
+
+ Create the ACL policy file at the specified path in your Headscale
+ configuration.
+
+
+ Alternatively, you can switch Headscale to use database{' '}
+ mode for ACLs by updating your Headscale configuration. This will
+ allow Headplane to manage the ACL policy directly through the web
+ interface.
+
+
+
+
+ );
}
diff --git a/app/routes/acls/utils/parsing.ts b/app/routes/acls/utils/parsing.ts
new file mode 100644
index 00000000..1a782aaa
--- /dev/null
+++ b/app/routes/acls/utils/parsing.ts
@@ -0,0 +1,154 @@
+import {
+ extractTestsFromPolicy,
+ parsePolicy,
+ type TestACLResponse,
+} from '~/server/headscale/api/endpoints/policy';
+
+// Re-export for convenience
+export { extractTestsFromPolicy, parsePolicy };
+
+/**
+ * Represents a syntax error with location information.
+ */
+export interface SyntaxErrorLocation {
+ line: number;
+ column: number;
+ message: string;
+}
+
+/**
+ * Parse an error message to extract line/column information.
+ * Supports formats like:
+ * - "hujson: line 67, column 5: invalid character..."
+ * - "Syntax Error: line 67, column 5: ..."
+ * - JSON.parse errors: "... at position 1234"
+ */
+export function parseSyntaxErrorLocation(
+ error: string,
+ policyContent?: string,
+): SyntaxErrorLocation | undefined {
+ // Pattern for "line X, column Y" format (Headscale/hujson)
+ const lineColMatch = error.match(/line\s+(\d+),?\s*column\s+(\d+)/i);
+ if (lineColMatch) {
+ return {
+ line: Number.parseInt(lineColMatch[1], 10),
+ column: Number.parseInt(lineColMatch[2], 10),
+ message: error,
+ };
+ }
+
+ // Pattern for "at line X" format
+ const lineOnlyMatch = error.match(/at\s+line\s+(\d+)/i);
+ if (lineOnlyMatch) {
+ return {
+ line: Number.parseInt(lineOnlyMatch[1], 10),
+ column: 1,
+ message: error,
+ };
+ }
+
+ // Pattern for JSON.parse "at position N" - convert to line/column
+ const positionMatch = error.match(/at\s+position\s+(\d+)/i);
+ if (positionMatch && policyContent) {
+ const position = Number.parseInt(positionMatch[1], 10);
+ const beforeError = policyContent.substring(0, position);
+ const lines = beforeError.split('\n');
+ return {
+ line: lines.length,
+ column: (lines[lines.length - 1]?.length ?? 0) + 1,
+ message: error,
+ };
+ }
+
+ return undefined;
+}
+
+/**
+ * Extract error message from API error data.
+ */
+export function getApiErrorMessage(errorData: unknown): string | undefined {
+ if (
+ errorData != null &&
+ typeof errorData === 'object' &&
+ 'message' in errorData &&
+ typeof errorData.message === 'string'
+ ) {
+ return errorData.message;
+ }
+ return undefined;
+}
+
+/**
+ * Parse syntax error message based on Headscale version.
+ */
+export function parseSyntaxError(
+ message: string,
+ isModernVersion: boolean,
+): string | undefined {
+ const patterns = isModernVersion
+ ? [
+ { match: 'parsing HuJSON:', offset: 16 },
+ { match: 'parsing policy from bytes:', offset: 26 },
+ ]
+ : [
+ { match: 'err: hujson:', offset: 12, trigger: 'parsing hujson' },
+ { match: 'err:', offset: 5, trigger: 'unmarshalling policy' },
+ ];
+
+ for (const pattern of patterns) {
+ const trigger = 'trigger' in pattern ? pattern.trigger : pattern.match;
+ if (!message.includes(trigger)) continue;
+
+ const cutIndex = message.indexOf(pattern.match);
+ if (cutIndex > -1) {
+ return `Syntax Error: ${message.slice(cutIndex + pattern.offset).trim()}`;
+ }
+ return message;
+ }
+
+ return undefined;
+}
+
+/**
+ * Try to parse test results from an error response.
+ * Returns undefined if the error doesn't contain test results.
+ */
+export function parseTestResultsFromError(
+ errorData: Record | null,
+ policyData: string,
+): TestACLResponse | undefined {
+ if (!errorData) return undefined;
+
+ const results = errorData.results;
+ if (!Array.isArray(results)) return undefined;
+
+ const originalTests = extractTestsFromPolicy(policyData);
+
+ const parsedResults = results.map((r: unknown, index: number) => {
+ if (typeof r !== 'object' || r === null) {
+ return { src: 'unknown', passed: false, testIndex: index };
+ }
+
+ const result = r as Record;
+ return {
+ src: typeof result.src === 'string' ? result.src : 'unknown',
+ passed: result.passed === true,
+ errors: Array.isArray(result.errors) ? result.errors : undefined,
+ acceptOk: Array.isArray(result.accept_ok) ? result.accept_ok : undefined,
+ acceptFail: Array.isArray(result.accept_fail)
+ ? result.accept_fail
+ : undefined,
+ denyOk: Array.isArray(result.deny_ok) ? result.deny_ok : undefined,
+ denyFail: Array.isArray(result.deny_fail) ? result.deny_fail : undefined,
+ testIndex: index,
+ proto: originalTests[index]?.proto,
+ accept: originalTests[index]?.accept,
+ deny: originalTests[index]?.deny,
+ };
+ });
+
+ return {
+ allPassed: parsedResults.every((r) => r.passed),
+ results: parsedResults,
+ };
+}
diff --git a/app/routes/acls/utils/responses.ts b/app/routes/acls/utils/responses.ts
new file mode 100644
index 00000000..af3921b7
--- /dev/null
+++ b/app/routes/acls/utils/responses.ts
@@ -0,0 +1,59 @@
+import { data } from 'react-router';
+import type { TestACLResponse } from '~/server/headscale/api/endpoints/policy';
+
+export type TestPolicyResponse = {
+ success: boolean;
+ action: 'test_policy';
+ testResults: TestACLResponse | undefined;
+ error: string | undefined;
+};
+
+export type SavePolicyResponse = {
+ success: boolean;
+ action: 'save_policy';
+ error: string | undefined;
+ policy: string | undefined;
+ updatedAt: Date | undefined;
+ testResults: TestACLResponse | undefined;
+};
+
+export const testError = (error: string, status = 400) =>
+ data(
+ { success: false, action: 'test_policy', testResults: undefined, error },
+ status,
+ );
+
+export const testSuccess = (testResults: TestACLResponse) =>
+ data({
+ success: true,
+ action: 'test_policy',
+ testResults,
+ error: undefined,
+ });
+
+export const saveError = (
+ error: string,
+ testResults?: TestACLResponse,
+ status = 400,
+) =>
+ data(
+ {
+ success: false,
+ action: 'save_policy',
+ error,
+ policy: undefined,
+ updatedAt: undefined,
+ testResults,
+ },
+ status,
+ );
+
+export const saveSuccess = (policy: string, updatedAt: Date) =>
+ data({
+ success: true,
+ action: 'save_policy',
+ error: undefined,
+ policy,
+ updatedAt,
+ testResults: undefined,
+ });
diff --git a/app/server/headscale/api/endpoints/policy.ts b/app/server/headscale/api/endpoints/policy.ts
index 28c8f376..da4b4303 100644
--- a/app/server/headscale/api/endpoints/policy.ts
+++ b/app/server/headscale/api/endpoints/policy.ts
@@ -1,5 +1,87 @@
import { defineApiEndpoints } from '../factory';
+export type ParsePolicyResult =
+ | {
+ success: true;
+ tests: Array<{
+ src: string;
+ proto?: string;
+ accept?: string[];
+ deny?: string[];
+ }>;
+ }
+ | { success: false; error: string };
+
+/**
+ * Parse HuJSON policy to extract embedded tests.
+ * Strips comments and trailing commas before parsing.
+ * Returns a result object that distinguishes between syntax errors and missing tests.
+ */
+export function parsePolicy(policyData: string): ParsePolicyResult {
+ try {
+ const cleanJson = policyData
+ .replace(/\/\/.*$/gm, '') // Remove single-line comments
+ .replace(/\/\*[\s\S]*?\*\//g, '') // Remove multi-line comments
+ .replace(/,(\s*[}\]])/g, '$1'); // Remove trailing commas
+ const parsed = JSON.parse(cleanJson);
+ return {
+ success: true,
+ tests: Array.isArray(parsed.tests) ? parsed.tests : [],
+ };
+ } catch (e) {
+ const message = e instanceof Error ? e.message : 'Unknown parsing error';
+ return { success: false, error: `Syntax Error: ${message}` };
+ }
+}
+
+/**
+ * Parse HuJSON policy to extract embedded tests.
+ * Strips comments and trailing commas before parsing.
+ * @deprecated Use parsePolicy() for better error handling
+ */
+export function extractTestsFromPolicy(
+ policyData: string,
+): Array<{ src: string; proto?: string; accept?: string[]; deny?: string[] }> {
+ const result = parsePolicy(policyData);
+ return result.success ? result.tests : [];
+}
+
+/**
+ * Represents a single ACL test case.
+ */
+export interface ACLTest {
+ src: string;
+ proto?: string;
+ accept?: string[];
+ deny?: string[];
+}
+
+/**
+ * Result of running a single ACL test.
+ */
+export interface ACLTestResult {
+ src: string;
+ passed: boolean;
+ errors?: string[];
+ acceptOk?: string[];
+ acceptFail?: string[];
+ denyOk?: string[];
+ denyFail?: string[];
+ // Original test definition for display
+ testIndex: number;
+ proto?: string;
+ accept?: string[];
+ deny?: string[];
+}
+
+/**
+ * Response from the ACL test endpoint.
+ */
+export interface TestACLResponse {
+ allPassed: boolean;
+ results: ACLTestResult[];
+}
+
export interface PolicyEndpoints {
/**
* Retrieves the current ACL policy from the Headscale instance.
@@ -15,6 +97,16 @@ export interface PolicyEndpoints {
* @returns The updated ACL policy as a string and the date it was last updated.
*/
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
+
+ /**
+ * Tests ACL rules against a policy.
+ * If tests array is empty, runs embedded tests from the policy.
+ *
+ * @param policy The ACL policy to test against.
+ * @param tests Optional array of test cases. If empty, runs embedded tests.
+ * @returns Test results with pass/fail status for each test case.
+ */
+ testPolicy(policy: string, tests?: ACLTest[]): Promise;
}
export default defineApiEndpoints((client, apiKey) => ({
@@ -38,4 +130,57 @@ export default defineApiEndpoints((client, apiKey) => ({
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
+
+ testPolicy: async (policy, tests) => {
+ // If tests provided, use them directly
+ let testsToSend = tests && tests.length > 0 ? tests : undefined;
+
+ // Otherwise try to extract embedded tests from the policy
+ if (!testsToSend) {
+ const parseResult = parsePolicy(policy);
+ if (!parseResult.success) {
+ throw new Error(parseResult.error);
+ }
+ testsToSend = parseResult.tests;
+ }
+
+ if (testsToSend.length === 0) {
+ throw new Error(
+ 'No tests found in the policy. Add a "tests" array to your ACL.',
+ );
+ }
+
+ const body = { policy, tests: testsToSend };
+
+ const response = await client.apiFetch<{
+ all_passed: boolean;
+ results: Array<{
+ src: string;
+ passed: boolean;
+ errors: string[];
+ accept_ok: string[];
+ accept_fail: string[];
+ deny_ok: string[];
+ deny_fail: string[];
+ }>;
+ }>('POST', 'v1/policy/test', apiKey, body);
+
+ return {
+ allPassed: response.all_passed,
+ results: response.results.map((r, index) => ({
+ src: r.src,
+ passed: r.passed,
+ errors: r.errors,
+ acceptOk: r.accept_ok,
+ acceptFail: r.accept_fail,
+ denyOk: r.deny_ok,
+ denyFail: r.deny_fail,
+ // Include original test definition for display
+ testIndex: index,
+ proto: testsToSend[index]?.proto,
+ accept: testsToSend[index]?.accept,
+ deny: testsToSend[index]?.deny,
+ })),
+ };
+ },
}));