Compare commits

...

37 Commits

Author SHA1 Message Date
Aarnav Tale 69cc6985b4 chore: v0.3.8 2024-12-06 19:07:36 -05:00
Aarnav Tale f9b38939ba chore: add border for footer 2024-12-06 19:06:27 -05:00
Aarnav Tale 4cfa1e5209 chore: remove unused patch 2024-12-06 19:05:15 -05:00
Aarnav Tale e713dae91b fix: validate and respect oidc validation settings 2024-12-06 19:05:07 -05:00
Aarnav Tale c9bcc1d7c6 chore: update deps 2024-12-06 18:37:36 -05:00
Aarnav Tale 401731fd09 fix: allow generating higher expiring preauthkeys 2024-12-06 18:37:17 -05:00
Aarnav Tale f623e7bc66 feat: enable all remix future flags 2024-12-06 11:58:17 -05:00
Aarnav Tale 1af292a5b0 fix: we need git in docker 2024-12-05 11:35:41 -05:00
Aarnav Tale 21778a43f1 feat: add footer with version and donate link 2024-12-05 02:33:17 -05:00
Aarnav Tale 33762e53b5 chore: document the /admin endpoint 2024-12-05 02:07:28 -05:00
Aarnav Tale 8867cca494 chore: use 0.3.7 in examples 2024-11-30 15:42:33 -05:00
Aarnav Tale 41b1d3c847 chore: v0.3.7 2024-11-30 15:34:54 -05:00
Aarnav Tale 712fc28683 feat: allow a public headscale URL separate from the main one 2024-11-30 15:33:58 -05:00
Aarnav Tale 320dab1d4f fix: join children in copyable codeblock 2024-11-30 15:05:07 -05:00
Aarnav Tale da0ee1382b feat: allow setting OIDC_CLIENT_SECRET_METHOD 2024-11-30 15:00:51 -05:00
Aarnav Tale a7d127c7bf feat: add health check route 2024-11-27 11:23:42 -05:00
Aarnav Tale b433e607e2 chore: v0.3.6 2024-11-20 18:15:19 -05:00
Aarnav Tale 8aad883c21 fix: make selects scrollable 2024-11-20 18:13:49 -05:00
Aarnav Tale 3cd28d2136 feat: make the code snippets copyable 2024-11-20 18:01:20 -05:00
Aarnav Tale 9d9cbd8e0e chore: v0.3.5 2024-11-08 12:05:33 -05:00
Aarnav Tale feb8b8bba5 chore: remove logging on expected errors 2024-11-08 12:04:24 -05:00
Aarnav Tale c304effcdf fix: externalize process.env 2024-11-08 12:03:08 -05:00
Aarnav Tale 982d811d53 chore: v0.3.4 2024-11-07 13:09:35 -05:00
Aarnav Tale d369e36fa4 fix: potentially catch undefined bearer errors 2024-11-07 13:08:43 -05:00
Aarnav Tale a2e659f36c feat(TALE-36): redesign machines page to account for exit nodes 2024-11-07 13:04:53 -05:00
Aarnav Tale 1490406784 feat(TALE-36): show exit node status on machine page 2024-11-06 17:01:36 -05:00
Aarnav Tale e1c87412d4 feat(TALE-36): support exit node enabling/disabling 2024-11-06 16:48:14 -05:00
Aarnav Tale 6e55f442fd fix: make the last acl tag removable on machines 2024-11-06 16:10:39 -05:00
Aarnav Tale 09e1b1f261 fix: address a bug that prevented remove split dns records 2024-11-06 15:57:43 -05:00
Aarnav Tale 3e83f8617b fix: use correct vite config in dev 2024-11-06 15:57:27 -05:00
Aarnav Tale 1d6472765a docs: better explain the native integration 2024-11-06 15:18:21 -05:00
Aarnav Tale 2bc85085f5 fix: handle non-prefixed routes on the production server 2024-11-06 14:29:04 -05:00
Aarnav Tale 5c949e2da5 feat: use all native node deps for the server 2024-11-04 22:05:46 -05:00
Aarnav Tale 12754bd0aa fix: resolve type errors across the codebase 2024-11-04 18:13:10 -05:00
Aarnav Tale 10bb4eba97 fix: skip loading env file if context is loaded 2024-11-04 18:00:50 -05:00
Aarnav Tale 13a734923d feat: ditch remix-serve for custom-baked server 2024-11-04 18:00:35 -05:00
Aarnav Tale d5ee8ae0f3 fix: clicking a machine name in users page redirects 2024-11-04 14:59:13 -05:00
44 changed files with 4006 additions and 4988 deletions
+29
View File
@@ -1,3 +1,32 @@
### 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)
- 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.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
- Improve documentation and support when running Headplane outside of Docker.
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
+2 -1
View File
@@ -2,6 +2,7 @@ FROM node:20-alpine AS build
WORKDIR /app
RUN npm install -g pnpm
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
@@ -19,4 +20,4 @@ RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
CMD [ "node", "./build/headplane/server.js" ]
+7 -2
View File
@@ -6,14 +6,19 @@ interface Props {
name: string
value: string
isCopyable?: boolean
link?: string
}
export default function Attribute({ name, value, isCopyable }: Props) {
export default function Attribute({ name, value, link, isCopyable }: Props) {
const canCopy = isCopyable ?? false
return (
<dl className="flex gap-1 text-sm w-full">
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
{name}
{link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : name}
</dt>
{canCopy
+40 -7
View File
@@ -1,12 +1,45 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import { useState, HTMLProps } from 'react'
import { CopyIcon, CheckIcon } from '@primer/octicons-react'
type Properties = HTMLProps<HTMLSpanElement>
import { cn } from '~/utils/cn'
import { toast } from '~/components/Toaster'
interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean
}
export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false)
export default function Code(properties: Properties) {
return (
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
{properties.children}
</code>
<>
<code className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className
)}>
{props.children}
</code>
{props.isCopyable && (
<button
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center'
)}
onClick={() => {
navigator.clipboard.writeText(props.children.join(''))
toast('Copied to clipboard')
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1000)
}}
>
{isCopied ?
<CheckIcon className="h-3 w-3" /> :
<CopyIcon className="h-3 w-3" />
}
</button>
)}
</>
)
}
+2 -2
View File
@@ -46,7 +46,7 @@ function Select(props: SelectProps) {
className={cn(
'mt-2 rounded-md w-[var(--trigger-width)]',
'bg-ui-100 dark:bg-ui-800 shadow-sm',
'overflow-hidden z-50',
'z-50 overflow-y-auto',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
@@ -54,7 +54,7 @@ function Select(props: SelectProps) {
'fill-mode-forwards origin-left-right',
)}
>
<ListBox>
<ListBox orientation="vertical">
{props.children}
</ListBox>
</Popover>
+10 -4
View File
@@ -1,7 +1,7 @@
import { XIcon } from '@primer/octicons-react'
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
import { type ReactNode, useRef } from 'react'
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'
import { ReactNode, useRef } from 'react'
import { Button } from 'react-aria-components'
import { createPortal } from 'react-dom'
import { ClientOnly } from 'remix-utils/client-only'
@@ -14,6 +14,8 @@ type ToastProperties = AriaToastProps<ReactNode> & {
function Toast({ state, ...properties }: ToastProperties) {
const reference = useRef(null)
// @ts-expect-error: RefObject doesn't map to FocusableElement?
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
return (
@@ -52,11 +54,15 @@ export function toast(text: string) {
export function Toaster() {
const reference = useRef(null)
const state = useToastQueue(toasts)
// @ts-expect-error: React 19 has weird types for Portal vs Node
const { regionProps } = useToastRegion({}, state, reference)
return (
<ClientOnly>
{() => createPortal(
{
// @ts-expect-error: Portal doesn't match Node in React 19 yet
() => createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn(
+2 -2
View File
@@ -10,6 +10,7 @@ import { loadContext } from './utils/config/headplane'
await loadContext()
export const streamTimeout = 5000
export default function handleRequest(
request: Request,
responseStatusCode: number,
@@ -27,7 +28,6 @@ export default function handleRequest(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={5000}
/>,
{
[isBot ? 'onAllReady' : 'onShellReady']() {
@@ -57,6 +57,6 @@ export default function handleRequest(
},
)
setTimeout(abort, 5000)
setTimeout(abort, streamTimeout + 1000)
})
}
+3
View File
@@ -0,0 +1,3 @@
import { flatRoutes } from '@remix-run/fs-routes'
export default flatRoutes()
@@ -1,9 +1,9 @@
import React, { useEffect } from 'react'
import Merge from 'react-codemirror-merge'
import CodeMirror from '@uiw/react-codemirror'
import * as shopify from '@shopify/lang-jsonc'
import { ClientOnly } from 'remix-utils/client-only'
import { ErrorBoundary } from 'react-error-boundary'
import { jsonc } from '@shopify/lang-jsonc'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import { useState } from 'react'
import { cn } from '~/utils/cn'
@@ -45,7 +45,7 @@ export function Editor(props: EditorProps) {
<CodeMirror
value={props.value}
height="100%"
extensions={[jsonc()]}
extensions={[shopify.jsonc()]}
style={{ height: "100%" }}
theme={light ? githubLight : githubDark}
onChange={(value) => props.onChange(value)}
@@ -104,12 +104,12 @@ export function Differ(props: DifferProps) {
<Merge.Original
readOnly
value={props.left}
extensions={[jsonc()]}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[jsonc()]}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
+23 -15
View File
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData, useRevalidator } from '@remix-run/react'
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
import { useEffect, useState, useMemo } from 'react'
@@ -18,9 +18,10 @@ 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'
import { Editor, Differ } from './cm.client'
import { Unavailable } from './unavailable'
import { ErrorView } from './error'
@@ -55,7 +56,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
let modeGuess = 'database' // Assume database mode
if (context.config.read) {
const config = await loadConfig()
modeGuess = config.policy.mode
modeGuess = config.policy?.mode ?? 'database'
}
// Attempt to load the policy, for both the frontend and for checking
@@ -96,7 +97,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return {
read: false,
write: false,
mode: modeGuess
mode: modeGuess,
policy: null
}
}
@@ -106,7 +108,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return {
read: true,
write: true,
mode: modeGuess
mode: modeGuess,
policy: null
}
}
}
@@ -114,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 }, {
status: 401,
})
return send({ success: false, error: null }, 401)
}
try {
@@ -129,16 +130,18 @@ export async function action({ request }: ActionFunctionArgs) {
}
)
return json({ success: true, policy })
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 })
return { success: true, error: null }
}
export default function Page() {
@@ -154,6 +157,7 @@ export default function Page() {
return
}
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data.success) {
toast('Updated tailnet ACL policy')
} else {
@@ -183,6 +187,7 @@ export default function Page() {
}
// If we have a failed fetcher state allow the user to try again
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data?.success === false) {
return false
}
@@ -231,8 +236,11 @@ export default function Page() {
.
</p>
{fetcher.data?.success === false
{
// @ts-ignore: useDebounceFetcher is not typed correctly
fetcher.data?.success === false
? (
// @ts-ignore: useDebounceFetcher is not typed correctly
<ErrorView message={fetcher.data.error} />
) : undefined}
@@ -291,7 +299,7 @@ export default function Page() {
</TabPanel>
<TabPanel id="diff">
<Differ
left={data.policy}
left={data?.policy ?? ''}
right={acl}
/>
</TabPanel>
@@ -335,13 +343,13 @@ export default function Page() {
<Button
isDisabled={disabled}
onPress={() => {
setAcl(data.policy)
setAcl(data?.policy ?? '')
}}
>
Discard Changes
</Button>
</>
) : <Unavailable mode={data.mode} />}
) : <Unavailable mode={data.mode as "database" | "file"} />}
</div>
)
}
+12 -7
View File
@@ -35,7 +35,7 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers[key]}
nameservers={nameservers}
name={key}
/>
))}
@@ -59,6 +59,10 @@ interface ListProps {
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
const submit = useSubmit()
const list = isGlobal ? nameservers['global'] : nameservers[name]
if (list.length === 0) {
return null
}
return (
<div className="mb-8">
@@ -68,7 +72,7 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
</h2>
</div>
<TableList>
{nameservers.map((ns, index) => (
{list.length > 0 ? list.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
@@ -83,17 +87,18 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
onPress={() => {
if (isGlobal) {
submit({
'dns.nameservers.global': nameservers
'dns.nameservers.global': list
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const key = `dns.nameservers.split."${name}"`
const list = nameservers.filter((_, i) => i !== index)
submit({
[key]: list.length ? list : null,
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
}
}, {
method: 'PATCH',
encType: 'application/json',
@@ -104,7 +109,7 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
Remove
</Button>
</TableList.Item>
))}
)) : undefined}
</TableList>
</div>
)
+3 -7
View File
@@ -43,16 +43,12 @@ export async function loader() {
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401,
})
return send({ success: false }, 401)
}
const context = await loadContext()
if (!context.config.write) {
return json({ success: false }, {
status: 403,
})
return send({ success: false }, 403)
}
const data = await request.json() as Record<string, unknown>
@@ -62,7 +58,7 @@ export async function action({ request }: ActionFunctionArgs) {
await context.integration.onConfigChange(context.integration.context)
}
return json({ success: true })
return { success: true }
}
export default function Page() {
+257 -66
View File
@@ -1,9 +1,14 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import { Link as RemixLink, useLoaderData } from '@remix-run/react'
import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon } from '@primer/octicons-react'
import { useMemo, useState } from 'react'
import Attribute from '~/components/Attribute'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Menu from '~/components/Menu'
import Tooltip from '~/components/Tooltip'
import StatusCircle from '~/components/StatusCircle'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
@@ -12,9 +17,11 @@ import { loadConfig } from '~/utils/config/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Link from '~/components/Link'
import { menuAction } from '../_data.machines._index/action'
import MenuOptions from '../_data.machines._index/menu'
import Routes from '../_data.machines._index/dialogs/routes'
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
@@ -52,6 +59,7 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
const routesState = useState(false)
useLiveData({ interval: 1000 })
const expired = machine.expiry === '0001-01-01 00:00:00'
@@ -69,21 +77,53 @@ export default function Page() {
tags.unshift('Expired')
}
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
if (route.enabled) {
acc.subnetApproved.push(route)
return acc
}
acc.subnet.push(route)
return acc
}, { exit: [], subnetApproved: [], subnet: [] })
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
if (exitEnabled) {
tags.unshift('Exit Node')
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets')
}
return (
<div>
<p className="mb-8 text-md">
<Link
<RemixLink
to="/machines"
className="font-medium"
>
All Machines
</Link>
</RemixLink>
<span className="mx-2">
/
</span>
{machine.givenName}
</p>
<div className="flex justify-between items-center">
<div className={cn(
'flex justify-between items-center',
'border-b border-ui-100 dark:border-ui-800',
)}>
<span className="flex items-baseline gap-x-4 text-sm mb-4">
<h1 className="text-2xl font-medium">
{machine.givenName}
@@ -92,26 +132,227 @@ export default function Page() {
</span>
<MenuOptions
className={cn(
'bg-ui-100 dark:bg-ui-800',
)}
machine={machine}
routes={routes}
users={users}
magic={magic}
buttonChild={
<Menu.Button className={cn(
'flex items-center justify-center gap-x-2',
'bg-main-200 dark:bg-main-700/30',
'hover:bg-main-300 dark:hover:bg-main-600/30',
'text-ui-700 dark:text-ui-300 mb-2',
'w-fit text-sm rounded-lg px-3 py-2'
)}>
<GearIcon className="w-5" />
Machine Settings
</Menu.Button>
}
/>
</div>
<div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
<div className="flex gap-1 mb-4">
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Managed by
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip>
</span>
))}
<div className="flex items-center gap-x-2.5 mt-1">
<div className={cn(
'rounded-full h-7 w-7 flex items-center justify-center',
'border border-ui-200 dark:border-ui-700',
)}>
<PersonIcon className="w-4 h-4" />
</div>
{machine.user.name}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-sm text-ui-600 dark:text-ui-300">
Status
</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
{tag}
</span>
))}
</div>
</div>
</div>
<h2 className="text-xl font-medium mb-4 mt-8">
Subnets & Routing
</h2>
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.
{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</p>
<Button
variant="light"
control={routesState}
>
Review
</Button>
</div>
<Card
variant="flat"
className={cn(
'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)}
>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Traffic to these routes are being
routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnetApproved.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
) : (
<ul className="leading-normal">
{subnetApproved.map(route => (
<li key={route.id}>
{route.prefix}
</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
This machine is advertising these routes,
but they must be approved before traffic
will be routed to them.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnet.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
) : (
<ul className="leading-normal">
{subnet.map(route => (
<li key={route.id}>
{route.prefix}
</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Whether this machine can act as an
exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{exit.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
) : exitEnabled ? (
<span className="flex items-center gap-x-1">
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
Allowed
</span>
) : (
<span className="flex items-center gap-x-1">
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
</Card>
<h2 className="text-xl font-medium mb-4">
Machine Details
</h2>
@@ -150,56 +391,6 @@ export default function Page() {
)
: undefined}
</Card>
<h2 className="text-xl font-medium mb-4 mt-8">
Machine Routes
</h2>
<Card variant="flat" className="w-full max-w-full">
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: routes.map((route, i) => (
<div
key={route.id}
className={cn(
'flex items-center justify-between',
routes.length - 1 === i ? 'border-b pb-3 mb-2' : '',
'border-ui-100 dark:border-ui-800',
)}
>
<div>
<p className="font-mono mb-1">
{route.prefix}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{' '}
(Created:
{' '}
{new Date(route.createdAt).toLocaleString()}
)
</p>
</div>
<div className="text-right">
<p className="mb-1">
{route.enabled ? 'Enabled' : 'Disabled'}
</p>
<p className="text-sm text-ui-600 dark:text-ui-300">
{route.isPrimary ? 'Primary' : 'Secondary'}
</p>
</div>
</div>
))}
</Card>
</div>
)
}
+45 -24
View File
@@ -1,20 +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,
})
}
@@ -25,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,
})
}
@@ -43,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,
})
}
@@ -58,12 +58,30 @@ 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 send({ message: 'No route or enabled provided' }, {
status: 400,
})
}
const routes = data.get('routes')?.toString().split(',') ?? []
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await Promise.all(routes.map(async (route) => {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
}))
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,
})
}
@@ -72,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,
})
}
@@ -82,16 +100,19 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
case 'tags': {
const tags = data.get('tags')?.toString()
.split(',') ?? []
.split(',')
.filter((tag) => tag.trim() !== '')
?? []
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
})
return json({ message: 'Tags updated' })
} catch {
return json({ message: 'Failed to update tags' }, {
return { message: 'Tags updated' }
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error)
return send({ message: 'Failed to update tags' }, {
status: 500,
})
}
@@ -102,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,
})
}
@@ -123,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'
}, {
@@ -138,7 +159,7 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
}
default: {
return json({ message: 'Invalid method' }, {
return send({ message: 'Invalid method' }, {
status: 400,
})
}
@@ -18,7 +18,7 @@ export interface NewProps {
}
export default function New(data: NewProps) {
const fetcher = useFetcher()
const fetcher = useFetcher<{ success?: boolean }>()
const mkeyState = useState(false)
const [mkey, setMkey] = useState('')
const [user, setUser] = useState('')
@@ -50,10 +50,8 @@ export default function New(data: NewProps) {
<Dialog.Text className='mb-4'>
The machine key is given when you run
{' '}
<Code>
<Code isCopyable>
tailscale up --login-server=
</Code>
<Code>
{data.server}
</Code>
{' '}
@@ -1,9 +1,10 @@
import { useFetcher } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import { Dispatch, SetStateAction, useMemo } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import { type Machine, type Route } from '~/types'
import Link from '~/components/Link'
import { Machine, Route } from '~/types'
import { cn } from '~/utils/cn'
interface RoutesProps {
@@ -16,6 +17,22 @@ interface RoutesProps {
export default function Routes({ machine, routes, state }: RoutesProps) {
const fetcher = useFetcher()
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
acc.subnet.push(route)
return acc
}, { exit: [], subnet: [] })
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
return (
<Dialog>
<Dialog.Panel control={state}>
@@ -26,9 +43,19 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text className="font-bold">
Subnet routes
</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
@@ -36,7 +63,7 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
'border border-zinc-200 dark:border-zinc-700',
)}
>
{routes.length === 0
{subnet.length === 0
? (
<div
className={cn(
@@ -51,9 +78,9 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
</div>
)
: undefined}
{routes.map(route => (
{subnet.map(route => (
<div
key={route.node.id}
key={route.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
@@ -80,6 +107,66 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
</div>
))}
</div>
<Dialog.Text className="font-bold mt-8">
Exit nodes
</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.
{' '}
<Link
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation"
>
Learn More
</Link>
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{exit.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
This machine is not an exit node.
</p>
</div>
) : (
<div
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>
Use as exit node
</p>
<Switch
defaultSelected={exitEnabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'exit-node')
form.set('routes', exit.map(route => route.id).join(','))
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST',
})
}}
/>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
@@ -1,11 +1,11 @@
import { PlusIcon, XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import { Dispatch, SetStateAction, useState } from 'react'
import { Button, Input } from 'react-aria-components'
import Dialog from '~/components/Dialog'
import Link from '~/components/Link'
import { type Machine } from '~/types'
import { Machine } from '~/types'
import { cn } from '~/utils/cn'
interface TagsProps {
@@ -36,6 +36,35 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
? magic.replace('[user]', machine.user.name)
: magic
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
if (route.enabled) {
acc.subnetApproved.push(route)
return acc
}
acc.subnet.push(route)
return acc
}, { exit: [], subnetApproved: [], subnet: [] })
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
if (exitEnabled) {
tags.unshift('Exit Node')
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets')
}
return (
<tr
key={machine.id}
+14 -11
View File
@@ -1,5 +1,5 @@
import { KebabHorizontalIcon } from '@primer/octicons-react'
import { useState } from 'react'
import { ReactNode, useState } from 'react'
import MenuComponent from '~/components/Menu'
import { Machine, Route, User } from '~/types'
@@ -17,9 +17,10 @@ interface MenuProps {
routes: Route[]
users: User[]
magic?: string
buttonChild?: ReactNode
}
export default function Menu({ machine, routes, magic, users }: MenuProps) {
export default function Menu({ machine, routes, magic, users, buttonChild }: MenuProps) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
@@ -69,15 +70,17 @@ export default function Menu({ machine, routes, magic, users }: MenuProps) {
/>
<MenuComponent>
<MenuComponent.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
)}
>
<KebabHorizontalIcon className="w-5" />
</MenuComponent.Button>
{buttonChild ?? (
<MenuComponent.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
)}
>
<KebabHorizontalIcon className="w-5" />
</MenuComponent.Button>
)}
<MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}>
Edit machine name
+5 -1
View File
@@ -46,6 +46,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
users: users.users,
magic,
server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
}
}
@@ -73,7 +74,10 @@ export default function Page() {
</Link>
</p>
</div>
<NewMachine server={data.server} users={data.users} />
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
/>
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400">
+1 -4
View File
@@ -31,10 +31,7 @@ export default function Page() {
</Link>
</p>
</div>
<RemixLink
to="/settings/auth-keys"
name="Auth Keys"
>
<RemixLink to="/settings/auth-keys">
<span className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
@@ -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',
@@ -94,7 +94,7 @@ export default function AddPreAuthKey(data: Props) {
onChange={() => { setReusable(!reusable) }}
/>
</div>
<input type="hidden" name="reusable" value={reusable} />
<input type="hidden" name="reusable" value={reusable.toString()} />
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
@@ -121,7 +121,7 @@ export default function AddPreAuthKey(data: Props) {
}}
/>
</div>
<input type="hidden" name="ephemeral" value={ephemeral} />
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
@@ -39,7 +39,7 @@ export default function AuthKeyRow({ authKey, server }: Props) {
<Button
variant="light"
className="my-4"
onClick={async () => {
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server ${server} --authkey ${authKey.key}`
)
@@ -1,4 +1,4 @@
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
import { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useLiveData } from '~/utils/useLiveData'
import { getSession } from '~/utils/sessions'
@@ -7,6 +7,7 @@ import { PreAuthKey, User } from '~/types'
import { pull, post } from '~/utils/headscale'
import { loadContext } from '~/utils/config/headplane'
import { useState } from 'react'
import { send } from '~/utils/res'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
@@ -19,7 +20,7 @@ import AuthKeyRow from './key'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
return send({ message: 'Unauthorized' }, {
status: 401,
})
}
@@ -32,7 +33,7 @@ export async function action({ request }: ActionFunctionArgs) {
const user = data.get('user')
if (!key || !user) {
return json({ message: 'Missing parameters' }, {
return send({ message: 'Missing parameters' }, {
status: 400,
})
}
@@ -46,7 +47,7 @@ export async function action({ request }: ActionFunctionArgs) {
}
)
return json({ message: 'Pre-auth key expired' })
return { message: 'Pre-auth key expired' }
}
// Creating a new pre-auth key
@@ -57,7 +58,7 @@ export async function action({ request }: ActionFunctionArgs) {
const ephemeral = data.get('ephemeral')
if (!user || !expiry || !reusable || !ephemeral) {
return json({ message: 'Missing parameters' }, {
return send({ message: 'Missing parameters' }, {
status: 400,
})
}
@@ -80,7 +81,7 @@ export async function action({ request }: ActionFunctionArgs) {
}
)
return json({ message: 'Pre-auth key created', key })
return { message: 'Pre-auth key created', key }
}
}
@@ -102,7 +103,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
users: users.users,
server: context.headscaleUrl,
server: context.headscalePublicUrl ?? context.headscaleUrl,
}
}
+47 -1
View File
@@ -1,9 +1,10 @@
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
import { ProgressBar } from 'react-aria-components'
import { ErrorPopup } from '~/components/Error'
import Header from '~/components/Header'
import Link from '~/components/Link'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
@@ -36,10 +37,53 @@ export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
return {
config: context.config,
url: context.headscalePublicUrl ?? context.headscaleUrl,
debug: context.debug,
user: session.get('user'),
}
}
interface FooterProps {
url: string
debug: boolean
}
function Footer({ url, debug, integration }: FooterProps) {
return (
<footer className={cn(
'fixed bottom-0 left-0 z-50 w-full h-14',
'bg-ui-100 dark:bg-ui-900 text-ui-500',
'flex flex-col justify-center gap-1',
'border-t border-ui-200 dark:border-ui-800',
)}>
<p className="container text-xs">
Headplane is entirely free to use.
{' '}
If you find it useful, consider
{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>
{' '}
to support development.
{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' | '}
Connecting to
{' '}
<strong>{url}</strong>
{' '}
{debug && '(Debug mode enabled)'}
</p>
</footer>
)
}
export default function Layout() {
const data = useLoaderData<typeof loader>()
const nav = useNavigation()
@@ -61,6 +105,7 @@ export default function Layout() {
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
<Footer {...data} />
</>
)
}
@@ -70,6 +115,7 @@ export function ErrorBoundary() {
<>
<Header />
<ErrorPopup type="embedded" />
<Footer url="Unknown" debug={false} />
</>
)
}
+17 -31
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { PersonIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type 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'
@@ -10,13 +10,14 @@ import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type User } from '~/types'
import { Machine, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
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)
}
}
}
@@ -299,6 +284,7 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
<Attribute
name={machine.givenName}
link={`machines/${machine.id}`}
value={machine.ipAddresses[0]}
/>
</div>
+29
View File
@@ -0,0 +1,29 @@
import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull } from '~/utils/headscale'
import log from '~/utils/log'
export async function loader() {
const context = await loadContext()
try {
// Doesn't matter, we just need a 401
await pull('v1/', 'wrongkey')
} catch (e) {
if (!(e instanceof HeadscaleError)) {
log.debug('Healthz', 'Headscale is not reachable')
return new Response('Headscale is not reachable', {
status: 500,
headers: {
'Content-Type': 'text/plain',
},
})
}
}
return new Response('OK', {
status: 200,
headers: {
'Content-Type': 'text/plain',
},
})
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { ActionFunctionArgs, LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
@@ -6,7 +6,7 @@ import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import TextField from '~/components/TextField'
import { type Key } from '~/types'
import { Key } from '~/types'
import { loadContext } from '~/utils/config/headplane'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
@@ -81,9 +81,9 @@ export async function action({ request }: ActionFunctionArgs) {
},
})
} catch {
return json({
return {
error: 'Invalid API key',
})
}
}
}
+39 -4
View File
@@ -10,11 +10,13 @@ import { parse } from 'yaml'
import { IntegrationFactory, loadIntegration } from '~/integration'
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
import { testOidc } from '~/utils/oidc'
import log from '~/utils/log'
export interface HeadplaneContext {
debug: boolean
headscaleUrl: string
headscalePublicUrl?: string
cookieSecret: string
integration: IntegrationFactory | undefined
@@ -28,6 +30,7 @@ export interface HeadplaneContext {
client: string
secret: string
rootKey: string
method: string
disableKeyLogin: boolean
}
}
@@ -35,16 +38,16 @@ export interface HeadplaneContext {
let context: HeadplaneContext | undefined
export async function loadContext(): Promise<HeadplaneContext> {
if (context) {
return context
}
const envFile = process.env.LOAD_ENV_FILE === 'true'
if (envFile) {
log.info('CTXT', 'Loading environment variables from .env')
await import('dotenv/config')
}
if (context) {
return context
}
const debug = process.env.DEBUG === 'true'
if (debug) {
log.info('CTXT', 'Debug mode is enabled! Logs will spam a lot.')
@@ -55,12 +58,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
const { config, contextData } = await checkConfig(path)
let headscaleUrl = process.env.HEADSCALE_URL
let headscalePublicUrl = process.env.HEADSCALE_PUBLIC_URL
if (!headscaleUrl && !config) {
throw new Error('HEADSCALE_URL not set')
}
if (config) {
headscaleUrl = headscaleUrl ?? config.server_url
if (!headscalePublicUrl) {
// Fallback to the config value if the env var is not set
headscalePublicUrl = config.public_url
}
}
if (!headscaleUrl) {
@@ -75,6 +84,7 @@ export async function loadContext(): Promise<HeadplaneContext> {
context = {
debug,
headscaleUrl,
headscalePublicUrl,
cookieSecret,
integration: await loadIntegration(),
config: contextData,
@@ -83,6 +93,10 @@ export async function loadContext(): Promise<HeadplaneContext> {
log.info('CTXT', 'Starting Headplane with Context')
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl)
if (headscalePublicUrl) {
log.info('CTXT', 'HEADSCALE_PUBLIC_URL: %s', headscalePublicUrl)
}
log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None')
log.info('CTXT', 'Config: %s', contextData.read
? `Found ${contextData.write ? '' : '(Read Only)'}`
@@ -143,6 +157,8 @@ async function checkOidc(config?: HeadscaleConfig) {
let issuer = process.env.OIDC_ISSUER
let client = process.env.OIDC_CLIENT_ID
let secret = process.env.OIDC_CLIENT_SECRET
let method = process.env.OIDC_CLIENT_SECRET_METHOD ?? 'client_secret_basic'
let skip = process.env.OIDC_SKIP_CONFIG_VALIDATION === 'true'
log.debug('CTXT', 'Checking OIDC environment variables')
log.debug('CTXT', 'Issuer: %s', issuer)
@@ -157,10 +173,19 @@ async function checkOidc(config?: HeadscaleConfig) {
}
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 {
issuer,
client,
secret,
method,
rootKey,
disableKeyLogin,
}
@@ -199,11 +224,21 @@ async function checkOidc(config?: HeadscaleConfig) {
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 {
issuer,
client,
secret,
rootKey,
method,
disableKeyLogin,
}
}
+16
View File
@@ -19,6 +19,10 @@ export class FatalError extends Error {
}
export async function pull<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?')
}
const context = await loadContext()
const prefix = context.headscaleUrl
@@ -38,6 +42,10 @@ export async function pull<T>(url: string, key: string) {
}
export async function post<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?')
}
const context = await loadContext()
const prefix = context.headscaleUrl
@@ -59,6 +67,10 @@ export async function post<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) {
throw new Error('Missing API key, could this be a cookie setting issue?')
}
const context = await loadContext()
const prefix = context.headscaleUrl
@@ -80,6 +92,10 @@ export async function put<T>(url: string, key: string, body?: unknown) {
}
export async function del<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?')
}
const context = await loadContext()
const prefix = context.headscaleUrl
+30 -2
View File
@@ -17,6 +17,7 @@ import {
import { post } from '~/utils/headscale'
import { commitSession, getSession } from '~/utils/sessions'
import log from '~/utils/log'
import { HeadplaneContext } from './config/headplane'
@@ -36,7 +37,7 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
const issuerUrl = new URL(oidc.issuer)
const oidcClient = {
client_id: oidc.client,
token_endpoint_auth_method: 'client_secret_basic',
token_endpoint_auth_method: oidc.method,
} satisfies Client
const response = await discoveryRequest(issuerUrl)
@@ -91,7 +92,7 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
const oidcClient = {
client_id: oidc.client,
client_secret: oidc.secret,
token_endpoint_auth_method: 'client_secret_basic',
token_endpoint_auth_method: oidc.method,
} satisfies Client
const response = await discoveryRequest(issuerUrl)
@@ -169,3 +170,30 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
},
})
}
// Runs at application startup to validate the OIDC configuration
export async function testOidc(issuer: string, client: string, secret: string) {
const oidcClient = {
client_id: client,
client_secret: secret,
token_endpoint_auth_method: 'client_secret_post',
} satisfies Client
const issuerUrl = new URL(issuer)
try {
log.debug('OIDC', 'Checking OIDC well-known endpoint')
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
if (!processed.authorization_endpoint) {
log.debug('OIDC', 'No authorization endpoint found on the OIDC provider')
return false
}
log.debug('OIDC', 'Found auth endpoint: %s', processed.authorization_endpoint)
return true
} catch (e) {
log.debug('OIDC', 'Validation failed: %s', e.message)
return false
}
}
+5
View File
@@ -0,0 +1,5 @@
import { data } from '@remix-run/node'
export function send<T>(payload: T, init?: number | ResponseInit) {
return data(payload, init)
}
+7
View File
@@ -69,3 +69,10 @@ Currently there are 3 integration providers that can do this for you:
- [Kubernetes Integration](/docs/integration/Kubernetes.md)
- [Native Linux Integration](/docs/integration/Native.md)
Once configured, the Headplane UI will be available at the `/admin` path
of the server you deployed it on. This is currently not configurable unless
you build the Docker image yourself or run the Node.js server directly.
Additionally, if you require access to health information for either Docker
or Kubernetes, the `/admin/healthz` path will be available. This is useful for
monitoring services like Prometheus or Grafana.
+5 -1
View File
@@ -29,7 +29,7 @@ Here is a simple Docker Compose deployment:
services:
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:0.3.2
image: ghcr.io/tale/headplane:0.3.8
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
+4
View File
@@ -9,12 +9,14 @@ You can configure Headplane using environment variables.
#### Optional Variables
- **`HEADSCALE_PUBLIC_URL`**: The public URL of your Headscale server (if different from `HEADSCALE_URL`).
- **`DEBUG`**: Enable debug logging (default: `false`).
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
- **`PORT`**: The port to bind the server to (default: `3000`).
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
- **`COOKIE_SECURE`**: This option enables the `Secure` flag for cookies, ensuring they are sent only over HTTPS, which helps prevent interception and enhances data security. It should be disabled when using HTTP instead of HTTPS (default: `true`).
- **`LOAD_ENV_FILE`**: Tell Headplane to read the `.env` file and load it into the environment (default: `false`).
#### Docker Integration
The Docker integration allows Headplane to manage the Headscale docker container.
@@ -33,6 +35,8 @@ If you use the Headscale configuration integration, these are not required.
- **`OIDC_ISSUER`**: The issuer URL of your OIDC provider.
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
- **`OIDC_CLIENT_SECRET_METHOD`**: The method used to send the client secret (default: `client_secret_basic`).
- **`OIDC_SKIP_CONFIG_VALIDATION`**: Skip the OIDC configuration validation (default: `false`).
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
+1 -1
View File
@@ -50,7 +50,7 @@ services:
TZ: 'America/New_York'
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:0.3.2
image: ghcr.io/tale/headplane:0.3.8
restart: unless-stopped
volumes:
- './data:/var/lib/headscale'
+1 -1
View File
@@ -88,7 +88,7 @@ spec:
serviceAccountName: default
containers:
- name: headplane
image: ghcr.io/tale/headplane:0.3.2
image: ghcr.io/tale/headplane:0.3.8
env:
- name: COOKIE_SECRET
value: 'abcdefghijklmnopqrstuvwxyz'
+52 -11
View File
@@ -10,19 +10,60 @@ to undefined behavior or the service not restarting.
### Deployment
Follow the instructions to install Headscale from the
[Linux Installation Guide](https://headscale.net/running-headscale-linux/). As
of now, Headplane requires Node.js 20 to be installed on the system. Once you
are ready, clone the repository (`git clone https://github.com/tale/headplane`),
install dependencies (`npm install`), build the project (`npm run build`), and
start the server (`npm start`).
1. Follow the instructions to install Headscale from the
[Linux Installation Guide](https://headscale.net/setup/install/official).
2. Install [Node.js](https://nodejs.org/en/download/package-manager)
version 20 or higher (your package manager most likely already has this).
3. Install [PNPM](https://pnpm.io/installation). This is required
as Headplane has issues running correctly when installed and built via NPM or Yarn.
4. Clone the Headplane repository, install dependencies, and build the project:
```sh
git clone https://github.com/tale/headplane # (or clone via SSH)
cd headplane
pnpm install
pnpm build
```
### Running Headplane
Start Headplane with `node build/headplane/server.js`.
Headplane does need various environment variables to run correctly. The required
variables can be found in the [Configuration](/docs/Configuration.md) guide.
If you choose to do this with a `.env` file, you can use the `LOAD_ENV_FILE`
variable to tell Headplane to load the file.
Finally, make sure to set `HEADSCALE_INTEGRATION=proc` to take advantage
of controlling Headscale natively on Linux.
```sh
LOAD_ENV_FILE=true node ./build/headplane/server.js
```
> If you'd like, you can turn this into a `systemd` unit to manage the service.
> I plan to provide packages and unit files to make this easier in the future.
When running Headplane, you'll need to set environment variables to configure
the application. The `HEADSCALE_INTEGRATION` variable should be set to `proc`.
### Cannot find ./build directory?
Headplane expects the `build` directory to be present when running the server.
The structure of this folder is very important and should not be tampered with.
If you would like to keep the build directory in a different location, you can
set the `BUILD_PATH` environment variable to the path of the build directory
at runtime.
> 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 default values are.
```sh
LOAD_ENV_FILE=true BUILD_PATH=/path/to/build node ./build/headplane/server.js
```
### Changing Headplane's Path from `/admin`
Additionally, because you are building Headplane from source, you're able to
change the default path that Headplane is served from. This can be done by
specifying the `__INTERNAL_PREFIX` environment variable, when building.
```sh
__INTERNAL_PREFIX=/my-admin-path pnpm build
```
> Keep in mind that this is very much an experimental feature. Things can easily
> break and until it's more stable, it's not recommended to use in production.
-5
View File
@@ -1,5 +0,0 @@
import tale from 'eslint-config-tale'
export default [
...tale
]
+36 -43
View File
@@ -4,63 +4,57 @@
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix vite:build",
"build": "remix vite:build && vite build",
"dev": "remix vite:dev",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"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.21.0",
"@primer/octicons-react": "^19.10.0",
"@react-aria/toast": "3.0.0-beta.12",
"@react-stately/toast": "3.0.0-beta.4",
"@remix-run/node": "^2.10.2",
"@remix-run/react": "^2.10.2",
"@remix-run/serve": "^2.10.2",
"@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",
"@uiw/codemirror-theme-github": "^4.23.5",
"@uiw/react-codemirror": "^4.23.5",
"@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",
"isbot": "^5.1.11",
"oauth4webapi": "^2.11.1",
"react": "19.0.0-beta-26f2496093-20240514",
"react-aria-components": "^1.2.1",
"react-codemirror-merge": "^4.23.5",
"react-dom": "19.0.0-beta-26f2496093-20240514",
"dotenv": "^16.4.7",
"isbot": "^5.1.17",
"mime": "^4.0.4",
"oauth4webapi": "^2.17.0",
"react": "19.0.0",
"react-aria-components": "^1.5.0",
"react-codemirror-merge": "^4.23.6",
"react-dom": "19.0.0",
"react-error-boundary": "^4.1.2",
"remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.3",
"undici": "^6.19.2",
"remix-utils": "^7.7.0",
"tailwind-merge": "^2.5.5",
"tailwindcss-react-aria-components": "^1.2.0",
"undici": "^7.1.0",
"usehooks-ts": "^3.1.0",
"yaml": "^2.4.5",
"yaml": "^2.6.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@remix-run/dev": "^2.10.2",
"@types/react": "npm:types-react@beta",
"@types/react-dom": "npm:types-react-dom@beta",
"autoprefixer": "^10.4.19",
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
"eslint": "^8.57.1",
"eslint-config-tale": "^2.0.4",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"@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-df7b47d-20241124",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.5.3",
"vite": "^5.3.3",
"vite-plugin-babel": "^1.2.0",
"vite-tsconfig-paths": "^4.3.2"
},
"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",
@@ -68,7 +62,6 @@
},
"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"
}
}
@@ -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.
+2885 -4662
View File
File diff suppressed because it is too large Load Diff
Executable
+159
View File
@@ -0,0 +1,159 @@
// This is a polyglot entrypoint for Headplane when running in production
// It doesn't use any dependencies aside from @remix-run/node and mime
// During build we bundle the used dependencies into the file so that
// we can only need this file and a Node.js installation to run the server.
// PREFIX is defined globally, see vite.config.ts
import { access, constants } from 'node:fs/promises'
import { createReadStream, existsSync, statSync } from 'node:fs'
import { createServer } from 'node:http'
import { join, resolve } from 'node:path'
import { env } from 'node:process'
function log(level, message) {
const date = new Date().toISOString()
console.log(`${date} (${level}) [SRVX] ${message}`)
}
log('INFO', `Running with Node.js ${process.versions.node}`)
try {
await access('./node_modules/@remix-run', constants.F_OK | constants.R_OK)
log('INFO', 'Found node_modules dependencies')
} catch (error) {
log('ERROR', 'No node_modules found. Please run `pnpm install` first')
log('ERROR', error)
process.exit(1)
}
try {
await access('./build/server', constants.F_OK | constants.R_OK)
log('INFO', 'Found build directory')
} catch (error) {
const date = new Date().toISOString()
log('ERROR', 'No build directory found. Please run `pnpm build` first')
log('ERROR', error)
process.exit(1)
}
const {
createRequestHandler: remixRequestHandler,
createReadableStreamFromReadable,
writeReadableStreamToWritable
} = await import('@remix-run/node')
const { default: mime } = await import('mime')
const port = env.PORT || 3000
const host = env.HOST || '0.0.0.0'
const buildPath = env.BUILD_PATH || './build'
// Because this is a dynamic import without an easily discernable path
// we gain the "deoptimization" we want so that Vite doesn't bundle this
const build = await import(resolve(join(buildPath, 'server', 'index.js')))
const baseDir = resolve(join(buildPath, 'client'))
const handler = remixRequestHandler(build, 'production')
const http = createServer(async (req, res) => {
const url = new URL(`http://${req.headers.host}${req.url}`)
if (!url.pathname.startsWith(PREFIX)) {
res.writeHead(404)
res.end()
return
}
// We need to handle an issue where say we are navigating to $PREFIX
// but Remix does not handle it without the trailing slash. This is
// because Remix uses the URL constructor to parse the URL and it
// will remove the trailing slash. We need to redirect to the correct
// URL so that Remix can handle it correctly.
if (url.pathname === PREFIX) {
res.writeHead(302, {
Location: `${PREFIX}/`
})
res.end()
return
}
// Before we pass any requests to our Remix handler we need to check
// if we can handle a raw file request. This is important for the
// Remix loader to work correctly.
//
// To optimize this, we send them as readable streams in the node
// response and we also set headers for aggressive caching.
if (url.pathname.startsWith(`${PREFIX}/assets/`)) {
const filePath = join(baseDir, url.pathname.replace(PREFIX, ''))
const exists = existsSync(filePath)
const stats = statSync(filePath)
if (exists && stats.isFile()) {
// Build assets are cache-bust friendly so we can cache them heavily
if (req.url.startsWith('/build')) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
}
// Send the file as a readable stream
const fileStream = createReadStream(filePath)
const type = mime.getType(filePath)
res.setHeader('Content-Length', stats.size)
res.setHeader('Content-Type', type)
fileStream.pipe(res)
return
}
}
// Handling the request
const controller = new AbortController()
res.on('close', () => controller.abort())
const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
if (!value) continue
if (Array.isArray(value)) {
for (const v of value) {
headers.append(key, v)
}
continue
}
headers.append(key, value)
}
const remixReq = new Request(url.href, {
headers,
method: req.method,
signal: controller.signal,
// If we have a body we set a duplex and we load the body
...(req.method !== 'GET' && req.method !== 'HEAD' ? {
body: createReadableStreamFromReadable(req),
duplex: 'half'
} : {}
)
})
// Pass our request to the Remix handler and get a response
const response = await handler(remixReq, {}) // No context
// Handle our response and reply
res.statusCode = response.status
res.statusMessage = response.statusText
for (const [key, value] of response.headers.entries()) {
res.appendHeader(key, value)
}
if (response.body) {
await writeReadableStreamToWritable(response.body, res)
return
}
res.end()
})
http.listen(port, host, () => {
log('INFO', `Running on ${host}:${port}`)
})
+72 -20
View File
@@ -1,27 +1,79 @@
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'
import { execSync } from 'node:child_process'
installGlobals()
const prefix = process.env.__INTERNAL_PREFIX || '/admin'
if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash')
}
export default defineConfig(({ isSsrBuild }) => ({
base: '/admin/',
build: isSsrBuild ? { target: 'ES2022' } : {},
plugins: [
remix({
basename: '/admin/',
}),
tsconfigPaths(),
babel({
filter: /\.[jt]sx?$/,
babelConfig: {
presets: ['@babel/preset-typescript'],
plugins: [
['babel-plugin-react-compiler', {}],
],
// 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
// We know the remix invoked command is vite:build
if (!process.argv.includes('vite:build') && !process.argv.includes('vite:dev')) {
return {
build: {
minify: false,
target: 'esnext',
rollupOptions: {
input: './server.mjs',
output: {
entryFileNames: 'server.js',
dir: 'build/headplane',
banner: '#!/usr/bin/env node\n',
},
external: (id) => id.startsWith('node:'),
}
},
}),
],
}))
define: {
PREFIX: JSON.stringify(prefix),
},
resolve: {
alias: {
stream: 'node:stream',
crypto: 'node:crypto',
}
}
}
}
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({
filter: /\.[jt]sx?$/,
babelConfig: {
presets: ['@babel/preset-typescript'],
plugins: [
['babel-plugin-react-compiler', {}],
],
},
}),
],
})
})