mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 08:08:56 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 331a9c8dcf | |||
| 808b01c5d7 | |||
| a8abd37e3a | |||
| d1fa76971b | |||
| a9e8394dec | |||
| 9dafd8e8b0 | |||
| 0c1f6969da | |||
| 4214e14ba8 | |||
| 447a31f91e | |||
| 9e21823163 | |||
| aa7e2a3128 | |||
| ecef45c98a | |||
| d165264876 | |||
| dd479d4117 | |||
| 29d91785fb | |||
| 98d1cb1333 | |||
| 2229f547a9 | |||
| 65cc278a59 | |||
| 1555846df2 | |||
| e8c1cadf54 | |||
| d867769025 | |||
| 1d6066d3f0 | |||
| 1d821251a9 | |||
| a0d6905123 | |||
| 4095ed2a68 | |||
| 58e98278d1 | |||
| 90f0bf2555 | |||
| ea2ffdf0c1 | |||
| 9aedd9baad | |||
| 690b52d8c6 | |||
| a72a3d6e5f | |||
| c4c1fd8aab | |||
| 9801ef453d |
@@ -1,3 +1,32 @@
|
||||
### 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
|
||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
|
||||
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
|
||||
|
||||
### 0.3.2 (October 11, 2024)
|
||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
||||
- Handle `null` values on machine expiry when using a database like PostgreSQL.
|
||||
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
|
||||
|
||||
### 0.3.1 (October 3, 2024)
|
||||
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
||||
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||
|
||||
### 0.3.0 (September 25, 2024)
|
||||
- Bumped the minimum supported version of Headscale to 0.23.
|
||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||
|
||||
### 0.2.4 (August 24, 2024)
|
||||
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
||||
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||
- Fixed a bug where removing Split DNS configurations would crash the UI.
|
||||
|
||||
### 0.2.3 (August 23, 2024)
|
||||
- Change the minimum required version of Headscale to 0.23-beta2
|
||||
- Support the new API policy mode for Headscale 0.23-beta1
|
||||
|
||||
@@ -10,7 +10,6 @@ import TabLink from './TabLink'
|
||||
|
||||
interface Properties {
|
||||
readonly data?: {
|
||||
acl: HeadplaneContext['acl']
|
||||
config: HeadplaneContext['config']
|
||||
user?: SessionData['user']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PlusIcon, DashIcon } from '@primer/octicons-react'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
NumberField as AriaNumberField
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
|
||||
label: string;
|
||||
state?: [number, Dispatch<SetStateAction<number>>];
|
||||
}
|
||||
|
||||
export default function NumberField(props: NumberFieldProps) {
|
||||
return (
|
||||
<AriaNumberField
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
className="w-full"
|
||||
value={props.state?.[0]}
|
||||
onChange={value => {
|
||||
props.state?.[1](value)
|
||||
}}
|
||||
>
|
||||
<Group className={cn(
|
||||
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300 gap-2',
|
||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||
props.className
|
||||
)}>
|
||||
<Input
|
||||
className="w-full bg-transparent focus:outline-none"
|
||||
/>
|
||||
<Button slot="decrement">
|
||||
<DashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button slot="increment">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Group>
|
||||
</AriaNumberField>
|
||||
)
|
||||
}
|
||||
+18
-36
@@ -24,6 +24,7 @@ export default createIntegration<Context>({
|
||||
isAvailable: async (context) => {
|
||||
// Check for the HEADSCALE_CONTAINER environment variable first
|
||||
// to avoid unnecessary fetching of the Docker socket
|
||||
log.debug('INTG', 'Checking Docker integration availability')
|
||||
context.container = process.env.HEADSCALE_CONTAINER
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
@@ -54,16 +55,19 @@ export default createIntegration<Context>({
|
||||
// The API is available as an HTTP endpoint and this
|
||||
// will simplify the fetching logic in undici
|
||||
if (url.protocol === 'tcp:') {
|
||||
url.protocol = 'http:'
|
||||
// Apparently setting url.protocol doesn't work anymore?
|
||||
const fetchU = url.href.replace(url.protocol, 'http:')
|
||||
|
||||
try {
|
||||
log.info('INTG', 'Checking API: %s', url.href)
|
||||
await fetch(new URL('/v1.30/version', url).href)
|
||||
} catch {
|
||||
log.info('INTG', 'Checking API: %s', fetchU)
|
||||
await fetch(new URL('/v1.30/version', fetchU).href)
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to connect to Docker API', error)
|
||||
log.error('INTG', 'Failed to connect to Docker API')
|
||||
return false
|
||||
}
|
||||
|
||||
context.client = new Client(url.href)
|
||||
context.client = new Client(fetchU)
|
||||
}
|
||||
|
||||
// Check if the socket is accessible
|
||||
@@ -73,7 +77,8 @@ export default createIntegration<Context>({
|
||||
url.pathname,
|
||||
)
|
||||
await access(url.pathname, constants.R_OK)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to access Docker socket: %s', error)
|
||||
log.error('INTG', 'Failed to access Docker socket: %s',
|
||||
path,
|
||||
)
|
||||
@@ -88,36 +93,6 @@ export default createIntegration<Context>({
|
||||
return context.client !== undefined
|
||||
},
|
||||
|
||||
onAclChange: async (context) => {
|
||||
if (!context.client || !context.container) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Sending SIGHUP to Headscale via Docker')
|
||||
|
||||
let attempts = 0
|
||||
while (attempts <= context.maxAttempts) {
|
||||
const response = await context.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${context.container}/kill?signal=SIGHUP`,
|
||||
})
|
||||
|
||||
if (response.statusCode !== 204) {
|
||||
if (attempts < context.maxAttempts) {
|
||||
attempts++
|
||||
await setTimeout(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
const stringCode = response.statusCode.toString()
|
||||
const body = await response.body.text()
|
||||
throw new Error(`API request failed: ${stringCode} ${body}`)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
onConfigChange: async (context) => {
|
||||
if (!context.client || !context.container) {
|
||||
return
|
||||
@@ -127,6 +102,12 @@ export default createIntegration<Context>({
|
||||
|
||||
let attempts = 0
|
||||
while (attempts <= context.maxAttempts) {
|
||||
log.debug(
|
||||
'INTG', 'Restarting container: %s (attempt %d)',
|
||||
context.container,
|
||||
attempts,
|
||||
)
|
||||
|
||||
const response = await context.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${context.container}/restart`,
|
||||
@@ -150,6 +131,7 @@ export default createIntegration<Context>({
|
||||
attempts = 0
|
||||
while (attempts <= context.maxAttempts) {
|
||||
try {
|
||||
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
|
||||
await pull('v1', '')
|
||||
return
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,7 +3,6 @@ export interface IntegrationFactory<T = any> {
|
||||
name: string
|
||||
context: T
|
||||
isAvailable: (context: T) => Promise<boolean> | boolean
|
||||
onAclChange?: (context: T) => Promise<void> | void
|
||||
onConfigChange?: (context: T) => Promise<void> | void
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export default createIntegration<Context>({
|
||||
|
||||
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
||||
try {
|
||||
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
|
||||
const files = await readdir(svcRoot)
|
||||
if (files.length === 0) {
|
||||
log.error('INTG', 'Kubernetes service account not found')
|
||||
@@ -39,6 +40,7 @@ export default createIntegration<Context>({
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
]
|
||||
|
||||
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
|
||||
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
||||
log.error('INTG', 'Malformed Kubernetes service account')
|
||||
return false
|
||||
@@ -48,6 +50,7 @@ export default createIntegration<Context>({
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
||||
const namespace = await readFile(
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
'utf8',
|
||||
@@ -68,7 +71,13 @@ export default createIntegration<Context>({
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
try {
|
||||
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
|
||||
const kc = new KubeConfig()
|
||||
kc.loadFromCluster()
|
||||
|
||||
@@ -91,6 +100,7 @@ export default createIntegration<Context>({
|
||||
kCoreV1Api.basePath,
|
||||
)
|
||||
|
||||
log.debug('INTG', 'Reading pod info for %s', pod)
|
||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||
pod,
|
||||
namespace,
|
||||
@@ -103,6 +113,7 @@ export default createIntegration<Context>({
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Got pod info: %o', body.spec)
|
||||
const shared = body.spec?.shareProcessNamespace
|
||||
if (shared === undefined) {
|
||||
log.error(
|
||||
@@ -127,6 +138,7 @@ export default createIntegration<Context>({
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Looking for namespaced process in /proc')
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
const subdirs = await readdir(dir)
|
||||
@@ -139,11 +151,14 @@ export default createIntegration<Context>({
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
if (data.includes('headscale')) {
|
||||
return pid
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to read %s: %s', path, error)
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
@@ -155,6 +170,7 @@ export default createIntegration<Context>({
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||
if (pids.length > 1) {
|
||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
@@ -177,15 +193,6 @@ export default createIntegration<Context>({
|
||||
}
|
||||
},
|
||||
|
||||
onAclChange: (context) => {
|
||||
if (!context.pid) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Sending SIGHUP to Headscale')
|
||||
kill(context.pid, 'SIGHUP')
|
||||
},
|
||||
|
||||
onConfigChange: (context) => {
|
||||
if (!context.pid) {
|
||||
return
|
||||
|
||||
+7
-11
@@ -22,6 +22,7 @@ export default createIntegration<Context>({
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking /proc for Headscale process')
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
const subdirs = await readdir(dir)
|
||||
@@ -34,11 +35,14 @@ export default createIntegration<Context>({
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
if (data.includes('headscale')) {
|
||||
return pid
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read %s: %s', path, error)
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
@@ -50,6 +54,7 @@ export default createIntegration<Context>({
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||
if (pids.length > 1) {
|
||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
@@ -70,14 +75,5 @@ export default createIntegration<Context>({
|
||||
log.error('INTG', 'Failed to read /proc')
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
onAclChange: (context) => {
|
||||
if (!context.pid) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Sending SIGHUP to Headscale')
|
||||
kill(context.pid, 'SIGHUP')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import Merge from 'react-codemirror-merge'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
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'
|
||||
|
||||
import Fallback from './fallback'
|
||||
|
||||
interface EditorProps {
|
||||
isDisabled?: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
<ErrorBoundary fallback={
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}>
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
value={props.value}
|
||||
height="100%"
|
||||
extensions={[jsonc()]}
|
||||
style={{ height: "100%" }}
|
||||
theme={light ? githubLight : githubDark}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DifferProps {
|
||||
left: string
|
||||
right: string
|
||||
}
|
||||
|
||||
export function Differ(props: DifferProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
{props.left === props.right ? (
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
No changes
|
||||
</p>
|
||||
) : (
|
||||
<ErrorBoundary fallback={
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}>
|
||||
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
||||
{() => (
|
||||
<Merge
|
||||
orientation="a-b"
|
||||
theme={light ? githubLight : githubDark}
|
||||
>
|
||||
<Merge.Original
|
||||
readOnly
|
||||
value={props.left}
|
||||
extensions={[jsonc()]}
|
||||
/>
|
||||
<Merge.Modified
|
||||
readOnly
|
||||
value={props.right}
|
||||
extensions={[jsonc()]}
|
||||
/>
|
||||
</Merge>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import Editor, { DiffEditor, Monaco } from '@monaco-editor/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Fallback from '~/routes/_data.acls._index/fallback'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
variant: 'edit' | 'diff'
|
||||
language: 'json' | 'yaml'
|
||||
state: [string, (value: string) => void]
|
||||
policy?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
function monacoCallback(monaco: Monaco) {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
allowComments: true,
|
||||
schemas: [],
|
||||
enableSchemaRequest: true,
|
||||
trailingCommas: 'ignore',
|
||||
})
|
||||
|
||||
monaco.languages.register({ id: 'json' })
|
||||
monaco.languages.register({ id: 'yaml' })
|
||||
}
|
||||
|
||||
export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
|
||||
const [light, setLight] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
<ClientOnly fallback={<Fallback acl={state[0]} />}>
|
||||
{() => variant === 'edit'
|
||||
? (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={light ? 'light' : 'vs-dark'}
|
||||
value={state[0]}
|
||||
onChange={(updated) => {
|
||||
if (!updated) {
|
||||
return
|
||||
}
|
||||
|
||||
if (updated !== state[0]) {
|
||||
state[1](updated)
|
||||
}
|
||||
}}
|
||||
loading={<Fallback acl={state[0]} />}
|
||||
beforeMount={monacoCallback}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
readOnly: isDisabled,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={light ? 'light' : 'vs-dark'}
|
||||
original={policy}
|
||||
modified={state[0]}
|
||||
loading={<Fallback acl={state[0]} />}
|
||||
beforeMount={monacoCallback}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
readOnly: isDisabled,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from '~/utils/cn'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Code from '~/components/Code'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function ErrorView({ message }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-full mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
Error
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500"/>
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Could not apply changes to your ACL policy
|
||||
due to the following error:
|
||||
<br />
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
</Card.Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface FallbackProps {
|
||||
interface Props {
|
||||
readonly acl: string
|
||||
}
|
||||
|
||||
export default function Fallback({ acl }: FallbackProps) {
|
||||
export default function Fallback({ acl }: Props) {
|
||||
return (
|
||||
<div className="inline-block relative w-full h-editor">
|
||||
<Spinner className="w-4 h-4 absolute p-2" />
|
||||
@@ -15,7 +15,7 @@ export default function Fallback({ acl }: FallbackProps) {
|
||||
'w-full h-editor font-mono resize-none',
|
||||
'text-sm text-gray-600 dark:text-gray-300',
|
||||
'bg-ui-100 dark:bg-ui-800',
|
||||
'pl-16 pr-8 pt-0.5 leading-snug',
|
||||
'pl-10 pt-1 leading-snug',
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActionFunctionArgs, json, 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'
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
@@ -12,75 +14,101 @@ import Notice from '~/components/Notice'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
|
||||
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 log from '~/utils/log'
|
||||
|
||||
import Monaco from './editor'
|
||||
import { Editor, Differ } from './cm'
|
||||
import { Unavailable } from './unavailable'
|
||||
import { ErrorView } from './error'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
// The way policy is handled in 0.23 of Headscale and later is verbose.
|
||||
// The 2 ACL policy modes are either the database one or file one
|
||||
//
|
||||
// File: The ACL policy is readonly to the API and manually edited
|
||||
// Database: The ACL policy is read/write to the API
|
||||
//
|
||||
// To determine if we first have an ACL policy available we need to check
|
||||
// if fetching the v1/policy route gives us a 500 status code or a 200.
|
||||
//
|
||||
// 500 can mean many different things here unfortunately:
|
||||
// - In file based that means the file is not accessible
|
||||
// - In database mode this can mean that we have never set an ACL policy
|
||||
// - In database mode this can mean that the ACL policy is not available
|
||||
// - A general server error may have occurred
|
||||
//
|
||||
// Unfortunately the server errors are not very descriptive so we have to
|
||||
// do some silly guesswork here. If we are running in an integration mode
|
||||
// and have the Headscale configuration available to us, our assumptions
|
||||
// can be more accurate, otherwise we just HAVE to assume that the ACL
|
||||
// policy has never been set.
|
||||
//
|
||||
// We can do damage control by checking for write access and if we are not
|
||||
// able to PUT an ACL policy on the v1/policy route, we can already know
|
||||
// that the policy is at the very-least readonly or not available.
|
||||
const context = await loadContext()
|
||||
let modeGuess = 'database' // Assume database mode
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
modeGuess = config.policy.mode
|
||||
}
|
||||
|
||||
// Attempt to load the policy, for both the frontend and for checking
|
||||
// if we are able to write to the policy for write access
|
||||
try {
|
||||
const { policy } = await pull<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
)
|
||||
|
||||
console.log(policy)
|
||||
try {
|
||||
// We have read access, now do we have write access?
|
||||
// Attempt to set the policy to what we just got
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy,
|
||||
})
|
||||
let write = false // On file mode we already know it's readonly
|
||||
if (modeGuess === 'database' && policy.length > 0) {
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: policy,
|
||||
})
|
||||
|
||||
write = true
|
||||
} catch (error) {
|
||||
write = false
|
||||
log.debug(
|
||||
'APIC',
|
||||
'Failed to write to ACL policy with error %s',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read: true,
|
||||
write,
|
||||
mode: modeGuess,
|
||||
policy
|
||||
}
|
||||
} catch {
|
||||
// If we are explicit on file mode then this is the end of the road
|
||||
if (modeGuess === 'file') {
|
||||
return {
|
||||
hasAclWrite: true,
|
||||
isPolicyApi: true,
|
||||
currentAcl: policy,
|
||||
aclType: 'json',
|
||||
} as const
|
||||
} catch (error) {
|
||||
if (!(error instanceof HeadscaleError)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error.status === 500) {
|
||||
return {
|
||||
hasAclWrite: false,
|
||||
isPolicyApi: true,
|
||||
currentAcl: policy,
|
||||
aclType: 'json',
|
||||
} as const
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Propagate our errors through normal error handling
|
||||
if (!(error instanceof HeadscaleError)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Not on 0.23-beta1 or later
|
||||
if (error.status === 404) {
|
||||
const { data, type, read, write } = await loadAcl()
|
||||
return {
|
||||
hasAclWrite: write,
|
||||
isPolicyApi: false,
|
||||
currentAcl: read ? data : '',
|
||||
aclType: type,
|
||||
read: false,
|
||||
write: false,
|
||||
mode: modeGuess
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
// Assume that we have write access otherwise?
|
||||
// This is sort of a brittle assumption to make but we don't want
|
||||
// to create a default policy if we don't have to.
|
||||
return {
|
||||
read: true,
|
||||
write: true,
|
||||
mode: modeGuess
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasAclWrite: true,
|
||||
isPolicyApi: true,
|
||||
currentAcl: '',
|
||||
aclType: 'json',
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
@@ -91,117 +119,34 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as { acl: string, api: boolean }
|
||||
if (data.api) {
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: data.acl,
|
||||
})
|
||||
try {
|
||||
const { acl } = await request.json() as { acl: string }
|
||||
const { policy } = await put<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
policy: acl,
|
||||
}
|
||||
)
|
||||
|
||||
return json({ success: true })
|
||||
} catch (error) {
|
||||
return json({ success: false }, {
|
||||
status: error instanceof HeadscaleError ? error.status : 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
if (!context.acl.write) {
|
||||
return json({ success: false }, {
|
||||
status: 403,
|
||||
return json({ success: true, policy })
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update ACL policy with error %s', error)
|
||||
const text = JSON.parse(error.message)
|
||||
return json({ success: false, error: text.message }, {
|
||||
status: error instanceof HeadscaleError ? error.status : 500,
|
||||
})
|
||||
}
|
||||
|
||||
await patchAcl(data.acl)
|
||||
|
||||
if (context.integration?.onAclChange) {
|
||||
await context.integration.onAclChange(context.integration.context)
|
||||
}
|
||||
|
||||
return json({ success: true })
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<div>
|
||||
<Notice className="mb-4">
|
||||
An ACL policy is not available or an error occurred while trying to fetch it.
|
||||
</Notice>
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Access Control List (ACL)
|
||||
</h1>
|
||||
|
||||
<p className="mb-4 max-w-prose">
|
||||
The ACL file is used to define the access control rules for your network.
|
||||
You can find more information about the ACL file in the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>
|
||||
{' '}
|
||||
and the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/acls"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div>
|
||||
<div className="max-w-prose">
|
||||
<p className="mb-4 text-md">
|
||||
If you are running Headscale 0.23-beta1 or later, the
|
||||
ACL configuration is most likely set to
|
||||
{' '}
|
||||
<Code>file</Code>
|
||||
{' '}
|
||||
mode but the ACL file is not available. In order to
|
||||
resolve this you will either need to correctly set
|
||||
{' '}
|
||||
<Code>policy.path</Code>
|
||||
{' '}
|
||||
in your Headscale configuration or set the
|
||||
{' '}
|
||||
<Code>policy.mode</Code>
|
||||
{' '}
|
||||
to
|
||||
{' '}
|
||||
<Code>database</Code>
|
||||
.
|
||||
</p>
|
||||
<p className="mb-2 text-md">
|
||||
If you are running an older version of Headscale, the
|
||||
{' '}
|
||||
<Code>ACL_FILE</Code>
|
||||
{' '}
|
||||
environment variable is not set. Refer to the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://github.com/tale/headplane/blob/main/docs/Configuration.md"
|
||||
name="Headplane Configuration"
|
||||
>
|
||||
Headplane Configuration
|
||||
</Link>
|
||||
{' '}
|
||||
documentation for more information on how to set the
|
||||
ACL file and integrate it with Headscale.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher<typeof action>()
|
||||
const [acl, setAcl] = useState(data.currentAcl)
|
||||
const fetcher = useDebounceFetcher<typeof action>()
|
||||
const revalidator = useRevalidator()
|
||||
|
||||
const [acl, setAcl] = useState(data.policy ?? '')
|
||||
const [toasted, setToasted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -216,36 +161,49 @@ export default function Page() {
|
||||
}
|
||||
|
||||
setToasted(true)
|
||||
setAcl(data.currentAcl)
|
||||
}, [fetcher.data, toasted, data.currentAcl])
|
||||
if (revalidator.state === 'idle') {
|
||||
revalidator.revalidate()
|
||||
}
|
||||
}, [fetcher.data, toasted, data.policy])
|
||||
|
||||
// The state for if the save and discard buttons should be disabled
|
||||
// is pretty complicated to calculate and varies on different states.
|
||||
const disabled = useMemo(() => {
|
||||
if (!data.read || !data.write) {
|
||||
return true
|
||||
}
|
||||
|
||||
// First check our fetcher states
|
||||
if (fetcher.state === 'loading') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (revalidator.state === 'loading') {
|
||||
return true
|
||||
}
|
||||
|
||||
// If we have a failed fetcher state allow the user to try again
|
||||
if (fetcher.data?.success === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return data.policy === acl
|
||||
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.hasAclWrite
|
||||
? undefined
|
||||
: (
|
||||
{data.read && !data.write
|
||||
? (
|
||||
<div className="mb-4">
|
||||
{data.isPolicyApi
|
||||
? (
|
||||
<Notice className="w-fit">
|
||||
The ACL policy is read-only. You can view the current policy
|
||||
but you cannot make changes to it.
|
||||
<br />
|
||||
To resolve this, you need to set the ACL policy mode to
|
||||
database in your Headscale configuration.
|
||||
</Notice>
|
||||
)
|
||||
: (
|
||||
<Notice className="w-fit">
|
||||
The ACL policy is read-only. You can view the current policy
|
||||
but you cannot make changes to it.
|
||||
<br />
|
||||
To resolve this, you need to configure a Headplane integration
|
||||
or make the ACL_FILE environment variable available.
|
||||
</Notice>
|
||||
)}
|
||||
<Notice className="w-fit">
|
||||
The ACL policy is read-only. You can view the current policy
|
||||
but you cannot make changes to it.
|
||||
<br />
|
||||
To resolve this, you need to set the ACL policy mode to
|
||||
database in your Headscale configuration.
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
) : undefined}
|
||||
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Access Control List (ACL)
|
||||
@@ -273,110 +231,117 @@ export default function Page() {
|
||||
.
|
||||
</p>
|
||||
|
||||
<Tabs>
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Tab
|
||||
id="edit"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
{fetcher.data?.success === false
|
||||
? (
|
||||
<ErrorView message={fetcher.data.error} />
|
||||
) : undefined}
|
||||
|
||||
{data.read ? (
|
||||
<>
|
||||
<Tabs>
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Tab
|
||||
id="edit"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="diff"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="preview"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className="w-5 h-5" />
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="edit">
|
||||
<Editor
|
||||
isDisabled={!data.write}
|
||||
value={acl}
|
||||
onChange={setAcl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<Differ
|
||||
left={data.policy}
|
||||
right={acl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setToasted(false)
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="diff"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setAcl(data.policy)
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="preview"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className="w-5 h-5" />
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="edit">
|
||||
<Monaco
|
||||
isDisabled={!data.hasAclWrite}
|
||||
variant="edit"
|
||||
language={data.aclType}
|
||||
state={[acl, setAcl]}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<Monaco
|
||||
variant="diff"
|
||||
language={data.aclType}
|
||||
state={[acl, setAcl]}
|
||||
policy={data.currentAcl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
setToasted(false)
|
||||
fetcher.submit({
|
||||
acl,
|
||||
api: data.isPolicyApi,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
|
||||
onPress={() => { setAcl(data.currentAcl) }}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : <Unavailable mode={data.mode} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cn } from '~/utils/cn'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Card from '~/components/Card'
|
||||
|
||||
interface Props {
|
||||
mode: 'file' | 'database'
|
||||
}
|
||||
|
||||
export function Unavailable({ mode }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-prose mt-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
ACL Policy Unavailable
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500"/>
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Unable to load a valid ACL policy configuration.
|
||||
This is most likely due to a misconfiguration in your
|
||||
Headscale configuration file.
|
||||
</Card.Text>
|
||||
|
||||
{mode !== 'file' ? (
|
||||
<p className="mt-4 text-sm">
|
||||
According to your configuration, the ACL policy mode
|
||||
is set to <Code>file</Code> but the ACL file is not
|
||||
available. Ensure that the <Code>policy.path</Code> is
|
||||
set to a valid path in your Headscale configuration.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-sm">
|
||||
In order to fully utilize the ACL management features of
|
||||
Headplane, please set <Code>policy.mode</Code> to either
|
||||
{' '}<Code>file</Code> or <Code>database</Code> in your
|
||||
Headscale configuration.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -91,9 +91,9 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
|
||||
})
|
||||
} else {
|
||||
const key = `dns.nameservers.split."${name}"`
|
||||
const list = nameservers.filter((_, i) => i !== index)
|
||||
submit({
|
||||
[key]: nameservers
|
||||
.filter((_, i) => i !== index),
|
||||
[key]: list.length ? list : null,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Modal({ name, disabled }: Properties) {
|
||||
Devices are accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].[user].{name}
|
||||
[device].{name}
|
||||
</Code>
|
||||
{' '}
|
||||
when Magic DNS is enabled.
|
||||
|
||||
@@ -25,7 +25,9 @@ export async function loader() {
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
baseDomain: config.dns.base_domain,
|
||||
baseDomain: config.dns.use_username_in_magic_dns
|
||||
? `[user].${config.dns.base_domain}`
|
||||
: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
@@ -107,7 +109,7 @@ export default function Page() {
|
||||
on the tailnet. Devices will be accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].[user].
|
||||
[device].
|
||||
{data.baseDomain}
|
||||
</Code>
|
||||
{' '}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { Link, useLoaderData } from '@remix-run/react'
|
||||
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { type Machine, Route, User } from '~/types'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
@@ -13,8 +13,8 @@ import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import { menuAction } from './_data.machines._index/action'
|
||||
import MenuOptions from './_data.machines._index/menu'
|
||||
import { menuAction } from '../_data.machines._index/action'
|
||||
import MenuOptions from '../_data.machines._index/menu'
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
@@ -56,6 +56,7 @@ export default function Page() {
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
@@ -134,14 +135,17 @@ export default function Page() {
|
||||
/>
|
||||
<Attribute
|
||||
name="Expiry"
|
||||
value={new Date(machine.expiry).toLocaleString()}
|
||||
value={expired
|
||||
? new Date(machine.expiry).toLocaleString()
|
||||
: 'Never'
|
||||
}
|
||||
/>
|
||||
{magic
|
||||
? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${machine.givenName}.${machine.user.name}.${magic}`}
|
||||
value={`${machine.givenName}.${magic}`}
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
@@ -97,6 +97,46 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
const key = data.get('mkey')?.toString()
|
||||
const user = data.get('user')?.toString()
|
||||
|
||||
if (!key) {
|
||||
return json({ message: 'No machine key provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return json({ message: 'No user provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const qp = new URLSearchParams()
|
||||
qp.append('user', user)
|
||||
qp.append('key', key)
|
||||
|
||||
const url = `v1/node/register?${qp.toString()}`
|
||||
await post(url, session.get('hsApiKey')!, {
|
||||
user, key,
|
||||
})
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: 'Machine registered'
|
||||
})
|
||||
} catch {
|
||||
return json({
|
||||
success: false,
|
||||
message: 'Failed to register machine'
|
||||
}, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return json({ message: 'Invalid method' }, {
|
||||
status: 400,
|
||||
|
||||
@@ -29,8 +29,6 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
When MagicDNS is enabled, the username of the owner
|
||||
will control the hostname of the machine.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
@@ -54,46 +52,16 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
|
||||
</Select>
|
||||
{magic
|
||||
? (
|
||||
owner === machine.user.name
|
||||
? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{owner}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{owner}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{machine.user.name}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
)
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Form, useFetcher, Link } from '@remix-run/react'
|
||||
import { Dispatch, SetStateAction, useState, useEffect } from 'react'
|
||||
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import Select from '~/components/Select'
|
||||
import Menu from '~/components/Menu'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { Machine, User } from '~/types'
|
||||
|
||||
export interface NewProps {
|
||||
server: string
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export default function New(data: NewProps) {
|
||||
const fetcher = useFetcher()
|
||||
const mkeyState = useState(false)
|
||||
const [mkey, setMkey] = useState('')
|
||||
const [user, setUser] = useState('')
|
||||
const [toasted, setToasted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fetcher.data.success) {
|
||||
toast('Registered new machine')
|
||||
} else {
|
||||
toast('Failed to register machine due to an invalid key')
|
||||
}
|
||||
|
||||
setToasted(true)
|
||||
}, [fetcher.data, toasted, mkey])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog>
|
||||
<Dialog.Panel control={mkeyState}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Register Machine Key
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className='mb-4'>
|
||||
The machine key is given when you run
|
||||
{' '}
|
||||
<Code>
|
||||
tailscale up --login-server=
|
||||
</Code>
|
||||
<Code>
|
||||
{data.server}
|
||||
</Code>
|
||||
{' '}
|
||||
on your device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method="POST" onSubmit={e => {
|
||||
fetcher.submit(e.currentTarget)
|
||||
close()
|
||||
}}>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
<TextField
|
||||
label='Machine Key'
|
||||
placeholder='mkey:ff.....'
|
||||
name="mkey"
|
||||
state={[mkey, setMkey]}
|
||||
className='my-2 font-mono'
|
||||
/>
|
||||
<Select
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
{data.users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Register
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
)}
|
||||
>
|
||||
Add Device
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.ItemButton control={mkeyState}>
|
||||
<ServerIcon className='w-4 h-4 mr-2'/>
|
||||
Register Machine Key
|
||||
</Menu.ItemButton>
|
||||
<Menu.ItemButton>
|
||||
<Link to="/settings/auth-keys">
|
||||
<KeyIcon className='w-4 h-4 mr-2'/>
|
||||
Generate Pre-auth Key
|
||||
</Link>
|
||||
</Menu.ItemButton>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Link } from '@remix-run/react'
|
||||
import Menu from '~/components/Menu'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type Route, User } from '~/types'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import MenuOptions from './menu'
|
||||
@@ -19,6 +19,7 @@ interface Props {
|
||||
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
@@ -31,6 +32,10 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||
tags.unshift('Expired')
|
||||
}
|
||||
|
||||
let prefix = magic?.startsWith('[user]')
|
||||
? magic.replace('[user]', machine.user.name)
|
||||
: magic
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={machine.id}
|
||||
@@ -102,16 +107,14 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||
'justify-between w-full break-keep',
|
||||
)}
|
||||
onPress={async () => {
|
||||
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
|
||||
const ip = `${machine.givenName}.${prefix}`
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied hostname to clipboard')
|
||||
}}
|
||||
>
|
||||
{machine.givenName}
|
||||
.
|
||||
{machine.user.name}
|
||||
.
|
||||
{magic}
|
||||
{prefix}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ export default function Menu({ machine, routes, magic, users }: MenuProps) {
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import { type Machine, type Route, User } from '~/types'
|
||||
import Link from '~/components/Link'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import type { Machine, Route, User } from '~/types'
|
||||
|
||||
import { menuAction } from './action'
|
||||
import MachineRow from './machine'
|
||||
import NewMachine from './dialogs/new'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
@@ -32,6 +34,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
|
||||
if (config.dns.use_username_in_magic_dns) {
|
||||
magic = `[user].${magic}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -39,6 +45,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
magic,
|
||||
server: context.headscaleUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +59,22 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
Manage the devices connected to your Tailnet.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
name="Tailscale Manage Devices Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine server={data.server} users={data.users} />
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-gray-500 dark:text-gray-400">
|
||||
<tr className="text-left uppercase text-xs font-bold px-0.5">
|
||||
@@ -76,7 +98,7 @@ export default function Page() {
|
||||
based on their name and also at
|
||||
{' '}
|
||||
<Code>
|
||||
[name].[user].
|
||||
[name].
|
||||
{data.magic}
|
||||
</Code>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IssueDraftIcon } from '@primer/octicons-react'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
|
||||
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='text-lg mt-8'>
|
||||
The settings page is currently unavailable.
|
||||
It will be available in a future release.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from '~/components/Link'
|
||||
import Button from '~/components/Button'
|
||||
import { Link as RemixLink } from '@remix-run/react'
|
||||
import { ArrowRightIcon } from '@primer/octicons-react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
The settings page is still under construction.
|
||||
As I'm able to add more features, I'll be adding them here.
|
||||
If you require any features, feel free to open an issue on
|
||||
the GitHub repository.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Headscale fully supports pre-authentication keys in order to
|
||||
easily add devices to your Tailnet.
|
||||
To learn more about using pre-authentication keys, visit the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
name="Tailscale Auth Keys documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink
|
||||
to="/settings/auth-keys"
|
||||
name="Auth Keys"
|
||||
>
|
||||
<span className={cn(
|
||||
'text-lg font-medium',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
)}>
|
||||
Manage Auth Keys
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</span>
|
||||
</RemixLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import type { PreAuthKey } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey
|
||||
}
|
||||
|
||||
export default function ExpireKey({ authKey }: Props) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">
|
||||
Expire Key
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Expire auth key?
|
||||
</Dialog.Title>
|
||||
<fetcher.Form method="DELETE" onSubmit={e => {
|
||||
fetcher.submit(e.currentTarget)
|
||||
close()
|
||||
}}>
|
||||
<input type="hidden" name="user" value={authKey.user} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately
|
||||
prevent it from being used to authenticate new devices.
|
||||
{' '}
|
||||
This action cannot be undone.
|
||||
</Dialog.Text>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Expire
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import NumberField from '~/components/NumberField'
|
||||
import Tooltip from '~/components/Tooltip'
|
||||
import Select from '~/components/Select'
|
||||
import Switch from '~/components/Switch'
|
||||
import Link from '~/components/Link'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
import { User } from '~/types'
|
||||
|
||||
interface Props {
|
||||
users: User[]
|
||||
}
|
||||
|
||||
// TODO: Tags
|
||||
export default function AddPreAuthKey(data: Props) {
|
||||
const fetcher = useFetcher()
|
||||
const [user, setUser] = useState('')
|
||||
const [reusable, setReusable] = useState(false)
|
||||
const [ephemeral, setEphemeral] = useState(false)
|
||||
const [aclTags, setAclTags] = useState([])
|
||||
const [expiry, setExpiry] = useState(90)
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">
|
||||
Create pre-auth key
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Generate auth key
|
||||
</Dialog.Title>
|
||||
<fetcher.Form method="POST" onSubmit={e => {
|
||||
fetcher.submit(e.currentTarget)
|
||||
close()
|
||||
}}>
|
||||
<Dialog.Text className="font-semibold">
|
||||
User
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Attach this key to a user
|
||||
</Dialog.Text>
|
||||
<Select
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
{data.users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<Dialog.Text className="font-semibold mt-4">
|
||||
Key Expiration
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Set this key to expire between 1 and 90 days.
|
||||
</Dialog.Text>
|
||||
<NumberField
|
||||
label="Expiry"
|
||||
name="expiry"
|
||||
minValue={1}
|
||||
maxValue={90}
|
||||
state={[expiry, setExpiry]}
|
||||
formatOptions={{
|
||||
style: 'unit',
|
||||
unit: 'day',
|
||||
unitDisplay: 'short',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">
|
||||
Reusable
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this key to authenticate more than one device.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Reusable"
|
||||
name="reusable"
|
||||
defaultSelected={reusable}
|
||||
onChange={() => { setReusable(!reusable) }}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="reusable" value={reusable} />
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">
|
||||
Ephemeral
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Devices authenticated with this key will
|
||||
be automatically removed once they go offline.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
||||
name="Tailscale Ephemeral Nodes Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Ephemeral"
|
||||
name="ephemeral"
|
||||
defaultSelected={ephemeral}
|
||||
onChange={() => {
|
||||
setEphemeral(!ephemeral)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="ephemeral" value={ephemeral} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
isDisabled={!user || !expiry}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Generate
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PreAuthKey } from '~/types'
|
||||
import { toast } from '~/components/Toaster'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Button from '~/components/Button'
|
||||
import Attribute from '~/components/Attribute'
|
||||
import ExpireKey from './dialogs/expire'
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey
|
||||
server: string
|
||||
}
|
||||
|
||||
export default function AuthKeyRow({ authKey, server }: Props) {
|
||||
const createdAt = new Date(authKey.createdAt).toLocaleString()
|
||||
const expiration = new Date(authKey.expiration).toLocaleString()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} isCopyable />
|
||||
<Attribute name="User" value={authKey.user} isCopyable />
|
||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
<p className="mb-1 mt-4">
|
||||
To use this key, run the following command on your device:
|
||||
</p>
|
||||
<Code className="text-sm">
|
||||
tailscale up --login-server {server} --authkey {authKey.key}
|
||||
</Code>
|
||||
<div className="flex gap-4 items-center">
|
||||
{authKey.used || new Date(authKey.expiration) < new Date()
|
||||
? undefined
|
||||
: (
|
||||
<ExpireKey authKey={authKey} />
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
className="my-4"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server ${server} --authkey ${authKey.key}`
|
||||
)
|
||||
|
||||
toast('Copied command to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy Tailscale Command
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { Link as RemixLink } from '@remix-run/react'
|
||||
import { PreAuthKey, User } from '~/types'
|
||||
import { pull, post } from '~/utils/headscale'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Link from '~/components/Link'
|
||||
import TableList from '~/components/TableList'
|
||||
import Select from '~/components/Select'
|
||||
import Switch from '~/components/Switch'
|
||||
|
||||
import AddPreAuthKey from './dialogs/new'
|
||||
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' }, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
|
||||
// Expiring a pre-auth key
|
||||
if (request.method === 'DELETE') {
|
||||
const key = data.get('key')
|
||||
const user = data.get('user')
|
||||
|
||||
if (!key || !user) {
|
||||
return json({ message: 'Missing parameters' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
await post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey/expire',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
user: user,
|
||||
key: key,
|
||||
}
|
||||
)
|
||||
|
||||
return json({ message: 'Pre-auth key expired' })
|
||||
}
|
||||
|
||||
// Creating a new pre-auth key
|
||||
if (request.method === 'POST') {
|
||||
const user = data.get('user')
|
||||
const expiry = data.get('expiry')
|
||||
const reusable = data.get('reusable')
|
||||
const ephemeral = data.get('ephemeral')
|
||||
|
||||
if (!user || !expiry || !reusable || !ephemeral) {
|
||||
return json({ message: 'Missing parameters' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract the first "word" from expiry which is the day number
|
||||
// Calculate the date X days from now using the day number
|
||||
const day = Number(expiry.toString().split(' ')[0])
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + day)
|
||||
|
||||
const key = await post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
user: user,
|
||||
ephemeral: ephemeral === 'on',
|
||||
reusable: reusable === 'on',
|
||||
expiration: date.toISOString(),
|
||||
aclTags: [], // TODO
|
||||
}
|
||||
)
|
||||
|
||||
return json({ message: 'Pre-auth key created', key })
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const context = await loadContext()
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!)
|
||||
|
||||
const preAuthKeys = await Promise.all(users.users.map(user => {
|
||||
const qp = new URLSearchParams()
|
||||
qp.set('user', user.name)
|
||||
|
||||
return pull<{ preAuthKeys: PreAuthKey[] }>(
|
||||
`v1/preauthkey?${qp.toString()}`,
|
||||
session.get('hsApiKey')!
|
||||
)
|
||||
}))
|
||||
|
||||
return {
|
||||
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
|
||||
users: users.users,
|
||||
server: context.headscaleUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { keys, users, server } = useLoaderData<typeof loader>()
|
||||
const [user, setUser] = useState('All')
|
||||
const [status, setStatus] = useState('Active')
|
||||
useLiveData({ interval: 3000 })
|
||||
|
||||
const filteredKeys = keys.filter(key => {
|
||||
if (user !== 'All' && key.user !== user) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (status !== 'All') {
|
||||
const now = new Date()
|
||||
const expiry = new Date(key.expiration)
|
||||
|
||||
if (status === 'Active') {
|
||||
return !(expiry < now) && !key.used
|
||||
}
|
||||
|
||||
if (status === 'Used/Expired') {
|
||||
return key.used || expiry < now
|
||||
}
|
||||
|
||||
if (status === 'Reusable') {
|
||||
return key.reusable
|
||||
}
|
||||
|
||||
if (status === 'Ephemeral') {
|
||||
return key.ephemeral
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink
|
||||
to="/settings"
|
||||
className="font-medium"
|
||||
>
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">
|
||||
/
|
||||
</span>
|
||||
{' '}
|
||||
Pre-Auth Keys
|
||||
</p>
|
||||
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to
|
||||
easily add devices to your Tailnet.
|
||||
To learn more about using pre-authentication keys, visit the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
name="Tailscale Auth Keys documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
<AddPreAuthKey users={users} />
|
||||
<div className="flex justify-between gap-4 mt-4">
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
Filter by user
|
||||
</p>
|
||||
<Select
|
||||
label="Filter by User"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
<Select.Item id="All">All</Select.Item>
|
||||
{users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
Filter by status
|
||||
</p>
|
||||
<Select
|
||||
label="Filter by status"
|
||||
placeholder="Select a status"
|
||||
state={[status, setStatus]}
|
||||
>
|
||||
<Select.Item id="All">All</Select.Item>
|
||||
<Select.Item id="Active">Active</Select.Item>
|
||||
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
|
||||
<Select.Item id="Reusable">Reusable</Select.Item>
|
||||
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{filteredKeys.length === 0 ? (
|
||||
<TableList.Item>
|
||||
<p className="opacity-50 text-sm mx-auto">
|
||||
No pre-auth keys
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : filteredKeys.map(key => (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} server={server} />
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
const context = await loadContext()
|
||||
return {
|
||||
acl: context.acl,
|
||||
config: context.config,
|
||||
user: session.get('user'),
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export default function Add({ magic }: Props) {
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username.length > 0 ? username : '[username]'}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
|
||||
@@ -8,10 +8,9 @@ import Dialog from '~/components/Dialog'
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Remove({ username, magic }: Props) {
|
||||
export default function Remove({ username }: Props) {
|
||||
const submit = useSubmit()
|
||||
const dialogState = useState(false)
|
||||
|
||||
@@ -41,24 +40,6 @@ export default function Remove({ username, magic }: Props) {
|
||||
?
|
||||
{' '}
|
||||
A deleted user cannot be recovered.
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
Since Magic DNS is enabled, machines
|
||||
currently accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will become orphaned and inaccessible.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
|
||||
@@ -40,33 +39,6 @@ export default function Rename({ username, magic }: Props) {
|
||||
Enter a new username for
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
Since Magic DNS is enabled, machines
|
||||
currently accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will now become accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{newName.length > 0 ? newName : '[new-username]'}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
|
||||
@@ -335,7 +335,7 @@ function UserCard({ user, magic }: CardProps) {
|
||||
<Rename username={user.name} magic={magic} />
|
||||
{user.machines.length === 0
|
||||
? (
|
||||
<Remove username={user.name} magic={magic} />
|
||||
<Remove username={user.name} />
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
|
||||
@@ -122,6 +122,7 @@ export default function Page() {
|
||||
label="API Key"
|
||||
name="api-key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
/>
|
||||
<Button
|
||||
className="w-full mt-2.5"
|
||||
|
||||
@@ -7,3 +7,24 @@
|
||||
scrollbar-gutter: stable
|
||||
}
|
||||
}
|
||||
|
||||
.cm-merge-theme {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeView {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditors {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Weirdest class name characters but ok */
|
||||
.cm-mergeView .ͼ1 .cm-scroller, .cm-mergeView .ͼ1 {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface PreAuthKey {
|
||||
id: string
|
||||
key: string
|
||||
user: string
|
||||
reusable: boolean
|
||||
ephemeral: boolean
|
||||
used: boolean
|
||||
expiration: string
|
||||
createdAt: string
|
||||
aclTags: string[]
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './Key'
|
||||
export * from './Machine'
|
||||
export * from './Route'
|
||||
export * from './User'
|
||||
export * from './PreAuthKey'
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
|
||||
import log from '~/utils/log'
|
||||
|
||||
export interface HeadplaneContext {
|
||||
debug: boolean
|
||||
headscaleUrl: string
|
||||
cookieSecret: string
|
||||
integration: IntegrationFactory | undefined
|
||||
@@ -22,11 +23,6 @@ export interface HeadplaneContext {
|
||||
write: boolean
|
||||
}
|
||||
|
||||
acl: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
|
||||
oidc?: {
|
||||
issuer: string
|
||||
client: string
|
||||
@@ -39,10 +35,22 @@ export interface HeadplaneContext {
|
||||
let context: HeadplaneContext | undefined
|
||||
|
||||
export async function loadContext(): Promise<HeadplaneContext> {
|
||||
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.')
|
||||
log.info('CTXT', 'Please disable debug mode in production.')
|
||||
}
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
const { config, contextData } = await checkConfig(path)
|
||||
|
||||
@@ -65,11 +73,11 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
||||
}
|
||||
|
||||
context = {
|
||||
debug,
|
||||
headscaleUrl,
|
||||
cookieSecret,
|
||||
integration: await loadIntegration(),
|
||||
config: contextData,
|
||||
acl: await checkAcl(config),
|
||||
oidc: await checkOidc(config),
|
||||
}
|
||||
|
||||
@@ -81,80 +89,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
||||
: 'Unavailable',
|
||||
)
|
||||
|
||||
log.info('CTXT', 'ACL: %s', context.acl.read
|
||||
? `Found ${context.acl.write ? '' : '(Read Only)'}`
|
||||
: 'Unavailable',
|
||||
)
|
||||
|
||||
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
|
||||
return context
|
||||
}
|
||||
|
||||
export async function loadAcl(): Promise<{
|
||||
data: string
|
||||
type: 'json' | 'yaml'
|
||||
read: boolean
|
||||
write: boolean
|
||||
}> {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error('No ACL file defined')
|
||||
}
|
||||
|
||||
// Check for attributes
|
||||
let read = false
|
||||
let write = false
|
||||
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
read = true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
|
||||
const data = await readFile(path, 'utf8')
|
||||
|
||||
// Naive check for YAML over JSON
|
||||
// This is because JSON.parse doesn't support comments
|
||||
try {
|
||||
parse(data)
|
||||
return { data, type: 'yaml', read, write }
|
||||
} catch {
|
||||
return { data, type: 'json', read, write }
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchAcl(data: string) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error('No ACL file defined')
|
||||
}
|
||||
|
||||
await writeFile(path, data, 'utf8')
|
||||
}
|
||||
|
||||
async function checkConfig(path: string) {
|
||||
log.debug('CTXT', 'Checking config at %s', path)
|
||||
|
||||
let config: HeadscaleConfig | undefined
|
||||
try {
|
||||
config = await loadConfig(path)
|
||||
} catch {
|
||||
log.debug('CTXT', 'Config at %s failed to load', path)
|
||||
return {
|
||||
config: undefined,
|
||||
contextData: {
|
||||
@@ -166,9 +112,12 @@ async function checkConfig(path: string) {
|
||||
|
||||
let write = false
|
||||
try {
|
||||
log.debug('CTXT', 'Checking write access to %s', path)
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
} catch {
|
||||
log.debug('CTXT', 'No write access to %s', path)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
@@ -179,34 +128,13 @@ async function checkConfig(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAcl(config?: HeadscaleConfig) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path && config) {
|
||||
path = config.acl_policy_path
|
||||
}
|
||||
|
||||
let read = false
|
||||
let write = false
|
||||
if (path) {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
read = true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOidc(config?: HeadscaleConfig) {
|
||||
log.debug('CTXT', 'Checking OIDC configuration')
|
||||
|
||||
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
||||
log.debug('CTXT', 'API Key Login Enabled: %s', !disableKeyLogin)
|
||||
|
||||
log.debug('CTXT', 'Checking ROOT_API_KEY and falling back to API_KEY')
|
||||
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
|
||||
if (!rootKey) {
|
||||
throw new Error('ROOT_API_KEY or API_KEY not set')
|
||||
@@ -216,6 +144,10 @@ async function checkOidc(config?: HeadscaleConfig) {
|
||||
let client = process.env.OIDC_CLIENT_ID
|
||||
let secret = process.env.OIDC_CLIENT_SECRET
|
||||
|
||||
log.debug('CTXT', 'Checking OIDC environment variables')
|
||||
log.debug('CTXT', 'Issuer: %s', issuer)
|
||||
log.debug('CTXT', 'Client: %s', client)
|
||||
|
||||
if (
|
||||
(issuer ?? client ?? secret)
|
||||
&& !(issuer && client && secret)
|
||||
@@ -240,6 +172,7 @@ async function checkOidc(config?: HeadscaleConfig) {
|
||||
secret = config.oidc?.client_secret
|
||||
|
||||
if (!secret && config.oidc?.client_secret_path) {
|
||||
log.debug('CTXT', 'Trying to read OIDC client secret from %s', config.oidc.client_secret_path)
|
||||
try {
|
||||
const data = await readFile(
|
||||
config.oidc.client_secret_path,
|
||||
@@ -249,7 +182,9 @@ async function checkOidc(config?: HeadscaleConfig) {
|
||||
if (data && data.length > 0) {
|
||||
secret = data.trim()
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
log.error('CTXT', 'Failed to read OIDC client secret from %s', config.oidc.client_secret_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ const HeadscaleConfig = z.object({
|
||||
disable_check_updates: goBool.default(false),
|
||||
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
|
||||
randomize_client_port: goBool.default(false),
|
||||
acl_policy_path: z.string().optional(),
|
||||
|
||||
acme_email: z.string().optional(),
|
||||
acme_url: z.string().optional(),
|
||||
@@ -188,10 +187,12 @@ export async function loadConfig(path?: string) {
|
||||
throw new Error('Path is required to lazy load config')
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Loading Headscale configuration from %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
configYaml = parseDocument(data)
|
||||
|
||||
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
|
||||
log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode')
|
||||
const loaded = configYaml.toJSON() as Record<string, unknown>
|
||||
config = {
|
||||
...loaded,
|
||||
@@ -250,8 +251,10 @@ export async function loadConfig(path?: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('CFGX', 'Attempting to parse Headscale configuration')
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
} catch (error) {
|
||||
log.debug('CFGX', 'Failed to load Headscale configuration')
|
||||
if (error instanceof z.ZodError) {
|
||||
log.error('CFGX', 'Recieved invalid configuration file')
|
||||
log.error('CFGX', 'The following schema issues were found:')
|
||||
@@ -280,7 +283,9 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
||||
throw new Error('Config not loaded')
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Patching Headscale configuration')
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
log.debug('CFGX', 'Patching %s with %s', key, value)
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
@@ -309,6 +314,11 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
||||
|
||||
// Push the remaining element
|
||||
path.push(temp.replaceAll('"', ''))
|
||||
if (value === null) {
|
||||
configYaml.deleteIn(path)
|
||||
continue
|
||||
}
|
||||
|
||||
configYaml.setIn(path, value)
|
||||
}
|
||||
|
||||
@@ -317,5 +327,6 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
||||
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
log.debug('CFGX', 'Writing patched configuration to %s', path)
|
||||
await writeFile(path, configYaml.toString(), 'utf8')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loadContext } from './config/headplane'
|
||||
import log from './log'
|
||||
|
||||
export class HeadscaleError extends Error {
|
||||
status: number
|
||||
@@ -20,6 +21,8 @@ export class FatalError extends Error {
|
||||
export async function pull<T>(url: string, key: string) {
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
@@ -27,6 +30,7 @@ export async function pull<T>(url: string, key: string) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'GET %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
@@ -36,6 +40,8 @@ export async function pull<T>(url: string, key: string) {
|
||||
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
@@ -45,6 +51,7 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'POST %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
@@ -54,6 +61,8 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
export async function put<T>(url: string, key: string, body?: unknown) {
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
@@ -63,6 +72,7 @@ export async function put<T>(url: string, key: string, body?: unknown) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'PUT %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
@@ -72,6 +82,8 @@ export async function put<T>(url: string, key: string, body?: unknown) {
|
||||
export async function del<T>(url: string, key: string) {
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@@ -80,6 +92,7 @@ export async function del<T>(url: string, key: string) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'DELETE %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ export default {
|
||||
error: (category: string, message: string, ...args: unknown[]) => {
|
||||
defaultLog('ERRO', category, message, ...args)
|
||||
},
|
||||
|
||||
debug: (category: string, message: string, ...args: unknown[]) => {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
defaultLog('DEBG', category, message, ...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLog(
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@ import { redirect } from '@remix-run/node'
|
||||
import {
|
||||
authorizationCodeGrantRequest,
|
||||
calculatePKCECodeChallenge,
|
||||
type Client,
|
||||
Client,
|
||||
discoveryRequest,
|
||||
generateRandomCodeVerifier,
|
||||
generateRandomNonce,
|
||||
@@ -51,8 +51,8 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
|
||||
const challenge = await calculatePKCECodeChallenge(verifier)
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
|
||||
callback.host = req.headers.get('Host') ?? ''
|
||||
const authUrl = new URL(processed.authorization_endpoint)
|
||||
|
||||
authUrl.searchParams.set('client_id', oidcClient.client_id)
|
||||
@@ -119,8 +119,8 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
|
||||
}
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
|
||||
callback.host = req.headers.get('Host') ?? ''
|
||||
|
||||
const tokenResponse = await authorizationCodeGrantRequest(
|
||||
processed,
|
||||
|
||||
@@ -27,9 +27,8 @@ export const {
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secrets: [process.env.COOKIE_SECRET!],
|
||||
secure: true
|
||||
secure: process.env.COOKIE_SECURE !== 'false',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ networks:
|
||||
driver: 'bridge'
|
||||
services:
|
||||
headscale:
|
||||
image: 'headscale/headscale:0.23.0-beta1'
|
||||
image: 'headscale/headscale:0.23.0-beta2'
|
||||
container_name: 'headscale'
|
||||
restart: 'unless-stopped'
|
||||
command: 'serve'
|
||||
|
||||
@@ -54,14 +54,14 @@ When the ACL file is available for editing, the `Access Controls` tab will
|
||||
become available. All of the integrations support automatic reloading of the
|
||||
ACLs when the file is changed.
|
||||
|
||||
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`. This
|
||||
can be overridden by setting the `ACL_FILE` environment variable and is also
|
||||
overriden by the `acl_policy_path` key in the configuration file if set.
|
||||
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`.
|
||||
> If `policy.path` is set and `policy.mode` is set to `file`, the ACL file will
|
||||
> be read from the path specified in the configuration file instead.
|
||||
|
||||
## Deployment
|
||||
|
||||
Requirements:
|
||||
- Headscale 0.23 beta-2 or later
|
||||
- Headscale 0.23 or newer
|
||||
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
||||
|
||||
Currently there are 3 integration providers that can do this for you:
|
||||
|
||||
@@ -16,7 +16,7 @@ Headplane in a production environment.
|
||||
## Deployment
|
||||
|
||||
Requirements:
|
||||
- Headscale 0.23 beta-2 or later
|
||||
- Headscale 0.23 or newer
|
||||
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
||||
|
||||
Docker heavily simplifies the deployment process, but this process can be
|
||||
@@ -29,7 +29,7 @@ Here is a simple Docker Compose deployment:
|
||||
services:
|
||||
headplane:
|
||||
container_name: headplane
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
image: ghcr.io/tale/headplane:0.3.2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
OIDC_ISSUER: 'https://sso.example.com'
|
||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||
DISABLE_API_KEY_LOGIN: 'true'
|
||||
COOKIE_SECURE: 'false'
|
||||
|
||||
# These are the default values
|
||||
HOST: '0.0.0.0'
|
||||
|
||||
@@ -9,11 +9,12 @@ You can configure Headplane using environment variables.
|
||||
|
||||
#### Optional Variables
|
||||
|
||||
- **`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`).
|
||||
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
|
||||
- **`HEADSCALE_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`).
|
||||
|
||||
#### Docker Integration
|
||||
The Docker integration allows Headplane to manage the Headscale docker container.
|
||||
|
||||
@@ -37,7 +37,7 @@ that you'll NEED to setup a reverse proxy and this is incomplete:
|
||||
```yaml
|
||||
services:
|
||||
headscale:
|
||||
image: 'headscale/headscale:0.23.0-beta2'
|
||||
image: 'headscale/headscale:0.23.0'
|
||||
container_name: 'headscale'
|
||||
restart: 'unless-stopped'
|
||||
command: 'serve'
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
TZ: 'America/New_York'
|
||||
headplane:
|
||||
container_name: headplane
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
image: ghcr.io/tale/headplane:0.3.2
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- './data:/var/lib/headscale'
|
||||
@@ -68,6 +68,9 @@ services:
|
||||
HOST: '0.0.0.0'
|
||||
PORT: '3000'
|
||||
|
||||
# Only set this to false if you aren't behind a reverse proxy
|
||||
COOKIE_SECURE: 'false'
|
||||
|
||||
# Overrides the configuration file values if they are set in config.yaml
|
||||
# If you want to share the same OIDC configuration you do not need this
|
||||
OIDC_CLIENT_ID: 'headscale'
|
||||
|
||||
@@ -88,7 +88,7 @@ spec:
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- name: headplane
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
image: ghcr.io/tale/headplane:0.3.2
|
||||
env:
|
||||
- name: COOKIE_SECRET
|
||||
value: 'abcdefghijklmnopqrstuvwxyz'
|
||||
@@ -98,12 +98,16 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
|
||||
# Only set this to false if you aren't behind a reverse proxy
|
||||
- name: COOKIE_SECURE
|
||||
value: 'false'
|
||||
volumeMounts:
|
||||
- name: headscale-config
|
||||
mountPath: /etc/headscale
|
||||
|
||||
- name: headscale
|
||||
image: headscale/headscale:0.23.0-beta2
|
||||
image: headscale/headscale:0.23.0
|
||||
command: ['serve']
|
||||
env:
|
||||
- name: TZ
|
||||
|
||||
+13
-7
@@ -16,19 +16,24 @@
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@kubernetes/client-node": "^0.21.0",
|
||||
"@monaco-editor/react": "^4.6.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",
|
||||
"@shopify/lang-jsonc": "^1.0.0",
|
||||
"@uiw/codemirror-theme-github": "^4.23.5",
|
||||
"@uiw/react-codemirror": "^4.23.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"isbot": "^5.1.11",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"react": "19.0.0-rc-f38c22b244-20240704",
|
||||
"react": "19.0.0-beta-26f2496093-20240514",
|
||||
"react-aria-components": "^1.2.1",
|
||||
"react-dom": "19.0.0-rc-f38c22b244-20240704",
|
||||
"react-codemirror-merge": "^4.23.5",
|
||||
"react-dom": "19.0.0-beta-26f2496093-20240514",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"remix-utils": "^7.6.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-react-aria-components": "^1.1.3",
|
||||
@@ -42,8 +47,8 @@
|
||||
"@types/react": "npm:types-react@beta",
|
||||
"@types/react-dom": "npm:types-react-dom@beta",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
|
||||
"eslint": "^8.57.0",
|
||||
"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",
|
||||
@@ -51,7 +56,7 @@
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-babel": "^1.2.0",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@beta",
|
||||
@@ -63,7 +68,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@react-aria/overlays@3.22.1": "patches/@react-aria__overlays@3.22.1.patch"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
||||
index 8b5b71f8eee6606ca8de47c15eef0ce73c01d93f..2bfa4e7c7ff650b35b1bdee75c28bd6fbf6c6704 100644
|
||||
--- a/dist/esm/index.js
|
||||
+++ b/dist/esm/index.js
|
||||
@@ -1,3 +1,3 @@
|
||||
-export { jsonc, jsoncLanguage } from './jsonc';
|
||||
-export { parser } from './parser';
|
||||
+export { jsonc, jsoncLanguage } from './jsonc.js';
|
||||
+export { parser } from './parser.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/esm/jsonc.js b/dist/esm/jsonc.js
|
||||
index 5f968887d47696122c6bbfc4b98f94af6477f37a..b144021c4b5d4504c73f6c2172e8d1c2ddbfe3ac 100644
|
||||
--- a/dist/esm/jsonc.js
|
||||
+++ b/dist/esm/jsonc.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { parser } from './parser';
|
||||
+import { parser } from './parser.js';
|
||||
import { continuedIndent, indentNodeProp, foldNodeProp, foldInside, LRLanguage, LanguageSupport, } from '@codemirror/language';
|
||||
/// A language provider that provides JSON parsing.
|
||||
export const jsoncLanguage = LRLanguage.define({
|
||||
diff --git a/dist/esm/parser.js b/dist/esm/parser.js
|
||||
index 3d966fcf7c55003b1ba6c2b3f531b7c9b8045cb8..f6e8f56c3506f76f31571a49af8cd44364a864a3 100644
|
||||
--- a/dist/esm/parser.js
|
||||
+++ b/dist/esm/parser.js
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
-import {jsonHighlighting} from "./highlight"
|
||||
+import {jsonHighlighting} from "./highlight.js"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "$zO]QPOOOOQO'#Cd'#CdOtQPO'#CgO|QPO'#ClOOQO'#Cr'#CrQOQPOOOOQO'#Ci'#CiO!TQPO'#ChO!YQPO'#CtOOQO,59R,59RO!bQPO,59RO!gQPO'#CwOOQO,59W,59WO!oQPO,59WO]QPO,59SO!tQPO,59`O!|QPO,59`OOQO1G.m1G.mO#UQPO,59cO#]QPO,59cOOQO1G.r1G.rOOQO1G.n1G.nOOQO,59X,59XO#eQPO1G.zOOQO-E6k-E6kOOQO,59Y,59YO#mQPO1G.}OOQO-E6l-E6lPwQPO'#CmP]QPO'#Cn",
|
||||
diff --git a/package.json b/package.json
|
||||
index 9c3a56db25535b7eb0b1f951abe486d530a2714f..5e858500f155c16ecb068854552a71e1da5f04fc 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "@shopify/lang-jsonc",
|
||||
+ "type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "JSONC language support for CodeMirror",
|
||||
"publishConfig": {
|
||||
Generated
+1324
-1007
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user