diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts new file mode 100644 index 0000000..11e4bab --- /dev/null +++ b/app/routes/acls/acl-action.ts @@ -0,0 +1,113 @@ +import { ActionFunctionArgs, data } from 'react-router'; +import { LoadContext } from '~/server'; +import ResponseError from '~/server/headscale/api-error'; +import { Capabilities } from '~/server/web/roles'; +import { data400, data403 } from '~/utils/res'; + +// 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, +}: ActionFunctionArgs) { + const session = await context.sessions.auth(request); + const check = await context.sessions.check( + request, + Capabilities.write_policy, + ); + if (!check) { + throw data403('You do not have permission to write to the ACL policy'); + } + + // 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 data400('Missing `policy` in the form data.'); + } + + try { + const { policy, updatedAt } = await context.client.put<{ + policy: string; + updatedAt: string; + }>('v1/policy', session.get('api_key')!, { + policy: policyData, + }); + + return data({ + success: true, + error: undefined, + 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 data403('Policy is not writable'); + } + + // 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, + ); + } + } + + // Otherwise, this is a Headscale error that we can just propagate. + throw error; + } +} diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts new file mode 100644 index 0000000..4b7ce56 --- /dev/null +++ b/app/routes/acls/acl-loader.ts @@ -0,0 +1,64 @@ +import { LoaderFunctionArgs } from 'react-router'; +import { LoadContext } from '~/server'; +import ResponseError from '~/server/headscale/api-error'; +import { Capabilities } from '~/server/web/roles'; +import { data403 } from '~/utils/res'; + +// The logic for deciding policy factors is very complicated because +// there are so many factors that need to be accounted for: +// 1. Does the user have permission to read the policy? +// 2. Does the user have permission to write to the policy? +// 3. Is the Headscale policy in file or database mode? +// If database, we can read/write easily via the API. +// If in file mode, we can only write if context.config is available. +// TODO: Consider adding back file editing mode instead of database +export async function aclLoader({ + request, + context, +}: LoaderFunctionArgs) { + const session = await context.sessions.auth(request); + const check = await context.sessions.check(request, Capabilities.read_policy); + if (!check) { + throw data403('You do not have permission to read the ACL policy.'); + } + + const flags = { + // Can the user write to the ACL policy + access: await context.sessions.check(request, Capabilities.write_policy), + writable: false, + policy: '', + }; + + // Try to load the ACL policy from the API. + try { + const { policy, updatedAt } = await context.client.get<{ + policy: string; + updatedAt: string | null; + }>('v1/policy', session.get('api_key')!); + + // Successfully loaded the policy, mark it as readable + // If `updatedAt` is null, it means the policy is in file mode. + flags.writable = updatedAt !== null; + flags.policy = policy; + return flags; + } 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; + } + + return flags; + } + + // Otherwise, this is a Headscale error that we can just propagate. + throw error; + } +} diff --git a/app/routes/acls/components/cm.client.tsx b/app/routes/acls/components/cm.client.tsx index ec25124..d35b945 100644 --- a/app/routes/acls/components/cm.client.tsx +++ b/app/routes/acls/components/cm.client.tsx @@ -14,6 +14,7 @@ interface EditorProps { onChange: (value: string) => void; } +// TODO: Remove ClientOnly export function Editor(props: EditorProps) { const [light, setLight] = useState(false); useEffect(() => { @@ -38,6 +39,8 @@ export function Editor(props: EditorProps) { {() => ( +
- Error + {title} + +
+ {children} +
+ ); +} + +interface ErrorViewProps { + children: string; +} + +export function ErrorView({ children }: ErrorViewProps) { + const [title, ...rest] = children.split(':'); + const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children; + + return ( + +
+ + {title.trim() ?? 'Error'} +
- Could not apply changes to your ACL policy due to the following error: + Could not apply changes to the ACL policy:
- {message} + {formattedMessage}
); diff --git a/app/routes/acls/components/fallback.tsx b/app/routes/acls/components/fallback.tsx index ba24088..de6b24c 100644 --- a/app/routes/acls/components/fallback.tsx +++ b/app/routes/acls/components/fallback.tsx @@ -1,4 +1,3 @@ -import Spinner from '~/components/Spinner'; import cn from '~/utils/cn'; interface Props { diff --git a/app/routes/acls/components/unavailable.tsx b/app/routes/acls/components/unavailable.tsx deleted file mode 100644 index 652946b..0000000 --- a/app/routes/acls/components/unavailable.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { AlertIcon } from '@primer/octicons-react'; -import cn from '~/utils/cn'; - -import Card from '~/components/Card'; -import Code from '~/components/Code'; - -interface Props { - mode: 'file' | 'database'; -} - -export function Unavailable({ mode }: Props) { - return ( - -
- ACL Policy Unavailable - -
- - Unable to load a valid ACL policy configuration. This is most likely due - to a misconfiguration in your Headscale configuration file. - - - {mode !== 'file' ? ( -

- According to your configuration, the ACL policy mode is set to{' '} - file but the ACL file is not available. Ensure that the{' '} - policy.path is set to a valid path in your Headscale - configuration. -

- ) : ( -

- In order to fully utilize the ACL management features of Headplane, - please set policy.mode to either file or{' '} - database in your Headscale configuration. -

- )} -
- ); -} diff --git a/app/routes/acls/editor.tsx b/app/routes/acls/editor.tsx deleted file mode 100644 index cee760e..0000000 --- a/app/routes/acls/editor.tsx +++ /dev/null @@ -1,305 +0,0 @@ -import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; -import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; -import { - redirect, - useFetcher, - useLoaderData, - useRevalidator, -} from 'react-router'; -import Button from '~/components/Button'; -import Link from '~/components/Link'; -import Notice from '~/components/Notice'; -import Spinner from '~/components/Spinner'; -import Tabs from '~/components/Tabs'; -import type { LoadContext } from '~/server'; -import { ResponseError } from '~/server/headscale/api-client'; -import log from '~/utils/log'; -import { send } from '~/utils/res'; -import toast from '~/utils/toast'; -import { Differ, Editor } from './components/cm.client'; -import { ErrorView } from './components/error'; -import { Unavailable } from './components/unavailable'; - -export async function loader({ - request, - context, -}: LoaderFunctionArgs) { - const session = await context.sessions.auth(request); - - // The way policy is handled in 0.23 of Headscale and later is verbose. - // The 2 ACL policy modes are either the database one or file one - // - // File: The ACL policy is readonly to the API and manually edited - // Database: The ACL policy is read/write to the API - // - // To determine if we first have an ACL policy available we need to check - // if fetching the v1/policy route gives us a 500 status code or a 200. - // - // 500 can mean many different things here unfortunately: - // - In file based that means the file is not accessible - // - In database mode this can mean that we have never set an ACL policy - // - In database mode this can mean that the ACL policy is not available - // - A general server error may have occurred - // - // Unfortunately the server errors are not very descriptive so we have to - // do some silly guesswork here. If we are running in an integration mode - // and have the Headscale configuration available to us, our assumptions - // can be more accurate, otherwise we just HAVE to assume that the ACL - // policy has never been set. - // - // We can do damage control by checking for write access and if we are not - // able to PUT an ACL policy on the v1/policy route, we can already know - // that the policy is at the very-least readonly or not available. - let modeGuess = 'database'; // Assume database mode - if (!context.hs.readable()) { - modeGuess = context.hs.c!.policy?.mode ?? 'database'; - } - - // Attempt to load the policy, for both the frontend and for checking - // if we are able to write to the policy for write access - try { - const { policy } = await context.client.get<{ policy: string }>( - 'v1/policy', - session.get('api_key')!, - ); - - let write = false; // On file mode we already know it's readonly - if (modeGuess === 'database' && policy.length > 0) { - try { - await context.client.put('v1/policy', session.get('api_key')!, { - policy: policy, - }); - - write = true; - } catch (error) { - write = false; - log.debug('api', 'Failed to write to ACL policy with error %s', error); - } - } - - return { - read: true, - write, - mode: modeGuess, - policy, - }; - } catch { - // If we are explicit on file mode then this is the end of the road - if (modeGuess === 'file') { - return { - read: false, - write: false, - mode: modeGuess, - policy: null, - }; - } - - // Assume that we have write access otherwise? - // This is sort of a brittle assumption to make but we don't want - // to create a default policy if we don't have to. - return { - read: true, - write: true, - mode: modeGuess, - policy: null, - }; - } -} - -export async function action({ - request, - context, -}: ActionFunctionArgs) { - const session = await context.sessions.auth(request); - - try { - const { acl } = (await request.json()) as { acl: string }; - const { policy } = await context.client.put<{ policy: string }>( - 'v1/policy', - session.get('api_key')!, - { - policy: acl, - }, - ); - - return { success: true, policy, error: null }; - } catch (error) { - log.debug('api', 'Failed to update ACL policy with error %s', error); - - // @ts-ignore: TODO: Shut UP we know it's a string most of the time - const text = JSON.parse(error.message); - return send( - { success: false, error: text.message }, - { - status: error instanceof ResponseError ? error.status : 500, - }, - ); - } -} - -export default function Page() { - const data = useLoaderData(); - const fetcher = useFetcher(); - const revalidator = useRevalidator(); - - const [acl, setAcl] = useState(data.policy ?? ''); - const [toasted, setToasted] = useState(false); - - useEffect(() => { - if (!fetcher.data || toasted) { - return; - } - - if (fetcher.data.success) { - toast('Updated tailnet ACL policy'); - } else { - toast('Failed to update tailnet ACL policy'); - } - - setToasted(true); - if (revalidator.state === 'idle') { - revalidator.revalidate(); - } - }, [fetcher.data, toasted, data.policy]); - - // The state for if the save and discard buttons should be disabled - // is pretty complicated to calculate and varies on different states. - const disabled = useMemo(() => { - if (!data.read || !data.write) { - return true; - } - - // First check our fetcher states - if (fetcher.state === 'loading') { - return true; - } - - if (revalidator.state === 'loading') { - return true; - } - - // If we have a failed fetcher state allow the user to try again - if (fetcher.data?.success === false) { - return false; - } - - return data.policy === acl; - }, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]); - - return ( -
- {data.read && !data.write ? ( -
- - The ACL policy is read-only. You can view the current policy but you - cannot make changes to it. -
- To resolve this, you need to set the ACL 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{' '} - - Tailscale ACL guide - {' '} - and the{' '} - - Headscale docs - - . -

- {fetcher.data?.success === false ? ( - - ) : undefined} - {data.read ? ( - <> - - - - Edit file -
- } - > - - - - - Preview changes - - } - > - - - - - 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. -

-
-
- - - - - ) : ( - - )} - - ); -} diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx new file mode 100644 index 0000000..91d8039 --- /dev/null +++ b/app/routes/acls/overview.tsx @@ -0,0 +1,173 @@ +import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { + ActionFunctionArgs, + LoaderFunctionArgs, + useFetcher, + useLoaderData, + useRevalidator, +} from 'react-router'; +import Button from '~/components/Button'; +import Code from '~/components/Code'; +import Link from '~/components/Link'; +import Notice from '~/components/Notice'; +import Tabs from '~/components/Tabs'; +import type { LoadContext } from '~/server'; +import toast from '~/utils/toast'; +import { aclAction } from './acl-action'; +import { aclLoader } from './acl-loader'; +import { Differ, Editor } from './components/cm.client'; +import { ErrorView, NoticeView } from './components/error'; + +export async function loader(request: LoaderFunctionArgs) { + return aclLoader(request); +} + +export async function action(request: ActionFunctionArgs) { + return aclAction(request); +} + +export default function Page() { + // Access is a write check here, we already check read in aclLoader + const { access, writable, policy } = useLoaderData(); + 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]); + + return ( +
+ {!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 ? ( + + The ACL policy mode is most likely set to file in your + Headscale configuration. This means that the ACL file cannot be edited + through the web interface. In order to resolve this, you'll need to + set acl.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{' '} + + Tailscale ACL guide + {' '} + and the{' '} + + Headscale docs + + . +

+ {fetcher.data?.error !== undefined ? ( + {fetcher.data.error} + ) : undefined} + + + + Edit file +
+ } + > + + + + + Preview changes + + } + > + + + + + 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. +

+
+
+ + + + + ); +}