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
No known key found for this signature in database
7 changed files with 139 additions and 87 deletions

16
app/components/Notice.tsx Normal file
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>
)
}

View File

@ -27,9 +27,11 @@ import TableList from '~/components/TableList'
type Properties = {
readonly baseDomain?: 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
const [activeId, setActiveId] = useState<number | string | null>(null)
const [localDomains, setLocalDomains] = useState(searchDomains)
@ -88,8 +90,14 @@ export default function Domains({ baseDomain, searchDomains }: Properties) {
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
// eslint-disable-next-line react/no-array-index-key
<Domain key={index} domain={sd} id={index + 1} localDomains={localDomains}/>
<Domain
// eslint-disable-next-line react/no-array-index-key
key={index}
domain={sd}
id={index + 1}
localDomains={localDomains}
disabled={disabled}
/>
))}
<DragOverlay adjustScale>
{activeId ? <Domain
@ -97,38 +105,41 @@ export default function Domains({ baseDomain, searchDomains }: Properties) {
domain={localDomains[activeId as number - 1]}
localDomains={localDomains}
id={activeId as number - 1}
disabled={disabled}
/> : undefined}
</DragOverlay>
</SortableContext>
<TableList.Item key='add-sd'>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Search Domain'
value={newDomain}
onChange={event => {
setNewDomain(event.target.value)
}}
/>
<Button
className='text-sm'
disabled={newDomain.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
})
{disabled ? undefined : (
<TableList.Item key='add-sd'>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Search Domain'
value={newDomain}
onChange={event => {
setNewDomain(event.target.value)
}}
/>
<Button
className='text-sm'
disabled={newDomain.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNewDomain('')
}}
>
Add
</Button>
</TableList.Item>
setNewDomain('')
}}
>
Add
</Button>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
@ -140,9 +151,11 @@ type DomainProperties = {
readonly id: number;
readonly isDrag?: boolean;
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 {
@ -169,17 +182,20 @@ function Domain({ domain, id, localDomains, isDrag }: DomainProperties) {
}}
>
<p className='font-mono text-sm flex items-center gap-4'>
<Bars3Icon
className='h-4 w-4 text-gray-400 focus:outline-none'
{...attributes}
{...listeners}
/>
{disabled ? undefined : (
<Bars3Icon
className='h-4 w-4 text-gray-400 focus:outline-none'
{...attributes}
{...listeners}
/>
)}
{domain}
</p>
{isDrag ? undefined : (
<Button
variant='destructive'
className='text-sm'
disabled={disabled}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention

View File

@ -7,9 +7,11 @@ import Button from '~/components/Button'
type Properties = {
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 fetcher = useFetcher()
@ -18,6 +20,7 @@ export default function Modal({ isEnabled }: Properties) {
<Button
variant='emphasized'
className='w-fit text-sm'
disabled={disabled}
onClick={() => {
setIsOpen(true)
}}

View File

@ -10,9 +10,11 @@ import Input from '~/components/Input'
type Properties = {
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 [newName, setNewName] = useState(name)
const fetcher = useFetcher()
@ -32,7 +34,7 @@ export default function Modal({ name }: Properties) {
</p>
<Input
readOnly
className='font-mono text-sm my-4'
className='font-mono text-sm my-4 w-1/2'
type='text'
value={name}
onFocus={event => {
@ -42,6 +44,7 @@ export default function Modal({ name }: Properties) {
<Button
variant='emphasized'
className='text-sm w-fit'
disabled={disabled}
onClick={() => {
setIsOpen(true)
}}

View File

@ -7,8 +7,9 @@ import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Input from '~/components/Input'
import Notice from '~/components/Notice'
import TableList from '~/components/TableList'
import { getConfig, patchConfig } from '~/utils/config'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker'
import Domains from './domains'
@ -18,6 +19,8 @@ import RenameModal from './rename'
// We do not want to expose every config value
export async function loader() {
const config = await getConfig()
const context = await getContext()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns,
@ -29,10 +32,18 @@ export async function loader() {
extraRecords: config.dns_config.extra_records
}
return dns
return {
...dns,
...context
}
}
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>
await patchConfig(data)
await restartHeadscale()
@ -47,7 +58,12 @@ export default function Page() {
return (
<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'>
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
<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>
<Switch
checked={localOverride}
disabled={!data.hasConfigWrite}
className={clsx(
localOverride ? 'bg-gray-800' : 'bg-gray-200',
'relative inline-flex h-4 w-9 items-center rounded-full'
@ -97,6 +114,7 @@ export default function Page() {
<Button
variant='destructive'
className='text-sm'
disabled={!data.hasConfigWrite}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -111,35 +129,37 @@ export default function Page() {
</Button>
</TableList.Item>
))}
<TableList.Item>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Nameserver'
value={ns}
onChange={event => {
setNs(event.target.value)
}}
/>
<Button
className='text-sm'
disabled={ns.length === 0}
onClick={() => {
fetcher.submit({
{data.hasConfigWrite ? (
<TableList.Item>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Nameserver'
value={ns}
onChange={event => {
setNs(event.target.value)
}}
/>
<Button
className='text-sm'
disabled={ns.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns]
}, {
method: 'PATCH',
encType: 'application/json'
})
'dns_config.nameservers': [...data.nameservers, ns]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNs('')
}}
>
Add
</Button>
</TableList.Item>
setNs('')
}}
>
Add
</Button>
</TableList.Item>
) : undefined}
</TableList>
{/* TODO: Split DNS and Custom A Records */}
</div>
@ -148,6 +168,7 @@ export default function Page() {
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite}
/>
<div className='flex flex-col w-2/3'>
@ -162,7 +183,7 @@ export default function Page() {
{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns}/>
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
</div>
</div>
)

View File

@ -141,7 +141,6 @@ export async function patchConfig(partial: Record<string, unknown>) {
}
type Context = {
isDocker: boolean;
hasDockerSock: boolean;
hasConfigWrite: boolean;
}
@ -151,7 +150,6 @@ export let context: Context
export async function getContext() {
if (!context) {
context = {
isDocker: await checkDocker(),
hasDockerSock: await checkSock(),
hasConfigWrite: await checkConfigWrite()
}
@ -177,20 +175,9 @@ async function checkSock() {
return true
} catch {}
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 {}
if (!process.env.HEADSCALE_CONTAINER) {
return false
}
return false
}

View File

@ -5,9 +5,15 @@ import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { getContext } from './config'
import { pull } from './headscale'
export async function restartHeadscale() {
const context = await getContext()
if (!context.hasDockerSock) {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}