diff --git a/app/routes/dns/components/magic.tsx b/app/routes/dns/components/magic.tsx deleted file mode 100644 index 996a85d..0000000 --- a/app/routes/dns/components/magic.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useFetcher } from 'react-router'; -import Dialog from '~/components/Dialog'; -import Spinner from '~/components/Spinner'; - -type Properties = { - readonly isEnabled: boolean; - readonly disabled?: boolean; -}; - -// TODO: Use form action instead of JSON patching -// AND FIX JSON END OF UNEXPECTED INPUT -export default function Modal({ isEnabled, disabled }: Properties) { - const fetcher = useFetcher(); - - return ( - - - {fetcher.state === 'idle' ? undefined : } - {isEnabled ? 'Disable' : 'Enable'} Magic DNS - - { - fetcher.submit( - { - 'dns.magic_dns': !isEnabled, - }, - { - method: 'PATCH', - encType: 'application/json', - }, - ); - }} - > - - {isEnabled ? 'Disable' : 'Enable'} Magic DNS - - - Devices will no longer be accessible via your tailnet domain. The - search domain will also be disabled. - - - - ); -} diff --git a/app/routes/dns/components/domains.tsx b/app/routes/dns/components/manage-domains.tsx similarity index 55% rename from app/routes/dns/components/domains.tsx rename to app/routes/dns/components/manage-domains.tsx index 53ba1e9..346064f 100644 --- a/app/routes/dns/components/domains.tsx +++ b/app/routes/dns/components/manage-domains.tsx @@ -1,4 +1,3 @@ -/* eslint-disable unicorn/no-keyword-prefix */ import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core'; import { restrictToParentElement, @@ -13,29 +12,25 @@ import { import { CSS } from '@dnd-kit/utilities'; import { GripVertical, Lock } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { type FetcherWithComponents, useFetcher } from 'react-router'; +import { type FetcherWithComponents, Form, useFetcher } from 'react-router'; import Button from '~/components/Button'; import Input from '~/components/Input'; - -import Spinner from '~/components/Spinner'; import TableList from '~/components/TableList'; import cn from '~/utils/cn'; -type Properties = { - readonly baseDomain?: string; - readonly searchDomains: string[]; - readonly disabled?: boolean; // TODO: isDisabled -}; +interface Props { + searchDomains: string[]; + isDisabled: boolean; + magic?: string; +} -export default function Domains({ - baseDomain, +export default function ManageDomains({ searchDomains, - disabled, -}: Properties) { + isDisabled, + magic, +}: Props) { const [activeId, setActiveId] = useState(null); const [localDomains, setLocalDomains] = useState(searchDomains); - const [newDomain, setNewDomain] = useState(''); - const fetcher = useFetcher(); useEffect(() => { setLocalDomains(searchDomains); @@ -77,16 +72,16 @@ export default function Domains({ }} > - {baseDomain ? ( + {magic ? (
-

{baseDomain}

+

{magic}

) : undefined} @@ -99,63 +94,49 @@ export default function Domains({ key={sd} domain={sd} id={index + 1} - localDomains={localDomains} - disabled={disabled} - fetcher={fetcher} + isDisabled={isDisabled} /> ))} {activeId ? ( ) : undefined} - {disabled ? undefined : ( + {isDisabled ? undefined : ( - - {fetcher.state === 'idle' ? ( +
+ + - ) : ( - - )} +
)}
@@ -164,38 +145,29 @@ export default function Domains({ ); } -type DomainProperties = { - readonly domain: string; - readonly id: number; - readonly isDrag?: boolean; - readonly localDomains: string[]; - readonly disabled?: boolean; // TODO: isDisabled - readonly fetcher: FetcherWithComponents; -}; +interface DomainProps { + domain: string; + id: number; + isDragging?: boolean; + isDisabled: boolean; +} -function Domain({ - domain, - id, - localDomains, - isDrag, - disabled, - fetcher, -}: DomainProperties) { +function Domain({ domain, id, isDragging, isDisabled }: DomainProps) { const { attributes, listeners, setNodeRef, transform, transition, - isDragging, + isDragging: isSortableDragging, } = useSortable({ id }); return (

- {disabled ? undefined : ( - + {isDisabled ? undefined : ( + )} {domain}

- {isDrag ? undefined : ( - + {isDragging ? undefined : ( +
+ + + +
)}
); diff --git a/app/routes/dns/components/nameservers.tsx b/app/routes/dns/components/manage-ns.tsx similarity index 57% rename from app/routes/dns/components/nameservers.tsx rename to app/routes/dns/components/manage-ns.tsx index 6ee86bf..74a3948 100644 --- a/app/routes/dns/components/nameservers.tsx +++ b/app/routes/dns/components/manage-ns.tsx @@ -1,16 +1,16 @@ -import { useSubmit } from 'react-router'; +import { Form } from 'react-router'; import Button from '~/components/Button'; import Link from '~/components/Link'; import TableList from '~/components/TableList'; import cn from '~/utils/cn'; -import AddNameserver from '../dialogs/nameserver'; +import AddNS from '../dialogs/add-ns'; interface Props { nameservers: Record; isDisabled: boolean; } -export default function Nameservers({ nameservers, isDisabled }: Props) { +export default function ManageNS({ nameservers, isDisabled }: Props) { return (

Nameservers

@@ -35,7 +35,7 @@ export default function Nameservers({ nameservers, isDisabled }: Props) { /> ))} - {isDisabled ? undefined : } + {isDisabled ? undefined : }
); @@ -54,7 +54,6 @@ function NameserverList({ nameservers, name, }: ListProps) { - const submit = useSubmit(); const list = isGlobal ? nameservers.global : nameservers[name]; if (list.length === 0) { return null; @@ -69,46 +68,28 @@ function NameserverList({ {list.length > 0 - ? list.map((ns, index) => ( + ? list.map((ns) => (

{ns}

- +
+ + + + +
)) : undefined} diff --git a/app/routes/dns/components/dns.tsx b/app/routes/dns/components/manage-records.tsx similarity index 65% rename from app/routes/dns/components/dns.tsx rename to app/routes/dns/components/manage-records.tsx index 8325e77..2d6f9de 100644 --- a/app/routes/dns/components/dns.tsx +++ b/app/routes/dns/components/manage-records.tsx @@ -1,19 +1,17 @@ -import { useSubmit } from 'react-router'; +import { Form } from 'react-router'; import Button from '~/components/Button'; import Code from '~/components/Code'; import Link from '~/components/Link'; import TableList from '~/components/TableList'; import cn from '~/utils/cn'; -import AddDNS from '../dialogs/dns'; +import AddRecord from '../dialogs/add-record'; interface Props { - records: { name: string; type: 'A'; value: string }[]; + records: { name: string; type: 'A' | string; value: string }[]; isDisabled: boolean; } -export default function DNS({ records, isDisabled }: Props) { - const submit = useSubmit(); - +export default function ManageRecords({ records, isDisabled }: Props) { return (

DNS Records

@@ -50,34 +48,27 @@ export default function DNS({ records, isDisabled }: Props) {

{record.value}

- +
+ + + + +
)) )}
- {isDisabled ? undefined : } + {isDisabled ? undefined : } ); diff --git a/app/routes/dns/components/rename.tsx b/app/routes/dns/components/rename-tailnet.tsx similarity index 51% rename from app/routes/dns/components/rename.tsx rename to app/routes/dns/components/rename-tailnet.tsx index fac2bfb..773e232 100644 --- a/app/routes/dns/components/rename.tsx +++ b/app/routes/dns/components/rename-tailnet.tsx @@ -1,22 +1,13 @@ -import { useState } from 'react'; -import { useFetcher } from 'react-router'; - import Code from '~/components/Code'; import Dialog from '~/components/Dialog'; import Input from '~/components/Input'; -import Spinner from '~/components/Spinner'; -import cn from '~/utils/cn'; -type Properties = { - readonly name: string; - readonly disabled?: boolean; -}; - -// TODO: Switch to form submit instead of JSON patch -export default function Modal({ name, disabled }: Properties) { - const [newName, setNewName] = useState(name); - const fetcher = useFetcher(); +interface Props { + name: string; + isDisabled: boolean; +} +export default function RenameTailnet({ name, isDisabled }: Props) { return (

Tailnet Name

@@ -35,34 +26,20 @@ export default function Modal({ name, disabled }: Properties) { }} /> - - {fetcher.state === 'idle' ? undefined : ( - - )} - Rename Tailnet - - { - fetcher.submit( - { - 'dns.base_domain': newName, - }, - { - method: 'PATCH', - encType: 'application/json', - }, - ); - }} - > + Rename Tailnet + Rename Tailnet - + Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet. + diff --git a/app/routes/dns/components/toggle-magic.tsx b/app/routes/dns/components/toggle-magic.tsx new file mode 100644 index 0000000..44ab16f --- /dev/null +++ b/app/routes/dns/components/toggle-magic.tsx @@ -0,0 +1,31 @@ +import Dialog from '~/components/Dialog'; + +interface Props { + isEnabled: boolean; + isDisabled: boolean; +} + +export default function Modal({ isEnabled, isDisabled }: Props) { + return ( + + + {isEnabled ? 'Disable' : 'Enable'} Magic DNS + + + + {isEnabled ? 'Disable' : 'Enable'} Magic DNS + + + Devices will no longer be accessible via your tailnet domain. The + search domain will also be disabled. + + + + + + ); +} diff --git a/app/routes/dns/dialogs/nameserver.tsx b/app/routes/dns/dialogs/add-ns.tsx similarity index 59% rename from app/routes/dns/dialogs/nameserver.tsx rename to app/routes/dns/dialogs/add-ns.tsx index ff2e198..823c421 100644 --- a/app/routes/dns/dialogs/nameserver.tsx +++ b/app/routes/dns/dialogs/add-ns.tsx @@ -1,8 +1,6 @@ import { RepoForkedIcon } from '@primer/octicons-react'; -import { useState } from 'react'; -import { useSubmit } from 'react-router'; +import { useMemo, useState } from 'react'; import Chip from '~/components/Chip'; - import Dialog from '~/components/Dialog'; import Input from '~/components/Input'; import Switch from '~/components/Switch'; @@ -14,69 +12,40 @@ interface Props { } export default function AddNameserver({ nameservers }: Props) { - const submit = useSubmit(); const [split, setSplit] = useState(false); const [ns, setNs] = useState(''); const [domain, setDomain] = useState(''); + const isInvalid = useMemo(() => { + if (ns === '') return false; + // Test if it's a valid IPv4 or IPv6 address + const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/; + const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/; + if (!ipv4.test(ns) && !ipv6.test(ns)) return true; + + if (split) { + return nameservers[domain]?.includes(ns); + } + + return Object.values(nameservers).some((nsList) => nsList.includes(ns)); + }, [nameservers, ns]); + return ( Add nameserver - { - 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.nameservers.split': splitNs, - }, - { - method: 'PATCH', - encType: 'application/json', - }, - ); - } else { - const globalNs = nameservers.global; - globalNs.push(ns); - - submit( - { - 'dns.nameservers.global': globalNs, - }, - { - method: 'PATCH', - encType: 'application/json', - }, - ); - } - - setNs(''); - setDomain(''); - setSplit(false); - }} - > - Add nameserver + + Add nameserver + -
+
@@ -99,21 +68,16 @@ export default function AddNameserver({ nameservers }: Props) { This nameserver will only be used for some domains.
- { - setSplit(!split); - }} - /> +
{split ? ( <> Domain @@ -121,7 +85,9 @@ export default function AddNameserver({ nameservers }: Props) { should use the nameserver. - ) : undefined} + ) : ( + + )}
); diff --git a/app/routes/dns/dialogs/dns.tsx b/app/routes/dns/dialogs/add-record.tsx similarity index 67% rename from app/routes/dns/dialogs/dns.tsx rename to app/routes/dns/dialogs/add-record.tsx index 061ca15..0f6f962 100644 --- a/app/routes/dns/dialogs/dns.tsx +++ b/app/routes/dns/dialogs/add-record.tsx @@ -1,15 +1,13 @@ import { useMemo, useState } from 'react'; -import { useSubmit } from 'react-router'; import Code from '~/components/Code'; import Dialog from '~/components/Dialog'; import Input from '~/components/Input'; interface Props { - records: { name: string; type: 'A'; value: string }[]; + records: { name: string; type: 'A' | string; value: string }[]; } -export default function AddDNS({ records }: Props) { - const submit = useSubmit(); +export default function AddRecord({ records }: Props) { const [name, setName] = useState(''); const [ip, setIp] = useState(''); @@ -21,44 +19,22 @@ export default function AddDNS({ records }: Props) { return lookup.value === ip; }, [records, name, ip]); - // TODO: Ditch useSubmit here (non JSON form) return ( Add DNS record - { - event.preventDefault(); - if (!name || !ip) return; - - setName(''); - setIp(''); - submit( - { - 'dns.extra_records': [ - ...records, - { - name, - type: 'A', - value: ip, - }, - ], - }, - { - method: 'PATCH', - encType: 'application/json', - }, - ); - }} - > + Add DNS record Enter the domain and IP address for the new DNS record.
+ + @@ -66,7 +42,7 @@ export default function AddDNS({ records }: Props) { isRequired label="IP Address" placeholder="101.101.101.101" - name="ip" + name="record_value" onChange={setIp} isInvalid={isDuplicate} /> diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts new file mode 100644 index 0000000..98dfb44 --- /dev/null +++ b/app/routes/dns/dns-actions.ts @@ -0,0 +1,238 @@ +import { ActionFunctionArgs, data } from 'react-router'; +import { hs_patchConfig } from '~/utils/config/loader'; +import { auth } from '~/utils/sessions.server'; +import { hs_getConfig } from '~/utils/state'; + +export async function dnsAction({ request }: ActionFunctionArgs) { + const session = await auth(request); + if (!session) { + return data({ success: false }, 401); + } + + const { mode } = hs_getConfig(); + if (mode !== 'rw') { + return data({ success: false }, 403); + } + + const formData = await request.formData(); + const action = formData.get('action_id')?.toString(); + if (!action) { + return data({ success: false }, 400); + } + + switch (action) { + case 'rename_tailnet': + return renameTailnet(formData); + case 'toggle_magic': + return toggleMagic(formData); + case 'remove_ns': + return removeNs(formData); + case 'add_ns': + return addNs(formData); + case 'remove_domain': + return removeDomain(formData); + case 'add_domain': + return addDomain(formData); + case 'remove_record': + return removeRecord(formData); + case 'add_record': + return addRecord(formData); + default: + return data({ success: false }, 400); + } + + // TODO: Integration update +} + +async function renameTailnet(formData: FormData) { + const newName = formData.get('new_name')?.toString(); + if (!newName) { + return data({ success: false }, 400); + } + + await hs_patchConfig([ + { + path: 'dns.base_domain', + value: newName, + }, + ]); +} + +async function toggleMagic(formData: FormData) { + const newState = formData.get('new_state')?.toString(); + if (!newState) { + return data({ success: false }, 400); + } + + await hs_patchConfig([ + { + path: 'dns.magic_dns', + value: newState === 'enabled', + }, + ]); +} + +async function removeNs(formData: FormData) { + const ns = formData.get('ns')?.toString(); + const splitName = formData.get('split_name')?.toString(); + + if (!ns || !splitName) { + return data({ success: false }, 400); + } + + const { config, mode } = hs_getConfig(); + if (mode !== 'rw') { + return data({ success: false }, 403); + } + + if (splitName === 'global') { + const servers = config.dns.nameservers.global.filter((i) => i !== ns); + + await hs_patchConfig([ + { + path: 'dns.nameservers.global', + value: servers, + }, + ]); + } else { + const splits = config.dns.nameservers.split; + const servers = splits[splitName].filter((i) => i !== ns); + + await hs_patchConfig([ + { + path: `dns.nameservers.split."${splitName}"`, + value: servers, + }, + ]); + } +} + +async function addNs(formData: FormData) { + const ns = formData.get('ns')?.toString(); + const splitName = formData.get('split_name')?.toString(); + + if (!ns || !splitName) { + return data({ success: false }, 400); + } + + const { config, mode } = hs_getConfig(); + if (mode !== 'rw') { + return data({ success: false }, 403); + } + + if (splitName === 'global') { + const servers = config.dns.nameservers.global; + servers.push(ns); + + await hs_patchConfig([ + { + path: 'dns.nameservers.global', + value: servers, + }, + ]); + } else { + const splits = config.dns.nameservers.split; + const servers = splits[splitName] ?? []; + servers.push(ns); + + await hs_patchConfig([ + { + path: `dns.nameservers.split."${splitName}"`, + value: servers, + }, + ]); + } +} + +async function removeDomain(formData: FormData) { + const domain = formData.get('domain')?.toString(); + if (!domain) { + return data({ success: false }, 400); + } + + const { config, mode } = hs_getConfig(); + if (mode !== 'rw') { + return data({ success: false }, 403); + } + + const domains = config.dns.search_domains.filter((i) => i !== domain); + + await hs_patchConfig([ + { + path: 'dns.search_domains', + value: domains, + }, + ]); +} + +async function addDomain(formData: FormData) { + const domain = formData.get('domain')?.toString(); + if (!domain) { + return data({ success: false }, 400); + } + + const { config, mode } = hs_getConfig(); + if (mode !== 'rw') { + return data({ success: false }, 403); + } + + const domains = config.dns.search_domains; + domains.push(domain); + + await hs_patchConfig([ + { + path: 'dns.search_domains', + value: domains, + }, + ]); +} + +async function removeRecord(formData: FormData) { + const recordName = formData.get('record_name')?.toString(); + const recordType = formData.get('record_type')?.toString(); + + if (!recordName || !recordType) { + return data({ success: false }, 400); + } + + const { config, mode } = hs_getConfig(); + if (mode !== 'rw') { + return data({ success: false }, 403); + } + + const records = config.dns.extra_records.filter( + (i) => i.name !== recordName || i.type !== recordType, + ); + + await hs_patchConfig([ + { + path: 'dns.extra_records', + value: records, + }, + ]); +} + +async function addRecord(formData: FormData) { + const recordName = formData.get('record_name')?.toString(); + const recordType = formData.get('record_type')?.toString(); + const recordValue = formData.get('record_value')?.toString(); + + if (!recordName || !recordType || !recordValue) { + return data({ success: false }, 400); + } + + const { config, mode } = hs_getConfig(); + if (mode !== 'rw') { + return data({ success: false }, 403); + } + + const records = config.dns.extra_records; + records.push({ name: recordName, type: recordType, value: recordValue }); + + await hs_patchConfig([ + { + path: 'dns.extra_records', + value: records, + }, + ]); +} diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index 317f1a1..2048980 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -1,26 +1,22 @@ import type { ActionFunctionArgs } from 'react-router'; -import { data, useLoaderData } from 'react-router'; - +import { useLoaderData } from 'react-router'; import Code from '~/components/Code'; import Notice from '~/components/Notice'; -import { loadContext } from '~/utils/config/headplane'; -import { loadConfig, patchConfig } from '~/utils/config/headscale'; -import { getSession } from '~/utils/sessions.server'; - -import DNS from './components/dns'; -import Domains from './components/domains'; -import MagicModal from './components/magic'; -import Nameservers from './components/nameservers'; -import RenameModal from './components/rename'; +import { hs_getConfig } from '~/utils/state'; +import ManageDomains from './components/manage-domains'; +import ManageNS from './components/manage-ns'; +import ManageRecords from './components/manage-records'; +import RenameTailnet from './components/rename-tailnet'; +import ToggleMagic from './components/toggle-magic'; +import { dnsAction } from './dns-actions'; // We do not want to expose every config value export async function loader() { - const context = await loadContext(); - if (!context.config.read) { + const { config, mode } = hs_getConfig(); + if (mode === 'no') { throw new Error('No configuration is available'); } - const config = await loadConfig(); const dns = { prefixes: config.prefixes, magicDns: config.dns.magic_dns, @@ -33,34 +29,12 @@ export async function loader() { return { ...dns, - ...context, + mode, }; } -export async function action({ request }: ActionFunctionArgs) { - const session = await getSession(request.headers.get('Cookie')); - if (!session.has('hsApiKey')) { - return data({ success: false }, { status: 401 }); - } - - const context = await loadContext(); - if (!context.config.write) { - return data({ success: false }, { status: 403 }); - } - - const textData = await request.text(); - if (!textData) { - return data({ success: true }); - } - - const patch = JSON.parse(textData) as Record; - await patchConfig(patch); - - if (context.integration?.onConfigChange) { - await context.integration.onConfigChange(context.integration.context); - } - - return data({ success: true }); +export async function action(data: ActionFunctionArgs) { + return dnsAction(data); } export default function Page() { @@ -72,24 +46,23 @@ export default function Page() { } allNs.global = data.nameservers; + const isDisabled = data.mode !== 'rw'; return (
- {data.config.write ? undefined : ( + {data.mode === 'rw' ? undefined : ( The Headscale configuration is read-only. You cannot make changes to the configuration )} - - - - - - + + +
@@ -103,7 +76,7 @@ export default function Page() { {' '} when Magic DNS is enabled.

- +
);