feat: implement raw acl editing on the web ui
This commit is contained in:
parent
ca32590e54
commit
39868b5043
6 changed files with 645 additions and 8 deletions
113
app/routes/_data.acls._index/editor.tsx
Normal file
113
app/routes/_data.acls._index/editor.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { json } from '@codemirror/lang-json'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useState } from 'react'
|
||||
import CodeMirrorMerge from 'react-codemirror-merge'
|
||||
import { toast } from 'react-hot-toast/headless'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
type EditorProperties = {
|
||||
readonly acl: string;
|
||||
readonly setAcl: (acl: string) => void;
|
||||
readonly mode: 'edit' | 'diff';
|
||||
|
||||
readonly data: {
|
||||
hasAclWrite: boolean;
|
||||
currentAcl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
|
||||
const [light, setLight] = useState(false)
|
||||
const fetcher = useFetcher()
|
||||
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
|
||||
theme.addEventListener('change', theme => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
<>
|
||||
<div className={clsx(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 overflow-hidden'
|
||||
)}
|
||||
>
|
||||
{mode === 'edit' ? (
|
||||
<CodeMirror
|
||||
value={acl}
|
||||
maxHeight='calc(100vh - 20rem)'
|
||||
theme={light ? githubLight : githubDark}
|
||||
extensions={[json()]}
|
||||
readOnly={!data.hasAclWrite}
|
||||
onChange={value => {
|
||||
setAcl(value)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className='overflow-y-scroll'
|
||||
style={{ height: 'calc(100vh - 20rem)' }}
|
||||
>
|
||||
<CodeMirrorMerge
|
||||
theme={light ? githubLight : githubDark}
|
||||
orientation='a-b'
|
||||
>
|
||||
<CodeMirrorMerge.Original
|
||||
readOnly
|
||||
value={data.currentAcl}
|
||||
extensions={[json()]}
|
||||
/>
|
||||
<CodeMirrorMerge.Modified
|
||||
readOnly
|
||||
value={acl}
|
||||
extensions={[json()]}
|
||||
/>
|
||||
</CodeMirrorMerge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='emphasized'
|
||||
className='text-sm w-fit mr-2'
|
||||
onClick={() => {
|
||||
fetcher.submit({
|
||||
acl
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
toast('Updated tailnet ACL policy')
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant='emphasized'
|
||||
className={clsx(
|
||||
'text-sm w-fit bg-gray-100 dark:bg-transparent',
|
||||
'border border-gray-200 dark:border-gray-700'
|
||||
)}
|
||||
onClick={() => {
|
||||
setAcl(data.currentAcl)
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,180 @@
|
|||
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
|
||||
import { Tab } from '@headlessui/react'
|
||||
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { Fragment } from 'react/jsx-runtime'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Notice from '~/components/Notice'
|
||||
import { getAcl, getContext, patchAcl } from '~/utils/config'
|
||||
import { sighupHeadscale } from '~/utils/docker'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
|
||||
import Editor from './editor'
|
||||
|
||||
export async function loader() {
|
||||
const context = await getContext()
|
||||
if (!context.hasAcl) {
|
||||
throw new Error('No ACL configuration is available')
|
||||
}
|
||||
|
||||
const acl = await getAcl()
|
||||
return {
|
||||
hasAclWrite: context.hasAclWrite,
|
||||
currentAcl: acl
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ success: false }, {
|
||||
status: 401
|
||||
})
|
||||
}
|
||||
|
||||
const context = await getContext()
|
||||
if (!context.hasAclWrite) {
|
||||
return json({ success: false }, {
|
||||
status: 403
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as { acl: string }
|
||||
await patchAcl(data.acl)
|
||||
await sighupHeadscale()
|
||||
return json({ success: true })
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const [acl, setAcl] = useState(data.currentAcl)
|
||||
|
||||
return (
|
||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
|
||||
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
|
||||
<p className='text-lg mt-8'>
|
||||
Access Control Lists are currently unavailable.
|
||||
They will be available in a future release.
|
||||
<div className='mx-16'>
|
||||
{data.hasAclWrite ? undefined : (
|
||||
<div className='mb-4'>
|
||||
<Notice>
|
||||
The ACL policy file is readonly to Headplane.
|
||||
You will not be able to make changes here.
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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 Tailscale documentation.
|
||||
{' '}
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
href='https://tailscale.com/kb/1018/acls'
|
||||
className='text-blue-500 dark:text-blue-400 hover:underline'
|
||||
>
|
||||
More information
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<Tab.Group>
|
||||
<Tab.List className={clsx(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-300 dark:text-gray-500'
|
||||
)}
|
||||
>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-l border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<PencilSquareIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Edit file
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<EyeIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Preview changes
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-r border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Preview rules
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<div
|
||||
className={clsx(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='w-1/2 text-center mt-4'>
|
||||
The Preview rules is very much still a work in progress.
|
||||
It's a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,23 @@ export async function getConfig(force = false) {
|
|||
return config.toJSON() as Config
|
||||
}
|
||||
|
||||
export async function getAcl() {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const data = await readFile(path, 'utf8')
|
||||
return data
|
||||
}
|
||||
|
||||
// This is so obscenely dangerous, please have a check around it
|
||||
export async function patchConfig(partial: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
|
|
@ -141,6 +158,22 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
|||
await writeFile(path, config.toString(), 'utf8')
|
||||
}
|
||||
|
||||
export async function patchAcl(data: string) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error('No ACL file defined')
|
||||
}
|
||||
|
||||
await writeFile(path, data, 'utf8')
|
||||
}
|
||||
|
||||
let watcher: FSWatcher
|
||||
|
||||
export function registerConfigWatcher() {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,31 @@ import { Client } from 'undici'
|
|||
import { getContext } from './config'
|
||||
import { pull } from './headscale'
|
||||
|
||||
export async function sighupHeadscale() {
|
||||
const context = await getContext()
|
||||
if (!context.hasDockerSock) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!process.env.HEADSCALE_CONTAINER) {
|
||||
throw new Error('HEADSCALE_CONTAINER is not set')
|
||||
}
|
||||
|
||||
const client = new Client('http://localhost', {
|
||||
socketPath: '/var/run/docker.sock'
|
||||
})
|
||||
|
||||
const container = process.env.HEADSCALE_CONTAINER
|
||||
const response = await client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`
|
||||
})
|
||||
|
||||
if (!response.statusCode || response.statusCode !== 204) {
|
||||
throw new Error('Failed to send SIGHUP to Headscale')
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartHeadscale() {
|
||||
const context = await getContext()
|
||||
if (!context.hasDockerSock) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue