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 { 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user