feat: support readonly config and no docker sock

This commit is contained in:
Aarnav Tale
2024-03-29 21:46:21 -04:00
parent 029f395673
commit e216d4171f
7 changed files with 139 additions and 87 deletions
+16
View File
@@ -0,0 +1,16 @@
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import clsx from 'clsx'
import { type ReactNode } from 'react'
export default function Notice({ children }: { readonly children: ReactNode }) {
return (
<div className={clsx(
'p-4 rounded-md w-fit flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700'
)}
>
<InformationCircleIcon className='h-6 w-6 text-white'/>
{children}
</div>
)
}
+53 -37
View File
@@ -27,9 +27,11 @@ import TableList from '~/components/TableList'
type Properties = { type Properties = {
readonly baseDomain?: string; readonly baseDomain?: string;
readonly searchDomains: string[]; readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
} }
export default function Domains({ baseDomain, searchDomains }: Properties) { export default function Domains({ baseDomain, searchDomains, disabled }: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types // eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null) const [activeId, setActiveId] = useState<number | string | null>(null)
const [localDomains, setLocalDomains] = useState(searchDomains) const [localDomains, setLocalDomains] = useState(searchDomains)
@@ -88,8 +90,14 @@ export default function Domains({ baseDomain, searchDomains }: Properties) {
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
{localDomains.map((sd, index) => ( {localDomains.map((sd, index) => (
// eslint-disable-next-line react/no-array-index-key <Domain
<Domain key={index} domain={sd} id={index + 1} localDomains={localDomains}/> // eslint-disable-next-line react/no-array-index-key
key={index}
domain={sd}
id={index + 1}
localDomains={localDomains}
disabled={disabled}
/>
))} ))}
<DragOverlay adjustScale> <DragOverlay adjustScale>
{activeId ? <Domain {activeId ? <Domain
@@ -97,38 +105,41 @@ export default function Domains({ baseDomain, searchDomains }: Properties) {
domain={localDomains[activeId as number - 1]} domain={localDomains[activeId as number - 1]}
localDomains={localDomains} localDomains={localDomains}
id={activeId as number - 1} id={activeId as number - 1}
disabled={disabled}
/> : undefined} /> : undefined}
</DragOverlay> </DragOverlay>
</SortableContext> </SortableContext>
<TableList.Item key='add-sd'> {disabled ? undefined : (
<Input <TableList.Item key='add-sd'>
variant='embedded' <Input
type='text' variant='embedded'
className='font-mono text-sm' type='text'
placeholder='Search Domain' className='font-mono text-sm'
value={newDomain} placeholder='Search Domain'
onChange={event => { value={newDomain}
setNewDomain(event.target.value) onChange={event => {
}} setNewDomain(event.target.value)
/> }}
<Button />
className='text-sm' <Button
disabled={newDomain.length === 0} className='text-sm'
onClick={() => { disabled={newDomain.length === 0}
fetcher.submit({ onClick={() => {
// eslint-disable-next-line @typescript-eslint/naming-convention fetcher.submit({
'dns_config.domains': [...localDomains, newDomain] // eslint-disable-next-line @typescript-eslint/naming-convention
}, { 'dns_config.domains': [...localDomains, newDomain]
method: 'PATCH', }, {
encType: 'application/json' method: 'PATCH',
}) encType: 'application/json'
})
setNewDomain('') setNewDomain('')
}} }}
> >
Add Add
</Button> </Button>
</TableList.Item> </TableList.Item>
)}
</TableList> </TableList>
</DndContext> </DndContext>
</div> </div>
@@ -140,9 +151,11 @@ type DomainProperties = {
readonly id: number; readonly id: number;
readonly isDrag?: boolean; readonly isDrag?: boolean;
readonly localDomains: string[]; readonly localDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
} }
function Domain({ domain, id, localDomains, isDrag }: DomainProperties) { function Domain({ domain, id, localDomains, isDrag, disabled }: DomainProperties) {
const fetcher = useFetcher() const fetcher = useFetcher()
const { const {
@@ -169,17 +182,20 @@ function Domain({ domain, id, localDomains, isDrag }: DomainProperties) {
}} }}
> >
<p className='font-mono text-sm flex items-center gap-4'> <p className='font-mono text-sm flex items-center gap-4'>
<Bars3Icon {disabled ? undefined : (
className='h-4 w-4 text-gray-400 focus:outline-none' <Bars3Icon
{...attributes} className='h-4 w-4 text-gray-400 focus:outline-none'
{...listeners} {...attributes}
/> {...listeners}
/>
)}
{domain} {domain}
</p> </p>
{isDrag ? undefined : ( {isDrag ? undefined : (
<Button <Button
variant='destructive' variant='destructive'
className='text-sm' className='text-sm'
disabled={disabled}
onClick={() => { onClick={() => {
fetcher.submit({ fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
+4 -1
View File
@@ -7,9 +7,11 @@ import Button from '~/components/Button'
type Properties = { type Properties = {
readonly isEnabled: boolean; readonly isEnabled: boolean;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
} }
export default function Modal({ isEnabled }: Properties) { export default function Modal({ isEnabled, disabled }: Properties) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const fetcher = useFetcher() const fetcher = useFetcher()
@@ -18,6 +20,7 @@ export default function Modal({ isEnabled }: Properties) {
<Button <Button
variant='emphasized' variant='emphasized'
className='w-fit text-sm' className='w-fit text-sm'
disabled={disabled}
onClick={() => { onClick={() => {
setIsOpen(true) setIsOpen(true)
}} }}
+5 -2
View File
@@ -10,9 +10,11 @@ import Input from '~/components/Input'
type Properties = { type Properties = {
readonly name: string; readonly name: string;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
} }
export default function Modal({ name }: Properties) { export default function Modal({ name, disabled }: Properties) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [newName, setNewName] = useState(name) const [newName, setNewName] = useState(name)
const fetcher = useFetcher() const fetcher = useFetcher()
@@ -32,7 +34,7 @@ export default function Modal({ name }: Properties) {
</p> </p>
<Input <Input
readOnly readOnly
className='font-mono text-sm my-4' className='font-mono text-sm my-4 w-1/2'
type='text' type='text'
value={name} value={name}
onFocus={event => { onFocus={event => {
@@ -42,6 +44,7 @@ export default function Modal({ name }: Properties) {
<Button <Button
variant='emphasized' variant='emphasized'
className='text-sm w-fit' className='text-sm w-fit'
disabled={disabled}
onClick={() => { onClick={() => {
setIsOpen(true) setIsOpen(true)
}} }}
+52 -31
View File
@@ -7,8 +7,9 @@ import { useState } from 'react'
import Button from '~/components/Button' import Button from '~/components/Button'
import Code from '~/components/Code' import Code from '~/components/Code'
import Input from '~/components/Input' import Input from '~/components/Input'
import Notice from '~/components/Notice'
import TableList from '~/components/TableList' import TableList from '~/components/TableList'
import { getConfig, patchConfig } from '~/utils/config' import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker' import { restartHeadscale } from '~/utils/docker'
import Domains from './domains' import Domains from './domains'
@@ -18,6 +19,8 @@ import RenameModal from './rename'
// We do not want to expose every config value // We do not want to expose every config value
export async function loader() { export async function loader() {
const config = await getConfig() const config = await getConfig()
const context = await getContext()
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns, magicDns: config.dns_config.magic_dns,
@@ -29,10 +32,18 @@ export async function loader() {
extraRecords: config.dns_config.extra_records extraRecords: config.dns_config.extra_records
} }
return dns return {
...dns,
...context
}
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const context = await getContext()
if (!context.hasConfigWrite) {
return json({ success: false })
}
const data = await request.json() as Record<string, unknown> const data = await request.json() as Record<string, unknown>
await patchConfig(data) await patchConfig(data)
await restartHeadscale() await restartHeadscale()
@@ -47,7 +58,12 @@ export default function Page() {
return ( return (
<div className='flex flex-col gap-16 max-w-screen-lg'> <div className='flex flex-col gap-16 max-w-screen-lg'>
<RenameModal name={data.baseDomain}/> {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'> <div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1> <h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
<p className='text-gray-700 dark:text-gray-300'> <p className='text-gray-700 dark:text-gray-300'>
@@ -63,6 +79,7 @@ export default function Page() {
<span className='text-sm opacity-50'>Override local DNS</span> <span className='text-sm opacity-50'>Override local DNS</span>
<Switch <Switch
checked={localOverride} checked={localOverride}
disabled={!data.hasConfigWrite}
className={clsx( className={clsx(
localOverride ? 'bg-gray-800' : 'bg-gray-200', localOverride ? 'bg-gray-800' : 'bg-gray-200',
'relative inline-flex h-4 w-9 items-center rounded-full' 'relative inline-flex h-4 w-9 items-center rounded-full'
@@ -97,6 +114,7 @@ export default function Page() {
<Button <Button
variant='destructive' variant='destructive'
className='text-sm' className='text-sm'
disabled={!data.hasConfigWrite}
onClick={() => { onClick={() => {
fetcher.submit({ fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -111,35 +129,37 @@ export default function Page() {
</Button> </Button>
</TableList.Item> </TableList.Item>
))} ))}
<TableList.Item> {data.hasConfigWrite ? (
<Input <TableList.Item>
variant='embedded' <Input
type='text' variant='embedded'
className='font-mono text-sm' type='text'
placeholder='Nameserver' className='font-mono text-sm'
value={ns} placeholder='Nameserver'
onChange={event => { value={ns}
setNs(event.target.value) onChange={event => {
}} setNs(event.target.value)
/> }}
<Button />
className='text-sm' <Button
disabled={ns.length === 0} className='text-sm'
onClick={() => { disabled={ns.length === 0}
fetcher.submit({ onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns] 'dns_config.nameservers': [...data.nameservers, ns]
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json' encType: 'application/json'
}) })
setNs('') setNs('')
}} }}
> >
Add Add
</Button> </Button>
</TableList.Item> </TableList.Item>
) : undefined}
</TableList> </TableList>
{/* TODO: Split DNS and Custom A Records */} {/* TODO: Split DNS and Custom A Records */}
</div> </div>
@@ -148,6 +168,7 @@ export default function Page() {
<Domains <Domains
baseDomain={data.magicDns ? data.baseDomain : undefined} baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains} searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite}
/> />
<div className='flex flex-col w-2/3'> <div className='flex flex-col w-2/3'>
@@ -162,7 +183,7 @@ export default function Page() {
{' '} {' '}
when Magic DNS is enabled. when Magic DNS is enabled.
</p> </p>
<MagicModal isEnabled={data.magicDns}/> <MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
</div> </div>
</div> </div>
) )
+3 -16
View File
@@ -141,7 +141,6 @@ export async function patchConfig(partial: Record<string, unknown>) {
} }
type Context = { type Context = {
isDocker: boolean;
hasDockerSock: boolean; hasDockerSock: boolean;
hasConfigWrite: boolean; hasConfigWrite: boolean;
} }
@@ -151,7 +150,6 @@ export let context: Context
export async function getContext() { export async function getContext() {
if (!context) { if (!context) {
context = { context = {
isDocker: await checkDocker(),
hasDockerSock: await checkSock(), hasDockerSock: await checkSock(),
hasConfigWrite: await checkConfigWrite() hasConfigWrite: await checkConfigWrite()
} }
@@ -177,20 +175,9 @@ async function checkSock() {
return true return true
} catch {} } catch {}
return false if (!process.env.HEADSCALE_CONTAINER) {
} return false
}
async function checkDocker() {
try {
await stat('/.dockerenv')
return true
} catch {}
try {
const data = await readFile('/proc/self/cgroup', 'utf8')
return data.includes('docker')
} catch {}
return false return false
} }
+6
View File
@@ -5,9 +5,15 @@ import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici' import { Client } from 'undici'
import { getContext } from './config'
import { pull } from './headscale' import { pull } from './headscale'
export async function restartHeadscale() { export async function restartHeadscale() {
const context = await getContext()
if (!context.hasDockerSock) {
return
}
if (!process.env.HEADSCALE_CONTAINER) { if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set') throw new Error('HEADSCALE_CONTAINER is not set')
} }