feat: add support for split dns

This commit is contained in:
Aarnav Tale 2024-05-21 17:21:14 -04:00
parent 2aae6bfc06
commit 06d7d1ccad
No known key found for this signature in database
5 changed files with 387 additions and 118 deletions

View File

@ -0,0 +1,43 @@
import { ReactNode } from 'react'
import {
Button as AriaButton,
Tooltip as AriaTooltip,
TooltipTrigger,
} from 'react-aria-components'
import { cn } from '~/utils/cn'
interface Props {
children: ReactNode
className?: string
}
function Tooltip({ children }: Props) {
return (
<TooltipTrigger delay={0}>
{children}
</TooltipTrigger>
)
}
function Button(props: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton {...props} />
)
}
function Body({ children, className }: Props) {
return (
<AriaTooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-ui-900 drop-shadow-sm',
'border border-gray-200 dark:border-zinc-700',
className,
)}
>
{children}
</AriaTooltip>
)
}
export default Object.assign(Tooltip, { Button, Body })

View File

@ -0,0 +1,162 @@
import { RepoForkedIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import TextField from '~/components/TextField'
import Tooltip from '~/components/Tooltip'
import { cn } from '~/utils/cn'
interface Props {
nameservers: Record<string, string[]>
}
export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit()
const [split, setSplit] = useState(false)
const [ns, setNs] = useState('')
const [domain, setDomain] = useState('')
return (
<Dialog>
<Dialog.Button>
Add nameserver
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add nameserver
</Dialog.Title>
<Dialog.Text className="font-semibold">
Nameserver
</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!ns) return
if (split) {
const splitNs: Record<string, string[]> = {}
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue
splitNs[key] = value
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns)
} else {
splitNs[domain] = [ns]
}
submit({
'dns_config.restricted_nameservers': splitNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const globalNs = nameservers.global
globalNs.push(ns)
submit({
'dns_config.nameservers': globalNs,
}, {
method: 'PATCH',
encType: 'application/json',
})
}
setNs('')
setDomain('')
setSplit(false)
close()
}}
>
<TextField
label="DNS Server"
placeholder="1.2.3.4"
name="ns"
state={[ns, setNs]}
className="mt-2 mb-8"
/>
<div className="flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
Split DNS
</Tooltip.Button>
<Tooltip.Body>
Only clients that support split DNS
(Tailscale v1.8 or later for most platforms)
will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => { setSplit(!split) }}
/>
</div>
{split
? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries
matching this suffix should use the nameserver.
</Dialog.Text>
</>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}

View File

@ -0,0 +1,139 @@
import { useSubmit } from '@remix-run/react'
import { useState } from 'react'
import { Button } from 'react-aria-components'
import Link from '~/components/Link'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import AddNameserver from './dialogs/nameserver'
interface Props {
nameservers: Record<string, string[]>
override: boolean
isDisabled: boolean
}
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
return (
<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.
{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map(key => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers[key]}
override={override}
name={key}
/>
))}
{isDisabled
? undefined
: (
<AddNameserver nameservers={nameservers} />
)}
</div>
</div>
)
}
interface ListProps {
isGlobal: boolean
isDisabled: boolean
nameservers: string[]
name: string
override: boolean
}
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
const [localOverride, setLocalOverride] = useState(override)
const submit = useSubmit()
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
{isGlobal
? (
<div className="flex gap-2 items-center">
<span className="text-sm opacity-50">
Override local DNS
</span>
<Switch
label="Override local DNS"
defaultSelected={localOverride}
isDisabled={isDisabled}
onChange={() => {
submit({
'dns_config.override_local_dns': !localOverride,
}, {
method: 'PATCH',
encType: 'application/json',
})
setLocalOverride(!localOverride)
}}
/>
</div>
)
: undefined}
</div>
<TableList>
{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>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
isDisabled={isDisabled}
onPress={() => {
if (isGlobal) {
submit({
'dns_config.nameservers': nameservers
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const key = `dns_config.restricted_nameservers."${name}"`
submit({
[key]: nameservers
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
}
}}
>
Remove
</Button>
</TableList.Item>
))}
</TableList>
</div>
)
}

View File

@ -1,14 +1,8 @@
import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { Button, Input } from 'react-aria-components'
import { json, useLoaderData } from '@remix-run/react'
import Code from '~/components/Code'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig, patchConfig } from '~/utils/config/headscale'
import { restartHeadscale } from '~/utils/docker'
@ -17,6 +11,7 @@ import { useLiveData } from '~/utils/useLiveData'
import Domains from './domains'
import MagicModal from './magic'
import Nameservers from './nameservers'
import RenameModal from './rename'
// We do not want to expose every config value
@ -68,9 +63,13 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() {
useLiveData({ interval: 5000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
const [ns, setNs] = useState('')
const allNs: Record<string, string[]> = {}
for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key]
}
allNs.global = data.nameservers
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
@ -82,113 +81,11 @@ export default function Page() {
</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">
Global Nameservers
</h2>
<div className="flex gap-2 items-center">
<span className="text-sm opacity-50">
Override local DNS
</span>
<Switch
label="Override local DNS"
defaultSelected={localOverride}
isDisabled={!data.config.write}
onChange={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.override_local_dns': !localOverride,
}, {
method: 'PATCH',
encType: 'application/json',
})
setLocalOverride(!localOverride)
}}
/>
</div>
</div>
<TableList>
{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>
<Button
className={cn(
'text-sm',
'text-red-600 dark:text-red-400',
'hover:text-red-700 dark:hover:text-red-300',
!data.config.write && 'opacity-50 cursor-not-allowed',
)}
isDisabled={!data.config.write}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
}}
>
Remove
</Button>
</TableList.Item>
))}
{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)
}}
/>
{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>
</div>
<Nameservers
nameservers={allNs}
override={data.overrideLocal}
isDisabled={!data.config.write}
/>
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}

View File

@ -178,7 +178,35 @@ export async function patchConfig(partial: Record<string, unknown>) {
}
for (const [key, value] of Object.entries(partial)) {
configYaml.setIn(key.split('.'), value)
// If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
// Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it.
const path = []
let temp = ''
let inQuote = false
for (const element of key) {
if (element === '"') {
inQuote = !inQuote
}
if (element === '.' && !inQuote) {
path.push(temp.replaceAll('"', ''))
temp = ''
continue
}
temp += element
}
// Push the remaining element
path.push(temp.replaceAll('"', ''))
configYaml.setIn(path, value)
}
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())