feat(TALE-29): support the headscale policy api changes

This commit is contained in:
Aarnav Tale 2024-08-04 11:32:29 -04:00
parent 4f57fdb43b
commit 75ba3a3dc7
No known key found for this signature in database
6 changed files with 205 additions and 52 deletions

View File

@ -1,15 +1,22 @@
import { InfoIcon } from '@primer/octicons-react'
import clsx from 'clsx'
import { type ReactNode } from 'react'
import type { ReactNode } from 'react'
export default function Notice({ children }: { readonly children: ReactNode }) {
import { cn } from '~/utils/cn'
interface Props {
className?: string
children: ReactNode
}
export default function Notice({ children, className }: Props) {
return (
<div className={clsx(
'p-4 rounded-md w-fit flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700'
<div className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'bg-ui-200 dark:bg-ui-800',
className,
)}
>
<InfoIcon className='h-6 w-6 text-white'/>
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children}
</div>
)

View File

@ -5,12 +5,12 @@ import { ClientOnly } from 'remix-utils/client-only'
import Fallback from '~/routes/_data.acls._index/fallback'
import { cn } from '~/utils/cn'
interface MonacoProps {
variant: 'editor' | 'diff'
interface Props {
variant: 'edit' | 'diff'
language: 'json' | 'yaml'
value: string
onChange: (value: string) => void
original?: string
state: [string, (value: string) => void]
policy?: string
isDisabled?: boolean
}
function monacoCallback(monaco: Monaco) {
@ -26,7 +26,7 @@ function monacoCallback(monaco: Monaco) {
monaco.languages.register({ id: 'yaml' })
}
export default function MonacoEditor({ value, onChange, variant, original, language }: MonacoProps) {
export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
const [light, setLight] = useState(false)
useEffect(() => {
@ -46,29 +46,30 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
)}
>
<div className="overflow-y-scroll h-editor text-sm">
<ClientOnly fallback={<Fallback acl={value} />}>
{() => variant === 'editor'
<ClientOnly fallback={<Fallback acl={state[0]} />}>
{() => variant === 'edit'
? (
<Editor
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
value={value}
value={state[0]}
onChange={(updated) => {
if (!updated) {
return
}
if (updated !== value) {
onChange(updated)
if (updated !== state[0]) {
state[1](updated)
}
}}
loading={<Fallback acl={value} />}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 14,
readOnly: isDisabled,
}}
/>
)
@ -77,14 +78,15 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
original={original}
modified={value}
loading={<Fallback acl={value} />}
original={policy}
modified={state[0]}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 13,
readOnly: isDisabled,
}}
/>
)}

View File

@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json } from '@remix-run/node'
import { type ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import Button from '~/components/Button'
@ -11,22 +12,71 @@ import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { cn } from '~/utils/cn'
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import Monaco from './editor'
export async function loader() {
const context = await loadContext()
if (!context.acl.read) {
throw new Error('No ACL configuration is available')
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
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,
})
return {
hasAclWrite: true,
isPolicyApi: true,
currentAcl: policy,
aclType: 'json',
} as const
} catch (error) {
if (!(error instanceof HeadscaleError)) {
throw error
}
if (error.status === 500) {
return {
hasAclWrite: false,
isPolicyApi: true,
currentAcl: policy,
aclType: 'json',
} as const
}
}
} catch (error) {
// Propagate our errors through normal error handling
if (!(error instanceof HeadscaleError)) {
throw error
}
// Not on 0.23-beta1 or later
if (error.status === 404) {
const { data, type, read, write } = await loadAcl()
return {
hasAclWrite: write,
isPolicyApi: false,
currentAcl: read ? data : '',
aclType: type,
}
}
}
const { data, type } = await loadAcl()
return {
hasAclWrite: context.acl.write,
currentAcl: data,
aclType: type,
}
hasAclWrite: true,
isPolicyApi: true,
currentAcl: '',
aclType: 'json',
} as const
}
export async function action({ request }: ActionFunctionArgs) {
@ -37,6 +87,21 @@ export async function action({ request }: ActionFunctionArgs) {
})
}
const data = await request.json() as { acl: string, api: boolean }
if (data.api) {
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: data.acl,
})
return json({ success: true })
} catch (error) {
return json({ success: false }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
}
const context = await loadContext()
if (!context.acl.write) {
return json({ success: false }, {
@ -44,7 +109,6 @@ export async function action({ request }: ActionFunctionArgs) {
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.integration?.onAclChange) {
@ -56,8 +120,24 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() {
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof action>()
const [acl, setAcl] = useState(data.currentAcl)
const fetcher = useFetcher()
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)
setAcl(data.currentAcl)
}, [fetcher.data, toasted, data.currentAcl])
return (
<div>
@ -65,10 +145,25 @@ export default function Page() {
? undefined
: (
<div className="mb-4">
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
{data.isPolicyApi
? (
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy
but you cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to
database in your Headscale configuration.
</Notice>
)
: (
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy
but you cannot make changes to it.
<br />
To resolve this, you need to configure a Headplane integration
or make the ACL_FILE environment variable available.
</Notice>
)}
</div>
)}
@ -144,19 +239,18 @@ export default function Page() {
</TabList>
<TabPanel id="edit">
<Monaco
variant="editor"
isDisabled={!data.hasAclWrite}
variant="edit"
language={data.aclType}
value={acl}
onChange={setAcl}
state={[acl, setAcl]}
/>
</TabPanel>
<TabPanel id="diff">
<Monaco
variant="diff"
language={data.aclType}
value={acl}
onChange={setAcl}
original={data.currentAcl}
state={[acl, setAcl]}
policy={data.currentAcl}
/>
</TabPanel>
<TabPanel id="preview">
@ -180,14 +274,14 @@ export default function Page() {
className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => {
setToasted(false)
fetcher.submit({
acl,
api: data.isPolicyApi,
}, {
method: 'PATCH',
encType: 'application/json',
})
toast('Updated tailnet ACL policy')
}}
>
{fetcher.state === 'idle'
@ -197,7 +291,10 @@ export default function Page() {
)}
Save
</Button>
<Button onPress={() => { setAcl(data.currentAcl) }}>
<Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
onPress={() => { setAcl(data.currentAcl) }}
>
Discard Changes
</Button>
</div>

View File

@ -90,7 +90,12 @@ export async function loadContext(): Promise<HeadplaneContext> {
return context
}
export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }> {
export async function loadAcl(): Promise<{
data: string
type: 'json' | 'yaml'
read: boolean
write: boolean
}> {
let path = process.env.ACL_FILE
if (!path) {
try {
@ -100,18 +105,37 @@ export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }
}
if (!path) {
return { data: '', type: 'json' }
return {
data: '',
type: 'json',
read: false,
write: false,
}
}
// Check for attributes
let read = false
let write = false
try {
await access(path, constants.R_OK)
read = true
} catch {}
try {
await access(path, constants.W_OK)
write = true
} catch {}
const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
try {
parse(data)
return { data, type: 'yaml' }
return { data, type: 'yaml', read, write }
} catch {
return { data, type: 'json' }
return { data, type: 'json', read, write }
}
}

View File

@ -53,6 +53,11 @@ const HeadscaleConfig = z.object({
unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
unix_socket_permission: z.string().default('0o770'),
policy: z.object({
mode: z.enum(['file', 'database']).default('file'),
path: z.string().optional(),
}).optional(),
tuning: z.object({
batch_change_delay: goDuration.default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30),

View File

@ -51,6 +51,24 @@ export async function post<T>(url: string, key: string, body?: unknown) {
return (response.json() as Promise<T>)
}
export async function put<T>(url: string, key: string, body?: unknown) {
const context = await loadContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
}
return (response.json() as Promise<T>)
}
export async function del<T>(url: string, key: string) {
const context = await loadContext()
const prefix = context.headscaleUrl