feat: support acl capabilities check
This commit is contained in:
parent
58cc7b742c
commit
234020eec5
113
app/routes/acls/acl-action.ts
Normal file
113
app/routes/acls/acl-action.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { ActionFunctionArgs, data } from 'react-router';
|
||||||
|
import { LoadContext } from '~/server';
|
||||||
|
import ResponseError from '~/server/headscale/api-error';
|
||||||
|
import { Capabilities } from '~/server/web/roles';
|
||||||
|
import { data400, data403 } from '~/utils/res';
|
||||||
|
|
||||||
|
// We only check capabilities here and assume it is writable
|
||||||
|
// If it isn't, it'll gracefully error anyways, since this means some
|
||||||
|
// fishy client manipulation is happening.
|
||||||
|
export async function aclAction({
|
||||||
|
request,
|
||||||
|
context,
|
||||||
|
}: ActionFunctionArgs<LoadContext>) {
|
||||||
|
const session = await context.sessions.auth(request);
|
||||||
|
const check = await context.sessions.check(
|
||||||
|
request,
|
||||||
|
Capabilities.write_policy,
|
||||||
|
);
|
||||||
|
if (!check) {
|
||||||
|
throw data403('You do not have permission to write to the ACL policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to write to the ACL policy via the API or via config file (TODO).
|
||||||
|
const formData = await request.formData();
|
||||||
|
const policyData = formData.get('policy')?.toString();
|
||||||
|
if (!policyData) {
|
||||||
|
throw data400('Missing `policy` in the form data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { policy, updatedAt } = await context.client.put<{
|
||||||
|
policy: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}>('v1/policy', session.get('api_key')!, {
|
||||||
|
policy: policyData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data({
|
||||||
|
success: true,
|
||||||
|
error: undefined,
|
||||||
|
policy,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// This means Headscale returned a protobuf error to us
|
||||||
|
// It also means we 100% know this is in database mode
|
||||||
|
if (error instanceof ResponseError && error.responseObject?.message) {
|
||||||
|
const message = error.responseObject.message as string;
|
||||||
|
// This is stupid, refer to the link
|
||||||
|
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
|
||||||
|
if (message.includes('update is disabled')) {
|
||||||
|
// This means the policy is not writable
|
||||||
|
throw data403('Policy is not writable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
|
||||||
|
if (message.includes('parsing hujson')) {
|
||||||
|
// This means the policy was invalid, return a 400
|
||||||
|
// with the actual error message from Headscale
|
||||||
|
const cutIndex = message.indexOf('err: hujson:');
|
||||||
|
const trimmed =
|
||||||
|
cutIndex > -1
|
||||||
|
? `Syntax error: ${message.slice(cutIndex + 12)}`
|
||||||
|
: message;
|
||||||
|
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: trimmed,
|
||||||
|
policy: undefined,
|
||||||
|
updatedAt: undefined,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('unmarshalling policy')) {
|
||||||
|
// This means the policy was invalid, return a 400
|
||||||
|
// with the actual error message from Headscale
|
||||||
|
const cutIndex = message.indexOf('err:');
|
||||||
|
const trimmed =
|
||||||
|
cutIndex > -1
|
||||||
|
? `Syntax error: ${message.slice(cutIndex + 5)}`
|
||||||
|
: message;
|
||||||
|
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: trimmed,
|
||||||
|
policy: undefined,
|
||||||
|
updatedAt: undefined,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('empty policy')) {
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Policy error: Supplied policy was empty',
|
||||||
|
policy: undefined,
|
||||||
|
updatedAt: undefined,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, this is a Headscale error that we can just propagate.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/routes/acls/acl-loader.ts
Normal file
64
app/routes/acls/acl-loader.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { LoaderFunctionArgs } from 'react-router';
|
||||||
|
import { LoadContext } from '~/server';
|
||||||
|
import ResponseError from '~/server/headscale/api-error';
|
||||||
|
import { Capabilities } from '~/server/web/roles';
|
||||||
|
import { data403 } from '~/utils/res';
|
||||||
|
|
||||||
|
// The logic for deciding policy factors is very complicated because
|
||||||
|
// there are so many factors that need to be accounted for:
|
||||||
|
// 1. Does the user have permission to read the policy?
|
||||||
|
// 2. Does the user have permission to write to the policy?
|
||||||
|
// 3. Is the Headscale policy in file or database mode?
|
||||||
|
// If database, we can read/write easily via the API.
|
||||||
|
// If in file mode, we can only write if context.config is available.
|
||||||
|
// TODO: Consider adding back file editing mode instead of database
|
||||||
|
export async function aclLoader({
|
||||||
|
request,
|
||||||
|
context,
|
||||||
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
|
const session = await context.sessions.auth(request);
|
||||||
|
const check = await context.sessions.check(request, Capabilities.read_policy);
|
||||||
|
if (!check) {
|
||||||
|
throw data403('You do not have permission to read the ACL policy.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = {
|
||||||
|
// Can the user write to the ACL policy
|
||||||
|
access: await context.sessions.check(request, Capabilities.write_policy),
|
||||||
|
writable: false,
|
||||||
|
policy: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to load the ACL policy from the API.
|
||||||
|
try {
|
||||||
|
const { policy, updatedAt } = await context.client.get<{
|
||||||
|
policy: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}>('v1/policy', session.get('api_key')!);
|
||||||
|
|
||||||
|
// Successfully loaded the policy, mark it as readable
|
||||||
|
// If `updatedAt` is null, it means the policy is in file mode.
|
||||||
|
flags.writable = updatedAt !== null;
|
||||||
|
flags.policy = policy;
|
||||||
|
return flags;
|
||||||
|
} catch (error) {
|
||||||
|
// This means Headscale returned a protobuf error to us
|
||||||
|
// It also means we 100% know this is in database mode
|
||||||
|
if (error instanceof ResponseError && error.responseObject?.message) {
|
||||||
|
const message = error.responseObject.message as string;
|
||||||
|
// This is stupid, refer to the link
|
||||||
|
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
|
||||||
|
if (message.includes('acl policy not found')) {
|
||||||
|
// This means the policy has never been initiated, and we can
|
||||||
|
// write to it to get it started or ignore it.
|
||||||
|
flags.policy = ''; // Start with an empty policy
|
||||||
|
flags.writable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, this is a Headscale error that we can just propagate.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ interface EditorProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Remove ClientOnly
|
||||||
export function Editor(props: EditorProps) {
|
export function Editor(props: EditorProps) {
|
||||||
const [light, setLight] = useState(false);
|
const [light, setLight] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -38,6 +39,8 @@ export function Editor(props: EditorProps) {
|
|||||||
{() => (
|
{() => (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
value={props.value}
|
value={props.value}
|
||||||
|
editable={!props.isDisabled} // Allow editing unless disabled
|
||||||
|
readOnly={props.isDisabled} // Use readOnly if disabled
|
||||||
height="100%"
|
height="100%"
|
||||||
extensions={[shopify.jsonc()]}
|
extensions={[shopify.jsonc()]}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
|
|||||||
@ -1,24 +1,44 @@
|
|||||||
import { AlertIcon } from '@primer/octicons-react';
|
import { AlertIcon } from '@primer/octicons-react';
|
||||||
import cn from '~/utils/cn';
|
import React from 'react';
|
||||||
|
|
||||||
import Card from '~/components/Card';
|
import Card from '~/components/Card';
|
||||||
import Code from '~/components/Code';
|
|
||||||
|
|
||||||
interface Props {
|
interface NoticeViewProps {
|
||||||
message: string;
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorView({ message }: Props) {
|
export function NoticeView({ children, title }: NoticeViewProps) {
|
||||||
return (
|
return (
|
||||||
<Card variant="flat" className="max-w-full mb-4">
|
<Card variant="flat" className="max-w-2xl my-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Card.Title className="text-xl mb-0">Error</Card.Title>
|
<Card.Title className="text-xl mb-0">{title}</Card.Title>
|
||||||
|
<AlertIcon className="w-8 h-8 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<Card.Text className="mt-4">{children}</Card.Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorViewProps {
|
||||||
|
children: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorView({ children }: ErrorViewProps) {
|
||||||
|
const [title, ...rest] = children.split(':');
|
||||||
|
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="flat" className="max-w-2xl mb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Card.Title className="text-xl mb-0">
|
||||||
|
{title.trim() ?? 'Error'}
|
||||||
|
</Card.Title>
|
||||||
<AlertIcon className="w-8 h-8 text-red-500" />
|
<AlertIcon className="w-8 h-8 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<Card.Text className="mt-4">
|
<Card.Text className="mt-4">
|
||||||
Could not apply changes to your ACL policy due to the following error:
|
Could not apply changes to the ACL policy:
|
||||||
<br />
|
<br />
|
||||||
<Code>{message}</Code>
|
<span className="font-mono">{formattedMessage}</span>
|
||||||
</Card.Text>
|
</Card.Text>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import Spinner from '~/components/Spinner';
|
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
import { AlertIcon } from '@primer/octicons-react';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
import Card from '~/components/Card';
|
|
||||||
import Code from '~/components/Code';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mode: 'file' | 'database';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Unavailable({ mode }: Props) {
|
|
||||||
return (
|
|
||||||
<Card variant="flat" className="max-w-prose mt-12">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
|
|
||||||
<AlertIcon className="w-8 h-8 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<Card.Text className="mt-4">
|
|
||||||
Unable to load a valid ACL policy configuration. This is most likely due
|
|
||||||
to a misconfiguration in your Headscale configuration file.
|
|
||||||
</Card.Text>
|
|
||||||
|
|
||||||
{mode !== 'file' ? (
|
|
||||||
<p className="mt-4 text-sm">
|
|
||||||
According to your configuration, the ACL policy mode is set to{' '}
|
|
||||||
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
|
|
||||||
<Code>policy.path</Code> is set to a valid path in your Headscale
|
|
||||||
configuration.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="mt-4 text-sm">
|
|
||||||
In order to fully utilize the ACL management features of Headplane,
|
|
||||||
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
|
|
||||||
<Code>database</Code> in your Headscale configuration.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,305 +0,0 @@
|
|||||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
|
||||||
import {
|
|
||||||
redirect,
|
|
||||||
useFetcher,
|
|
||||||
useLoaderData,
|
|
||||||
useRevalidator,
|
|
||||||
} from 'react-router';
|
|
||||||
import Button from '~/components/Button';
|
|
||||||
import Link from '~/components/Link';
|
|
||||||
import Notice from '~/components/Notice';
|
|
||||||
import Spinner from '~/components/Spinner';
|
|
||||||
import Tabs from '~/components/Tabs';
|
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { ResponseError } from '~/server/headscale/api-client';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
import { send } from '~/utils/res';
|
|
||||||
import toast from '~/utils/toast';
|
|
||||||
import { Differ, Editor } from './components/cm.client';
|
|
||||||
import { ErrorView } from './components/error';
|
|
||||||
import { Unavailable } from './components/unavailable';
|
|
||||||
|
|
||||||
export async function loader({
|
|
||||||
request,
|
|
||||||
context,
|
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
|
||||||
const session = await context.sessions.auth(request);
|
|
||||||
|
|
||||||
// The way policy is handled in 0.23 of Headscale and later is verbose.
|
|
||||||
// The 2 ACL policy modes are either the database one or file one
|
|
||||||
//
|
|
||||||
// File: The ACL policy is readonly to the API and manually edited
|
|
||||||
// Database: The ACL policy is read/write to the API
|
|
||||||
//
|
|
||||||
// To determine if we first have an ACL policy available we need to check
|
|
||||||
// if fetching the v1/policy route gives us a 500 status code or a 200.
|
|
||||||
//
|
|
||||||
// 500 can mean many different things here unfortunately:
|
|
||||||
// - In file based that means the file is not accessible
|
|
||||||
// - In database mode this can mean that we have never set an ACL policy
|
|
||||||
// - In database mode this can mean that the ACL policy is not available
|
|
||||||
// - A general server error may have occurred
|
|
||||||
//
|
|
||||||
// Unfortunately the server errors are not very descriptive so we have to
|
|
||||||
// do some silly guesswork here. If we are running in an integration mode
|
|
||||||
// and have the Headscale configuration available to us, our assumptions
|
|
||||||
// can be more accurate, otherwise we just HAVE to assume that the ACL
|
|
||||||
// policy has never been set.
|
|
||||||
//
|
|
||||||
// We can do damage control by checking for write access and if we are not
|
|
||||||
// able to PUT an ACL policy on the v1/policy route, we can already know
|
|
||||||
// that the policy is at the very-least readonly or not available.
|
|
||||||
let modeGuess = 'database'; // Assume database mode
|
|
||||||
if (!context.hs.readable()) {
|
|
||||||
modeGuess = context.hs.c!.policy?.mode ?? 'database';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to load the policy, for both the frontend and for checking
|
|
||||||
// if we are able to write to the policy for write access
|
|
||||||
try {
|
|
||||||
const { policy } = await context.client.get<{ policy: string }>(
|
|
||||||
'v1/policy',
|
|
||||||
session.get('api_key')!,
|
|
||||||
);
|
|
||||||
|
|
||||||
let write = false; // On file mode we already know it's readonly
|
|
||||||
if (modeGuess === 'database' && policy.length > 0) {
|
|
||||||
try {
|
|
||||||
await context.client.put('v1/policy', session.get('api_key')!, {
|
|
||||||
policy: policy,
|
|
||||||
});
|
|
||||||
|
|
||||||
write = true;
|
|
||||||
} catch (error) {
|
|
||||||
write = false;
|
|
||||||
log.debug('api', 'Failed to write to ACL policy with error %s', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
read: true,
|
|
||||||
write,
|
|
||||||
mode: modeGuess,
|
|
||||||
policy,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// If we are explicit on file mode then this is the end of the road
|
|
||||||
if (modeGuess === 'file') {
|
|
||||||
return {
|
|
||||||
read: false,
|
|
||||||
write: false,
|
|
||||||
mode: modeGuess,
|
|
||||||
policy: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assume that we have write access otherwise?
|
|
||||||
// This is sort of a brittle assumption to make but we don't want
|
|
||||||
// to create a default policy if we don't have to.
|
|
||||||
return {
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
mode: modeGuess,
|
|
||||||
policy: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action({
|
|
||||||
request,
|
|
||||||
context,
|
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
|
||||||
const session = await context.sessions.auth(request);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { acl } = (await request.json()) as { acl: string };
|
|
||||||
const { policy } = await context.client.put<{ policy: string }>(
|
|
||||||
'v1/policy',
|
|
||||||
session.get('api_key')!,
|
|
||||||
{
|
|
||||||
policy: acl,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true, policy, error: null };
|
|
||||||
} catch (error) {
|
|
||||||
log.debug('api', 'Failed to update ACL policy with error %s', error);
|
|
||||||
|
|
||||||
// @ts-ignore: TODO: Shut UP we know it's a string most of the time
|
|
||||||
const text = JSON.parse(error.message);
|
|
||||||
return send(
|
|
||||||
{ success: false, error: text.message },
|
|
||||||
{
|
|
||||||
status: error instanceof ResponseError ? error.status : 500,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const data = useLoaderData<typeof loader>();
|
|
||||||
const fetcher = useFetcher<typeof action>();
|
|
||||||
const revalidator = useRevalidator();
|
|
||||||
|
|
||||||
const [acl, setAcl] = useState(data.policy ?? '');
|
|
||||||
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);
|
|
||||||
if (revalidator.state === 'idle') {
|
|
||||||
revalidator.revalidate();
|
|
||||||
}
|
|
||||||
}, [fetcher.data, toasted, data.policy]);
|
|
||||||
|
|
||||||
// The state for if the save and discard buttons should be disabled
|
|
||||||
// is pretty complicated to calculate and varies on different states.
|
|
||||||
const disabled = useMemo(() => {
|
|
||||||
if (!data.read || !data.write) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First check our fetcher states
|
|
||||||
if (fetcher.state === 'loading') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (revalidator.state === 'loading') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have a failed fetcher state allow the user to try again
|
|
||||||
if (fetcher.data?.success === false) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.policy === acl;
|
|
||||||
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{data.read && !data.write ? (
|
|
||||||
<div className="mb-4">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
<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{' '}
|
|
||||||
<Link
|
|
||||||
to="https://tailscale.com/kb/1018/acls"
|
|
||||||
name="Tailscale ACL documentation"
|
|
||||||
>
|
|
||||||
Tailscale ACL guide
|
|
||||||
</Link>{' '}
|
|
||||||
and the{' '}
|
|
||||||
<Link
|
|
||||||
to="https://headscale.net/stable/ref/acls/"
|
|
||||||
name="Headscale ACL documentation"
|
|
||||||
>
|
|
||||||
Headscale docs
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
{fetcher.data?.success === false ? (
|
|
||||||
<ErrorView message={fetcher.data.error} />
|
|
||||||
) : undefined}
|
|
||||||
{data.read ? (
|
|
||||||
<>
|
|
||||||
<Tabs label="ACL Editor" className="mb-4">
|
|
||||||
<Tabs.Item
|
|
||||||
key="edit"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Pencil className="p-1" />
|
|
||||||
<span>Edit file</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
|
|
||||||
</Tabs.Item>
|
|
||||||
<Tabs.Item
|
|
||||||
key="diff"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Eye className="p-1" />
|
|
||||||
<span>Preview changes</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Differ left={data?.policy ?? ''} right={acl} />
|
|
||||||
</Tabs.Item>
|
|
||||||
<Tabs.Item
|
|
||||||
key="preview"
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FlaskConical className="p-1" />
|
|
||||||
<span>Preview rules</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center py-8">
|
|
||||||
<Construction />
|
|
||||||
<p className="w-1/2 text-center mt-4">
|
|
||||||
Previewing rules is not available yet. This feature is still
|
|
||||||
in development and is pretty complicated to implement.
|
|
||||||
Hopefully I will be able to get to it soon.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Tabs.Item>
|
|
||||||
</Tabs>
|
|
||||||
<Button
|
|
||||||
variant="heavy"
|
|
||||||
className="mr-2"
|
|
||||||
isDisabled={disabled}
|
|
||||||
onPress={() => {
|
|
||||||
setToasted(false);
|
|
||||||
fetcher.submit(
|
|
||||||
{
|
|
||||||
acl,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
|
||||||
<Spinner className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
isDisabled={disabled}
|
|
||||||
onPress={() => {
|
|
||||||
setAcl(data?.policy ?? '');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Unavailable mode={data.mode as 'database' | 'file'} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
173
app/routes/acls/overview.tsx
Normal file
173
app/routes/acls/overview.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionFunctionArgs,
|
||||||
|
LoaderFunctionArgs,
|
||||||
|
useFetcher,
|
||||||
|
useLoaderData,
|
||||||
|
useRevalidator,
|
||||||
|
} from 'react-router';
|
||||||
|
import Button from '~/components/Button';
|
||||||
|
import Code from '~/components/Code';
|
||||||
|
import Link from '~/components/Link';
|
||||||
|
import Notice from '~/components/Notice';
|
||||||
|
import Tabs from '~/components/Tabs';
|
||||||
|
import type { LoadContext } from '~/server';
|
||||||
|
import toast from '~/utils/toast';
|
||||||
|
import { aclAction } from './acl-action';
|
||||||
|
import { aclLoader } from './acl-loader';
|
||||||
|
import { Differ, Editor } from './components/cm.client';
|
||||||
|
import { ErrorView, NoticeView } from './components/error';
|
||||||
|
|
||||||
|
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
|
||||||
|
return aclLoader(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||||
|
return aclAction(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
// Access is a write check here, we already check read in aclLoader
|
||||||
|
const { access, writable, policy } = useLoaderData<typeof loader>();
|
||||||
|
const [codePolicy, setCodePolicy] = useState(policy);
|
||||||
|
const fetcher = useFetcher<typeof action>();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
const disabled = !access || !writable; // Disable if no permission or not writable
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Update the codePolicy when the loader data changes
|
||||||
|
if (policy !== codePolicy) {
|
||||||
|
setCodePolicy(policy);
|
||||||
|
}
|
||||||
|
}, [policy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fetcher.data) {
|
||||||
|
// No data yet, return
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetcher.data.success === true) {
|
||||||
|
toast('Updated policy');
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
}, [fetcher.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!access ? (
|
||||||
|
<NoticeView title="ACL Policy restricted">
|
||||||
|
You do not have the necessary permissions to edit the Access Control
|
||||||
|
List policy. Please contact your administrator to request access or to
|
||||||
|
make changes to the ACL policy.
|
||||||
|
</NoticeView>
|
||||||
|
) : !writable ? (
|
||||||
|
<NoticeView title="Read-only ACL Policy">
|
||||||
|
The ACL policy mode is most likely set to <Code>file</Code> in your
|
||||||
|
Headscale configuration. This means that the ACL file cannot be edited
|
||||||
|
through the web interface. In order to resolve this, you'll need to
|
||||||
|
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
|
||||||
|
configuration.
|
||||||
|
</NoticeView>
|
||||||
|
) : undefined}
|
||||||
|
<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{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1018/acls"
|
||||||
|
name="Tailscale ACL documentation"
|
||||||
|
>
|
||||||
|
Tailscale ACL guide
|
||||||
|
</Link>{' '}
|
||||||
|
and the{' '}
|
||||||
|
<Link
|
||||||
|
to="https://headscale.net/stable/ref/acls/"
|
||||||
|
name="Headscale ACL documentation"
|
||||||
|
>
|
||||||
|
Headscale docs
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
{fetcher.data?.error !== undefined ? (
|
||||||
|
<ErrorView>{fetcher.data.error}</ErrorView>
|
||||||
|
) : undefined}
|
||||||
|
<Tabs label="ACL Editor" className="mb-4">
|
||||||
|
<Tabs.Item
|
||||||
|
key="edit"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Pencil className="p-1" />
|
||||||
|
<span>Edit file</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Editor
|
||||||
|
isDisabled={disabled}
|
||||||
|
value={codePolicy}
|
||||||
|
onChange={setCodePolicy}
|
||||||
|
/>
|
||||||
|
</Tabs.Item>
|
||||||
|
<Tabs.Item
|
||||||
|
key="diff"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Eye className="p-1" />
|
||||||
|
<span>Preview changes</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Differ left={policy} right={codePolicy} />
|
||||||
|
</Tabs.Item>
|
||||||
|
<Tabs.Item
|
||||||
|
key="preview"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FlaskConical className="p-1" />
|
||||||
|
<span>Preview rules</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center py-8">
|
||||||
|
<Construction />
|
||||||
|
<p className="w-1/2 text-center mt-4">
|
||||||
|
Previewing rules is not available yet. This feature is still in
|
||||||
|
development and is pretty complicated to implement. Hopefully I
|
||||||
|
will be able to get to it soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Tabs.Item>
|
||||||
|
</Tabs>
|
||||||
|
<Button
|
||||||
|
variant="heavy"
|
||||||
|
className="mr-2"
|
||||||
|
isDisabled={
|
||||||
|
disabled ||
|
||||||
|
fetcher.state !== 'idle' ||
|
||||||
|
codePolicy.length === 0 ||
|
||||||
|
codePolicy === policy
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
const formData = new FormData();
|
||||||
|
console.log(codePolicy);
|
||||||
|
formData.append('policy', codePolicy);
|
||||||
|
fetcher.submit(formData, { method: 'PATCH' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isDisabled={
|
||||||
|
disabled || fetcher.state !== 'idle' || codePolicy === policy
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
// Reset the editor to the original policy
|
||||||
|
setCodePolicy(policy);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discard Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user