feat: use strictly typed configs and context
This commit is contained in:
parent
0a12cdb3d6
commit
3e51e4861d
@ -2,30 +2,30 @@ import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIco
|
||||
import { Form } from '@remix-run/react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
import { type Context } from '~/utils/config'
|
||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||
import { type SessionData } from '~/utils/sessions'
|
||||
|
||||
import Menu from './Menu'
|
||||
import TabLink from './TabLink'
|
||||
|
||||
type Properties = {
|
||||
readonly data?: Context & { user?: SessionData['user'] };
|
||||
interface Properties {
|
||||
readonly data?: HeadplaneContext & { user?: SessionData['user'] }
|
||||
}
|
||||
|
||||
type LinkProperties = {
|
||||
readonly href: string;
|
||||
readonly text: string;
|
||||
readonly isMenu?: boolean;
|
||||
interface LinkProperties {
|
||||
readonly href: string
|
||||
readonly text: string
|
||||
readonly isMenu?: boolean
|
||||
}
|
||||
|
||||
function Link({ href, text, isMenu }: LinkProperties) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block'
|
||||
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
@ -35,74 +35,82 @@ function Link({ href, text, isMenu }: LinkProperties) {
|
||||
|
||||
export default function Header({ data }: Properties) {
|
||||
return (
|
||||
<header className='bg-main-700 dark:bg-main-800 text-ui-50'>
|
||||
<div className='container flex items-center justify-between py-4'>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<PaperAirplaneIcon className='w-6 h-6'/>
|
||||
<h1 className='text-2xl'>Headplane</h1>
|
||||
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PaperAirplaneIcon className="w-6 h-6" />
|
||||
<h1 className="text-2xl">Headplane</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-4'>
|
||||
<Link href='https://tailscale.com/download' text='Download'/>
|
||||
<Link href='https://github.com/tale/headplane' text='GitHub'/>
|
||||
<Link href='https://github.com/juanfont/headscale' text='Headscale'/>
|
||||
{data?.user ? (
|
||||
<Menu>
|
||||
<Menu.Button className={cn(
|
||||
'rounded-full h-9 w-9',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700'
|
||||
)}
|
||||
>
|
||||
<PersonIcon className='h-5 w-5 mt-0.5'/>
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.Item className='text-right'>
|
||||
<p className='font-bold'>{data.user.name}</p>
|
||||
<p>{data.user.email}</p>
|
||||
</Menu.Item>
|
||||
<Menu.Item className='text-right sm:hidden'>
|
||||
<Link
|
||||
isMenu
|
||||
href='https://tailscale.com/download'
|
||||
text='Download'
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className='text-right sm:hidden'>
|
||||
<Link
|
||||
isMenu
|
||||
href='https://github.com/tale/headplane'
|
||||
text='GitHub'
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className='text-right sm:hidden'>
|
||||
<Link
|
||||
isMenu
|
||||
href='https://github.com/juanfont/headscale'
|
||||
text='Headscale'
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className='text-red-500 dark:text-red-400'>
|
||||
<Form method='POST' action='/logout'>
|
||||
<button type='submit' className='w-full text-right'>
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
) : undefined}
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Link href="https://tailscale.com/download" text="Download" />
|
||||
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||
{data?.user
|
||||
? (
|
||||
<Menu>
|
||||
<Menu.Button className={cn(
|
||||
'rounded-full h-9 w-9',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||
)}
|
||||
>
|
||||
<PersonIcon className="h-5 w-5 mt-0.5" />
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.Item className="text-right">
|
||||
<p className="font-bold">{data.user.name}</p>
|
||||
<p>{data.user.email}</p>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://tailscale.com/download"
|
||||
text="Download"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/tale/headplane"
|
||||
text="GitHub"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/juanfont/headscale"
|
||||
text="Headscale"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-red-500 dark:text-red-400">
|
||||
<Form method="POST" action="/logout">
|
||||
<button type="submit" className="w-full text-right">
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
</Menu.Item>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
</div>
|
||||
<nav className='container flex items-center gap-x-4 overflow-x-auto'>
|
||||
<TabLink to='/machines' name='Machines' icon={<ServerIcon className='w-4 h-4'/>}/>
|
||||
<TabLink to='/users' name='Users' icon={<PeopleIcon className='w-4 h-4'/>}/>
|
||||
{data?.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockIcon className='w-4 h-4'/>}/> : undefined}
|
||||
{data?.hasConfig ? (
|
||||
<>
|
||||
<TabLink to='/dns' name='DNS' icon={<GlobeIcon className='w-4 h-4'/>}/>
|
||||
<TabLink to='/settings' name='Settings' icon={<GearIcon className='w-4 h-4'/>}/>
|
||||
</>
|
||||
) : undefined}
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
||||
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
|
||||
{data?.acl.read
|
||||
? (
|
||||
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
||||
)
|
||||
: undefined}
|
||||
{data?.config.read
|
||||
? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
|
||||
45
app/root.tsx
45
app/root.tsx
@ -4,62 +4,45 @@ import {
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration
|
||||
ScrollRestoration,
|
||||
} from '@remix-run/react'
|
||||
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import { Toaster } from '~/components/Toaster'
|
||||
import stylesheet from '~/tailwind.css?url'
|
||||
import { getContext, registerConfigWatcher } from '~/utils/config'
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: 'Headplane' },
|
||||
{ name: 'description', content: 'A frontend for the headscale coordination server' }
|
||||
{ name: 'description', content: 'A frontend for the headscale coordination server' },
|
||||
]
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: 'stylesheet', href: stylesheet }
|
||||
{ rel: 'stylesheet', href: stylesheet },
|
||||
]
|
||||
|
||||
export async function loader() {
|
||||
const context = await getContext()
|
||||
registerConfigWatcher()
|
||||
|
||||
if (context.headscaleUrl.length === 0) {
|
||||
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
|
||||
}
|
||||
|
||||
if (!process.env.COOKIE_SECRET) {
|
||||
throw new Error('The COOKIE_SECRET environment variable is required')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-null
|
||||
return null
|
||||
}
|
||||
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang='en'>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet='utf-8'/>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'/>
|
||||
<Meta/>
|
||||
<Links/>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className='overscroll-none dark:bg-ui-950 dark:text-ui-50'>
|
||||
<body className="overscroll-none dark:bg-ui-950 dark:text-ui-50">
|
||||
{children}
|
||||
<Toaster/>
|
||||
<ScrollRestoration/>
|
||||
<Scripts/>
|
||||
<Toaster />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup/>
|
||||
return <ErrorPopup />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet/>
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import { ClientOnly } from 'remix-utils/client-only'
|
||||
import Link from '~/components/Link'
|
||||
import Notice from '~/components/Notice'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { getAcl, getContext, patchAcl } from '~/utils/config'
|
||||
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
|
||||
import { sighupHeadscale } from '~/utils/docker'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
|
||||
@ -16,16 +16,16 @@ import Editor from './editor'
|
||||
import Fallback from './fallback'
|
||||
|
||||
export async function loader() {
|
||||
const context = await getContext()
|
||||
if (!context.hasAcl) {
|
||||
const context = await loadContext()
|
||||
if (!context.acl.read) {
|
||||
throw new Error('No ACL configuration is available')
|
||||
}
|
||||
|
||||
const { data, type } = await getAcl()
|
||||
const { data, type } = await loadAcl()
|
||||
return {
|
||||
hasAclWrite: context.hasAclWrite,
|
||||
hasAclWrite: context.acl.write,
|
||||
currentAcl: data,
|
||||
aclType: type
|
||||
aclType: type,
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,21 +33,21 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ success: false }, {
|
||||
status: 401
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const context = await getContext()
|
||||
if (!context.hasAclWrite) {
|
||||
const context = await loadContext()
|
||||
if (!context.acl.write) {
|
||||
return json({ success: false }, {
|
||||
status: 403
|
||||
status: 403,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as { acl: string }
|
||||
await patchAcl(data.acl)
|
||||
|
||||
if (context.hasDockerSock) {
|
||||
if (context.docker) {
|
||||
await sighupHeadscale()
|
||||
}
|
||||
|
||||
@ -60,26 +60,28 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{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>
|
||||
)}
|
||||
{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'>
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Access Control List (ACL)
|
||||
</h1>
|
||||
|
||||
<p className='mb-4 max-w-prose'>
|
||||
<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'
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>
|
||||
@ -87,8 +89,8 @@ export default function Page() {
|
||||
and the
|
||||
{' '}
|
||||
<Link
|
||||
to='https://headscale.net/acls'
|
||||
name='Headscale ACL documentation'
|
||||
to="https://headscale.net/acls"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
@ -99,70 +101,70 @@ export default function Page() {
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500'
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Tab
|
||||
id='edit'
|
||||
id="edit"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<PencilIcon className='w-5 h-5'/>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id='diff'
|
||||
id="diff"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<EyeIcon className='w-5 h-5'/>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id='preview'
|
||||
id="preview"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className='w-5 h-5'/>
|
||||
<BeakerIcon className="w-5 h-5" />
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id='edit'>
|
||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
||||
<TabPanel id="edit">
|
||||
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode="edit" />
|
||||
)}
|
||||
</ClientOnly>
|
||||
</TabPanel>
|
||||
<TabPanel id='diff'>
|
||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
||||
<TabPanel id="diff">
|
||||
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode="diff" />
|
||||
)}
|
||||
</ClientOnly>
|
||||
</TabPanel>
|
||||
<TabPanel id='preview'>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
className={cn(
|
||||
'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'
|
||||
'p-16 flex flex-col items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='w-1/2 text-center mt-4'>
|
||||
<IssueDraftIcon 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 is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
|
||||
@ -9,7 +9,8 @@ import Spinner from '~/components/Spinner'
|
||||
import Switch from '~/components/Switch'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { getConfig, getContext, patchConfig } from '~/utils/config'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||
import { restartHeadscale } from '~/utils/docker'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
@ -20,27 +21,26 @@ import RenameModal from './rename'
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader() {
|
||||
const context = await getContext()
|
||||
if (!context.hasConfig) {
|
||||
const context = await loadContext()
|
||||
if (!context.config.read) {
|
||||
throw new Error('No configuration is available')
|
||||
}
|
||||
|
||||
const config = await getConfig()
|
||||
|
||||
const config = await loadConfig()
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns_config.magic_dns ?? false,
|
||||
magicDns: config.dns_config.magic_dns,
|
||||
baseDomain: config.dns_config.base_domain,
|
||||
overrideLocal: config.dns_config.override_local_dns ?? false,
|
||||
nameservers: config.dns_config.nameservers ?? [],
|
||||
splitDns: config.dns_config.restricted_nameservers ?? {},
|
||||
searchDomains: config.dns_config.domains ?? [],
|
||||
extraRecords: config.dns_config.extra_records ?? []
|
||||
overrideLocal: config.dns_config.override_local_dns,
|
||||
nameservers: config.dns_config.nameservers,
|
||||
splitDns: config.dns_config.restricted_nameservers,
|
||||
searchDomains: config.dns_config.domains,
|
||||
extraRecords: config.dns_config.extra_records,
|
||||
}
|
||||
|
||||
return {
|
||||
...dns,
|
||||
...context
|
||||
...context,
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,14 +48,14 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ success: false }, {
|
||||
status: 401
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const context = await getContext()
|
||||
if (!context.hasConfigWrite) {
|
||||
const context = await loadContext()
|
||||
if (!context.config.write) {
|
||||
return json({ success: false }, {
|
||||
status: 403
|
||||
status: 403,
|
||||
})
|
||||
}
|
||||
|
||||
@ -73,39 +73,41 @@ export default function Page() {
|
||||
const [ns, setNs] = useState('')
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-16 max-w-screen-lg'>
|
||||
{data.hasConfigWrite ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||
</Notice>
|
||||
)}
|
||||
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/>
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
{data.config.write
|
||||
? undefined
|
||||
: (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||
</Notice>
|
||||
)}
|
||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Set the nameservers used by devices on the Tailnet
|
||||
to resolve DNS queries.
|
||||
</p>
|
||||
<div className='mt-4'>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<h2 className='text-md font-medium opacity-80'>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
Global Nameservers
|
||||
</h2>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<span className='text-sm opacity-50'>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-sm opacity-50">
|
||||
Override local DNS
|
||||
</span>
|
||||
<Switch
|
||||
label='Override local DNS'
|
||||
label="Override local DNS"
|
||||
defaultSelected={localOverride}
|
||||
isDisabled={!data.hasConfigWrite}
|
||||
isDisabled={!data.config.write}
|
||||
onChange={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.override_local_dns': !localOverride
|
||||
'dns_config.override_local_dns': !localOverride,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
setLocalOverride(!localOverride)
|
||||
@ -117,22 +119,22 @@ export default function Page() {
|
||||
{data.nameservers.map((ns, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<TableList.Item key={index}>
|
||||
<p className='font-mono text-sm'>{ns}</p>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
!data.hasConfigWrite && 'opacity-50 cursor-not-allowed'
|
||||
!data.config.write && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={!data.hasConfigWrite}
|
||||
isDisabled={!data.config.write}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
|
||||
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
@ -140,45 +142,49 @@ export default function Page() {
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
{data.hasConfigWrite ? (
|
||||
<TableList.Item>
|
||||
<Input
|
||||
type='text'
|
||||
className='font-mono text-sm bg-transparent w-full mr-2'
|
||||
placeholder='Nameserver'
|
||||
value={ns}
|
||||
onChange={event => {
|
||||
setNs(event.target.value)
|
||||
}}
|
||||
/>
|
||||
{fetcher.state === 'idle' ? (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
'text-blue-600 dark:text-blue-400',
|
||||
'hover:text-blue-700 dark:hover:text-blue-300',
|
||||
ns.length === 0 && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
isDisabled={ns.length === 0}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.nameservers': [...data.nameservers, ns]
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
setNs('')
|
||||
{data.config.write
|
||||
? (
|
||||
<TableList.Item>
|
||||
<Input
|
||||
type="text"
|
||||
className="font-mono text-sm bg-transparent w-full mr-2"
|
||||
placeholder="Nameserver"
|
||||
value={ns}
|
||||
onChange={(event) => {
|
||||
setNs(event.target.value)
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
) : (
|
||||
<Spinner className='w-3 h-3 mr-0'/>
|
||||
)}
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
/>
|
||||
{fetcher.state === 'idle'
|
||||
? (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
'text-blue-600 dark:text-blue-400',
|
||||
'hover:text-blue-700 dark:hover:text-blue-300',
|
||||
ns.length === 0 && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={ns.length === 0}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.nameservers': [...data.nameservers, ns],
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
setNs('')
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Spinner className="w-3 h-3 mr-0" />
|
||||
)}
|
||||
</TableList.Item>
|
||||
)
|
||||
: undefined}
|
||||
</TableList>
|
||||
{/* TODO: Split DNS and Custom A Records */}
|
||||
</div>
|
||||
@ -187,22 +193,23 @@ export default function Page() {
|
||||
<Domains
|
||||
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||
searchDomains={data.searchDomains}
|
||||
disabled={!data.hasConfigWrite}
|
||||
disabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300 mb-4'>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Automatically register domain names for each device
|
||||
on the tailnet. Devices will be accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].[user].{data.baseDomain}
|
||||
[device].[user].
|
||||
{data.baseDomain}
|
||||
</Code>
|
||||
{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
|
||||
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,7 +7,8 @@ import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
import Code from '~/components/Code'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { getConfig, getContext } from '~/utils/config'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { del, post, pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
@ -21,11 +22,11 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
const context = await getContext()
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.hasConfig) {
|
||||
const config = await getConfig()
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import { ProgressBar } from 'react-aria-components'
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import Header from '~/components/Header'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { getContext } from '~/utils/config'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||
import { destroySession, getSession } from '~/utils/sessions'
|
||||
|
||||
@ -24,8 +24,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Set-Cookie': await destroySession(session)
|
||||
}
|
||||
'Set-Cookie': await destroySession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -33,10 +33,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const context = await getContext()
|
||||
const context = await loadContext()
|
||||
return {
|
||||
...context,
|
||||
user: session.get('user')
|
||||
user: session.get('user'),
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,19 +47,19 @@ export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<ProgressBar
|
||||
aria-label='Loading...'
|
||||
aria-label="Loading..."
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-50 w-1/2 h-1',
|
||||
'bg-blue-500 dark:bg-blue-400 opacity-0',
|
||||
nav.state === 'loading' && 'animate-loading opacity-100'
|
||||
nav.state === 'loading' && 'animate-loading opacity-100',
|
||||
)}
|
||||
/>
|
||||
</ProgressBar>
|
||||
<Header data={data}/>
|
||||
<main className='container mx-auto overscroll-contain mt-4 mb-24'>
|
||||
<Outlet/>
|
||||
<Header data={data} />
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
@ -68,8 +68,8 @@ export default function Layout() {
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<>
|
||||
<Header/>
|
||||
<ErrorPopup type='embedded'/>
|
||||
<Header />
|
||||
<ErrorPopup type="embedded" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,12 +2,12 @@ import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Link from '~/components/Link'
|
||||
import { type Context } from '~/utils/config'
|
||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||
|
||||
import Add from './add'
|
||||
|
||||
interface Props {
|
||||
readonly oidc: NonNullable<Context['oidcConfig']>
|
||||
readonly oidc: NonNullable<HeadplaneContext['oidc']>
|
||||
readonly magic: string | undefined
|
||||
}
|
||||
|
||||
|
||||
@ -12,7 +12,8 @@ import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { getConfig, getContext } from '~/utils/config'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { del, post, pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
@ -35,18 +36,18 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
machines: machines.nodes.filter(machine => machine.user.id === user.id),
|
||||
}))
|
||||
|
||||
const context = await getContext()
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.hasConfig) {
|
||||
const config = await getConfig()
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidcConfig: context.oidcConfig,
|
||||
oidc: context.oidc,
|
||||
magic,
|
||||
users,
|
||||
}
|
||||
@ -170,10 +171,10 @@ export default function Page() {
|
||||
Manage the users in your network and their permissions.
|
||||
Tip: You can drag machines between users to change ownership.
|
||||
</p>
|
||||
{data.oidcConfig
|
||||
{data.oidc
|
||||
? (
|
||||
<Oidc
|
||||
oidc={data.oidcConfig}
|
||||
oidc={data.oidc}
|
||||
magic={data.magic}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -7,7 +7,7 @@ import Card from '~/components/Card'
|
||||
import Code from '~/components/Code'
|
||||
import TextField from '~/components/TextField'
|
||||
import { type Key } from '~/types'
|
||||
import { getContext } from '~/utils/config'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { startOidc } from '~/utils/oidc'
|
||||
import { commitSession, getSession } from '~/utils/sessions'
|
||||
@ -23,31 +23,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
})
|
||||
}
|
||||
|
||||
const context = await getContext()
|
||||
const issuer = context.oidcConfig?.issuer
|
||||
const id = context.oidcConfig?.client
|
||||
const secret = context.oidcConfig?.secret
|
||||
const normal = process.env.DISABLE_API_KEY_LOGIN
|
||||
const context = await loadContext()
|
||||
|
||||
if (issuer && (!id || !secret)) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
// Only set if OIDC is properly enabled anyways
|
||||
if (context.oidc?.disableKeyLogin) {
|
||||
return startOidc(
|
||||
context.oidc.issuer,
|
||||
context.oidc.client,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
const data = {
|
||||
oidc: issuer,
|
||||
apiKey: normal !== 'true',
|
||||
return {
|
||||
oidc: context.oidc?.issuer,
|
||||
apiKey: !context.oidc?.disableKeyLogin,
|
||||
}
|
||||
|
||||
if (!data.oidc && !data.apiKey) {
|
||||
throw new Error('No authentication method is enabled')
|
||||
}
|
||||
|
||||
if (data.oidc && !data.apiKey) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return startOidc(data.oidc, id!, request)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
@ -55,13 +45,17 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
const oidcStart = formData.get('oidc-start')
|
||||
|
||||
if (oidcStart) {
|
||||
const context = await getContext()
|
||||
const issuer = context.oidcConfig?.issuer
|
||||
const id = context.oidcConfig?.client
|
||||
const context = await loadContext()
|
||||
const issuer = context.oidc?.issuer
|
||||
const id = context.oidc?.client
|
||||
|
||||
if (!issuer || !id) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
}
|
||||
|
||||
// We know it exists here because this action only happens on OIDC
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return startOidc(issuer!, id!, request)
|
||||
return startOidc(issuer, id, request)
|
||||
}
|
||||
|
||||
const apiKey = String(formData.get('api-key'))
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
||||
|
||||
import { getContext } from '~/utils/config'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { finishOidc } from '~/utils/oidc'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const context = await getContext()
|
||||
const oidc = context.oidcConfig
|
||||
|
||||
if (!oidc) {
|
||||
const context = await loadContext()
|
||||
if (!context.oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
}
|
||||
|
||||
return finishOidc(oidc.issuer, oidc.client, oidc.secret, request)
|
||||
return finishOidc(
|
||||
context.oidc.issuer,
|
||||
context.oidc.client,
|
||||
context.oidc.secret,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,353 +0,0 @@
|
||||
import { type Document, parse, parseDocument } from 'yaml'
|
||||
import { type FSWatcher, watch } from 'node:fs'
|
||||
import { access, constants, readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y`
|
||||
|
||||
interface Config {
|
||||
server_url: string
|
||||
listen_addr: string
|
||||
metrics_listen_addr: string
|
||||
grpc_listen_addr: string
|
||||
grpc_allow_insecure: boolean
|
||||
|
||||
private_key_path: string
|
||||
noise: {
|
||||
private_key_path: string
|
||||
}
|
||||
|
||||
prefixes: {
|
||||
v4: string
|
||||
v6: string
|
||||
}
|
||||
|
||||
derp: {
|
||||
server: {
|
||||
enabled: boolean
|
||||
region_id: number
|
||||
region_code: string
|
||||
region_name: string
|
||||
stun_listen_addr: string
|
||||
}
|
||||
|
||||
urls: string[]
|
||||
paths: string[]
|
||||
auto_update_enabled: boolean
|
||||
update_frequency: Duration
|
||||
}
|
||||
|
||||
disable_check_updates: boolean
|
||||
epheremal_node_inactivity_timeout: Duration
|
||||
node_update_check_interval: Duration
|
||||
|
||||
// Database is probably dangerous
|
||||
database: {
|
||||
type: 'sqlite3' | 'sqlite' | 'postgres'
|
||||
sqlite?: {
|
||||
path: string
|
||||
}
|
||||
|
||||
postgres?: {
|
||||
host: string
|
||||
port: number
|
||||
name: string
|
||||
user: string
|
||||
pass: string
|
||||
max_open_conns: number
|
||||
max_idle_conns: number
|
||||
conn_max_idle_time_secs: number
|
||||
ssl: boolean
|
||||
}
|
||||
}
|
||||
|
||||
acme_url: string
|
||||
acme_email: string
|
||||
tls_letsencrypt_hostname: string
|
||||
tls_letsencrypt_cache_dir: string
|
||||
tls_letsencrypt_challenge_type: string
|
||||
tls_letsencrypt_listen: string
|
||||
tls_cert_path: string
|
||||
tls_key_path: string
|
||||
|
||||
log: {
|
||||
format: 'text' | 'json'
|
||||
level: string
|
||||
}
|
||||
|
||||
acl_policy_path: string
|
||||
dns_config: {
|
||||
override_local_dns: boolean
|
||||
nameservers: string[]
|
||||
restricted_nameservers: Record<string, string[]> // Split DNS
|
||||
domains: string[]
|
||||
extra_records: {
|
||||
name: string
|
||||
type: 'A'
|
||||
value: string
|
||||
}[]
|
||||
|
||||
magic_dns: boolean
|
||||
base_domain: string
|
||||
}
|
||||
|
||||
unix_socket: string
|
||||
unix_socket_permission: string
|
||||
|
||||
oidc: {
|
||||
only_start_if_oidc_is_available: boolean
|
||||
issuer: string
|
||||
client_id: string
|
||||
client_secret: string
|
||||
expiry: Duration
|
||||
use_expiry_from_token: boolean
|
||||
scope: string[]
|
||||
extra_params: Record<string, string>
|
||||
|
||||
allowed_domains: string[]
|
||||
allowed_groups: string[]
|
||||
allowed_users: string[]
|
||||
|
||||
strip_email_domain: boolean
|
||||
}
|
||||
|
||||
logtail: {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
randomize_client_port: boolean
|
||||
}
|
||||
|
||||
let config: Document
|
||||
|
||||
export async function getConfig(force = false) {
|
||||
if (!config || force) {
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
const data = await readFile(path, 'utf8')
|
||||
config = parseDocument(data)
|
||||
}
|
||||
|
||||
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 { data: '', type: 'json' }
|
||||
}
|
||||
|
||||
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' }
|
||||
} catch {
|
||||
return { data, type: 'json' }
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
config.setIn(key.split('.'), value)
|
||||
}
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
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() {
|
||||
if (watcher) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
watcher = watch(path, async () => {
|
||||
console.log('Config file changed, reloading')
|
||||
await getConfig(true)
|
||||
})
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
hasDockerSock: boolean
|
||||
hasConfig: boolean
|
||||
hasConfigWrite: boolean
|
||||
hasAcl: boolean
|
||||
hasAclWrite: boolean
|
||||
headscaleUrl: string
|
||||
oidcConfig?: {
|
||||
issuer: string
|
||||
client: string
|
||||
secret: string
|
||||
}
|
||||
}
|
||||
|
||||
export let context: Context
|
||||
|
||||
export async function getContext() {
|
||||
if (!context) {
|
||||
context = {
|
||||
hasDockerSock: await checkSock(),
|
||||
hasConfig: await hasConfig(),
|
||||
hasConfigWrite: await hasConfigW(),
|
||||
hasAcl: await hasAcl(),
|
||||
hasAclWrite: await hasAclW(),
|
||||
headscaleUrl: await getHeadscaleUrl(),
|
||||
oidcConfig: await getOidcConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
async function getOidcConfig() {
|
||||
// Check for the OIDC environment variables first
|
||||
let issuer = process.env.OIDC_ISSUER
|
||||
let client = process.env.OIDC_CLIENT_ID
|
||||
let secret = process.env.OIDC_CLIENT_SECRET
|
||||
const rootKey = process.env.API_KEY
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
const config = await getConfig()
|
||||
issuer = config.oidc.issuer
|
||||
client = config.oidc.client_id
|
||||
secret = config.oidc.client_secret
|
||||
}
|
||||
|
||||
// If atleast one is defined but not all 3, throw an error
|
||||
if ((issuer || client || secret) && !(issuer && client && secret)) {
|
||||
throw new Error('OIDC configuration is incomplete')
|
||||
}
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!rootKey) {
|
||||
throw new Error('Cannot use OIDC without the root API_KEY variable set')
|
||||
}
|
||||
|
||||
return { issuer, client, secret }
|
||||
}
|
||||
|
||||
async function getHeadscaleUrl() {
|
||||
if (process.env.HEADSCALE_URL) {
|
||||
return process.env.HEADSCALE_URL
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getConfig()
|
||||
if (config.server_url) {
|
||||
return config.server_url
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
async function checkSock() {
|
||||
try {
|
||||
await access('/var/run/docker.sock', constants.R_OK)
|
||||
return true
|
||||
} catch {}
|
||||
|
||||
if (!process.env.HEADSCALE_CONTAINER) {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasConfig() {
|
||||
try {
|
||||
await getConfig()
|
||||
return true
|
||||
} catch {}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasConfigW() {
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
try {
|
||||
await access(path, constants.W_OK)
|
||||
return true
|
||||
} catch {}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasAcl() {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
path = resolve(path)
|
||||
await access(path, constants.R_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log('Cannot acquire read access to ACL file', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function hasAclW() {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
path = resolve(path)
|
||||
await access(path, constants.W_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log('Cannot acquire read access to ACL file', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
240
app/utils/config/headplane.ts
Normal file
240
app/utils/config/headplane.ts
Normal file
@ -0,0 +1,240 @@
|
||||
// Handle the configuration loading for headplane.
|
||||
// Functionally only used for all sorts of sanity checks across headplane.
|
||||
//
|
||||
// Around the codebase, this is referred to as the context
|
||||
|
||||
import { access, constants, readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { parse } from 'yaml'
|
||||
|
||||
import { HeadscaleConfig, loadConfig } from './headscale'
|
||||
|
||||
export interface HeadplaneContext {
|
||||
headscaleUrl: string
|
||||
cookieSecret: string
|
||||
|
||||
config: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
|
||||
acl: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
|
||||
docker?: {
|
||||
sock: string
|
||||
container: string
|
||||
}
|
||||
|
||||
oidc?: {
|
||||
issuer: string
|
||||
client: string
|
||||
secret: string
|
||||
rootKey: string
|
||||
disableKeyLogin: boolean
|
||||
}
|
||||
}
|
||||
|
||||
let context: HeadplaneContext | undefined
|
||||
|
||||
export async function loadContext(): Promise<HeadplaneContext> {
|
||||
if (context) {
|
||||
return context
|
||||
}
|
||||
|
||||
let config: HeadscaleConfig | undefined
|
||||
try {
|
||||
config = await loadConfig()
|
||||
} catch {}
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
|
||||
let headscaleUrl = process.env.HEADSCALE_URL
|
||||
if (!headscaleUrl && !config) {
|
||||
throw new Error('HEADSCALE_URL not set')
|
||||
}
|
||||
|
||||
if (config) {
|
||||
headscaleUrl = headscaleUrl ?? config.server_url
|
||||
}
|
||||
|
||||
if (!headscaleUrl) {
|
||||
throw new Error('Missing server_url in headscale config')
|
||||
}
|
||||
|
||||
const cookieSecret = process.env.COOKIE_SECRET
|
||||
if (!cookieSecret) {
|
||||
throw new Error('COOKIE_SECRET not set')
|
||||
}
|
||||
|
||||
context = {
|
||||
headscaleUrl,
|
||||
cookieSecret,
|
||||
config: await checkConfig(path, config),
|
||||
acl: await checkAcl(config),
|
||||
docker: await checkDocker(),
|
||||
oidc: await checkOidc(config),
|
||||
}
|
||||
|
||||
console.log('Context loaded:', context)
|
||||
return context
|
||||
}
|
||||
|
||||
export async function loadAcl() {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return { data: '', type: 'json' }
|
||||
}
|
||||
|
||||
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' }
|
||||
} catch {
|
||||
return { data, type: 'json' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchAcl(data: string) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error('No ACL file defined')
|
||||
}
|
||||
|
||||
await writeFile(path, data, 'utf8')
|
||||
}
|
||||
|
||||
async function checkConfig(path: string, config?: HeadscaleConfig) {
|
||||
let write = false
|
||||
try {
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
read: config ? true : false,
|
||||
write,
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAcl(config?: HeadscaleConfig) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path && config) {
|
||||
path = config.acl_policy_path
|
||||
}
|
||||
|
||||
let read = false
|
||||
let write = false
|
||||
if (path) {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
read = true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDocker() {
|
||||
const path = process.env.DOCKER_SOCK ?? '/var/run/docker.sock'
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!process.env.HEADSCALE_CONTAINER) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
sock: path,
|
||||
container: process.env.HEADSCALE_CONTAINER,
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOidc(config?: HeadscaleConfig) {
|
||||
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
||||
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
|
||||
if (!rootKey) {
|
||||
throw new Error('ROOT_API_KEY or API_KEY not set')
|
||||
}
|
||||
|
||||
let issuer = process.env.OIDC_ISSUER
|
||||
let client = process.env.OIDC_CLIENT_ID
|
||||
let secret = process.env.OIDC_CLIENT_SECRET
|
||||
|
||||
if (
|
||||
(issuer ?? client ?? secret)
|
||||
&& !(issuer && client && secret)
|
||||
&& !config
|
||||
) {
|
||||
throw new Error('OIDC environment variables are incomplete')
|
||||
}
|
||||
|
||||
if ((!issuer || !client || !secret) && config) {
|
||||
issuer = config.oidc?.issuer
|
||||
client = config.oidc?.client_id
|
||||
secret = config.oidc?.client_secret
|
||||
|
||||
if (!secret && config.oidc?.client_secret_path) {
|
||||
try {
|
||||
const data = await readFile(
|
||||
config.oidc.client_secret_path,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
if (data && data.length > 0) {
|
||||
secret = data.trim()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(issuer ?? client ?? secret)
|
||||
&& !(issuer && client && secret)
|
||||
) {
|
||||
throw new Error('OIDC configuration is incomplete')
|
||||
}
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
issuer,
|
||||
client,
|
||||
secret,
|
||||
rootKey,
|
||||
disableKeyLogin,
|
||||
}
|
||||
}
|
||||
187
app/utils/config/headscale.ts
Normal file
187
app/utils/config/headscale.ts
Normal file
@ -0,0 +1,187 @@
|
||||
// Handle the configuration loading for headscale.
|
||||
// Functionally only used for reading and writing the configuration file.
|
||||
// Availability checks and other configuration checks are done in the headplane
|
||||
// configuration file that's adjacent to this one.
|
||||
//
|
||||
// Around the codebase, this is referred to as the config
|
||||
// Refer to this file on juanfont/headscale for the default values:
|
||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/config.go
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { type Document, parseDocument } from 'yaml'
|
||||
import { z } from 'zod'
|
||||
|
||||
const HeadscaleConfig = z.object({
|
||||
tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'),
|
||||
tls_letsencrypt_challenge_type: z.enum(['HTTP-01', 'TLS-ALPN-01']).default('HTTP-01'),
|
||||
|
||||
tls_letsencrypt_hostname: z.string().optional(),
|
||||
tls_letsencrypt_listen: z.string().optional(),
|
||||
|
||||
tls_cert_path: z.string().optional(),
|
||||
tls_key_path: z.string().optional(),
|
||||
|
||||
server_url: z.string().regex(/^https?:\/\//),
|
||||
listen_addr: z.string(),
|
||||
metrics_listen_addr: z.string().optional(),
|
||||
grpc_listen_addr: z.string().default(':50443'),
|
||||
grpc_allow_insecure: z.boolean().default(false),
|
||||
|
||||
disable_check_updates: z.boolean().default(false),
|
||||
ephemeral_node_inactivity_timeout: z.string().default('120s'),
|
||||
randomize_client_port: z.boolean().default(false),
|
||||
acl_policy_path: z.string().optional(),
|
||||
|
||||
acme_email: z.string().optional(),
|
||||
acme_url: z.string().optional(),
|
||||
|
||||
unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
|
||||
unix_socket_permission: z.string().default('0o770'),
|
||||
|
||||
tuning: z.object({
|
||||
batch_change_delay: z.string().default('800ms'),
|
||||
node_mapsession_buffered_chan_size: z.number().default(30),
|
||||
}).optional(),
|
||||
|
||||
noise: z.object({
|
||||
private_key_path: z.string(),
|
||||
}),
|
||||
|
||||
log: z.object({
|
||||
level: z.string().default('info'),
|
||||
format: z.enum(['text', 'json']).default('text'),
|
||||
}).default({ level: 'info', format: 'text' }),
|
||||
|
||||
logtail: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
}).default({ enabled: false }),
|
||||
|
||||
cli: z.object({
|
||||
address: z.string().optional(),
|
||||
api_key: z.string().optional(),
|
||||
timeout: z.string().default('10s'),
|
||||
insecure: z.boolean().default(false),
|
||||
}).optional(),
|
||||
|
||||
prefixes: z.object({
|
||||
allocation: z.enum(['sequential', 'random']).default('sequential'),
|
||||
v4: z.string(),
|
||||
v6: z.string(),
|
||||
}),
|
||||
|
||||
dns_config: z.object({
|
||||
override_local_dns: z.boolean().default(true),
|
||||
nameservers: z.array(z.string()).default([]),
|
||||
restricted_nameservers: z.record(z.array(z.string())).default({}),
|
||||
domains: z.array(z.string()).default([]),
|
||||
extra_records: z.array(z.object({
|
||||
name: z.string(),
|
||||
type: z.literal('A'),
|
||||
value: z.string(),
|
||||
})).default([]),
|
||||
magic_dns: z.boolean().default(false),
|
||||
base_domain: z.string().default('headscale.net'),
|
||||
}),
|
||||
|
||||
oidc: z.object({
|
||||
only_start_if_oidc_is_available: z.boolean().default(true),
|
||||
issuer: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
client_secret_path: z.string().optional(),
|
||||
scope: z.array(z.string()).default(['openid', 'profile', 'email']),
|
||||
extra_params: z.record(z.string()).default({}),
|
||||
allowed_domains: z.array(z.string()).optional(),
|
||||
allowed_users: z.array(z.string()).optional(),
|
||||
allowed_groups: z.array(z.string()).optional(),
|
||||
strip_email_domain: z.boolean().default(true),
|
||||
expiry: z.string().default('180d'),
|
||||
use_expiry_from_token: z.boolean().default(false),
|
||||
}).optional(),
|
||||
|
||||
database: z.union([
|
||||
z.object({
|
||||
type: z.literal('sqlite'),
|
||||
debug: z.boolean().default(false),
|
||||
sqlite: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('sqlite3'),
|
||||
debug: z.boolean().default(false),
|
||||
sqlite: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('postgres'),
|
||||
debug: z.boolean().default(false),
|
||||
postgres: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
name: z.string(),
|
||||
user: z.string(),
|
||||
pass: z.string(),
|
||||
ssl: z.boolean().default(false),
|
||||
max_open_conns: z.number().default(10),
|
||||
max_idle_conns: z.number().default(10),
|
||||
conn_max_idle_time_secs: z.number().default(3600),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
|
||||
derp: z.object({
|
||||
server: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
region_id: z.number().optional(),
|
||||
region_code: z.string().optional(),
|
||||
region_name: z.string().optional(),
|
||||
stun_listen_addr: z.string().optional(),
|
||||
private_key_path: z.string().optional(),
|
||||
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
automatically_add_embedded_derp_region: z.boolean().default(true),
|
||||
}),
|
||||
|
||||
urls: z.array(z.string()).optional(),
|
||||
paths: z.array(z.string()).optional(),
|
||||
auto_update_enabled: z.boolean().default(true),
|
||||
update_frequency: z.string().default('24h'),
|
||||
}),
|
||||
})
|
||||
|
||||
export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>
|
||||
|
||||
export let configYaml: Document | undefined
|
||||
export let config: HeadscaleConfig | undefined
|
||||
|
||||
export async function loadConfig() {
|
||||
if (config) {
|
||||
return config
|
||||
}
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
const data = await readFile(path, 'utf8')
|
||||
|
||||
configYaml = parseDocument(data)
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
return config
|
||||
}
|
||||
|
||||
// This is so obscenely dangerous, please have a check around it
|
||||
export async function patchConfig(partial: Record<string, unknown>) {
|
||||
if (!configYaml || !config) {
|
||||
throw new Error('Config not loaded')
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
configYaml.setIn(key.split('.'), value)
|
||||
}
|
||||
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
await writeFile(path, configYaml.toString(), 'utf8')
|
||||
}
|
||||
@ -1,31 +1,25 @@
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
/* eslint-disable no-constant-condition */
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import { Client } from 'undici'
|
||||
|
||||
import { getContext } from './config'
|
||||
import { loadContext } from './config/headplane'
|
||||
import { HeadscaleError, pull } from './headscale'
|
||||
|
||||
export async function sighupHeadscale() {
|
||||
const context = await getContext()
|
||||
if (!context.hasDockerSock) {
|
||||
const context = await loadContext()
|
||||
if (!context.docker) {
|
||||
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'
|
||||
socketPath: context.docker.sock,
|
||||
})
|
||||
|
||||
const container = process.env.HEADSCALE_CONTAINER
|
||||
const response = await client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`
|
||||
path: `/v1.30/containers/${context.docker.container}/kill?signal=SIGHUP`,
|
||||
})
|
||||
|
||||
if (!response.statusCode || response.statusCode !== 204) {
|
||||
@ -34,23 +28,18 @@ export async function sighupHeadscale() {
|
||||
}
|
||||
|
||||
export async function restartHeadscale() {
|
||||
const context = await getContext()
|
||||
if (!context.hasDockerSock) {
|
||||
const context = await loadContext()
|
||||
if (!context.docker) {
|
||||
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'
|
||||
socketPath: context.docker.sock,
|
||||
})
|
||||
|
||||
const container = process.env.HEADSCALE_CONTAINER
|
||||
const response = await client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${container}/restart`
|
||||
path: `/v1.30/containers/${context.docker.container}/restart`,
|
||||
})
|
||||
|
||||
if (!response.statusCode || response.statusCode !== 204) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { getContext } from './config'
|
||||
import { loadContext } from './config/headplane'
|
||||
|
||||
export class HeadscaleError extends Error {
|
||||
status: number
|
||||
@ -18,12 +18,12 @@ export class FatalError extends Error {
|
||||
}
|
||||
|
||||
export async function pull<T>(url: string, key: string) {
|
||||
const context = await getContext()
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`
|
||||
}
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@ -34,14 +34,14 @@ export async function pull<T>(url: string, key: string) {
|
||||
}
|
||||
|
||||
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
const context = await getContext()
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`
|
||||
}
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@ -52,13 +52,13 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
}
|
||||
|
||||
export async function del<T>(url: string, key: string) {
|
||||
const context = await getContext()
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`
|
||||
}
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@ -37,7 +37,8 @@
|
||||
"tailwindcss-react-aria-components": "^1.1.2",
|
||||
"undici": "^6.16.1",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"yaml": "^2.4.2"
|
||||
"yaml": "^2.4.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.9.2",
|
||||
|
||||
@ -91,6 +91,9 @@ dependencies:
|
||||
yaml:
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.23.8
|
||||
|
||||
devDependencies:
|
||||
'@remix-run/dev':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user