From 75ba3a3dc7442cd81c5b018e9d4c18e9a4353468 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 4 Aug 2024 11:32:29 -0400 Subject: [PATCH] feat(TALE-29): support the headscale policy api changes --- app/components/Notice.tsx | 21 ++-- app/routes/_data.acls._index/editor.tsx | 32 ++--- app/routes/_data.acls._index/route.tsx | 149 +++++++++++++++++++----- app/utils/config/headplane.ts | 32 ++++- app/utils/config/headscale.ts | 5 + app/utils/headscale.ts | 18 +++ 6 files changed, 205 insertions(+), 52 deletions(-) diff --git a/app/components/Notice.tsx b/app/components/Notice.tsx index bccece9..db58c25 100644 --- a/app/components/Notice.tsx +++ b/app/components/Notice.tsx @@ -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 ( -
- + {children}
) diff --git a/app/routes/_data.acls._index/editor.tsx b/app/routes/_data.acls._index/editor.tsx index e7ef953..7ab87a4 100644 --- a/app/routes/_data.acls._index/editor.tsx +++ b/app/routes/_data.acls._index/editor.tsx @@ -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 )} >
- }> - {() => variant === 'editor' + }> + {() => variant === 'edit' ? ( { if (!updated) { return } - if (updated !== value) { - onChange(updated) + if (updated !== state[0]) { + state[1](updated) } }} - loading={} + loading={} 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={} + original={policy} + modified={state[0]} + loading={} beforeMount={monacoCallback} options={{ wordWrap: 'on', minimap: { enabled: false }, fontSize: 13, + readOnly: isDisabled, }} /> )} diff --git a/app/routes/_data.acls._index/route.tsx b/app/routes/_data.acls._index/route.tsx index 442f08a..05f1a0f 100644 --- a/app/routes/_data.acls._index/route.tsx +++ b/app/routes/_data.acls._index/route.tsx @@ -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() + const fetcher = useFetcher() 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 (
@@ -65,10 +145,25 @@ export default function Page() { ? undefined : (
- - The ACL policy file is readonly to Headplane. - You will not be able to make changes here. - + {data.isPolicyApi + ? ( + + The ACL policy is read-only. You can view the current policy + but you cannot make changes to it. +
+ To resolve this, you need to set the ACL policy mode to + database in your Headscale configuration. +
+ ) + : ( + + The ACL policy is read-only. You can view the current policy + but you cannot make changes to it. +
+ To resolve this, you need to configure a Headplane integration + or make the ACL_FILE environment variable available. +
+ )}
)} @@ -144,19 +239,18 @@ export default function Page() { @@ -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 -
diff --git a/app/utils/config/headplane.ts b/app/utils/config/headplane.ts index 0972967..c6f5708 100644 --- a/app/utils/config/headplane.ts +++ b/app/utils/config/headplane.ts @@ -90,7 +90,12 @@ export async function loadContext(): Promise { 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 } } } diff --git a/app/utils/config/headscale.ts b/app/utils/config/headscale.ts index 34842ff..e45252c 100644 --- a/app/utils/config/headscale.ts +++ b/app/utils/config/headscale.ts @@ -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), diff --git a/app/utils/headscale.ts b/app/utils/headscale.ts index db5707a..6d34ce5 100644 --- a/app/utils/headscale.ts +++ b/app/utils/headscale.ts @@ -51,6 +51,24 @@ export async function post(url: string, key: string, body?: unknown) { return (response.json() as Promise) } +export async function put(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) +} + export async function del(url: string, key: string) { const context = await loadContext() const prefix = context.headscaleUrl