mirror of
https://github.com/tale/headplane.git
synced 2026-08-24 11:36:31 +00:00
feat: support readonly config and no docker sock
This commit is contained in:
@@ -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 = {
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user