mirror of
https://github.com/tale/headplane.git
synced 2026-07-28 08:38:57 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69cc6985b4 | |||
| f9b38939ba | |||
| 4cfa1e5209 | |||
| e713dae91b | |||
| c9bcc1d7c6 | |||
| 401731fd09 | |||
| f623e7bc66 | |||
| 1af292a5b0 | |||
| 21778a43f1 | |||
| 33762e53b5 | |||
| 8867cca494 | |||
| 41b1d3c847 | |||
| 712fc28683 | |||
| 320dab1d4f | |||
| da0ee1382b | |||
| a7d127c7bf | |||
| b433e607e2 | |||
| 8aad883c21 | |||
| 3cd28d2136 | |||
| 9d9cbd8e0e | |||
| feb8b8bba5 | |||
| c304effcdf |
@@ -1,3 +1,22 @@
|
|||||||
|
### 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.
|
||||||
|
|
||||||
### 0.3.4 (November 7, 2024)
|
### 0.3.4 (November 7, 2024)
|
||||||
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
||||||
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ FROM node:20-alpine AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
|
RUN apk add --no-cache git
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|||||||
+40
-7
@@ -1,12 +1,45 @@
|
|||||||
import clsx from 'clsx'
|
import { useState, HTMLProps } from 'react'
|
||||||
import { type 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 (
|
return (
|
||||||
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
|
<>
|
||||||
{properties.children}
|
<code className={cn(
|
||||||
</code>
|
'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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function Select(props: SelectProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'mt-2 rounded-md w-[var(--trigger-width)]',
|
'mt-2 rounded-md w-[var(--trigger-width)]',
|
||||||
'bg-ui-100 dark:bg-ui-800 shadow-sm',
|
'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',
|
'border border-ui-200 dark:border-ui-600',
|
||||||
'entering:animate-in exiting:animate-out',
|
'entering:animate-in exiting:animate-out',
|
||||||
'entering:fade-in entering:zoom-in-95',
|
'entering:fade-in entering:zoom-in-95',
|
||||||
@@ -54,7 +54,7 @@ function Select(props: SelectProps) {
|
|||||||
'fill-mode-forwards origin-left-right',
|
'fill-mode-forwards origin-left-right',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ListBox>
|
<ListBox orientation="vertical">
|
||||||
{props.children}
|
{props.children}
|
||||||
</ListBox>
|
</ListBox>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { loadContext } from './utils/config/headplane'
|
|||||||
|
|
||||||
await loadContext()
|
await loadContext()
|
||||||
|
|
||||||
|
export const streamTimeout = 5000
|
||||||
export default function handleRequest(
|
export default function handleRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
responseStatusCode: number,
|
responseStatusCode: number,
|
||||||
@@ -27,7 +28,6 @@ export default function handleRequest(
|
|||||||
<RemixServer
|
<RemixServer
|
||||||
context={remixContext}
|
context={remixContext}
|
||||||
url={request.url}
|
url={request.url}
|
||||||
abortDelay={5000}
|
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
[isBot ? 'onAllReady' : 'onShellReady']() {
|
[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 */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
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 { useLoaderData, useRevalidator } from '@remix-run/react'
|
||||||
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
|
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
|
||||||
import { useEffect, useState, useMemo } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
@@ -18,6 +18,7 @@ import { loadContext } from '~/utils/config/headplane'
|
|||||||
import { loadConfig } from '~/utils/config/headscale'
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
import { send } from '~/utils/res'
|
||||||
import log from '~/utils/log'
|
import log from '~/utils/log'
|
||||||
|
|
||||||
import { Editor, Differ } from './cm.client'
|
import { Editor, Differ } from './cm.client'
|
||||||
@@ -116,9 +117,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ success: false, error: null }, {
|
return send({ success: false, error: null }, 401)
|
||||||
status: 401,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
log.debug('APIC', 'Failed to update ACL policy with error %s', 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
|
// @ts-ignore: Shut UP we know it's a string most of the time
|
||||||
const text = JSON.parse(error.message)
|
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,
|
status: error instanceof HeadscaleError ? error.status : 500,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ success: true, error: null })
|
return { success: true, error: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
|||||||
@@ -43,16 +43,12 @@ export async function loader() {
|
|||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ success: false }, {
|
return send({ success: false }, 401)
|
||||||
status: 401,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
if (!context.config.write) {
|
if (!context.config.write) {
|
||||||
return json({ success: false }, {
|
return send({ success: false }, 403)
|
||||||
status: 403,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.json() as Record<string, unknown>
|
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)
|
await context.integration.onConfigChange(context.integration.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
import { ActionFunctionArgs } from '@remix-run/node'
|
||||||
import { ActionFunctionArgs, json } from '@remix-run/node'
|
|
||||||
|
|
||||||
import { del, post } from '~/utils/headscale'
|
import { del, post } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
import { send } from '~/utils/res'
|
||||||
import log from '~/utils/log'
|
import log from '~/utils/log'
|
||||||
|
|
||||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ message: 'Unauthorized' }, {
|
return send({ message: 'Unauthorized' }, {
|
||||||
status: 401,
|
status: 401,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.formData()
|
const data = await request.formData()
|
||||||
if (!data.has('_method') || !data.has('id')) {
|
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,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -26,17 +25,17 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
switch (method) {
|
switch (method) {
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
await del(`v1/node/${id}`, session.get('hsApiKey')!)
|
await del(`v1/node/${id}`, session.get('hsApiKey')!)
|
||||||
return json({ message: 'Machine removed' })
|
return { message: 'Machine removed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'expire': {
|
case 'expire': {
|
||||||
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
|
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
|
||||||
return json({ message: 'Machine expired' })
|
return { message: 'Machine expired' }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'rename': {
|
case 'rename': {
|
||||||
if (!data.has('name')) {
|
if (!data.has('name')) {
|
||||||
return json({ message: 'No name provided' }, {
|
return send({ message: 'No name provided' }, {
|
||||||
status: 400,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -44,12 +43,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
const name = String(data.get('name'))
|
const name = String(data.get('name'))
|
||||||
|
|
||||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
||||||
return json({ message: 'Machine renamed' })
|
return { message: 'Machine renamed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'routes': {
|
case 'routes': {
|
||||||
if (!data.has('route') || !data.has('enabled')) {
|
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,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -59,12 +58,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
const postfix = enabled ? 'enable' : 'disable'
|
const postfix = enabled ? 'enable' : 'disable'
|
||||||
|
|
||||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||||
return json({ message: 'Route updated' })
|
return { message: 'Route updated' }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'exit-node': {
|
case 'exit-node': {
|
||||||
if (!data.has('routes') || !data.has('enabled')) {
|
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,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -77,12 +76,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return json({ message: 'Exit node updated' })
|
return { message: 'Exit node updated' }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'move': {
|
case 'move': {
|
||||||
if (!data.has('to')) {
|
if (!data.has('to')) {
|
||||||
return json({ message: 'No destination provided' }, {
|
return send({ message: 'No destination provided' }, {
|
||||||
status: 400,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -91,9 +90,9 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
|
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 {
|
} catch {
|
||||||
return json({ message: `Failed to move node ${id} to ${to}` }, {
|
return send({ message: `Failed to move node ${id} to ${to}` }, {
|
||||||
status: 500,
|
status: 500,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -110,10 +109,10 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
tags,
|
tags,
|
||||||
})
|
})
|
||||||
|
|
||||||
return json({ message: 'Tags updated' })
|
return { message: 'Tags updated' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.debug('APIC', 'Failed to update tags: %s', 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,
|
status: 500,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -124,13 +123,13 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
const user = data.get('user')?.toString()
|
const user = data.get('user')?.toString()
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return json({ message: 'No machine key provided' }, {
|
return send({ message: 'No machine key provided' }, {
|
||||||
status: 400,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return json({ message: 'No user provided' }, {
|
return send({ message: 'No user provided' }, {
|
||||||
status: 400,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -145,12 +144,12 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
user, key,
|
user, key,
|
||||||
})
|
})
|
||||||
|
|
||||||
return json({
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Machine registered'
|
message: 'Machine registered'
|
||||||
})
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return json({
|
return send({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to register machine'
|
message: 'Failed to register machine'
|
||||||
}, {
|
}, {
|
||||||
@@ -160,7 +159,7 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return json({ message: 'Invalid method' }, {
|
return send({ message: 'Invalid method' }, {
|
||||||
status: 400,
|
status: 400,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,8 @@ export default function New(data: NewProps) {
|
|||||||
<Dialog.Text className='mb-4'>
|
<Dialog.Text className='mb-4'>
|
||||||
The machine key is given when you run
|
The machine key is given when you run
|
||||||
{' '}
|
{' '}
|
||||||
<Code>
|
<Code isCopyable>
|
||||||
tailscale up --login-server=
|
tailscale up --login-server=
|
||||||
</Code>
|
|
||||||
<Code>
|
|
||||||
{data.server}
|
{data.server}
|
||||||
</Code>
|
</Code>
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
users: users.users,
|
users: users.users,
|
||||||
magic,
|
magic,
|
||||||
server: context.headscaleUrl,
|
server: context.headscaleUrl,
|
||||||
|
publicServer: context.headscalePublicUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +74,10 @@ export default function Page() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewMachine server={data.server} users={data.users} />
|
<NewMachine
|
||||||
|
server={data.publicServer ?? data.server}
|
||||||
|
users={data.users}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table className="table-auto w-full rounded-lg">
|
<table className="table-auto w-full rounded-lg">
|
||||||
<thead className="text-gray-500 dark:text-gray-400">
|
<thead className="text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
@@ -64,13 +64,13 @@ export default function AddPreAuthKey(data: Props) {
|
|||||||
Key Expiration
|
Key Expiration
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<Dialog.Text className="text-sm">
|
<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>
|
</Dialog.Text>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Expiry"
|
label="Expiry"
|
||||||
name="expiry"
|
name="expiry"
|
||||||
minValue={1}
|
minValue={1}
|
||||||
maxValue={90}
|
maxValue={365_000} // 1000 years
|
||||||
state={[expiry, setExpiry]}
|
state={[expiry, setExpiry]}
|
||||||
formatOptions={{
|
formatOptions={{
|
||||||
style: 'unit',
|
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 { useLoaderData } from '@remix-run/react'
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
@@ -7,6 +7,7 @@ import { PreAuthKey, User } from '~/types'
|
|||||||
import { pull, post } from '~/utils/headscale'
|
import { pull, post } from '~/utils/headscale'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { send } from '~/utils/res'
|
||||||
|
|
||||||
import Link from '~/components/Link'
|
import Link from '~/components/Link'
|
||||||
import TableList from '~/components/TableList'
|
import TableList from '~/components/TableList'
|
||||||
@@ -19,7 +20,7 @@ import AuthKeyRow from './key'
|
|||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ message: 'Unauthorized' }, {
|
return send({ message: 'Unauthorized' }, {
|
||||||
status: 401,
|
status: 401,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
const user = data.get('user')
|
const user = data.get('user')
|
||||||
|
|
||||||
if (!key || !user) {
|
if (!key || !user) {
|
||||||
return json({ message: 'Missing parameters' }, {
|
return send({ message: 'Missing parameters' }, {
|
||||||
status: 400,
|
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
|
// Creating a new pre-auth key
|
||||||
@@ -57,7 +58,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
const ephemeral = data.get('ephemeral')
|
const ephemeral = data.get('ephemeral')
|
||||||
|
|
||||||
if (!user || !expiry || !reusable || !ephemeral) {
|
if (!user || !expiry || !reusable || !ephemeral) {
|
||||||
return json({ message: 'Missing parameters' }, {
|
return send({ message: 'Missing parameters' }, {
|
||||||
status: 400,
|
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 {
|
return {
|
||||||
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
|
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
|
||||||
users: users.users,
|
users: users.users,
|
||||||
server: context.headscaleUrl,
|
server: context.headscalePublicUrl ?? context.headscaleUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
|
||||||
import { ProgressBar } from 'react-aria-components'
|
import { ProgressBar } from 'react-aria-components'
|
||||||
|
|
||||||
import { ErrorPopup } from '~/components/Error'
|
import { ErrorPopup } from '~/components/Error'
|
||||||
import Header from '~/components/Header'
|
import Header from '~/components/Header'
|
||||||
|
import Link from '~/components/Link'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||||
@@ -36,10 +37,53 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
return {
|
return {
|
||||||
config: context.config,
|
config: context.config,
|
||||||
|
url: context.headscalePublicUrl ?? context.headscaleUrl,
|
||||||
|
debug: context.debug,
|
||||||
user: session.get('user'),
|
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() {
|
export default function Layout() {
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
const nav = useNavigation()
|
const nav = useNavigation()
|
||||||
@@ -61,6 +105,7 @@ export default function Layout() {
|
|||||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<Footer {...data} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -70,6 +115,7 @@ export function ErrorBoundary() {
|
|||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<ErrorPopup type="embedded" />
|
<ErrorPopup type="embedded" />
|
||||||
|
<Footer url="Unknown" debug={false} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
||||||
import { PersonIcon } from '@primer/octicons-react'
|
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 { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ClientOnly } from 'remix-utils/client-only'
|
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 { del, post, pull } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
import { send } from '~/utils/res'
|
||||||
|
|
||||||
import Auth from './auth'
|
import Auth from './auth'
|
||||||
import Oidc from './oidc'
|
import Oidc from './oidc'
|
||||||
@@ -56,16 +57,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ message: 'Unauthorized' }, {
|
return send({ message: 'Unauthorized' }, 401)
|
||||||
status: 401,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.formData()
|
const data = await request.formData()
|
||||||
if (!data.has('_method')) {
|
if (!data.has('_method')) {
|
||||||
return json({ message: 'No method provided' }, {
|
return send({ message: 'No method provided' }, 400)
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = String(data.get('_method'))
|
const method = String(data.get('_method'))
|
||||||
@@ -73,9 +70,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
switch (method) {
|
switch (method) {
|
||||||
case 'create': {
|
case 'create': {
|
||||||
if (!data.has('username')) {
|
if (!data.has('username')) {
|
||||||
return json({ message: 'No name provided' }, {
|
return send({ message: 'No name provided' }, 400)
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = String(data.get('username'))
|
const username = String(data.get('username'))
|
||||||
@@ -83,39 +78,33 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
name: username,
|
name: username,
|
||||||
})
|
})
|
||||||
|
|
||||||
return json({ message: `User ${username} created` })
|
return { message: `User ${username} created` }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'delete': {
|
case 'delete': {
|
||||||
if (!data.has('username')) {
|
if (!data.has('username')) {
|
||||||
return json({ message: 'No name provided' }, {
|
return send({ message: 'No name provided' }, 400)
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = String(data.get('username'))
|
const username = String(data.get('username'))
|
||||||
await del(`v1/user/${username}`, session.get('hsApiKey')!)
|
await del(`v1/user/${username}`, session.get('hsApiKey')!)
|
||||||
return json({ message: `User ${username} deleted` })
|
return { message: `User ${username} deleted` }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'rename': {
|
case 'rename': {
|
||||||
if (!data.has('old') || !data.has('new')) {
|
if (!data.has('old') || !data.has('new')) {
|
||||||
return json({ message: 'No old or new name provided' }, {
|
return send({ message: 'No old or new name provided' }, 400)
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const old = String(data.get('old'))
|
const old = String(data.get('old'))
|
||||||
const newName = String(data.get('new'))
|
const newName = String(data.get('new'))
|
||||||
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
|
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': {
|
case 'move': {
|
||||||
if (!data.has('id') || !data.has('to') || !data.has('name')) {
|
if (!data.has('id') || !data.has('to') || !data.has('name')) {
|
||||||
return json({ message: 'No ID or destination provided' }, {
|
return send({ message: 'No ID or destination provided' }, 400)
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = String(data.get('id'))
|
const id = String(data.get('id'))
|
||||||
@@ -124,18 +113,14 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
|
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 {
|
} catch {
|
||||||
return json({ message: `Failed to move ${name} to ${to}` }, {
|
return send({ message: `Failed to move ${name} to ${to}` }, 500)
|
||||||
status: 500,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return json({ message: 'Invalid method' }, {
|
return send({ message: 'Invalid method' }, 400)
|
||||||
status: 400,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 { Form, useActionData, useLoaderData } from '@remix-run/react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import Button from '~/components/Button'
|
|||||||
import Card from '~/components/Card'
|
import Card from '~/components/Card'
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
import TextField from '~/components/TextField'
|
import TextField from '~/components/TextField'
|
||||||
import { type Key } from '~/types'
|
import { Key } from '~/types'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { pull } from '~/utils/headscale'
|
import { pull } from '~/utils/headscale'
|
||||||
import { startOidc } from '~/utils/oidc'
|
import { startOidc } from '~/utils/oidc'
|
||||||
@@ -81,9 +81,9 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return json({
|
return {
|
||||||
error: 'Invalid API key',
|
error: 'Invalid API key',
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import { parse } from 'yaml'
|
|||||||
|
|
||||||
import { IntegrationFactory, loadIntegration } from '~/integration'
|
import { IntegrationFactory, loadIntegration } from '~/integration'
|
||||||
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
|
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
|
||||||
|
import { testOidc } from '~/utils/oidc'
|
||||||
import log from '~/utils/log'
|
import log from '~/utils/log'
|
||||||
|
|
||||||
export interface HeadplaneContext {
|
export interface HeadplaneContext {
|
||||||
debug: boolean
|
debug: boolean
|
||||||
headscaleUrl: string
|
headscaleUrl: string
|
||||||
|
headscalePublicUrl?: string
|
||||||
cookieSecret: string
|
cookieSecret: string
|
||||||
integration: IntegrationFactory | undefined
|
integration: IntegrationFactory | undefined
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ export interface HeadplaneContext {
|
|||||||
client: string
|
client: string
|
||||||
secret: string
|
secret: string
|
||||||
rootKey: string
|
rootKey: string
|
||||||
|
method: string
|
||||||
disableKeyLogin: boolean
|
disableKeyLogin: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,12 +58,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
const { config, contextData } = await checkConfig(path)
|
const { config, contextData } = await checkConfig(path)
|
||||||
|
|
||||||
let headscaleUrl = process.env.HEADSCALE_URL
|
let headscaleUrl = process.env.HEADSCALE_URL
|
||||||
|
let headscalePublicUrl = process.env.HEADSCALE_PUBLIC_URL
|
||||||
|
|
||||||
if (!headscaleUrl && !config) {
|
if (!headscaleUrl && !config) {
|
||||||
throw new Error('HEADSCALE_URL not set')
|
throw new Error('HEADSCALE_URL not set')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config) {
|
if (config) {
|
||||||
headscaleUrl = headscaleUrl ?? config.server_url
|
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) {
|
if (!headscaleUrl) {
|
||||||
@@ -75,6 +84,7 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
context = {
|
context = {
|
||||||
debug,
|
debug,
|
||||||
headscaleUrl,
|
headscaleUrl,
|
||||||
|
headscalePublicUrl,
|
||||||
cookieSecret,
|
cookieSecret,
|
||||||
integration: await loadIntegration(),
|
integration: await loadIntegration(),
|
||||||
config: contextData,
|
config: contextData,
|
||||||
@@ -83,6 +93,10 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
|
|
||||||
log.info('CTXT', 'Starting Headplane with Context')
|
log.info('CTXT', 'Starting Headplane with Context')
|
||||||
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl)
|
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', 'Integration: %s', context.integration?.name ?? 'None')
|
||||||
log.info('CTXT', 'Config: %s', contextData.read
|
log.info('CTXT', 'Config: %s', contextData.read
|
||||||
? `Found ${contextData.write ? '' : '(Read Only)'}`
|
? `Found ${contextData.write ? '' : '(Read Only)'}`
|
||||||
@@ -143,6 +157,8 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
let issuer = process.env.OIDC_ISSUER
|
let issuer = process.env.OIDC_ISSUER
|
||||||
let client = process.env.OIDC_CLIENT_ID
|
let client = process.env.OIDC_CLIENT_ID
|
||||||
let secret = process.env.OIDC_CLIENT_SECRET
|
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', 'Checking OIDC environment variables')
|
||||||
log.debug('CTXT', 'Issuer: %s', issuer)
|
log.debug('CTXT', 'Issuer: %s', issuer)
|
||||||
@@ -157,10 +173,19 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (issuer && client && secret) {
|
if (issuer && client && secret) {
|
||||||
|
if (!skip) {
|
||||||
|
log.debug('CTXT', 'Validating OIDC configuration from environment variables')
|
||||||
|
testOidc(issuer, client, secret)
|
||||||
|
} else {
|
||||||
|
log.debug('CTXT', 'OIDC_SKIP_CONFIG_VALIDATION is set')
|
||||||
|
log.debug('CTXT', 'Skipping OIDC configuration validation')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
issuer,
|
issuer,
|
||||||
client,
|
client,
|
||||||
secret,
|
secret,
|
||||||
|
method,
|
||||||
rootKey,
|
rootKey,
|
||||||
disableKeyLogin,
|
disableKeyLogin,
|
||||||
}
|
}
|
||||||
@@ -199,11 +224,21 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.oidc.only_start_if_oidc_is_available) {
|
||||||
|
log.debug('CTXT', 'Validating OIDC configuration from headscale config')
|
||||||
|
testOidc(issuer, client, secret)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
log.debug('CTXT', 'OIDC validation is disabled in headscale config')
|
||||||
|
log.debug('CTXT', 'Skipping OIDC configuration validation')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
issuer,
|
issuer,
|
||||||
client,
|
client,
|
||||||
secret,
|
secret,
|
||||||
rootKey,
|
rootKey,
|
||||||
|
method,
|
||||||
disableKeyLogin,
|
disableKeyLogin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ export class FatalError extends Error {
|
|||||||
|
|
||||||
export async function pull<T>(url: string, key: string) {
|
export async function pull<T>(url: string, key: string) {
|
||||||
if (!key || key === 'undefined' || key.length === 0) {
|
if (!key || key === 'undefined' || key.length === 0) {
|
||||||
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
|
|
||||||
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
|
|
||||||
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
|
|
||||||
throw new Error('Missing API key, could this be a cookie setting issue?')
|
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,9 +43,6 @@ export async function pull<T>(url: string, key: string) {
|
|||||||
|
|
||||||
export async function post<T>(url: string, key: string, body?: unknown) {
|
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||||
if (!key || key === 'undefined' || key.length === 0) {
|
if (!key || key === 'undefined' || key.length === 0) {
|
||||||
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
|
|
||||||
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
|
|
||||||
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
|
|
||||||
throw new Error('Missing API key, could this be a cookie setting issue?')
|
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +68,6 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
|||||||
|
|
||||||
export async function put<T>(url: string, key: string, body?: unknown) {
|
export async function put<T>(url: string, key: string, body?: unknown) {
|
||||||
if (!key || key === 'undefined' || key.length === 0) {
|
if (!key || key === 'undefined' || key.length === 0) {
|
||||||
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
|
|
||||||
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
|
|
||||||
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
|
|
||||||
throw new Error('Missing API key, could this be a cookie setting issue?')
|
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +93,6 @@ export async function put<T>(url: string, key: string, body?: unknown) {
|
|||||||
|
|
||||||
export async function del<T>(url: string, key: string) {
|
export async function del<T>(url: string, key: string) {
|
||||||
if (!key || key === 'undefined' || key.length === 0) {
|
if (!key || key === 'undefined' || key.length === 0) {
|
||||||
log.error('APIC', 'Missing API key, could this be a cookie setting issue?')
|
|
||||||
log.error('APIC', 'Check that the hp_sess cookie is being set correctly')
|
|
||||||
log.error('APIC', 'If you are running without HTTPs, make sure the Secure flag is false')
|
|
||||||
throw new Error('Missing API key, could this be a cookie setting issue?')
|
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -17,6 +17,7 @@ import {
|
|||||||
|
|
||||||
import { post } from '~/utils/headscale'
|
import { post } from '~/utils/headscale'
|
||||||
import { commitSession, getSession } from '~/utils/sessions'
|
import { commitSession, getSession } from '~/utils/sessions'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
import { HeadplaneContext } from './config/headplane'
|
import { HeadplaneContext } from './config/headplane'
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
|
|||||||
const issuerUrl = new URL(oidc.issuer)
|
const issuerUrl = new URL(oidc.issuer)
|
||||||
const oidcClient = {
|
const oidcClient = {
|
||||||
client_id: oidc.client,
|
client_id: oidc.client,
|
||||||
token_endpoint_auth_method: 'client_secret_basic',
|
token_endpoint_auth_method: oidc.method,
|
||||||
} satisfies Client
|
} satisfies Client
|
||||||
|
|
||||||
const response = await discoveryRequest(issuerUrl)
|
const response = await discoveryRequest(issuerUrl)
|
||||||
@@ -91,7 +92,7 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
|
|||||||
const oidcClient = {
|
const oidcClient = {
|
||||||
client_id: oidc.client,
|
client_id: oidc.client,
|
||||||
client_secret: oidc.secret,
|
client_secret: oidc.secret,
|
||||||
token_endpoint_auth_method: 'client_secret_basic',
|
token_endpoint_auth_method: oidc.method,
|
||||||
} satisfies Client
|
} satisfies Client
|
||||||
|
|
||||||
const response = await discoveryRequest(issuerUrl)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
- [Kubernetes Integration](/docs/integration/Kubernetes.md)
|
||||||
- [Native Linux Integration](/docs/integration/Native.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:
|
services:
|
||||||
headplane:
|
headplane:
|
||||||
container_name: headplane
|
container_name: headplane
|
||||||
image: ghcr.io/tale/headplane:0.3.2
|
image: ghcr.io/tale/headplane:0.3.8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
@@ -50,6 +50,10 @@ services:
|
|||||||
PORT: '3000'
|
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
|
> For a breakdown of each configuration variable, please refer to the
|
||||||
[Configuration](/docs/Configuration.md) guide.
|
[Configuration](/docs/Configuration.md) guide.
|
||||||
> It explains what each variable does, how to configure them, and what the
|
> It explains what each variable does, how to configure them, and what the
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ You can configure Headplane using environment variables.
|
|||||||
|
|
||||||
#### Optional Variables
|
#### Optional Variables
|
||||||
|
|
||||||
|
- **`HEADSCALE_PUBLIC_URL`**: The public URL of your Headscale server (if different from `HEADSCALE_URL`).
|
||||||
- **`DEBUG`**: Enable debug logging (default: `false`).
|
- **`DEBUG`**: Enable debug logging (default: `false`).
|
||||||
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
|
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
|
||||||
- **`PORT`**: The port to bind the server to (default: `3000`).
|
- **`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_ISSUER`**: The issuer URL of your OIDC provider.
|
||||||
- **`OIDC_CLIENT_ID`**: The client ID 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`**: 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).
|
- **`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`.
|
- **`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'
|
TZ: 'America/New_York'
|
||||||
headplane:
|
headplane:
|
||||||
container_name: headplane
|
container_name: headplane
|
||||||
image: ghcr.io/tale/headplane:0.3.2
|
image: ghcr.io/tale/headplane:0.3.8
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- './data:/var/lib/headscale'
|
- './data:/var/lib/headscale'
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ spec:
|
|||||||
serviceAccountName: default
|
serviceAccountName: default
|
||||||
containers:
|
containers:
|
||||||
- name: headplane
|
- name: headplane
|
||||||
image: ghcr.io/tale/headplane:0.3.2
|
image: ghcr.io/tale/headplane:0.3.8
|
||||||
env:
|
env:
|
||||||
- name: COOKIE_SECRET
|
- name: COOKIE_SECRET
|
||||||
value: 'abcdefghijklmnopqrstuvwxyz'
|
value: 'abcdefghijklmnopqrstuvwxyz'
|
||||||
|
|||||||
+27
-36
@@ -9,54 +9,52 @@
|
|||||||
"typecheck": "tsc"
|
"typecheck": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.1.0",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^7.0.0",
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@kubernetes/client-node": "^0.22.2",
|
"@kubernetes/client-node": "^0.22.3",
|
||||||
"@primer/octicons-react": "^19.12.0",
|
"@primer/octicons-react": "^19.13.0",
|
||||||
"@react-aria/toast": "3.0.0-beta.12",
|
"@react-aria/toast": "3.0.0-beta.18",
|
||||||
"@react-stately/toast": "3.0.0-beta.4",
|
"@react-stately/toast": "3.0.0-beta.7",
|
||||||
"@remix-run/node": "^2.13.1",
|
"@remix-run/node": "^2.15.0",
|
||||||
"@remix-run/react": "^2.13.1",
|
"@remix-run/react": "^2.15.0",
|
||||||
"@shopify/lang-jsonc": "^1.0.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/codemirror-theme-github": "^4.23.6",
|
||||||
"@uiw/react-codemirror": "^4.23.6",
|
"@uiw/react-codemirror": "^4.23.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.7",
|
||||||
"isbot": "^5.1.17",
|
"isbot": "^5.1.17",
|
||||||
"mime": "^4.0.4",
|
"mime": "^4.0.4",
|
||||||
"oauth4webapi": "^2.17.0",
|
"oauth4webapi": "^2.17.0",
|
||||||
"react": "19.0.0-beta-26f2496093-20240514",
|
"react": "19.0.0",
|
||||||
"react-aria-components": "^1.2.1",
|
"react-aria-components": "^1.5.0",
|
||||||
"react-codemirror-merge": "^4.23.6",
|
"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",
|
"react-error-boundary": "^4.1.2",
|
||||||
"remix-utils": "^7.7.0",
|
"remix-utils": "^7.7.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-react-aria-components": "^1.1.6",
|
"tailwindcss-react-aria-components": "^1.2.0",
|
||||||
"undici": "^6.20.1",
|
"undici": "^7.1.0",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"yaml": "^2.6.0",
|
"yaml": "^2.6.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.13.1",
|
"@remix-run/dev": "^2.15.0",
|
||||||
"@types/react": "npm:types-react@beta",
|
"@remix-run/fs-routes": "^2.15.0",
|
||||||
"@types/react-dom": "npm:types-react-dom@beta",
|
"@remix-run/route-config": "^2.15.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
|
"babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.16",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^5.4.10",
|
"vite": "^6.0.3",
|
||||||
"vite-plugin-babel": "^1.2.0",
|
"vite-plugin-babel": "^1.3.0",
|
||||||
"vite-tsconfig-paths": "^5.1.0"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"@types/react": "npm:types-react@beta",
|
|
||||||
"@types/react-dom": "npm:types-react-dom@beta"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20",
|
"node": ">=20",
|
||||||
@@ -64,14 +62,7 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"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"
|
"@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
+4
-3
@@ -8,6 +8,7 @@ import { access, constants } from 'node:fs/promises'
|
|||||||
import { createReadStream, existsSync, statSync } from 'node:fs'
|
import { createReadStream, existsSync, statSync } from 'node:fs'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { join, resolve } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
|
import { env } from 'node:process'
|
||||||
|
|
||||||
function log(level, message) {
|
function log(level, message) {
|
||||||
const date = new Date().toISOString()
|
const date = new Date().toISOString()
|
||||||
@@ -42,9 +43,9 @@ const {
|
|||||||
} = await import('@remix-run/node')
|
} = await import('@remix-run/node')
|
||||||
const { default: mime } = await import('mime')
|
const { default: mime } = await import('mime')
|
||||||
|
|
||||||
const port = process.env.PORT || 3000
|
const port = env.PORT || 3000
|
||||||
const host = process.env.HOST || '0.0.0.0'
|
const host = env.HOST || '0.0.0.0'
|
||||||
const buildPath = process.env.BUILD_PATH || './build'
|
const buildPath = env.BUILD_PATH || './build'
|
||||||
|
|
||||||
// Because this is a dynamic import without an easily discernable path
|
// Because this is a dynamic import without an easily discernable path
|
||||||
// we gain the "deoptimization" we want so that Vite doesn't bundle this
|
// we gain the "deoptimization" we want so that Vite doesn't bundle this
|
||||||
|
|||||||
+18
-3
@@ -1,16 +1,20 @@
|
|||||||
import { vitePlugin as remix } from '@remix-run/dev'
|
import { vitePlugin as remix } from '@remix-run/dev'
|
||||||
import { installGlobals } from '@remix-run/node'
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import babel from 'vite-plugin-babel'
|
import babel from 'vite-plugin-babel'
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
installGlobals()
|
|
||||||
|
|
||||||
const prefix = process.env.__INTERNAL_PREFIX || '/admin'
|
const prefix = process.env.__INTERNAL_PREFIX || '/admin'
|
||||||
if (prefix.endsWith('/')) {
|
if (prefix.endsWith('/')) {
|
||||||
throw new Error('Prefix must not end with a slash')
|
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 }) => {
|
export default defineConfig(({ isSsrBuild }) => {
|
||||||
// If we have the Headplane entry we build it as a single
|
// If we have the Headplane entry we build it as a single
|
||||||
// server.mjs file that is built for production server bundle
|
// server.mjs file that is built for production server bundle
|
||||||
@@ -45,9 +49,20 @@ export default defineConfig(({ isSsrBuild }) => {
|
|||||||
return ({
|
return ({
|
||||||
base: `${prefix}/`,
|
base: `${prefix}/`,
|
||||||
build: isSsrBuild ? { target: 'ES2022' } : {},
|
build: isSsrBuild ? { target: 'ES2022' } : {},
|
||||||
|
define: {
|
||||||
|
__VERSION__: JSON.stringify(version),
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
remix({
|
remix({
|
||||||
basename: `${prefix}/`,
|
basename: `${prefix}/`,
|
||||||
|
future: {
|
||||||
|
v3_fetcherPersist: true,
|
||||||
|
v3_relativeSplatPath: true,
|
||||||
|
v3_throwAbortReason: true,
|
||||||
|
v3_lazyRouteDiscovery: true,
|
||||||
|
v3_singleFetch: true,
|
||||||
|
v3_routeConfig: true
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
tsconfigPaths(),
|
tsconfigPaths(),
|
||||||
babel({
|
babel({
|
||||||
|
|||||||
Reference in New Issue
Block a user