/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react' import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node' import { useLoaderData, useRevalidator } from '@remix-run/react' import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher' import { useEffect, useState, useMemo } from 'react' import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components' import { setTimeout } from 'node:timers/promises' import Button from '~/components/Button' import Code from '~/components/Code' import Link from '~/components/Link' import Notice from '~/components/Notice' import Spinner from '~/components/Spinner' import { toast } from '~/components/Toaster' import { cn } from '~/utils/cn' import { loadContext } from '~/utils/config/headplane' import { loadConfig } from '~/utils/config/headscale' import { HeadscaleError, pull, put } from '~/utils/headscale' import { getSession } from '~/utils/sessions' import log from '~/utils/log' import { Editor, Differ } from './cm' import { Unavailable } from './unavailable' import { ErrorView } from './error' export async function loader({ request }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')) // 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. const context = await loadContext() let modeGuess = 'database' // Assume database mode if (context.config.read) { const config = await loadConfig() modeGuess = config.policy.mode } // 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 pull<{ policy: string }>( 'v1/policy', session.get('hsApiKey')!, ) let write = false // On file mode we already know it's readonly if (modeGuess === 'database' && policy.length > 0) { try { await put('v1/policy', session.get('hsApiKey')!, { policy: policy, }) write = true } catch (error) { write = false log.debug( 'APIC', '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 } } // 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 } } } export async function action({ request }: ActionFunctionArgs) { const session = await getSession(request.headers.get('Cookie')) if (!session.has('hsApiKey')) { return json({ success: false }, { status: 401, }) } try { const { acl } = await request.json() as { acl: string } const { policy } = await put<{ policy: string }>( 'v1/policy', session.get('hsApiKey')!, { policy: acl, } ) return json({ success: true, policy }) } catch (error) { log.debug('APIC', 'Failed to update ACL policy with error %s', error) const text = JSON.parse(error.message) return json({ success: false, error: text.message }, { status: error instanceof HeadscaleError ? error.status : 500, }) } return json({ success: true }) } export default function Page() { const data = useLoaderData() const fetcher = useDebounceFetcher() 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 ? ( <> cn( 'px-4 py-2 rounded-tl-lg', 'focus:outline-none flex items-center gap-2', 'border-x border-gray-200 dark:border-gray-700', isSelected ? 'text-gray-900 dark:text-gray-100' : '', )} >

Edit file

cn( 'px-4 py-2', 'focus:outline-none flex items-center gap-2', 'border-x border-gray-200 dark:border-gray-700', isSelected ? 'text-gray-900 dark:text-gray-100' : '', )} >

Preview changes

cn( 'px-4 py-2 rounded-tr-lg', 'focus:outline-none flex items-center gap-2', 'border-x border-gray-200 dark:border-gray-700', isSelected ? 'text-gray-900 dark:text-gray-100' : '', )} >

Preview rules

The Preview rules is very much still a work in progress. It is a bit complicated to implement right now but hopefully it will be available soon.

) : }
) }