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 { 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 ( return (
<div className={clsx( <div className={cn(
'p-4 rounded-md w-fit flex items-center gap-3', 'p-4 rounded-md w-full flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700' '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} {children}
</div> </div>
) )

View File

@ -5,12 +5,12 @@ import { ClientOnly } from 'remix-utils/client-only'
import Fallback from '~/routes/_data.acls._index/fallback' import Fallback from '~/routes/_data.acls._index/fallback'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
interface MonacoProps { interface Props {
variant: 'editor' | 'diff' variant: 'edit' | 'diff'
language: 'json' | 'yaml' language: 'json' | 'yaml'
value: string state: [string, (value: string) => void]
onChange: (value: string) => void policy?: string
original?: string isDisabled?: boolean
} }
function monacoCallback(monaco: Monaco) { function monacoCallback(monaco: Monaco) {
@ -26,7 +26,7 @@ function monacoCallback(monaco: Monaco) {
monaco.languages.register({ id: 'yaml' }) 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) const [light, setLight] = useState(false)
useEffect(() => { useEffect(() => {
@ -46,29 +46,30 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
)} )}
> >
<div className="overflow-y-scroll h-editor text-sm"> <div className="overflow-y-scroll h-editor text-sm">
<ClientOnly fallback={<Fallback acl={value} />}> <ClientOnly fallback={<Fallback acl={state[0]} />}>
{() => variant === 'editor' {() => variant === 'edit'
? ( ? (
<Editor <Editor
height="100%" height="100%"
language={language} language={language}
theme={light ? 'light' : 'vs-dark'} theme={light ? 'light' : 'vs-dark'}
value={value} value={state[0]}
onChange={(updated) => { onChange={(updated) => {
if (!updated) { if (!updated) {
return return
} }
if (updated !== value) { if (updated !== state[0]) {
onChange(updated) state[1](updated)
} }
}} }}
loading={<Fallback acl={value} />} loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback} beforeMount={monacoCallback}
options={{ options={{
wordWrap: 'on', wordWrap: 'on',
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 14, fontSize: 14,
readOnly: isDisabled,
}} }}
/> />
) )
@ -77,14 +78,15 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
height="100%" height="100%"
language={language} language={language}
theme={light ? 'light' : 'vs-dark'} theme={light ? 'light' : 'vs-dark'}
original={original} original={policy}
modified={value} modified={state[0]}
loading={<Fallback acl={value} />} loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback} beforeMount={monacoCallback}
options={{ options={{
wordWrap: 'on', wordWrap: 'on',
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 13, 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 { 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 { 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 { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import Button from '~/components/Button' import Button from '~/components/Button'
@ -11,22 +12,71 @@ 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 { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane' import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import Monaco from './editor' import Monaco from './editor'
export async function loader() { export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext() const session = await getSession(request.headers.get('Cookie'))
if (!context.acl.read) {
throw new Error('No ACL configuration is available') 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 { return {
hasAclWrite: context.acl.write, hasAclWrite: true,
currentAcl: data, isPolicyApi: true,
aclType: type, currentAcl: '',
} aclType: 'json',
} as const
} }
export async function action({ request }: ActionFunctionArgs) { 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() const context = await loadContext()
if (!context.acl.write) { if (!context.acl.write) {
return json({ success: false }, { 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) await patchAcl(data.acl)
if (context.integration?.onAclChange) { if (context.integration?.onAclChange) {
@ -56,8 +120,24 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof action>()
const [acl, setAcl] = useState(data.currentAcl) 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 ( return (
<div> <div>
@ -65,10 +145,25 @@ export default function Page() {
? undefined ? undefined
: ( : (
<div className="mb-4"> <div className="mb-4">
<Notice> {data.isPolicyApi
The ACL policy file is readonly to Headplane. ? (
You will not be able to make changes here. <Notice className="w-fit">
</Notice> 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> </div>
)} )}
@ -144,19 +239,18 @@ export default function Page() {
</TabList> </TabList>
<TabPanel id="edit"> <TabPanel id="edit">
<Monaco <Monaco
variant="editor" isDisabled={!data.hasAclWrite}
variant="edit"
language={data.aclType} language={data.aclType}
value={acl} state={[acl, setAcl]}
onChange={setAcl}
/> />
</TabPanel> </TabPanel>
<TabPanel id="diff"> <TabPanel id="diff">
<Monaco <Monaco
variant="diff" variant="diff"
language={data.aclType} language={data.aclType}
value={acl} state={[acl, setAcl]}
onChange={setAcl} policy={data.currentAcl}
original={data.currentAcl}
/> />
</TabPanel> </TabPanel>
<TabPanel id="preview"> <TabPanel id="preview">
@ -180,14 +274,14 @@ export default function Page() {
className="mr-2" className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl} isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => { onPress={() => {
setToasted(false)
fetcher.submit({ fetcher.submit({
acl, acl,
api: data.isPolicyApi,
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
}) })
toast('Updated tailnet ACL policy')
}} }}
> >
{fetcher.state === 'idle' {fetcher.state === 'idle'
@ -197,7 +291,10 @@ export default function Page() {
)} )}
Save Save
</Button> </Button>
<Button onPress={() => { setAcl(data.currentAcl) }}> <Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
onPress={() => { setAcl(data.currentAcl) }}
>
Discard Changes Discard Changes
</Button> </Button>
</div> </div>

View File

@ -90,7 +90,12 @@ export async function loadContext(): Promise<HeadplaneContext> {
return context 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 let path = process.env.ACL_FILE
if (!path) { if (!path) {
try { try {
@ -100,18 +105,37 @@ export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }
} }
if (!path) { 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') const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON // Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments // This is because JSON.parse doesn't support comments
try { try {
parse(data) parse(data)
return { data, type: 'yaml' } return { data, type: 'yaml', read, write }
} catch { } 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: z.string().default('/var/run/headscale/headscale.sock'),
unix_socket_permission: z.string().default('0o770'), 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({ tuning: z.object({
batch_change_delay: goDuration.default('800ms'), batch_change_delay: goDuration.default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30), 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>) 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) { export async function del<T>(url: string, key: string) {
const context = await loadContext() const context = await loadContext()
const prefix = context.headscaleUrl const prefix = context.headscaleUrl