Compare commits

..

21 Commits

Author SHA1 Message Date
Aarnav Tale 240d8d6197 chore: v0.3.9 2024-12-06 19:30:12 -05:00
Aarnav Tale b80bb0cc09 fix: await testOidc 2024-12-06 19:29:44 -05:00
Aarnav Tale 69cc6985b4 chore: v0.3.8 2024-12-06 19:07:36 -05:00
Aarnav Tale f9b38939ba chore: add border for footer 2024-12-06 19:06:27 -05:00
Aarnav Tale 4cfa1e5209 chore: remove unused patch 2024-12-06 19:05:15 -05:00
Aarnav Tale e713dae91b fix: validate and respect oidc validation settings 2024-12-06 19:05:07 -05:00
Aarnav Tale c9bcc1d7c6 chore: update deps 2024-12-06 18:37:36 -05:00
Aarnav Tale 401731fd09 fix: allow generating higher expiring preauthkeys 2024-12-06 18:37:17 -05:00
Aarnav Tale f623e7bc66 feat: enable all remix future flags 2024-12-06 11:58:17 -05:00
Aarnav Tale 1af292a5b0 fix: we need git in docker 2024-12-05 11:35:41 -05:00
Aarnav Tale 21778a43f1 feat: add footer with version and donate link 2024-12-05 02:33:17 -05:00
Aarnav Tale 33762e53b5 chore: document the /admin endpoint 2024-12-05 02:07:28 -05:00
Aarnav Tale 8867cca494 chore: use 0.3.7 in examples 2024-11-30 15:42:33 -05:00
Aarnav Tale 41b1d3c847 chore: v0.3.7 2024-11-30 15:34:54 -05:00
Aarnav Tale 712fc28683 feat: allow a public headscale URL separate from the main one 2024-11-30 15:33:58 -05:00
Aarnav Tale 320dab1d4f fix: join children in copyable codeblock 2024-11-30 15:05:07 -05:00
Aarnav Tale da0ee1382b feat: allow setting OIDC_CLIENT_SECRET_METHOD 2024-11-30 15:00:51 -05:00
Aarnav Tale a7d127c7bf feat: add health check route 2024-11-27 11:23:42 -05:00
Aarnav Tale b433e607e2 chore: v0.3.6 2024-11-20 18:15:19 -05:00
Aarnav Tale 8aad883c21 fix: make selects scrollable 2024-11-20 18:13:49 -05:00
Aarnav Tale 3cd28d2136 feat: make the code snippets copyable 2024-11-20 18:01:20 -05:00
29 changed files with 2686 additions and 2145 deletions
+18
View File
@@ -1,3 +1,21 @@
### 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
### 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
### 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
+1
View File
@@ -2,6 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app
RUN npm install -g pnpm
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
+40 -7
View File
@@ -1,12 +1,45 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import { useState, HTMLProps } from 'react'
import { CopyIcon, CheckIcon } from '@primer/octicons-react'
type Properties = HTMLProps<HTMLSpanElement>
import { cn } from '~/utils/cn'
import { toast } from '~/components/Toaster'
interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean
}
export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false)
export default function Code(properties: Properties) {
return (
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
{properties.children}
</code>
<>
<code className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className
)}>
{props.children}
</code>
{props.isCopyable && (
<button
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center'
)}
onClick={() => {
navigator.clipboard.writeText(props.children.join(''))
toast('Copied to clipboard')
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1000)
}}
>
{isCopied ?
<CheckIcon className="h-3 w-3" /> :
<CopyIcon className="h-3 w-3" />
}
</button>
)}
</>
)
}
+2 -2
View File
@@ -46,7 +46,7 @@ function Select(props: SelectProps) {
className={cn(
'mt-2 rounded-md w-[var(--trigger-width)]',
'bg-ui-100 dark:bg-ui-800 shadow-sm',
'overflow-hidden z-50',
'z-50 overflow-y-auto',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
@@ -54,7 +54,7 @@ function Select(props: SelectProps) {
'fill-mode-forwards origin-left-right',
)}
>
<ListBox>
<ListBox orientation="vertical">
{props.children}
</ListBox>
</Popover>
+2 -2
View File
@@ -10,6 +10,7 @@ import { loadContext } from './utils/config/headplane'
await loadContext()
export const streamTimeout = 5000
export default function handleRequest(
request: Request,
responseStatusCode: number,
@@ -27,7 +28,6 @@ export default function handleRequest(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={5000}
/>,
{
[isBot ? 'onAllReady' : 'onShellReady']() {
@@ -57,6 +57,6 @@ export default function handleRequest(
},
)
setTimeout(abort, 5000)
setTimeout(abort, streamTimeout + 1000)
})
}
+3
View File
@@ -0,0 +1,3 @@
import { flatRoutes } from '@remix-run/fs-routes'
export default flatRoutes()
+6 -7
View File
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData, useRevalidator } from '@remix-run/react'
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
import { useEffect, useState, useMemo } from 'react'
@@ -18,6 +18,7 @@ import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { send } from '~/utils/res'
import log from '~/utils/log'
import { Editor, Differ } from './cm.client'
@@ -116,9 +117,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false, error: null }, {
status: 401,
})
return send({ success: false, error: null }, 401)
}
try {
@@ -131,18 +130,18 @@ export async function action({ request }: ActionFunctionArgs) {
}
)
return json({ success: true, policy, error: null })
return { success: true, policy, error: null }
} catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error)
// @ts-ignore: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message)
return json({ success: false, error: text.message }, {
return send({ success: false, error: text.message }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
return json({ success: true, error: null })
return { success: true, error: null }
}
export default function Page() {
+3 -7
View File
@@ -43,16 +43,12 @@ 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,
})
return send({ success: false }, 401)
}
const context = await loadContext()
if (!context.config.write) {
return json({ success: false }, {
status: 403,
})
return send({ success: false }, 403)
}
const data = await request.json() as Record<string, unknown>
@@ -62,7 +58,7 @@ export async function action({ request }: ActionFunctionArgs) {
await context.integration.onConfigChange(context.integration.context)
}
return json({ success: true })
return { success: true }
}
export default function Page() {
+23 -24
View File
@@ -1,21 +1,20 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, json } from '@remix-run/node'
import { ActionFunctionArgs } from '@remix-run/node'
import { del, post } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { send } from '~/utils/res'
import log from '~/utils/log'
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
return send({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
return send({ message: 'No method or ID provided' }, {
status: 400,
})
}
@@ -26,17 +25,17 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
return { message: 'Machine removed' }
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return json({ message: 'Machine expired' })
return { message: 'Machine expired' }
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
return send({ message: 'No name provided' }, {
status: 400,
})
}
@@ -44,12 +43,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
return { message: 'Machine renamed' }
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
return send({ message: 'No route or enabled provided' }, {
status: 400,
})
}
@@ -59,12 +58,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
return { message: 'Route updated' }
}
case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
return send({ message: 'No route or enabled provided' }, {
status: 400,
})
}
@@ -77,12 +76,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
}))
return json({ message: 'Exit node updated' })
return { message: 'Exit node updated' }
}
case 'move': {
if (!data.has('to')) {
return json({ message: 'No destination provided' }, {
return send({ message: 'No destination provided' }, {
status: 400,
})
}
@@ -91,9 +90,9 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved node ${id} to ${to}` })
return { message: `Moved node ${id} to ${to}` }
} catch {
return json({ message: `Failed to move node ${id} to ${to}` }, {
return send({ message: `Failed to move node ${id} to ${to}` }, {
status: 500,
})
}
@@ -110,10 +109,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
tags,
})
return json({ message: 'Tags updated' })
return { message: 'Tags updated' }
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error)
return json({ message: 'Failed to update tags' }, {
return send({ message: 'Failed to update tags' }, {
status: 500,
})
}
@@ -124,13 +123,13 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
const user = data.get('user')?.toString()
if (!key) {
return json({ message: 'No machine key provided' }, {
return send({ message: 'No machine key provided' }, {
status: 400,
})
}
if (!user) {
return json({ message: 'No user provided' }, {
return send({ message: 'No user provided' }, {
status: 400,
})
}
@@ -145,12 +144,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
user, key,
})
return json({
return {
success: true,
message: 'Machine registered'
})
}
} catch {
return json({
return send({
success: false,
message: 'Failed to register machine'
}, {
@@ -160,7 +159,7 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
}
default: {
return json({ message: 'Invalid method' }, {
return send({ message: 'Invalid method' }, {
status: 400,
})
}
@@ -50,10 +50,8 @@ export default function New(data: NewProps) {
<Dialog.Text className='mb-4'>
The machine key is given when you run
{' '}
<Code>
<Code isCopyable>
tailscale up --login-server=
</Code>
<Code>
{data.server}
</Code>
{' '}
+5 -1
View File
@@ -46,6 +46,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
users: users.users,
magic,
server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
}
}
@@ -73,7 +74,10 @@ export default function Page() {
</Link>
</p>
</div>
<NewMachine server={data.server} users={data.users} />
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
/>
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400">
@@ -64,13 +64,13 @@ export default function AddPreAuthKey(data: Props) {
Key Expiration
</Dialog.Text>
<Dialog.Text className="text-sm">
Set this key to expire between 1 and 90 days.
Set this key to expire after a certain number of days.
</Dialog.Text>
<NumberField
label="Expiry"
name="expiry"
minValue={1}
maxValue={90}
maxValue={365_000} // 1000 years
state={[expiry, setExpiry]}
formatOptions={{
style: 'unit',
@@ -1,4 +1,4 @@
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
import { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useLiveData } from '~/utils/useLiveData'
import { getSession } from '~/utils/sessions'
@@ -7,6 +7,7 @@ import { PreAuthKey, User } from '~/types'
import { pull, post } from '~/utils/headscale'
import { loadContext } from '~/utils/config/headplane'
import { useState } from 'react'
import { send } from '~/utils/res'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
@@ -19,7 +20,7 @@ import AuthKeyRow from './key'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
return send({ message: 'Unauthorized' }, {
status: 401,
})
}
@@ -32,7 +33,7 @@ export async function action({ request }: ActionFunctionArgs) {
const user = data.get('user')
if (!key || !user) {
return json({ message: 'Missing parameters' }, {
return send({ message: 'Missing parameters' }, {
status: 400,
})
}
@@ -46,7 +47,7 @@ export async function action({ request }: ActionFunctionArgs) {
}
)
return json({ message: 'Pre-auth key expired' })
return { message: 'Pre-auth key expired' }
}
// Creating a new pre-auth key
@@ -57,7 +58,7 @@ export async function action({ request }: ActionFunctionArgs) {
const ephemeral = data.get('ephemeral')
if (!user || !expiry || !reusable || !ephemeral) {
return json({ message: 'Missing parameters' }, {
return send({ message: 'Missing parameters' }, {
status: 400,
})
}
@@ -80,7 +81,7 @@ export async function action({ request }: ActionFunctionArgs) {
}
)
return json({ message: 'Pre-auth key created', key })
return { message: 'Pre-auth key created', key }
}
}
@@ -102,7 +103,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
users: users.users,
server: context.headscaleUrl,
server: context.headscalePublicUrl ?? context.headscaleUrl,
}
}
+47 -1
View File
@@ -1,9 +1,10 @@
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error'
import Header from '~/components/Header'
import Link from '~/components/Link'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
@@ -36,10 +37,53 @@ export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
return {
config: context.config,
url: context.headscalePublicUrl ?? context.headscaleUrl,
debug: context.debug,
user: session.get('user'),
}
}
interface FooterProps {
url: string
debug: boolean
}
function Footer({ url, debug, integration }: FooterProps) {
return (
<footer className={cn(
'fixed bottom-0 left-0 z-50 w-full h-14',
'bg-ui-100 dark:bg-ui-900 text-ui-500',
'flex flex-col justify-center gap-1',
'border-t border-ui-200 dark:border-ui-800',
)}>
<p className="container text-xs">
Headplane is entirely free to use.
{' '}
If you find it useful, consider
{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>
{' '}
to support development.
{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' | '}
Connecting to
{' '}
<strong>{url}</strong>
{' '}
{debug && '(Debug mode enabled)'}
</p>
</footer>
)
}
export default function Layout() {
const data = useLoaderData<typeof loader>()
const nav = useNavigation()
@@ -61,6 +105,7 @@ export default function Layout() {
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
<Footer {...data} />
</>
)
}
@@ -70,6 +115,7 @@ export function ErrorBoundary() {
<>
<Header />
<ErrorPopup type="embedded" />
<Footer url="Unknown" debug={false} />
</>
)
}
+14 -29
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
@@ -17,6 +17,7 @@ import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { send } from '~/utils/res'
import Auth from './auth'
import Oidc from './oidc'
@@ -56,16 +57,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
return send({ message: 'Unauthorized' }, 401)
}
const data = await request.formData()
if (!data.has('_method')) {
return json({ message: 'No method provided' }, {
status: 400,
})
return send({ message: 'No method provided' }, 400)
}
const method = String(data.get('_method'))
@@ -73,9 +70,7 @@ export async function action({ request }: ActionFunctionArgs) {
switch (method) {
case 'create': {
if (!data.has('username')) {
return json({ message: 'No name provided' }, {
status: 400,
})
return send({ message: 'No name provided' }, 400)
}
const username = String(data.get('username'))
@@ -83,39 +78,33 @@ export async function action({ request }: ActionFunctionArgs) {
name: username,
})
return json({ message: `User ${username} created` })
return { message: `User ${username} created` }
}
case 'delete': {
if (!data.has('username')) {
return json({ message: 'No name provided' }, {
status: 400,
})
return send({ message: 'No name provided' }, 400)
}
const username = String(data.get('username'))
await del(`v1/user/${username}`, session.get('hsApiKey')!)
return json({ message: `User ${username} deleted` })
return { message: `User ${username} deleted` }
}
case 'rename': {
if (!data.has('old') || !data.has('new')) {
return json({ message: 'No old or new name provided' }, {
status: 400,
})
return send({ message: 'No old or new name provided' }, 400)
}
const old = String(data.get('old'))
const newName = String(data.get('new'))
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
return json({ message: `User ${old} renamed to ${newName}` })
return { message: `User ${old} renamed to ${newName}` }
}
case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) {
return json({ message: 'No ID or destination provided' }, {
status: 400,
})
return send({ message: 'No ID or destination provided' }, 400)
}
const id = String(data.get('id'))
@@ -124,18 +113,14 @@ export async function action({ request }: ActionFunctionArgs) {
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved ${name} to ${to}` })
return { message: `Moved ${name} to ${to}` }
} catch {
return json({ message: `Failed to move ${name} to ${to}` }, {
status: 500,
})
return send({ message: `Failed to move ${name} to ${to}` }, 500)
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
return send({ message: 'Invalid method' }, 400)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import log from '~/utils/log'
export async function loader() {
const context = await loadContext()
try {
// Doesn't matter, we just need a 401
await pull('v1/', 'wrongkey')
} catch (e) {
if (!(e instanceof HeadscaleError)) {
log.debug('Healthz', 'Headscale is not reachable')
return new Response('Headscale is not reachable', {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
})
}
}
return new Response('OK', {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
@@ -6,7 +6,7 @@ import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import TextField from '~/components/TextField'
import { type Key } from '~/types'
import { Key } from '~/types'
import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
@@ -81,9 +81,9 @@ export async function action({ request }: ActionFunctionArgs) {
},
})
} catch {
return json({
return {
error: 'Invalid API key',
})
}
}
}
+40
View File
@@ -10,11 +10,13 @@ import { parse } from 'yaml'
import { IntegrationFactory, loadIntegration } from '~/integration'
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
import { testOidc } from '~/utils/oidc'
import log from '~/utils/log'
export interface HeadplaneContext {
debug: boolean
headscaleUrl: string
headscalePublicUrl?: string
cookieSecret: string
integration: IntegrationFactory | undefined
@@ -28,6 +30,7 @@ export interface HeadplaneContext {
client: string
secret: string
rootKey: string
method: string
disableKeyLogin: boolean
}
}
@@ -55,12 +58,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
const { config, contextData } = await checkConfig(path)
let headscaleUrl = process.env.HEADSCALE_URL
let headscalePublicUrl = process.env.HEADSCALE_PUBLIC_URL
if (!headscaleUrl && !config) {
throw new Error('HEADSCALE_URL not set')
}
if (config) {
headscaleUrl = headscaleUrl ?? config.server_url
if (!headscalePublicUrl) {
// Fallback to the config value if the env var is not set
headscalePublicUrl = config.public_url
}
}
if (!headscaleUrl) {
@@ -75,6 +84,7 @@ export async function loadContext(): Promise<HeadplaneContext> {
context = {
debug,
headscaleUrl,
headscalePublicUrl,
cookieSecret,
integration: await loadIntegration(),
config: contextData,
@@ -83,6 +93,10 @@ export async function loadContext(): Promise<HeadplaneContext> {
log.info('CTXT', 'Starting Headplane with Context')
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl)
if (headscalePublicUrl) {
log.info('CTXT', 'HEADSCALE_PUBLIC_URL: %s', headscalePublicUrl)
}
log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None')
log.info('CTXT', 'Config: %s', contextData.read
? `Found ${contextData.write ? '' : '(Read Only)'}`
@@ -143,6 +157,8 @@ async function checkOidc(config?: HeadscaleConfig) {
let issuer = process.env.OIDC_ISSUER
let client = process.env.OIDC_CLIENT_ID
let secret = process.env.OIDC_CLIENT_SECRET
let method = process.env.OIDC_CLIENT_SECRET_METHOD ?? 'client_secret_basic'
let skip = process.env.OIDC_SKIP_CONFIG_VALIDATION === 'true'
log.debug('CTXT', 'Checking OIDC environment variables')
log.debug('CTXT', 'Issuer: %s', issuer)
@@ -157,10 +173,22 @@ async function checkOidc(config?: HeadscaleConfig) {
}
if (issuer && client && secret) {
if (!skip) {
log.debug('CTXT', 'Validating OIDC configuration from environment variables')
const result = await testOidc(issuer, client, secret)
if (!result) {
return
}
} else {
log.debug('CTXT', 'OIDC_SKIP_CONFIG_VALIDATION is set')
log.debug('CTXT', 'Skipping OIDC configuration validation')
}
return {
issuer,
client,
secret,
method,
rootKey,
disableKeyLogin,
}
@@ -199,11 +227,23 @@ async function checkOidc(config?: HeadscaleConfig) {
return
}
if (config.oidc.only_start_if_oidc_is_available) {
log.debug('CTXT', 'Validating OIDC configuration from headscale config')
const result = await testOidc(issuer, client, secret)
if (!result) {
return
}
} else {
log.debug('CTXT', 'OIDC validation is disabled in headscale config')
log.debug('CTXT', 'Skipping OIDC configuration validation')
}
return {
issuer,
client,
secret,
rootKey,
method,
disableKeyLogin,
}
}
+30 -2
View File
@@ -17,6 +17,7 @@ import {
import { post } from '~/utils/headscale'
import { commitSession, getSession } from '~/utils/sessions'
import log from '~/utils/log'
import { HeadplaneContext } from './config/headplane'
@@ -36,7 +37,7 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
const issuerUrl = new URL(oidc.issuer)
const oidcClient = {
client_id: oidc.client,
token_endpoint_auth_method: 'client_secret_basic',
token_endpoint_auth_method: oidc.method,
} satisfies Client
const response = await discoveryRequest(issuerUrl)
@@ -91,7 +92,7 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
const oidcClient = {
client_id: oidc.client,
client_secret: oidc.secret,
token_endpoint_auth_method: 'client_secret_basic',
token_endpoint_auth_method: oidc.method,
} satisfies Client
const response = await discoveryRequest(issuerUrl)
@@ -169,3 +170,30 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
},
})
}
// Runs at application startup to validate the OIDC configuration
export async function testOidc(issuer: string, client: string, secret: string) {
const oidcClient = {
client_id: client,
client_secret: secret,
token_endpoint_auth_method: 'client_secret_post',
} satisfies Client
const issuerUrl = new URL(issuer)
try {
log.debug('OIDC', 'Checking OIDC well-known endpoint')
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
if (!processed.authorization_endpoint) {
log.debug('OIDC', 'No authorization endpoint found on the OIDC provider')
return false
}
log.debug('OIDC', 'Found auth endpoint: %s', processed.authorization_endpoint)
return true
} catch (e) {
log.debug('OIDC', 'Validation failed: %s', e.message)
return false
}
}
+5
View File
@@ -0,0 +1,5 @@
import { data } from '@remix-run/node'
export function send<T>(payload: T, init?: number | ResponseInit) {
return data(payload, init)
}
+7
View File
@@ -69,3 +69,10 @@ Currently there are 3 integration providers that can do this for you:
- [Kubernetes Integration](/docs/integration/Kubernetes.md)
- [Native Linux Integration](/docs/integration/Native.md)
Once configured, the Headplane UI will be available at the `/admin` path
of the server you deployed it on. This is currently not configurable unless
you build the Docker image yourself or run the Node.js server directly.
Additionally, if you require access to health information for either Docker
or Kubernetes, the `/admin/healthz` path will be available. This is useful for
monitoring services like Prometheus or Grafana.
+5 -1
View File
@@ -29,7 +29,7 @@ Here is a simple Docker Compose deployment:
services:
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:0.3.5
image: ghcr.io/tale/headplane:0.3.9
restart: unless-stopped
ports:
- '3000:3000'
@@ -50,6 +50,10 @@ services:
PORT: '3000'
```
Once configured, the Headplane UI will be available at the `/admin` path
of the server you deployed it on. This is currently not configurable unless
you build the Docker image yourself or run the Node.js server directly.
> For a breakdown of each configuration variable, please refer to the
[Configuration](/docs/Configuration.md) guide.
> It explains what each variable does, how to configure them, and what the
+3
View File
@@ -9,6 +9,7 @@ You can configure Headplane using environment variables.
#### Optional Variables
- **`HEADSCALE_PUBLIC_URL`**: The public URL of your Headscale server (if different from `HEADSCALE_URL`).
- **`DEBUG`**: Enable debug logging (default: `false`).
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
- **`PORT`**: The port to bind the server to (default: `3000`).
@@ -34,6 +35,8 @@ If you use the Headscale configuration integration, these are not required.
- **`OIDC_ISSUER`**: The issuer URL of your OIDC provider.
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
- **`OIDC_CLIENT_SECRET_METHOD`**: The method used to send the client secret (default: `client_secret_basic`).
- **`OIDC_SKIP_CONFIG_VALIDATION`**: Skip the OIDC configuration validation (default: `false`).
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
+1 -1
View File
@@ -50,7 +50,7 @@ services:
TZ: 'America/New_York'
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:0.3.5
image: ghcr.io/tale/headplane:0.3.9
restart: unless-stopped
volumes:
- './data:/var/lib/headscale'
+1 -1
View File
@@ -88,7 +88,7 @@ spec:
serviceAccountName: default
containers:
- name: headplane
image: ghcr.io/tale/headplane:0.3.5
image: ghcr.io/tale/headplane:0.3.9
env:
- name: COOKIE_SECRET
value: 'abcdefghijklmnopqrstuvwxyz'
+27 -36
View File
@@ -9,54 +9,52 @@
"typecheck": "tsc"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kubernetes/client-node": "^0.22.2",
"@primer/octicons-react": "^19.12.0",
"@react-aria/toast": "3.0.0-beta.12",
"@react-stately/toast": "3.0.0-beta.4",
"@remix-run/node": "^2.13.1",
"@remix-run/react": "^2.13.1",
"@kubernetes/client-node": "^0.22.3",
"@primer/octicons-react": "^19.13.0",
"@react-aria/toast": "3.0.0-beta.18",
"@react-stately/toast": "3.0.0-beta.7",
"@remix-run/node": "^2.15.0",
"@remix-run/react": "^2.15.0",
"@shopify/lang-jsonc": "^1.0.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"@uiw/codemirror-theme-github": "^4.23.6",
"@uiw/react-codemirror": "^4.23.6",
"clsx": "^2.1.1",
"dotenv": "^16.4.5",
"dotenv": "^16.4.7",
"isbot": "^5.1.17",
"mime": "^4.0.4",
"oauth4webapi": "^2.17.0",
"react": "19.0.0-beta-26f2496093-20240514",
"react-aria-components": "^1.2.1",
"react": "19.0.0",
"react-aria-components": "^1.5.0",
"react-codemirror-merge": "^4.23.6",
"react-dom": "19.0.0-beta-26f2496093-20240514",
"react-dom": "19.0.0",
"react-error-boundary": "^4.1.2",
"remix-utils": "^7.7.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-react-aria-components": "^1.1.6",
"undici": "^6.20.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-react-aria-components": "^1.2.0",
"undici": "^7.1.0",
"usehooks-ts": "^3.1.0",
"yaml": "^2.6.0",
"yaml": "^2.6.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@remix-run/dev": "^2.13.1",
"@types/react": "npm:types-react@beta",
"@types/react-dom": "npm:types-react-dom@beta",
"@remix-run/dev": "^2.15.0",
"@remix-run/fs-routes": "^2.15.0",
"@remix-run/route-config": "^2.15.0",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-babel": "^1.2.0",
"vite-tsconfig-paths": "^5.1.0"
},
"overrides": {
"@types/react": "npm:types-react@beta",
"@types/react-dom": "npm:types-react-dom@beta"
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vite-plugin-babel": "^1.3.0",
"vite-tsconfig-paths": "^5.1.4"
},
"engines": {
"node": ">=20",
@@ -64,14 +62,7 @@
},
"pnpm": {
"patchedDependencies": {
"@react-aria/overlays@3.22.1": "patches/@react-aria__overlays@3.22.1.patch",
"@shopify/lang-jsonc@1.0.0": "patches/@shopify__lang-jsonc@1.0.0.patch"
},
"peerDependencyRules": {
"allowAny": [
"react",
"react-dom"
]
}
}
}
@@ -1,22 +0,0 @@
diff --git a/dist/usePreventScroll.mjs b/dist/usePreventScroll.mjs
index 69b84ce2aec5b637a9f0ba8158b6a1ba4173c266..5e546a6e4fc3b7a0468c1c89d887a4392c010624 100644
--- a/dist/usePreventScroll.mjs
+++ b/dist/usePreventScroll.mjs
@@ -48,7 +48,7 @@ function $49c51c25361d4cd2$export$ee0f7cc6afcd1c18(options = {}) {
// For most browsers, all we need to do is set `overflow: hidden` on the root element, and
// add some padding to prevent the page from shifting when the scrollbar is hidden.
function $49c51c25361d4cd2$var$preventScrollStandard() {
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'));
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'));
}
// Mobile Safari is a whole different beast. Even with overflow: hidden,
// it still scrolls the page in many situations:
@@ -161,7 +161,7 @@ function $49c51c25361d4cd2$var$preventScrollMobileSafari() {
// enable us to scroll the window to the top, which is required for the rest of this to work.
let scrollX = window.pageXOffset;
let scrollY = window.pageYOffset;
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, 'scroll', onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'), $49c51c25361d4cd2$var$setStyle(document.body, 'marginTop', `-${scrollY}px`), ()=>{
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, 'scroll', onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'), $49c51c25361d4cd2$var$setStyle(document.body, 'marginTop', `-${scrollY}px`), ()=>{
window.scrollTo(scrollX, scrollY);
});
// Scroll to the top. The negative margin on the body will make this appear the same.
+2341 -1983
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -1,16 +1,20 @@
import { vitePlugin as remix } from '@remix-run/dev'
import { installGlobals } from '@remix-run/node'
import { defineConfig } from 'vite'
import babel from 'vite-plugin-babel'
import tsconfigPaths from 'vite-tsconfig-paths'
installGlobals()
import { execSync } from 'node:child_process'
const prefix = process.env.__INTERNAL_PREFIX || '/admin'
if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash')
}
// Load the version via git tags
const version = execSync('git describe --tags --always').toString().trim()
if (!version) {
throw new Error('Unable to execute git describe')
}
export default defineConfig(({ isSsrBuild }) => {
// If we have the Headplane entry we build it as a single
// server.mjs file that is built for production server bundle
@@ -45,9 +49,20 @@ export default defineConfig(({ isSsrBuild }) => {
return ({
base: `${prefix}/`,
build: isSsrBuild ? { target: 'ES2022' } : {},
define: {
__VERSION__: JSON.stringify(version),
},
plugins: [
remix({
basename: `${prefix}/`,
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_lazyRouteDiscovery: true,
v3_singleFetch: true,
v3_routeConfig: true
},
}),
tsconfigPaths(),
babel({