+
+ Could not apply changes to your ACL policy
+ due to the following error:
+
+
+ {message}
+
+
+
+ )
+}
diff --git a/app/routes/_data.acls._index/route.tsx b/app/routes/_data.acls._index/route.tsx
index c2725ff..e3972a6 100644
--- a/app/routes/_data.acls._index/route.tsx
+++ b/app/routes/_data.acls._index/route.tsx
@@ -1,8 +1,9 @@
/* 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 { useFetcher, useLoaderData } from '@remix-run/react'
-import { useEffect, useState } from 'react'
+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'
@@ -14,52 +15,100 @@ 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')!,
)
- try {
- // We have read access, now do we have write access?
- // Attempt to set the policy to what we just got
- await put('v1/policy', session.get('hsApiKey')!, {
- policy,
- })
+ 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,
+ })
- return {
- hasAclWrite: true,
- currentAcl: policy,
- aclType: 'json',
- } as const
- } catch (error) {
- if (!(error instanceof HeadscaleError)) {
- throw error
- }
-
- if (error.status === 500) {
- return {
- hasAclWrite: false,
- currentAcl: policy,
- aclType: 'json',
- } as const
+ write = true
+ } catch (error) {
+ write = false
+ log.debug(
+ 'APIC',
+ 'Failed to write to ACL policy with error %s',
+ error
+ )
}
}
- } catch {}
- return {
- hasAclWrite: true,
- currentAcl: '',
- aclType: 'json',
- } as const
+ 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) {
@@ -70,16 +119,21 @@ export async function action({ request }: ActionFunctionArgs) {
})
}
- const { acl } = await request.json() as { acl: string, api: boolean }
try {
- await put('v1/policy', session.get('hsApiKey')!, {
- policy: acl,
- })
+ const { acl } = await request.json() as { acl: string }
+ const { policy } = await put<{ policy: string }>(
+ 'v1/policy',
+ session.get('hsApiKey')!,
+ {
+ policy: acl,
+ }
+ )
- await setTimeout(250)
- return json({ success: true })
+ return json({ success: true, policy })
} catch (error) {
- return json({ success: false }, {
+ 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,
})
}
@@ -87,69 +141,12 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ success: true })
}
-export function ErrorBoundary() {
- return (
-
-
- An ACL policy is not available or an error occurred while trying to fetch it.
-
-
- 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
-
- .
-
-
-
-
- If you are running Headscale 0.23-beta1 or later, the
- ACL configuration is most likely set to
- {' '}
- file
- {' '}
- mode but the ACL file is not available. In order to
- resolve this you will either need to correctly set
- {' '}
- policy.path
- {' '}
- in your Headscale configuration or set the
- {' '}
- policy.mode
- {' '}
- to
- {' '}
- database
- .
-
-
-
-
- )
-}
-
export default function Page() {
const data = useLoaderData()
- const fetcher = useFetcher()
- const [acl, setAcl] = useState(data.currentAcl)
+ const fetcher = useDebounceFetcher()
+ const revalidator = useRevalidator()
+
+ const [acl, setAcl] = useState(data.policy ?? '')
const [toasted, setToasted] = useState(false)
useEffect(() => {
@@ -164,14 +161,39 @@ export default function Page() {
}
setToasted(true)
- setAcl(data.currentAcl)
- }, [fetcher.data, toasted, data.currentAcl])
+ 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 (
The ACL policy is read-only. You can view the current policy
@@ -181,7 +203,7 @@ export default function Page() {
database in your Headscale configuration.
- 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.
-
-
-
-
-
-
+ Discard Changes
+
+ >
+ ) : }
)
}
diff --git a/app/routes/_data.acls._index/unavailable.tsx b/app/routes/_data.acls._index/unavailable.tsx
new file mode 100644
index 0000000..be605a7
--- /dev/null
+++ b/app/routes/_data.acls._index/unavailable.tsx
@@ -0,0 +1,43 @@
+import { cn } from '~/utils/cn'
+import { AlertIcon } from '@primer/octicons-react'
+
+import Code from '~/components/Code'
+import Card from '~/components/Card'
+
+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.
+