feat(TALE-29): support the headscale policy api changes
This commit is contained in:
parent
4f57fdb43b
commit
75ba3a3dc7
@ -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>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user