fix: handle errors and fix api logic with acl updating

This commit is contained in:
Aarnav Tale 2024-10-26 23:06:29 -04:00
parent d1fa76971b
commit a8abd37e3a
No known key found for this signature in database
7 changed files with 394 additions and 242 deletions

View File

@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
import Merge from 'react-codemirror-merge' import Merge from 'react-codemirror-merge'
import CodeMirror from '@uiw/react-codemirror' import CodeMirror from '@uiw/react-codemirror'
import { ClientOnly } from 'remix-utils/client-only' import { ClientOnly } from 'remix-utils/client-only'
import { ErrorBoundary } from 'react-error-boundary'
import { jsonc } from '@shopify/lang-jsonc' import { jsonc } from '@shopify/lang-jsonc'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github' import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import { useState } from 'react' import { useState } from 'react'
@ -11,12 +12,11 @@ import Fallback from './fallback'
interface EditorProps { interface EditorProps {
isDisabled?: boolean isDisabled?: boolean
value: string
onChange: (value: string) => void onChange: (value: string) => void
defaultValue?: string
} }
export function Editor(props: EditorProps) { export function Editor(props: EditorProps) {
const [value, setValue] = useState(props.defaultValue ?? '')
const [light, setLight] = useState(false) const [light, setLight] = useState(false)
useEffect(() => { useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)') const theme = window.matchMedia('(prefers-color-scheme: light)')
@ -32,17 +32,27 @@ export function Editor(props: EditorProps) {
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden', 'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}> )}>
<div className="overflow-y-scroll h-editor text-sm"> <div className="overflow-y-scroll h-editor text-sm">
<ClientOnly fallback={<Fallback acl={value} />}> <ErrorBoundary fallback={
<p className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}>
Failed to load the editor.
</p>
}>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => ( {() => (
<CodeMirror <CodeMirror
value={value} value={props.value}
height="100%" height="100%"
extensions={[jsonc()]} extensions={[jsonc()]}
style={{ height: "100%" }}
theme={light ? githubLight : githubDark} theme={light ? githubLight : githubDark}
onChange={(value) => props.onChange(value)} onChange={(value) => props.onChange(value)}
/> />
)} )}
</ClientOnly> </ClientOnly>
</ErrorBoundary>
</div> </div>
</div> </div>
) )
@ -77,6 +87,14 @@ export function Differ(props: DifferProps) {
No changes No changes
</p> </p>
) : ( ) : (
<ErrorBoundary fallback={
<p className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}>
Failed to load the editor.
</p>
}>
<ClientOnly fallback={<Fallback acl={props.right} />}> <ClientOnly fallback={<Fallback acl={props.right} />}>
{() => ( {() => (
<Merge <Merge
@ -96,6 +114,7 @@ export function Differ(props: DifferProps) {
</Merge> </Merge>
)} )}
</ClientOnly> </ClientOnly>
</ErrorBoundary>
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,30 @@
import { cn } from '~/utils/cn'
import { AlertIcon } from '@primer/octicons-react'
import Card from '~/components/Card'
import Code from '~/components/Code'
interface Props {
message: string
}
export function ErrorView({ message }: Props) {
return (
<Card variant="flat" className="max-w-full mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
Error
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500"/>
</div>
<Card.Text className="mt-4">
Could not apply changes to your ACL policy
due to the following error:
<br />
<Code>
{message}
</Code>
</Card.Text>
</Card>
)
}

View File

@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react' import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react' import { useLoaderData, useRevalidator } from '@remix-run/react'
import { useEffect, useState } from '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 { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { setTimeout } from 'node:timers/promises' import { setTimeout } from 'node:timers/promises'
@ -14,52 +15,100 @@ import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { HeadscaleError, pull, put } from '~/utils/headscale' import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import log from '~/utils/log'
import { Editor, Differ } from './cm' import { Editor, Differ } from './cm'
import { Unavailable } from './unavailable'
import { ErrorView } from './error'
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) 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 { try {
const { policy } = await pull<{ policy: string }>( const { policy } = await pull<{ policy: string }>(
'v1/policy', 'v1/policy',
session.get('hsApiKey')!, session.get('hsApiKey')!,
) )
let write = false // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
try { 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')!, { await put('v1/policy', session.get('hsApiKey')!, {
policy, policy: policy,
}) })
return { write = true
hasAclWrite: true,
currentAcl: policy,
aclType: 'json',
} as const
} catch (error) { } catch (error) {
if (!(error instanceof HeadscaleError)) { write = false
throw error log.debug(
'APIC',
'Failed to write to ACL policy with error %s',
error
)
}
} }
if (error.status === 500) {
return { return {
hasAclWrite: false, read: true,
currentAcl: policy, write,
aclType: 'json', mode: modeGuess,
} as const 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
} }
} }
} catch {}
// 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 { return {
hasAclWrite: true, read: true,
currentAcl: '', write: true,
aclType: 'json', mode: modeGuess
} as const }
}
} }
export async function action({ request }: ActionFunctionArgs) { 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 { try {
await put('v1/policy', session.get('hsApiKey')!, { const { acl } = await request.json() as { acl: string }
const { policy } = await put<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
{
policy: acl, policy: acl,
}) }
)
await setTimeout(250) return json({ success: true, policy })
return json({ success: true })
} catch (error) { } 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, status: error instanceof HeadscaleError ? error.status : 500,
}) })
} }
@ -87,69 +141,12 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ success: true }) return json({ success: true })
} }
export function ErrorBoundary() {
return (
<div>
<Notice className="mb-4">
An ACL policy is not available or an error occurred while trying to fetch it.
</Notice>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose">
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
{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
<Link
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
<div>
<div className="max-w-prose">
<p className="mb-4 text-md">
If you are running Headscale 0.23-beta1 or later, the
ACL configuration is most likely set to
{' '}
<Code>file</Code>
{' '}
mode but the ACL file is not available. In order to
resolve this you will either need to correctly set
{' '}
<Code>policy.path</Code>
{' '}
in your Headscale configuration or set the
{' '}
<Code>policy.mode</Code>
{' '}
to
{' '}
<Code>database</Code>
.
</p>
</div>
</div>
</div>
)
}
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof action>() const fetcher = useDebounceFetcher<typeof action>()
const [acl, setAcl] = useState(data.currentAcl) const revalidator = useRevalidator()
const [acl, setAcl] = useState(data.policy ?? '')
const [toasted, setToasted] = useState(false) const [toasted, setToasted] = useState(false)
useEffect(() => { useEffect(() => {
@ -164,14 +161,39 @@ export default function Page() {
} }
setToasted(true) setToasted(true)
setAcl(data.currentAcl) if (revalidator.state === 'idle') {
}, [fetcher.data, toasted, data.currentAcl]) 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 ( return (
<div> <div>
{data.hasAclWrite {data.read && !data.write
? undefined ? (
: (
<div className="mb-4"> <div className="mb-4">
<Notice className="w-fit"> <Notice className="w-fit">
The ACL policy is read-only. You can view the current policy 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. database in your Headscale configuration.
</Notice> </Notice>
</div> </div>
)} ) : undefined}
<h1 className="text-2xl font-medium mb-4"> <h1 className="text-2xl font-medium mb-4">
Access Control List (ACL) Access Control List (ACL)
@ -209,6 +231,13 @@ export default function Page() {
. .
</p> </p>
{fetcher.data?.success === false
? (
<ErrorView message={fetcher.data.error} />
) : undefined}
{data.read ? (
<>
<Tabs> <Tabs>
<TabList className={cn( <TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700', 'flex border-t border-gray-200 dark:border-gray-700',
@ -255,14 +284,14 @@ export default function Page() {
</TabList> </TabList>
<TabPanel id="edit"> <TabPanel id="edit">
<Editor <Editor
isDisabled={!data.hasAclWrite} isDisabled={!data.write}
defaultValue={data.currentAcl} value={acl}
onChange={setAcl} onChange={setAcl}
/> />
</TabPanel> </TabPanel>
<TabPanel id="diff"> <TabPanel id="diff">
<Differ <Differ
left={data.currentAcl} left={data.policy}
right={acl} right={acl}
/> />
</TabPanel> </TabPanel>
@ -285,7 +314,7 @@ export default function Page() {
<Button <Button
variant="heavy" variant="heavy"
className="mr-2" className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl} isDisabled={disabled}
onPress={() => { onPress={() => {
setToasted(false) setToasted(false)
fetcher.submit({ fetcher.submit({
@ -304,11 +333,15 @@ export default function Page() {
Save Save
</Button> </Button>
<Button <Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite} isDisabled={disabled}
onPress={() => { setAcl(data.currentAcl) }} onPress={() => {
setAcl(data.policy)
}}
> >
Discard Changes Discard Changes
</Button> </Button>
</>
) : <Unavailable mode={data.mode} />}
</div> </div>
) )
} }

View File

@ -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 (
<Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
ACL Policy Unavailable
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500"/>
</div>
<Card.Text className="mt-4">
Unable to load a valid ACL policy configuration.
This is most likely due to a misconfiguration in your
Headscale configuration file.
</Card.Text>
{mode !== 'file' ? (
<p className="mt-4 text-sm">
According to your configuration, the ACL policy mode
is set to <Code>file</Code> but the ACL file is not
available. Ensure that the <Code>policy.path</Code> is
set to a valid path in your Headscale configuration.
</p>
) : (
<p className="mt-4 text-sm">
In order to fully utilize the ACL management features of
Headplane, please set <Code>policy.mode</Code> to either
{' '}<Code>file</Code> or <Code>database</Code> in your
Headscale configuration.
</p>
)}
</Card>
)
}

View File

@ -7,3 +7,24 @@
scrollbar-gutter: stable scrollbar-gutter: stable
} }
} }
.cm-merge-theme {
height: 100% !important;
}
.cm-mergeView {
height: 100% !important;
}
.cm-mergeViewEditors {
height: 100%;
}
.cm-mergeViewEditor {
height: 100% !important;
}
/* Weirdest class name characters but ok */
.cm-mergeView .ͼ1 .cm-scroller, .cm-mergeView .ͼ1 {
height: 100% !important;
}

View File

@ -33,6 +33,7 @@
"react-aria-components": "^1.2.1", "react-aria-components": "^1.2.1",
"react-codemirror-merge": "^4.23.5", "react-codemirror-merge": "^4.23.5",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-error-boundary": "^4.1.2",
"remix-utils": "^7.6.0", "remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.3", "tailwindcss-react-aria-components": "^1.1.3",

View File

@ -82,6 +82,9 @@ importers:
react-dom: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-error-boundary:
specifier: ^4.1.2
version: 4.1.2(react@18.3.1)
remix-utils: remix-utils:
specifier: ^7.6.0 specifier: ^7.6.0
version: 7.6.0(@remix-run/node@2.10.2(typescript@5.5.3))(@remix-run/react@2.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3))(@remix-run/router@1.20.0)(react@18.3.1)(zod@3.23.8) version: 7.6.0(@remix-run/node@2.10.2(typescript@5.5.3))(@remix-run/react@2.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.3))(@remix-run/router@1.20.0)(react@18.3.1)(zod@3.23.8)
@ -331,10 +334,6 @@ packages:
resolution: {integrity: sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==} resolution: {integrity: sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/runtime@7.24.7':
resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.25.7': '@babel/runtime@7.25.7':
resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==} resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -3915,6 +3914,11 @@ packages:
peerDependencies: peerDependencies:
react: ^18.3.1 react: ^18.3.1
react-error-boundary@4.1.2:
resolution: {integrity: sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==}
peerDependencies:
react: '>=16.13.1'
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -5019,10 +5023,6 @@ snapshots:
dependencies: dependencies:
regenerator-runtime: 0.14.1 regenerator-runtime: 0.14.1
'@babel/runtime@7.24.7':
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.25.7': '@babel/runtime@7.25.7':
dependencies: dependencies:
regenerator-runtime: 0.14.1 regenerator-runtime: 0.14.1
@ -8739,7 +8739,7 @@ snapshots:
media-query-parser@2.0.2: media-query-parser@2.0.2:
dependencies: dependencies:
'@babel/runtime': 7.24.7 '@babel/runtime': 7.25.7
media-typer@0.3.0: {} media-typer@0.3.0: {}
@ -9579,6 +9579,11 @@ snapshots:
react: 18.3.1 react: 18.3.1
scheduler: 0.23.2 scheduler: 0.23.2
react-error-boundary@4.1.2(react@18.3.1):
dependencies:
'@babel/runtime': 7.25.7
react: 18.3.1
react-is@16.13.1: {} react-is@16.13.1: {}
react-refresh@0.14.2: {} react-refresh@0.14.2: {}