mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 00:08:14 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ebadc26bc | |||
| 252e78d618 | |||
| c6930732ee | |||
| f04b17109b | |||
| 0ff9e6fdc3 | |||
| 94174ebcce | |||
| c2fe69ec17 | |||
| b285753b24 | |||
| 8eac733a5d | |||
| 89a7cb5aae | |||
| 39868b5043 | |||
| ca32590e54 | |||
| a846249be1 | |||
| bcf00beb75 | |||
| 6dae5d647a | |||
| d787b8517e | |||
| bdb00b6cd7 | |||
| f1347803a4 |
@@ -2,7 +2,7 @@ name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -39,3 +39,4 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
||||
@@ -8,7 +8,7 @@ This is a relatively tiny Remix app that aims to provide a usable GUI for the He
|
||||
It's still very early in it's development, however these are some of the features that are planned.
|
||||
|
||||
- [ ] Editable tags, machine names, users, etc
|
||||
- [ ] ACL control through Docker integration
|
||||
- [x] ACL control through Docker integration
|
||||
- [x] OIDC based login for the web UI
|
||||
- [x] Automated API key regeneration
|
||||
- [x] Editable headscale configuration
|
||||
@@ -16,6 +16,7 @@ It's still very early in it's development, however these are some of the feature
|
||||
## Deployment
|
||||
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
||||
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
|
||||
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
|
||||
## Contributing
|
||||
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
|
||||
|
||||
@@ -7,6 +7,7 @@ type Properties = {
|
||||
readonly button: ReactNode;
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||
readonly className?: string;
|
||||
readonly width?: string;
|
||||
}
|
||||
|
||||
function Dropdown(properties: Properties) {
|
||||
@@ -26,12 +27,13 @@ function Dropdown(properties: Properties) {
|
||||
leaveTo='transform opacity-0 scale-95'
|
||||
>
|
||||
<Menu.Items className={clsx(
|
||||
'absolute right-0 w-fit max-w-36 mt-2 rounded-md',
|
||||
'absolute right-0 mt-2 rounded-md',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
'bg-white dark:bg-zinc-800 text-right',
|
||||
'bg-white dark:bg-zinc-800',
|
||||
'overflow-hidden z-50',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
'divide-y divide-gray-200 dark:divide-zinc-700'
|
||||
'divide-y divide-gray-200 dark:divide-zinc-700',
|
||||
properties.width ?? 'w-36'
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
@@ -66,7 +68,7 @@ function Item(properties: ItemProperties) {
|
||||
<div
|
||||
{...properties}
|
||||
className={clsx(
|
||||
'px-4 py-2 w-full text-right',
|
||||
'px-4 py-2 w-full',
|
||||
'focus:outline-none focus:ring',
|
||||
'focus:ring-gray-300 dark:focus:ring-zinc-700',
|
||||
properties.className,
|
||||
|
||||
@@ -14,36 +14,62 @@ type HookParameters = {
|
||||
|
||||
// Optional because the button submits
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
onOpen?: () => void | Promise<void>;
|
||||
onClose?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
type Overrides = Omit<HookParameters, 'onOpen' | 'onClose'>
|
||||
|
||||
type Properties = {
|
||||
readonly isOpen: boolean;
|
||||
readonly setIsOpen: (value: SetStateAction<boolean>) => void;
|
||||
readonly parameters: HookParameters;
|
||||
readonly parameters?: HookParameters;
|
||||
}
|
||||
|
||||
export default function useModal(properties: HookParameters) {
|
||||
export type OpenFunction = (overrides?: Overrides) => void
|
||||
|
||||
export default function useModal(properties?: HookParameters) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [liveProperties, setLiveProperties] = useState(properties)
|
||||
|
||||
return {
|
||||
Modal: (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
parameters={properties}
|
||||
parameters={liveProperties}
|
||||
/>
|
||||
),
|
||||
|
||||
open: () => {
|
||||
open: (overrides?: Overrides) => {
|
||||
if (!overrides && !properties) {
|
||||
throw new Error('No properties provided')
|
||||
}
|
||||
|
||||
setIsOpen(true)
|
||||
if (properties?.onOpen) {
|
||||
void properties.onOpen()
|
||||
}
|
||||
|
||||
if (overrides) {
|
||||
setLiveProperties(overrides)
|
||||
}
|
||||
},
|
||||
|
||||
close: () => {
|
||||
setIsOpen(false)
|
||||
if (properties?.onClose) {
|
||||
void properties.onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Modal({ parameters, isOpen, setIsOpen }: Properties) {
|
||||
if (!parameters) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition
|
||||
show={isOpen}
|
||||
@@ -102,7 +128,7 @@ function Modal({ parameters, isOpen, setIsOpen }: Properties) {
|
||||
</Dialog.Description>
|
||||
) : undefined}
|
||||
{parameters.children ? (
|
||||
<div className='mt-12 w-full'>
|
||||
<div className='w-full mt-4'>
|
||||
{parameters.children}
|
||||
</div>
|
||||
) : undefined}
|
||||
@@ -110,8 +136,7 @@ function Modal({ parameters, isOpen, setIsOpen }: Properties) {
|
||||
variant='emphasized'
|
||||
type='submit'
|
||||
className={clsx(
|
||||
'w-full',
|
||||
parameters.children ? 'mt-4' : 'mt-12',
|
||||
'w-full mt-12',
|
||||
parameters.variant === 'danger'
|
||||
? 'bg-red-800 dark:bg-red-500 focus:ring-red-500 dark:focus:ring-red-500'
|
||||
: ''
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { yaml } from '@codemirror/lang-yaml'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import CodeMirrorMerge from 'react-codemirror-merge'
|
||||
import { toast } from 'react-hot-toast/headless'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
import Fallback from './fallback'
|
||||
|
||||
type EditorProperties = {
|
||||
readonly acl: string;
|
||||
readonly setAcl: (acl: string) => void;
|
||||
readonly mode: 'edit' | 'diff';
|
||||
|
||||
readonly data: {
|
||||
hasAclWrite: boolean;
|
||||
currentAcl: string;
|
||||
aclType: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
|
||||
const [light, setLight] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetcher = useFetcher()
|
||||
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
|
||||
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
|
||||
theme.addEventListener('change', theme => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
|
||||
// Prevents the FOUC
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 overflow-hidden'
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Fallback acl={acl} where='client'/>
|
||||
) : (
|
||||
mode === 'edit' ? (
|
||||
<CodeMirror
|
||||
value={acl}
|
||||
className='h-editor text-sm'
|
||||
theme={light ? githubLight : githubDark}
|
||||
extensions={[aclType]}
|
||||
readOnly={!data.hasAclWrite}
|
||||
onChange={value => {
|
||||
setAcl(value)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className='overflow-y-scroll'
|
||||
style={{ height: 'calc(100vh - 20rem)' }}
|
||||
>
|
||||
<CodeMirrorMerge
|
||||
theme={light ? githubLight : githubDark}
|
||||
orientation='a-b'
|
||||
>
|
||||
<CodeMirrorMerge.Original
|
||||
readOnly
|
||||
value={data.currentAcl}
|
||||
extensions={[aclType]}
|
||||
/>
|
||||
<CodeMirrorMerge.Modified
|
||||
readOnly
|
||||
value={acl}
|
||||
extensions={[aclType]}
|
||||
/>
|
||||
</CodeMirrorMerge>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='emphasized'
|
||||
className='text-sm w-fit mr-2'
|
||||
onClick={() => {
|
||||
fetcher.submit({
|
||||
acl
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
toast('Updated tailnet ACL policy')
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant='emphasized'
|
||||
className={clsx(
|
||||
'text-sm w-fit bg-gray-100 dark:bg-transparent',
|
||||
'border border-gray-200 dark:border-gray-700'
|
||||
)}
|
||||
onClick={() => {
|
||||
setAcl(data.currentAcl)
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
|
||||
type FallbackProperties = {
|
||||
readonly acl: string;
|
||||
readonly where: 'client' | 'server';
|
||||
}
|
||||
|
||||
export default function Fallback({ acl, where }: FallbackProperties) {
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(
|
||||
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
|
||||
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
className={clsx(
|
||||
'w-full h-editor font-mono resize-none',
|
||||
'text-sm text-gray-600 dark:text-gray-300',
|
||||
'pl-10 pt-1 leading-snug'
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
{where === 'server' ? (
|
||||
<>
|
||||
<Button
|
||||
disabled
|
||||
variant='emphasized'
|
||||
className='text-sm w-fit mr-2'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
disabled
|
||||
variant='emphasized'
|
||||
className={clsx(
|
||||
'text-sm w-fit bg-gray-100 dark:bg-transparent',
|
||||
'border border-gray-200 dark:border-gray-700'
|
||||
)}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,186 @@
|
||||
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
|
||||
import { Tab } from '@headlessui/react'
|
||||
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { Fragment } from 'react/jsx-runtime'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Notice from '~/components/Notice'
|
||||
import { getAcl, getContext, patchAcl } from '~/utils/config'
|
||||
import { sighupHeadscale } from '~/utils/docker'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
|
||||
import Editor from './editor'
|
||||
import Fallback from './fallback'
|
||||
|
||||
export async function loader() {
|
||||
const context = await getContext()
|
||||
if (!context.hasAcl) {
|
||||
throw new Error('No ACL configuration is available')
|
||||
}
|
||||
|
||||
const { data, type } = await getAcl()
|
||||
return {
|
||||
hasAclWrite: context.hasAclWrite,
|
||||
currentAcl: data,
|
||||
aclType: type
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ success: false }, {
|
||||
status: 401
|
||||
})
|
||||
}
|
||||
|
||||
const context = await getContext()
|
||||
if (!context.hasAclWrite) {
|
||||
return json({ success: false }, {
|
||||
status: 403
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as { acl: string }
|
||||
await patchAcl(data.acl)
|
||||
|
||||
if (context.hasDockerSock) {
|
||||
await sighupHeadscale()
|
||||
}
|
||||
|
||||
return json({ success: true })
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const [acl, setAcl] = useState(data.currentAcl)
|
||||
|
||||
return (
|
||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
|
||||
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
|
||||
<p className='text-lg mt-8'>
|
||||
Access Control Lists are currently unavailable.
|
||||
They will be available in a future release.
|
||||
<div>
|
||||
{data.hasAclWrite ? undefined : (
|
||||
<div className='mb-4'>
|
||||
<Notice>
|
||||
The ACL policy file is readonly to Headplane.
|
||||
You will not be able to make changes here.
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className='text-2xl font-medium mb-4'>
|
||||
Access Control List (ACL)
|
||||
</h1>
|
||||
|
||||
<p className='mb-4 max-w-prose'>
|
||||
The ACL file is used to define the access control rules for your network.
|
||||
You can find more information about the ACL file in the Tailscale documentation.
|
||||
{' '}
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
href='https://tailscale.com/kb/1018/acls'
|
||||
className='text-blue-500 dark:text-blue-400 hover:underline'
|
||||
>
|
||||
More information
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<Tab.Group>
|
||||
<Tab.List className={clsx(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500'
|
||||
)}
|
||||
>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-l border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<PencilSquareIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Edit file
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<EyeIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Preview changes
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-r border-gray-200 dark:border-gray-700',
|
||||
selected ? 'text-gray-900 dark:text-gray-100' : ''
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className='w-5 h-5'/>
|
||||
<p>
|
||||
Preview rules
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel>
|
||||
<div
|
||||
className={clsx(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center'
|
||||
)}
|
||||
>
|
||||
<CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='w-1/2 text-center mt-4'>
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import Spinner from '~/components/Spinner'
|
||||
import TableList from '~/components/TableList'
|
||||
import { getConfig, getContext, patchConfig } from '~/utils/config'
|
||||
import { restartHeadscale } from '~/utils/docker'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import Domains from './domains'
|
||||
@@ -45,9 +46,18 @@ export async function loader() {
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ success: false }, {
|
||||
status: 401
|
||||
})
|
||||
}
|
||||
|
||||
const context = await getContext()
|
||||
if (!context.hasConfigWrite) {
|
||||
return json({ success: false })
|
||||
return json({ success: false }, {
|
||||
status: 403
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as Record<string, unknown>
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
/* eslint-disable unicorn/filename-case */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'react-hot-toast/headless'
|
||||
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import useModal from '~/components/Modal'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { type Machine } from '~/types'
|
||||
import { del, pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
||||
return data.nodes
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const data = await request.json() as { id?: string }
|
||||
if (!data.id) {
|
||||
return json({ message: 'No ID provided' }, {
|
||||
status: 400
|
||||
})
|
||||
}
|
||||
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ message: 'Unauthorized' }, {
|
||||
status: 401
|
||||
})
|
||||
}
|
||||
|
||||
await del(`v1/node/${data.id}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine removed' })
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const [activeId, setActiveId] = useState<string | undefined>(undefined)
|
||||
const fetcher = useFetcher()
|
||||
|
||||
const { Modal, open } = useModal({
|
||||
title: 'Remove Machine',
|
||||
description: [
|
||||
'This action is irreversible and will disconnect the machine from the Headscale server.',
|
||||
'All data associated with this machine including ACLs and tags will be lost.'
|
||||
].join('\n'),
|
||||
variant: 'danger',
|
||||
buttonText: 'Remove',
|
||||
onConfirm: () => {
|
||||
fetcher.submit(
|
||||
{
|
||||
id: activeId!
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
encType: 'application/json'
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{Modal}
|
||||
<table className='table-auto w-full rounded-lg'>
|
||||
<thead className='text-gray-500 dark:text-gray-400'>
|
||||
<tr className='text-left uppercase text-sm font-bold'>
|
||||
<th className='pl-4'>Name</th>
|
||||
<th>IP Addresses</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={clsx(
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border-t border-zinc-200 dark:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
{data.map(machine => {
|
||||
const tags = [...machine.forcedTags, ...machine.validTags]
|
||||
return (
|
||||
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'>
|
||||
<td className='py-2 pl-4'>
|
||||
<Link to={`/machines/${machine.id}`}>
|
||||
<h1>{machine.givenName}</h1>
|
||||
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
|
||||
{machine.name}
|
||||
</span>
|
||||
<div className='flex gap-1 mt-1'>
|
||||
{tags.map(tag => (
|
||||
<span key={tag} className='text-xs bg-gray-200 text-gray-600 rounded-sm px-1 py-0.5'>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
|
||||
{machine.ipAddresses.map((ip, index) => (
|
||||
<button
|
||||
key={ip}
|
||||
type='button'
|
||||
className='flex items-center gap-x-1 w-full'
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied IP address to clipboard')
|
||||
}}
|
||||
>
|
||||
<span className={clsx(index === 0 ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500')}>
|
||||
{ip}
|
||||
</span>
|
||||
<ClipboardIcon className='text-gray-400 dark:text-gray-500 w-4 h-4'/>
|
||||
</button>
|
||||
))}
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<span
|
||||
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
|
||||
>
|
||||
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
|
||||
<p>
|
||||
{machine.online
|
||||
? 'Connected'
|
||||
: new Date(
|
||||
machine.lastSeen
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className='py-2 pr-4'>
|
||||
<div className={clsx(
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
<Dropdown
|
||||
className='left-1/4 w-min'
|
||||
button={(
|
||||
<EllipsisHorizontalIcon className='w-5 h-5'/>
|
||||
)}
|
||||
>
|
||||
<Dropdown.Item className='text-red-700'>
|
||||
<button
|
||||
type='button' onClick={() => {
|
||||
setActiveId(machine.id)
|
||||
open()
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
|
||||
import { type FetcherWithComponents, Link } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import toast from 'react-hot-toast/headless'
|
||||
|
||||
import Dropdown from '~/components/Dropdown'
|
||||
import type { OpenFunction } from '~/components/Modal'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { type Machine } from '~/types'
|
||||
|
||||
type MachineProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly open: OpenFunction;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly magic?: string;
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, open, fetcher, magic }: MachineProperties) {
|
||||
const tags = [...machine.forcedTags, ...machine.validTags]
|
||||
return (
|
||||
<tr
|
||||
key={machine.id}
|
||||
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
|
||||
>
|
||||
<td className='pl-0.5 py-2'>
|
||||
<Link
|
||||
to={`/machines/${machine.id}`}
|
||||
className='group/link h-full'
|
||||
>
|
||||
<p className={clsx(
|
||||
'font-semibold leading-snug',
|
||||
'group-hover/link:text-blue-600',
|
||||
'group-hover/link:dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<p className='text-sm text-gray-400 font-mono'>
|
||||
{machine.name}
|
||||
</p>
|
||||
<div className='flex gap-1 mt-1'>
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={clsx(
|
||||
'text-xs rounded-sm px-1 py-0.5',
|
||||
'bg-gray-100 dark:bg-zinc-700',
|
||||
'text-gray-600 dark:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
{machine.ipAddresses[0]}
|
||||
<Dropdown
|
||||
width='w-max'
|
||||
button={(
|
||||
<ChevronDownIcon className='w-4 h-4'/>
|
||||
)}
|
||||
>
|
||||
{machine.ipAddresses.map(ip => (
|
||||
<Dropdown.Item key={ip}>
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full'
|
||||
)}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied IP address to clipboard')
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
<ClipboardIcon className='w-3 h-3'/>
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
{magic ? (
|
||||
<Dropdown.Item>
|
||||
<button
|
||||
type='button'
|
||||
className={clsx(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full break-keep'
|
||||
)}
|
||||
onClick={async () => {
|
||||
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied hostname to clipboard')
|
||||
}}
|
||||
>
|
||||
{machine.givenName}.{machine.user.name}.{magic}
|
||||
<ClipboardIcon className='w-3 h-3'/>
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
) : undefined}
|
||||
</Dropdown>
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<span
|
||||
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
|
||||
>
|
||||
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
|
||||
<p>
|
||||
{machine.online
|
||||
? 'Connected'
|
||||
: new Date(
|
||||
machine.lastSeen
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className='py-2 pr-0.5'>
|
||||
<div className={clsx(
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
<Dropdown
|
||||
className='left-1/4'
|
||||
width='w-48'
|
||||
button={(
|
||||
<EllipsisHorizontalIcon className='w-5 h-5'/>
|
||||
)}
|
||||
>
|
||||
<Dropdown.Item variant='static'>
|
||||
<button
|
||||
disabled
|
||||
type='button'
|
||||
className='text-left w-full opacity-50'
|
||||
onClick={() => {
|
||||
open()
|
||||
}}
|
||||
>
|
||||
Edit machine name
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item variant='static'>
|
||||
<button
|
||||
disabled
|
||||
type='button'
|
||||
className='text-left w-full opacity-50'
|
||||
onClick={() => {
|
||||
open()
|
||||
}}
|
||||
>
|
||||
Edit route settings
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item variant='static'>
|
||||
<button
|
||||
disabled
|
||||
type='button'
|
||||
className='text-left w-full opacity-50'
|
||||
onClick={() => {
|
||||
open()
|
||||
}}
|
||||
>
|
||||
Edit ACL tags
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item>
|
||||
<button
|
||||
type='button'
|
||||
className='text-left text-red-700 w-full'
|
||||
onClick={() => {
|
||||
open({
|
||||
title: 'Remove Machine',
|
||||
description: [
|
||||
'This action is irreversible and will disconnect the machine from the Headscale server.',
|
||||
'All data associated with this machine including ACLs and tags will be lost.'
|
||||
].join('\n'),
|
||||
variant: 'danger',
|
||||
buttonText: 'Remove',
|
||||
onConfirm: () => {
|
||||
fetcher.submit(
|
||||
{
|
||||
id: machine.id
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
encType: 'application/json'
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</Dropdown.Item>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import clsx from 'clsx'
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import useModal from '~/components/Modal'
|
||||
import { type Machine } from '~/types'
|
||||
import { getConfig, getContext } from '~/utils/config'
|
||||
import { del, pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import MachineRow from './machine'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
||||
const context = await getContext()
|
||||
|
||||
let magic: string | undefined
|
||||
if (context.hasConfig) {
|
||||
const config = await getConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: data.nodes,
|
||||
magic
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const data = await request.json() as { id?: string }
|
||||
if (!data.id) {
|
||||
return json({ message: 'No ID provided' }, {
|
||||
status: 400
|
||||
})
|
||||
}
|
||||
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ message: 'Unauthorized' }, {
|
||||
status: 401
|
||||
})
|
||||
}
|
||||
|
||||
await del(`v1/node/${data.id}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine removed' })
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher()
|
||||
|
||||
const { Modal, open } = useModal()
|
||||
|
||||
return (
|
||||
<>
|
||||
{Modal}
|
||||
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
||||
<table className='table-auto w-full rounded-lg'>
|
||||
<thead className='text-gray-500 dark:text-gray-400'>
|
||||
<tr className='text-left uppercase text-xs font-bold px-0.5'>
|
||||
<th className='pb-2'>Name</th>
|
||||
<th className='pb-2'>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
Addresses
|
||||
{data.magic ? (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button>
|
||||
<InformationCircleIcon className='w-4 h-4 text-gray-400'/>
|
||||
</Button>
|
||||
<Tooltip className={clsx(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-zinc-800',
|
||||
'border border-gray-200 dark:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
Since MagicDNS is enabled, you can access devices
|
||||
based on their name and also at
|
||||
{' '}
|
||||
<Code>
|
||||
[name].[user].{data.magic}
|
||||
</Code>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
<th className='pb-2'>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={clsx(
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border-t border-zinc-200 dark:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
{data.nodes.map(machine => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
// Typescript isn't smart enough yet
|
||||
machine={machine as unknown as Machine}
|
||||
fetcher={fetcher}
|
||||
open={open}
|
||||
magic={data.magic}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function Layout() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
return (
|
||||
<>
|
||||
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
|
||||
<header className='mb-6 bg-gray-800 text-white dark:bg-gray-700'>
|
||||
<nav className='container mx-auto'>
|
||||
<div className='flex items-center justify-between mb-8 pt-4'>
|
||||
<div className='flex items-center gap-x-2'>
|
||||
@@ -69,9 +69,9 @@ export default function Layout() {
|
||||
<p className='font-bold'>{data.user?.name}</p>
|
||||
<p>{data.user?.email}</p>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item className='text-red-700'>
|
||||
<Dropdown.Item className='text-red-700 cursor-pointer'>
|
||||
<Form method='POST' action='/logout'>
|
||||
<button type='submit'>
|
||||
<button type='submit' className='w-full'>
|
||||
Logout
|
||||
</button>
|
||||
</Form>
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData()
|
||||
const oidcStart = String(formData.get('oidc-start'))
|
||||
const oidcStart = formData.get('oidc-start')
|
||||
|
||||
if (oidcStart) {
|
||||
const context = await getContext()
|
||||
|
||||
+51
-9
@@ -2,7 +2,7 @@ import { type FSWatcher, watch } from 'node:fs'
|
||||
import { access, constants, readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { type Document, parseDocument } from 'yaml'
|
||||
import { type Document, parse, parseDocument } from 'yaml'
|
||||
|
||||
type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y`
|
||||
|
||||
@@ -131,6 +131,31 @@ export async function getConfig(force = false) {
|
||||
return config.toJSON() as Config
|
||||
}
|
||||
|
||||
export async function getAcl() {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return { data: '', type: 'json' }
|
||||
}
|
||||
|
||||
const data = await readFile(path, 'utf8')
|
||||
|
||||
// Naive check for YAML over JSON
|
||||
// This is because JSON.parse doesn't support comments
|
||||
try {
|
||||
parse(data)
|
||||
return { data, type: 'yaml' }
|
||||
} catch {
|
||||
return { data, type: 'json' }
|
||||
}
|
||||
}
|
||||
|
||||
// This is so obscenely dangerous, please have a check around it
|
||||
export async function patchConfig(partial: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
@@ -141,6 +166,22 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
||||
await writeFile(path, config.toString(), 'utf8')
|
||||
}
|
||||
|
||||
export async function patchAcl(data: string) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error('No ACL file defined')
|
||||
}
|
||||
|
||||
await writeFile(path, data, 'utf8')
|
||||
}
|
||||
|
||||
let watcher: FSWatcher
|
||||
|
||||
export function registerConfigWatcher() {
|
||||
@@ -190,8 +231,8 @@ export async function getContext() {
|
||||
async function getOidcConfig() {
|
||||
// Check for the OIDC environment variables first
|
||||
let issuer = process.env.OIDC_ISSUER
|
||||
let client = process.env.OIDC_CLIENT
|
||||
let secret = process.env.OIDC_SECRET
|
||||
let client = process.env.OIDC_CLIENT_ID
|
||||
let secret = process.env.OIDC_CLIENT_SECRET
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
const config = await getConfig()
|
||||
@@ -266,8 +307,6 @@ async function hasAcl() {
|
||||
const config = await getConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
@@ -278,7 +317,9 @@ async function hasAcl() {
|
||||
path = resolve(path)
|
||||
await access(path, constants.R_OK)
|
||||
return true
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.log('Cannot acquire read access to ACL file', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -290,8 +331,6 @@ async function hasAclW() {
|
||||
const config = await getConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
@@ -302,7 +341,10 @@ async function hasAclW() {
|
||||
path = resolve(path)
|
||||
await access(path, constants.W_OK)
|
||||
return true
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.log('Cannot acquire read access to ACL file', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,31 @@ import { Client } from 'undici'
|
||||
import { getContext } from './config'
|
||||
import { pull } from './headscale'
|
||||
|
||||
export async function sighupHeadscale() {
|
||||
const context = await getContext()
|
||||
if (!context.hasDockerSock) {
|
||||
return
|
||||
}
|
||||
|
||||
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}/kill?signal=SIGHUP`
|
||||
})
|
||||
|
||||
if (!response.statusCode || response.statusCode !== 204) {
|
||||
throw new Error('Failed to send SIGHUP to Headscale')
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartHeadscale() {
|
||||
const context = await getContext()
|
||||
if (!context.hasDockerSock) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# THIS IS A DEVELOPER CONFIGURATION FILE
|
||||
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
|
||||
# I ONLY USE IT FOR DEVELOPING HEADPLANE
|
||||
|
||||
version: '3.9'
|
||||
networks:
|
||||
headplane-dev:
|
||||
|
||||
@@ -44,3 +44,7 @@ services:
|
||||
|
||||
You may also choose to run it natively with the distributed binaries on the releases page.
|
||||
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
|
||||
|
||||
## ACL Configuration
|
||||
If you would like to get the web ACL configuration working, you'll need to pass the `ACL_FILE` environment variable.
|
||||
This should point to the path of the ACL file on the Headscale server (ie. `ACL_FILE=/etc/headscale/acl_policy.json`).
|
||||
|
||||
@@ -13,7 +13,7 @@ You can configure Headplane using environment variables.
|
||||
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
|
||||
- **`PORT`**: The port to bind the server to (default: `3000`).
|
||||
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
|
||||
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`).
|
||||
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
|
||||
- **`HEADSCALE_CONTAINER`**: The name of the Headscale container (required for Docker integration).
|
||||
|
||||
### SSO/OpenID Connect
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@@ -20,12 +22,17 @@
|
||||
"@remix-run/node": "^2.8.1",
|
||||
"@remix-run/react": "^2.8.1",
|
||||
"@remix-run/serve": "^2.8.1",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"clsx": "^2.1.0",
|
||||
"isbot": "^4.1.0",
|
||||
"oauth4webapi": "^2.10.3",
|
||||
"react": "^18.2.0",
|
||||
"react-aria-components": "^1.1.1",
|
||||
"react-codemirror-merge": "^4.21.25",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"remix-utils": "^7.6.0",
|
||||
"undici": "^6.10.2",
|
||||
"usehooks-ts": "^3.0.2",
|
||||
"yaml": "^2.4.1"
|
||||
|
||||
Generated
+1766
-2
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -1,9 +1,24 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
content: ['./app/**/*.{js,jsx,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {}
|
||||
container: {
|
||||
center: true,
|
||||
padding: {
|
||||
DEFAULT: '1rem',
|
||||
sm: '2rem',
|
||||
lg: '4rem',
|
||||
xl: '5rem',
|
||||
'2xl': '6rem'
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
height: {
|
||||
editor: 'calc(100vh - 20rem)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
} satisfies Config
|
||||
|
||||
+1
-1
@@ -264,7 +264,7 @@ unix_socket_permission: "0777"
|
||||
# help us test it.
|
||||
# OpenID Connect
|
||||
oidc:
|
||||
only_start_if_oidc_is_available: true
|
||||
only_start_if_oidc_is_available: false
|
||||
issuer: "https://sso.example.com"
|
||||
client_id: "headscale"
|
||||
client_secret: "super_secret_client_secret"
|
||||
|
||||
Reference in New Issue
Block a user