feat: use strictly typed configs and context

This commit is contained in:
Aarnav Tale
2024-05-20 14:05:09 -04:00
parent 0a12cdb3d6
commit 3e51e4861d
17 changed files with 742 additions and 676 deletions
+47 -45
View File
@@ -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>
+91 -84
View File
@@ -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>
)
+5 -4
View File
@@ -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
}
+12 -12
View File
@@ -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 -2
View File
@@ -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
}
+8 -7
View File
@@ -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}
/>
)
+20 -26
View File
@@ -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'))
+9 -6
View File
@@ -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,
)
}