diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx new file mode 100644 index 0000000..9098e94 --- /dev/null +++ b/app/components/Tooltip.tsx @@ -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 ( + + {children} + + ) +} + +function Button(props: Parameters[0]) { + return ( + + ) +} + +function Body({ children, className }: Props) { + return ( + + {children} + + ) +} + +export default Object.assign(Tooltip, { Button, Body }) diff --git a/app/routes/_data.dns._index/dialogs/nameserver.tsx b/app/routes/_data.dns._index/dialogs/nameserver.tsx new file mode 100644 index 0000000..c22c5b5 --- /dev/null +++ b/app/routes/_data.dns._index/dialogs/nameserver.tsx @@ -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 +} + +export default function AddNameserver({ nameservers }: Props) { + const submit = useSubmit() + const [split, setSplit] = useState(false) + const [ns, setNs] = useState('') + const [domain, setDomain] = useState('') + + return ( + + + Add nameserver + + + {close => ( + <> + + Add nameserver + + + Nameserver + + + Use this IPv4 or IPv6 address to resolve names. + +
{ + event.preventDefault() + if (!ns) return + + if (split) { + const splitNs: Record = {} + 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() + }} + > + +
+
+
+ + Restrict to domain + + + + + Split DNS + + + Only clients that support split DNS + (Tailscale v1.8 or later for most platforms) + will use this nameserver. Older clients + will ignore it. + + +
+ + This nameserver will only be used for some domains. + +
+ { setSplit(!split) }} + /> +
+ {split + ? ( + <> + + Domain + + + + Only single-label or fully-qualified queries + matching this suffix should use the nameserver. + + + ) + : undefined} +
+ + Cancel + + + Add + +
+ + + )} +
+
+ ) +} diff --git a/app/routes/_data.dns._index/nameservers.tsx b/app/routes/_data.dns._index/nameservers.tsx new file mode 100644 index 0000000..ce0036f --- /dev/null +++ b/app/routes/_data.dns._index/nameservers.tsx @@ -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 + override: boolean + isDisabled: boolean +} + +export default function Nameservers({ nameservers, override, isDisabled }: Props) { + return ( +
+

Nameservers

+

+ Set the nameservers used by devices on the Tailnet + to resolve DNS queries. + {' '} + + Learn more + +

+
+ {Object.keys(nameservers).map(key => ( + + ))} + + {isDisabled + ? undefined + : ( + + )} +
+
+ ) +} + +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 ( +
+
+

+ {isGlobal ? 'Global Nameservers' : name} +

+ {isGlobal + ? ( +
+ + Override local DNS + + { + submit({ + 'dns_config.override_local_dns': !localOverride, + }, { + method: 'PATCH', + encType: 'application/json', + }) + + setLocalOverride(!localOverride) + }} + /> +
+ ) + : undefined} +
+ + {nameservers.map((ns, index) => ( + // eslint-disable-next-line react/no-array-index-key + +

{ns}

+ +
+ ))} +
+
+ ) +} diff --git a/app/routes/_data.dns._index/route.tsx b/app/routes/_data.dns._index/route.tsx index 846ad88..a91d878 100644 --- a/app/routes/_data.dns._index/route.tsx +++ b/app/routes/_data.dns._index/route.tsx @@ -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() - const fetcher = useFetcher() - const [localOverride, setLocalOverride] = useState(data.overrideLocal) - const [ns, setNs] = useState('') + + const allNs: Record = {} + for (const key of Object.keys(data.splitDns)) { + allNs[key] = data.splitDns[key] + } + + allNs.global = data.nameservers return (
@@ -82,113 +81,11 @@ export default function Page() { )} -
-

Nameservers

-

- Set the nameservers used by devices on the Tailnet - to resolve DNS queries. -

-
-
-

- Global Nameservers -

-
- - Override local DNS - - { - fetcher.submit({ - // eslint-disable-next-line @typescript-eslint/naming-convention - 'dns_config.override_local_dns': !localOverride, - }, { - method: 'PATCH', - encType: 'application/json', - }) - - setLocalOverride(!localOverride) - }} - /> -
-
- - {data.nameservers.map((ns, index) => ( - // eslint-disable-next-line react/no-array-index-key - -

{ns}

- -
- ))} - {data.config.write - ? ( - - { - setNs(event.target.value) - }} - /> - {fetcher.state === 'idle' - ? ( - - ) - : ( - - )} - - ) - : undefined} -
- {/* TODO: Split DNS and Custom A Records */} -
-
+ ) { } 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())