diff --git a/CHANGELOG.md b/CHANGELOG.md index b856805b..b68b45bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +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)). diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index b3b0ffa2..927b5d68 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -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); } diff --git a/app/routes/acls/components/action-buttons.tsx b/app/routes/acls/components/action-buttons.tsx new file mode 100644 index 00000000..44373a7e --- /dev/null +++ b/app/routes/acls/components/action-buttons.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/app/routes/acls/components/cm.client.tsx b/app/routes/acls/components/cm.client.tsx index cf54c8c9..b0dd6b71 100644 --- a/app/routes/acls/components/cm.client.tsx +++ b/app/routes/acls/components/cm.client.tsx @@ -1,22 +1,242 @@ import * as shopify from '@shopify/lang-jsonc'; import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode'; -import CodeMirror from '@uiw/react-codemirror'; +import CodeMirror, { + Decoration, + type DecorationSet, + EditorView, + type Extension, + ViewPlugin, + type ViewUpdate, +} from '@uiw/react-codemirror'; import { BookCopy, CircleX } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import Merge from 'react-codemirror-merge'; import { ErrorBoundary } from 'react-error-boundary'; import { ClientOnly } from 'remix-utils/client-only'; +import type { ACLTestResult } from '~/server/headscale/api/endpoints/policy'; +import type { SyntaxErrorLocation } from '../utils/parsing'; import Fallback from './fallback'; interface EditorProps { isDisabled?: boolean; value: string; onChange: (value: string) => void; + testResults?: ACLTestResult[]; + syntaxError?: SyntaxErrorLocation; +} + +// Find line ranges for each test in the JSON +function findTestLineRanges( + content: string, +): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + const lines = content.split('\n'); + + // Find "tests": [ line + let inTestsArray = false; + let bracketDepth = 0; + let currentTestStart = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if we're entering the tests array + if (!inTestsArray && /"tests"\s*:\s*\[/.test(line)) { + inTestsArray = true; + // Check if there's already a { on this line + const afterBracket = line.substring(line.indexOf('[') + 1); + if (afterBracket.includes('{')) { + currentTestStart = i; + bracketDepth = 1; + } + continue; + } + + if (!inTestsArray) continue; + + // Count brackets to track test object boundaries + for (const char of line) { + if (char === '{') { + if (bracketDepth === 0) { + currentTestStart = i; + } + bracketDepth++; + } else if (char === '}') { + bracketDepth--; + if (bracketDepth === 0 && currentTestStart !== -1) { + ranges.push({ start: currentTestStart, end: i }); + currentTestStart = -1; + } + } else if (char === ']' && bracketDepth === 0) { + // End of tests array + inTestsArray = false; + break; + } + } + } + + return ranges; +} + +// Create decorations for test results +function createTestDecorations( + view: EditorView, + testResults: ACLTestResult[], +): DecorationSet { + const content = view.state.doc.toString(); + const ranges = findTestLineRanges(content); + const decorations: Array<{ + from: number; + to: number; + decoration: Decoration; + }> = []; + + for (let i = 0; i < Math.min(ranges.length, testResults.length); i++) { + const range = ranges[i]; + const result = testResults[i]; + + const fromLine = view.state.doc.line(range.start + 1); + const toLine = view.state.doc.line(range.end + 1); + + const decoration = Decoration.mark({ + class: result.passed ? 'cm-test-passed' : 'cm-test-failed', + }); + + decorations.push({ + from: fromLine.from, + to: toLine.to, + decoration, + }); + } + + // Sort by position and create decoration set + decorations.sort((a, b) => a.from - b.from); + return Decoration.set( + decorations.map((d) => d.decoration.range(d.from, d.to)), + ); +} + +// Theme for test highlighting +const testHighlightTheme = EditorView.baseTheme({ + '.cm-test-passed': { + backgroundColor: 'rgba(34, 197, 94, 0.15)', + borderLeft: '3px solid rgb(34, 197, 94)', + }, + '.cm-test-failed': { + backgroundColor: 'rgba(239, 68, 68, 0.15)', + borderLeft: '3px solid rgb(239, 68, 68)', + }, +}); + +// Create a view plugin that updates decorations +function createTestHighlightPlugin(testResults: ACLTestResult[]) { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = createTestDecorations(view, testResults); + } + + update(update: ViewUpdate) { + if (update.docChanged) { + this.decorations = createTestDecorations(update.view, testResults); + } + } + }, + { + decorations: (v: { decorations: DecorationSet }) => v.decorations, + }, + ); +} + +// Theme for syntax error highlighting +const syntaxErrorTheme = EditorView.baseTheme({ + '.cm-syntax-error-line': { + backgroundColor: 'rgba(239, 68, 68, 0.2)', + borderLeft: '3px solid rgb(239, 68, 68)', + }, + '.cm-syntax-error-char': { + backgroundColor: 'rgba(239, 68, 68, 0.5)', + outline: '2px solid rgb(239, 68, 68)', + borderRadius: '2px', + }, +}); + +// Create syntax error decorations +function createSyntaxErrorDecorations( + view: EditorView, + error: SyntaxErrorLocation | null, +): DecorationSet { + if (!error) return Decoration.none; + + const doc = view.state.doc; + if (error.line < 1 || error.line > doc.lines) return Decoration.none; + + const line = doc.line(error.line); + const decorations: Array<{ + from: number; + to: number; + decoration: Decoration; + }> = []; + + // Highlight the entire line + decorations.push({ + from: line.from, + to: line.to, + decoration: Decoration.mark({ class: 'cm-syntax-error-line' }), + }); + + // Highlight specific character if column is valid + if (error.column >= 1) { + const charPos = line.from + error.column - 1; + if (charPos <= line.to) { + // Highlight 1-3 characters around the error position + const endPos = Math.min(charPos + 1, line.to); + decorations.push({ + from: charPos, + to: endPos, + decoration: Decoration.mark({ class: 'cm-syntax-error-char' }), + }); + } + } + + decorations.sort((a, b) => a.from - b.from); + return Decoration.set( + decorations.map((d) => d.decoration.range(d.from, d.to)), + ); +} + +// Create syntax error plugin that responds to external updates +function createSyntaxErrorPlugin(initialError: SyntaxErrorLocation | null) { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = createSyntaxErrorDecorations(view, initialError); + } + + update(update: ViewUpdate) { + if (update.docChanged) { + // Clear decorations when content changes + this.decorations = Decoration.none; + } + } + }, + { + decorations: (v: { decorations: DecorationSet }) => v.decorations, + }, + ); } // TODO: Remove ClientOnly export function Editor(props: EditorProps) { const [light, setLight] = useState(false); + const editorRef = useRef<{ view?: EditorView }>(null); + // Track which error we've already scrolled to (by line:column) + const lastScrolledErrorRef = useRef(null); + useEffect(() => { const theme = window.matchMedia('(prefers-color-scheme: light)'); setLight(theme.matches); @@ -25,6 +245,47 @@ export function Editor(props: EditorProps) { }); }); + // Build extensions including test highlighting if results provided + const extensions = useMemo(() => { + const ext: Extension[] = [shopify.jsonc()]; + if (props.testResults && props.testResults.length > 0) { + ext.push(testHighlightTheme); + ext.push(createTestHighlightPlugin(props.testResults)); + } + if (props.syntaxError) { + ext.push(syntaxErrorTheme); + ext.push(createSyntaxErrorPlugin(props.syntaxError)); + } + return ext; + }, [props.testResults, props.syntaxError]); + + // Scroll to error line only once when a NEW syntax error appears + useEffect(() => { + if (!props.syntaxError) { + // Clear the ref when error is cleared + lastScrolledErrorRef.current = null; + return; + } + + const errorKey = `${props.syntaxError.line}:${props.syntaxError.column}`; + // Only scroll if this is a different error than what we last scrolled to + if (lastScrolledErrorRef.current === errorKey) { + return; + } + + if (editorRef.current?.view) { + const view = editorRef.current.view; + const doc = view.state.doc; + if (props.syntaxError.line >= 1 && props.syntaxError.line <= doc.lines) { + const line = doc.line(props.syntaxError.line); + view.dispatch({ + effects: EditorView.scrollIntoView(line.from, { y: 'center' }), + }); + lastScrolledErrorRef.current = errorKey; + } + } + }, [props.syntaxError]); + return (
( props.onChange(value)} readOnly={props.isDisabled} + ref={editorRef} style={{ height: '100%' }} theme={light ? xcodeLight : xcodeDark} value={props.value} diff --git a/app/routes/acls/components/test-results.tsx b/app/routes/acls/components/test-results.tsx new file mode 100644 index 00000000..650bf12b --- /dev/null +++ b/app/routes/acls/components/test-results.tsx @@ -0,0 +1,249 @@ +import { + Check, + CheckCircle, + ChevronDown, + ChevronRight, + Shield, + ShieldOff, + X, + XCircle, +} from 'lucide-react'; +import { useState } from 'react'; +import Card from '~/components/Card'; +import IconButton from '~/components/IconButton'; +import type { TestACLResponse } from '~/server/headscale/api/endpoints/policy'; +import cn from '~/utils/cn'; + +interface TestResultsProps { + results: TestACLResponse; + onClose: () => void; +} + +export function TestResults({ results, onClose }: TestResultsProps) { + const passedCount = results.results.filter((r) => r.passed).length; + const failedCount = results.results.length - passedCount; + const allPassed = failedCount === 0 && results.results.length > 0; + + // Track which tests are expanded - failed tests expanded by default + const [expanded, setExpanded] = useState>(() => { + const initial = new Set(); + results.results.forEach((r, i) => { + if (!r.passed) initial.add(i); + }); + return initial; + }); + + const toggleExpand = (index: number) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + return ( + +
+
+ {allPassed ? ( + + ) : ( + + )} +
+ + {allPassed ? 'All Tests Passed' : 'Tests Failed'} + +

+ {passedCount} passed{failedCount > 0 && `, ${failedCount} failed`}{' '} + of {results.results.length} tests +

+
+
+ + + +
+ +
+ {results.results.map((result, index) => { + const isExpanded = expanded.has(index); + return ( +
+ {/* Header - clickable to expand */} + + + {/* Expanded details */} + {isExpanded && ( +
+ {/* Errors */} + {result.errors && result.errors.length > 0 && ( +
+

+ Errors +

+
    + {result.errors.map((err) => ( +
  • {err}
  • + ))} +
+
+ )} + + {/* Accept rules */} + {result.accept && result.accept.length > 0 && ( +
+
+ + + Expected to ACCEPT + +
+
+ {result.accept.map((dest) => { + const passed = result.acceptOk?.includes(dest); + const failed = result.acceptFail?.includes(dest); + return ( + + {passed && ( + + )} + {failed && ( + + )} + {dest} + + ); + })} +
+
+ )} + + {/* Deny rules */} + {result.deny && result.deny.length > 0 && ( +
+
+ + + Expected to DENY + +
+
+ {result.deny.map((dest) => { + const passed = result.denyOk?.includes(dest); + const failed = result.denyFail?.includes(dest); + return ( + + {passed && ( + + )} + {failed && ( + + )} + {dest} + + ); + })} +
+
+ )} + + {/* Summary for passed tests without explicit accept/deny */} + {result.passed && + !result.accept?.length && + !result.deny?.length && ( +

+ Test passed + {result.acceptOk && result.acceptOk.length > 0 && ( + + {' '} + - {result.acceptOk.length} accept rule + {result.acceptOk.length !== 1 && 's'} verified + + )} + {result.denyOk && result.denyOk.length > 0 && ( + + {' '} + - {result.denyOk.length} deny rule + {result.denyOk.length !== 1 && 's'} verified + + )} +

+ )} +
+ )} +
+ ); + })} +
+
+ ); +} 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({ .

- {fetcher.data?.error !== undefined ? ( + + {editor.saveError && ( - {fetcher.data.error.split(':').slice(1).join(': ') ?? - 'An unknown error occurred while trying to update the ACL policy.'} + + {editor.saveError.split(':').slice(1).join(':').trim() || + 'An unknown error occurred while trying to update the ACL policy.'} + - ) : undefined} + )} + } > - - - - - Preview rules -
- } - > -
- -

- 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. -

-
+ - - + + 0} + isLoading={editor.isLoading} + onDiscard={() => editor.setCodePolicy(policy)} + onRunTests={editor.runTests} + onSave={editor.save} + /> + + {editor.testError && ( + + {editor.testError} + + )} + + {editor.testResults && ( + + )}
); } 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, + })), + }; + }, }));