feat: restart docker container

This commit is contained in:
Aarnav Tale 2024-03-29 01:53:32 -04:00
parent 8148e242dc
commit abb957c573
No known key found for this signature in database
10 changed files with 115 additions and 61 deletions

View File

@ -1,6 +1,7 @@
API_KEY=abcdefghijklmnopqrstuvwxyz API_KEY=abcdefghijklmnopqrstuvwxyz
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
DISABLE_API_KEY_LOGIN=true DISABLE_API_KEY_LOGIN=true
HEADSCALE_CONTAINER=headscale
HOST=0.0.0.0 HOST=0.0.0.0
PORT=3000 PORT=3000
CONFIG_FILE=/etc/headscale/config.yaml CONFIG_FILE=/etc/headscale/config.yaml

View File

@ -39,6 +39,10 @@ export async function loader() {
throw new Error('The API_KEY environment variable is required') throw new Error('The API_KEY environment variable is required')
} }
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('The HEADSCALE_CONTAINER environment variable is required')
}
// eslint-disable-next-line unicorn/no-null // eslint-disable-next-line unicorn/no-null
return null return null
} }

View File

@ -30,7 +30,7 @@ export default function Domains({ baseDomain, searchDomains }: Properties) {
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)
const [newDomain, setNewDomain] = useState('') const [newDomain, setNewDomain] = useState('')
const fetcher = useFetcher({ key: 'search-domains' }) const fetcher = useFetcher()
useEffect(() => { useEffect(() => {
setLocalDomains(searchDomains) setLocalDomains(searchDomains)
@ -154,7 +154,7 @@ type DomainProperties = {
} }
function Domain({ domain, id, localDomains, isDrag }: DomainProperties) { function Domain({ domain, id, localDomains, isDrag }: DomainProperties) {
const fetcher = useFetcher({ key: 'individual-domain' }) const fetcher = useFetcher()
const { const {
attributes, attributes,

View File

@ -2,6 +2,7 @@
/* eslint-disable unicorn/no-keyword-prefix */ /* eslint-disable unicorn/no-keyword-prefix */
import { Dialog } from '@headlessui/react' import { Dialog } from '@headlessui/react'
import { useFetcher } from '@remix-run/react' import { useFetcher } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react' import { useState } from 'react'
type Properties = { type Properties = {
@ -14,7 +15,30 @@ export default function Modal({ name }: Properties) {
const fetcher = useFetcher() const fetcher = useFetcher()
return ( return (
<> <div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'>
This is the base domain name of your Tailnet.
Devices are accessible at
{' '}
<code className='bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md'>
[device].[user].{name}
</code>
{' '}
when Magic DNS is enabled.
</p>
<input
readOnly
className={clsx(
'my-4 px-3 py-2 border rounded-lg focus:ring-none w-2/3 font-mono text-sm',
'dark:bg-zinc-800 dark:text-white dark:border-zinc-700'
)}
type='text'
value={name}
onFocus={event => {
event.target.select()
}}
/>
<button <button
type='button' type='button'
className='rounded-lg px-3 py-2 bg-gray-800 text-white w-fit text-sm' className='rounded-lg px-3 py-2 bg-gray-800 text-white w-fit text-sm'
@ -32,7 +56,7 @@ export default function Modal({ name }: Properties) {
> >
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/> <div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
<div className='fixed inset-0 flex w-screen items-center justify-center'> <div className='fixed inset-0 flex w-screen items-center justify-center'>
<Dialog.Panel className='bg-white rounded-lg p-4 w-full max-w-md'> <Dialog.Panel className='bg-white rounded-lg p-4 w-full max-w-md dark:bg-zinc-800'>
<Dialog.Title className='text-lg font-bold'> <Dialog.Title className='text-lg font-bold'>
Rename {name} Rename {name}
</Dialog.Title> </Dialog.Title>
@ -43,7 +67,7 @@ export default function Modal({ name }: Properties) {
</Dialog.Description> </Dialog.Description>
<input <input
type='text' type='text'
className='border rounded-lg p-2 w-full mt-4' className='border rounded-lg p-2 w-full mt-4 dark:bg-zinc-700 dark:text-white dark:border-zinc-700'
value={newName} value={newName}
onChange={event => { onChange={event => {
setNewName(event.target.value) setNewName(event.target.value)
@ -69,6 +93,6 @@ export default function Modal({ name }: Properties) {
</Dialog.Panel> </Dialog.Panel>
</div> </div>
</Dialog> </Dialog>
</> </div>
) )
} }

View File

@ -5,6 +5,7 @@ import clsx from 'clsx'
import { useState } from 'react' import { useState } from 'react'
import { getConfig, patchConfig } from '~/utils/config' import { getConfig, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker'
import Domains from './domains' import Domains from './domains'
import MagicModal from './magic' import MagicModal from './magic'
@ -29,42 +30,20 @@ export async function loader() {
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as Record<string, unknown> const data = await request.json() as Record<string, unknown>
console.log(data)
await patchConfig(data) await patchConfig(data)
await restartHeadscale()
return json({ success: true }) return json({ success: true })
} }
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>()
const fetcher = useFetcher({ key: 'dns-page' }) const fetcher = useFetcher()
const [localOverride, setLocalOverride] = useState(data.overrideLocal) const [localOverride, setLocalOverride] = useState(data.overrideLocal)
const [ns, setNs] = useState('') const [ns, setNs] = useState('')
return ( return (
<div className='flex flex-col gap-16 max-w-screen-lg'> <div className='flex flex-col gap-16 max-w-screen-lg'>
<div className='flex flex-col w-2/3'> <RenameModal name={data.baseDomain}/>
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'>
This is the base domain name of your Tailnet.
Devices are accessible at
{' '}
<code className='bg-gray-100 p-1 rounded-md'>
[device].[user].{data.baseDomain}
</code>
{' '}
when Magic DNS is enabled.
</p>
<input
readOnly
className='my-4 px-3 py-2 border rounded-lg focus:ring-none w-2/3 font-mono text-sm'
type='text'
value={data.baseDomain}
onFocus={event => {
event.target.select()
}}
/>
<RenameModal name={data.baseDomain}/>
</div>
<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'>

44
app/utils/docker.ts Normal file
View File

@ -0,0 +1,44 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-constant-condition */
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { pull } from './headscale'
export async function restartHeadscale() {
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}
const client = new Client('http://localhost', {
socketPath: '/var/run/docker.sock'
})
const container = process.env.HEADSCALE_CONTAINER
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/restart`
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to restart Headscale')
}
// Wait for Headscale to restart before continuing
let attempts = 0
while (true) {
try {
await pull('v1/apikey', process.env.API_KEY!)
return
} catch {
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
attempts++
await setTimeout(1000)
}
}
}

View File

@ -9,50 +9,42 @@ export class HeadscaleError extends Error {
} }
export class FatalError extends Error { export class FatalError extends Error {
constructor(message: string) { constructor() {
super(message) super('The Headscale server is not accessible or the API_KEY is invalid.')
this.name = 'FatalError' this.name = 'FatalError'
} }
} }
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
export async function pull<T>(url: string, key: string) { export async function pull<T>(url: string, key: string) {
try { const prefix = process.env.HEADSCALE_URL!
const prefix = process.env.HEADSCALE_URL! const response = await fetch(`${prefix}/api/${url}`, {
const response = await fetch(`${prefix}/api/${url}`, { headers: {
headers: { Authorization: `Bearer ${key}`
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
} }
})
return await (response.json() as Promise<T>) if (!response.ok) {
} catch { throw new HeadscaleError(await response.text(), response.status)
throw new FatalError('The Headscale server is not reachable')
} }
return (response.json() as Promise<T>)
} }
export async function post<T>(url: string, key: string, body?: unknown) { export async function post<T>(url: string, key: string, body?: unknown) {
try { const prefix = process.env.HEADSCALE_URL!
const prefix = process.env.HEADSCALE_URL! const response = await fetch(`${prefix}/api/${url}`, {
const response = await fetch(`${prefix}/api/${url}`, { method: 'POST',
method: 'POST', body: body ? JSON.stringify(body) : undefined,
body: body ? JSON.stringify(body) : undefined, headers: {
headers: { Authorization: `Bearer ${key}`
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
} }
})
return await (response.json() as Promise<T>) if (!response.ok) {
} catch { throw new HeadscaleError(await response.text(), response.status)
throw new FatalError('The Headscale server is not reachable')
} }
return (response.json() as Promise<T>)
} }

View File

@ -7,6 +7,7 @@ services:
headscale: headscale:
image: 'headscale/headscale:0.23.0-alpha5' image: 'headscale/headscale:0.23.0-alpha5'
container_name: 'headscale' container_name: 'headscale'
restart: 'unless-stopped'
command: 'serve' command: 'serve'
networks: networks:
- 'headplane-dev' - 'headplane-dev'

View File

@ -26,6 +26,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"undici": "^6.10.2",
"usehooks-ts": "^3.0.2", "usehooks-ts": "^3.0.2",
"yaml": "^2.4.1" "yaml": "^2.4.1"
}, },

View File

@ -50,6 +50,9 @@ dependencies:
react-hot-toast: react-hot-toast:
specifier: ^2.4.1 specifier: ^2.4.1
version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0)
undici:
specifier: ^6.10.2
version: 6.10.2
usehooks-ts: usehooks-ts:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2(react@18.2.0) version: 3.0.2(react@18.2.0)
@ -6230,6 +6233,11 @@ packages:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: true dev: true
/undici@6.10.2:
resolution: {integrity: sha512-HcVuBy7ACaDejIMdwCzAvO22OsiE6ir6ziTIr9kAE0vB+PheVe29ZvRN8p7FXCO2uZHTjEoUs5bPiFpuc/hwwQ==}
engines: {node: '>=18.0'}
dev: false
/unified@10.1.2: /unified@10.1.2:
resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==}
dependencies: dependencies: