mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 240d8d6197 | |||
| b80bb0cc09 | |||
| 69cc6985b4 | |||
| f9b38939ba | |||
| 4cfa1e5209 | |||
| e713dae91b | |||
| c9bcc1d7c6 | |||
| 401731fd09 | |||
| f623e7bc66 | |||
| 1af292a5b0 | |||
| 21778a43f1 | |||
| 33762e53b5 | |||
| 8867cca494 |
@@ -1,3 +1,11 @@
|
||||
### 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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { flatRoutes } from '@remix-run/fs-routes'
|
||||
|
||||
export default flatRoutes()
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-1
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 {
|
||||
@@ -157,6 +158,7 @@ async function checkOidc(config?: HeadscaleConfig) {
|
||||
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)
|
||||
@@ -171,6 +173,17 @@ 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,
|
||||
@@ -214,6 +227,17 @@ 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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { data } from '@remix-run/node'
|
||||
|
||||
export function send<T>(payload: T, init?: number | ResponseInit) {
|
||||
return data(payload, init)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,7 @@ If you use the Headscale configuration integration, these are not required.
|
||||
- **`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`.
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Generated
+2341
-1983
File diff suppressed because it is too large
Load Diff
+18
-3
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user