feat: support readonly config and no docker sock
This commit is contained in:
parent
029f395673
commit
e216d4171f
16
app/components/Notice.tsx
Normal file
16
app/components/Notice.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user