mirror of
https://github.com/tale/headplane.git
synced 2026-07-28 16:48:56 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2229f547a9 | |||
| 65cc278a59 | |||
| 1555846df2 | |||
| e8c1cadf54 | |||
| d867769025 | |||
| 1d6066d3f0 | |||
| 1d821251a9 | |||
| a0d6905123 | |||
| 4095ed2a68 | |||
| 58e98278d1 | |||
| 90f0bf2555 | |||
| ea2ffdf0c1 | |||
| 9aedd9baad | |||
| 690b52d8c6 | |||
| a72a3d6e5f | |||
| c4c1fd8aab | |||
| 9801ef453d |
@@ -1,3 +1,18 @@
|
|||||||
|
### 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)
|
### 0.2.3 (August 23, 2024)
|
||||||
- Change the minimum required version of Headscale to 0.23-beta2
|
- Change the minimum required version of Headscale to 0.23-beta2
|
||||||
- Support the new API policy mode for Headscale 0.23-beta1
|
- Support the new API policy mode for Headscale 0.23-beta1
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import TabLink from './TabLink'
|
|||||||
|
|
||||||
interface Properties {
|
interface Properties {
|
||||||
readonly data?: {
|
readonly data?: {
|
||||||
acl: HeadplaneContext['acl']
|
|
||||||
config: HeadplaneContext['config']
|
config: HeadplaneContext['config']
|
||||||
user?: SessionData['user']
|
user?: SessionData['user']
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-36
@@ -24,6 +24,7 @@ export default createIntegration<Context>({
|
|||||||
isAvailable: async (context) => {
|
isAvailable: async (context) => {
|
||||||
// Check for the HEADSCALE_CONTAINER environment variable first
|
// Check for the HEADSCALE_CONTAINER environment variable first
|
||||||
// to avoid unnecessary fetching of the Docker socket
|
// to avoid unnecessary fetching of the Docker socket
|
||||||
|
log.debug('INTG', 'Checking Docker integration availability')
|
||||||
context.container = process.env.HEADSCALE_CONTAINER
|
context.container = process.env.HEADSCALE_CONTAINER
|
||||||
?.trim()
|
?.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -54,16 +55,19 @@ export default createIntegration<Context>({
|
|||||||
// The API is available as an HTTP endpoint and this
|
// The API is available as an HTTP endpoint and this
|
||||||
// will simplify the fetching logic in undici
|
// will simplify the fetching logic in undici
|
||||||
if (url.protocol === 'tcp:') {
|
if (url.protocol === 'tcp:') {
|
||||||
url.protocol = 'http:'
|
// Apparently setting url.protocol doesn't work anymore?
|
||||||
|
const fetchU = url.href.replace(url.protocol, 'http:')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.info('INTG', 'Checking API: %s', url.href)
|
log.info('INTG', 'Checking API: %s', fetchU)
|
||||||
await fetch(new URL('/v1.30/version', url).href)
|
await fetch(new URL('/v1.30/version', fetchU).href)
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
log.debug('INTG', 'Failed to connect to Docker API', error)
|
||||||
log.error('INTG', 'Failed to connect to Docker API')
|
log.error('INTG', 'Failed to connect to Docker API')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
context.client = new Client(url.href)
|
context.client = new Client(fetchU)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the socket is accessible
|
// Check if the socket is accessible
|
||||||
@@ -73,7 +77,8 @@ export default createIntegration<Context>({
|
|||||||
url.pathname,
|
url.pathname,
|
||||||
)
|
)
|
||||||
await access(url.pathname, constants.R_OK)
|
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',
|
log.error('INTG', 'Failed to access Docker socket: %s',
|
||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
@@ -88,36 +93,6 @@ export default createIntegration<Context>({
|
|||||||
return context.client !== undefined
|
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) => {
|
onConfigChange: async (context) => {
|
||||||
if (!context.client || !context.container) {
|
if (!context.client || !context.container) {
|
||||||
return
|
return
|
||||||
@@ -127,6 +102,12 @@ export default createIntegration<Context>({
|
|||||||
|
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
while (attempts <= context.maxAttempts) {
|
while (attempts <= context.maxAttempts) {
|
||||||
|
log.debug(
|
||||||
|
'INTG', 'Restarting container: %s (attempt %d)',
|
||||||
|
context.container,
|
||||||
|
attempts,
|
||||||
|
)
|
||||||
|
|
||||||
const response = await context.client.request({
|
const response = await context.client.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: `/v1.30/containers/${context.container}/restart`,
|
path: `/v1.30/containers/${context.container}/restart`,
|
||||||
@@ -150,6 +131,7 @@ export default createIntegration<Context>({
|
|||||||
attempts = 0
|
attempts = 0
|
||||||
while (attempts <= context.maxAttempts) {
|
while (attempts <= context.maxAttempts) {
|
||||||
try {
|
try {
|
||||||
|
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
|
||||||
await pull('v1', '')
|
await pull('v1', '')
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export interface IntegrationFactory<T = any> {
|
|||||||
name: string
|
name: string
|
||||||
context: T
|
context: T
|
||||||
isAvailable: (context: T) => Promise<boolean> | boolean
|
isAvailable: (context: T) => Promise<boolean> | boolean
|
||||||
onAclChange?: (context: T) => Promise<void> | void
|
|
||||||
onConfigChange?: (context: T) => Promise<void> | void
|
onConfigChange?: (context: T) => Promise<void> | void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default createIntegration<Context>({
|
|||||||
|
|
||||||
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
||||||
try {
|
try {
|
||||||
|
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
|
||||||
const files = await readdir(svcRoot)
|
const files = await readdir(svcRoot)
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
log.error('INTG', 'Kubernetes service account not found')
|
log.error('INTG', 'Kubernetes service account not found')
|
||||||
@@ -39,6 +40,7 @@ export default createIntegration<Context>({
|
|||||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
|
||||||
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
||||||
log.error('INTG', 'Malformed Kubernetes service account')
|
log.error('INTG', 'Malformed Kubernetes service account')
|
||||||
return false
|
return false
|
||||||
@@ -48,6 +50,7 @@ export default createIntegration<Context>({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
||||||
const namespace = await readFile(
|
const namespace = await readFile(
|
||||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -68,7 +71,13 @@ export default createIntegration<Context>({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
|
||||||
|
pod,
|
||||||
|
namespace,
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
|
||||||
const kc = new KubeConfig()
|
const kc = new KubeConfig()
|
||||||
kc.loadFromCluster()
|
kc.loadFromCluster()
|
||||||
|
|
||||||
@@ -91,6 +100,7 @@ export default createIntegration<Context>({
|
|||||||
kCoreV1Api.basePath,
|
kCoreV1Api.basePath,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log.debug('INTG', 'Reading pod info for %s', pod)
|
||||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||||
pod,
|
pod,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -103,6 +113,7 @@ export default createIntegration<Context>({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Got pod info: %o', body.spec)
|
||||||
const shared = body.spec?.shareProcessNamespace
|
const shared = body.spec?.shareProcessNamespace
|
||||||
if (shared === undefined) {
|
if (shared === undefined) {
|
||||||
log.error(
|
log.error(
|
||||||
@@ -127,6 +138,7 @@ export default createIntegration<Context>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Looking for namespaced process in /proc')
|
||||||
const dir = resolve('/proc')
|
const dir = resolve('/proc')
|
||||||
try {
|
try {
|
||||||
const subdirs = await readdir(dir)
|
const subdirs = await readdir(dir)
|
||||||
@@ -139,11 +151,14 @@ export default createIntegration<Context>({
|
|||||||
|
|
||||||
const path = join('/proc', dir, 'cmdline')
|
const path = join('/proc', dir, 'cmdline')
|
||||||
try {
|
try {
|
||||||
|
log.debug('INTG', 'Reading %s', path)
|
||||||
const data = await readFile(path, 'utf8')
|
const data = await readFile(path, 'utf8')
|
||||||
if (data.includes('headscale')) {
|
if (data.includes('headscale')) {
|
||||||
return pid
|
return pid
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
log.debug('INTG', 'Failed to read %s: %s', path, error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises)
|
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) {
|
if (pids.length > 1) {
|
||||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||||
pids.length,
|
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) => {
|
onConfigChange: (context) => {
|
||||||
if (!context.pid) {
|
if (!context.pid) {
|
||||||
return
|
return
|
||||||
|
|||||||
+7
-11
@@ -22,6 +22,7 @@ export default createIntegration<Context>({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Checking /proc for Headscale process')
|
||||||
const dir = resolve('/proc')
|
const dir = resolve('/proc')
|
||||||
try {
|
try {
|
||||||
const subdirs = await readdir(dir)
|
const subdirs = await readdir(dir)
|
||||||
@@ -34,11 +35,14 @@ export default createIntegration<Context>({
|
|||||||
|
|
||||||
const path = join('/proc', dir, 'cmdline')
|
const path = join('/proc', dir, 'cmdline')
|
||||||
try {
|
try {
|
||||||
|
log.debug('INTG', 'Reading %s', path)
|
||||||
const data = await readFile(path, 'utf8')
|
const data = await readFile(path, 'utf8')
|
||||||
if (data.includes('headscale')) {
|
if (data.includes('headscale')) {
|
||||||
return pid
|
return pid
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
log.error('INTG', 'Failed to read %s: %s', path, error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises)
|
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) {
|
if (pids.length > 1) {
|
||||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||||
pids.length,
|
pids.length,
|
||||||
@@ -70,14 +75,5 @@ export default createIntegration<Context>({
|
|||||||
log.error('INTG', 'Failed to read /proc')
|
log.error('INTG', 'Failed to read /proc')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
onAclChange: (context) => {
|
|
||||||
if (!context.pid) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('INTG', 'Sending SIGHUP to Headscale')
|
|
||||||
kill(context.pid, 'SIGHUP')
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||||
import { type ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||||
|
import { setTimeout } from 'node:timers/promises'
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
import Button from '~/components/Button'
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
@@ -12,7 +13,7 @@ import Notice from '~/components/Notice'
|
|||||||
import Spinner from '~/components/Spinner'
|
import Spinner from '~/components/Spinner'
|
||||||
import { toast } from '~/components/Toaster'
|
import { toast } from '~/components/Toaster'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
|
||||||
@@ -27,7 +28,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
session.get('hsApiKey')!,
|
session.get('hsApiKey')!,
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(policy)
|
|
||||||
try {
|
try {
|
||||||
// We have read access, now do we have write access?
|
// We have read access, now do we have write access?
|
||||||
// Attempt to set the policy to what we just got
|
// Attempt to set the policy to what we just got
|
||||||
@@ -37,7 +37,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hasAclWrite: true,
|
hasAclWrite: true,
|
||||||
isPolicyApi: true,
|
|
||||||
currentAcl: policy,
|
currentAcl: policy,
|
||||||
aclType: 'json',
|
aclType: 'json',
|
||||||
} as const
|
} as const
|
||||||
@@ -49,35 +48,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
if (error.status === 500) {
|
if (error.status === 500) {
|
||||||
return {
|
return {
|
||||||
hasAclWrite: false,
|
hasAclWrite: false,
|
||||||
isPolicyApi: true,
|
|
||||||
currentAcl: policy,
|
currentAcl: policy,
|
||||||
aclType: 'json',
|
aclType: 'json',
|
||||||
} as const
|
} as const
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {}
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasAclWrite: true,
|
hasAclWrite: true,
|
||||||
isPolicyApi: true,
|
|
||||||
currentAcl: '',
|
currentAcl: '',
|
||||||
aclType: 'json',
|
aclType: 'json',
|
||||||
} as const
|
} as const
|
||||||
@@ -91,32 +70,18 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.json() as { acl: string, api: boolean }
|
const { acl } = await request.json() as { acl: string, api: boolean }
|
||||||
if (data.api) {
|
try {
|
||||||
try {
|
await put('v1/policy', session.get('hsApiKey')!, {
|
||||||
await put('v1/policy', session.get('hsApiKey')!, {
|
policy: acl,
|
||||||
policy: data.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,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
await patchAcl(data.acl)
|
await setTimeout(250)
|
||||||
|
return json({ success: true })
|
||||||
if (context.integration?.onAclChange) {
|
} catch (error) {
|
||||||
await context.integration.onAclChange(context.integration.context)
|
return json({ success: false }, {
|
||||||
|
status: error instanceof HeadscaleError ? error.status : 500,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
return json({ success: true })
|
||||||
@@ -175,23 +140,6 @@ export function ErrorBoundary() {
|
|||||||
<Code>database</Code>
|
<Code>database</Code>
|
||||||
.
|
.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,25 +173,13 @@ export default function Page() {
|
|||||||
? undefined
|
? undefined
|
||||||
: (
|
: (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
{data.isPolicyApi
|
<Notice className="w-fit">
|
||||||
? (
|
The ACL policy is read-only. You can view the current policy
|
||||||
<Notice className="w-fit">
|
but you cannot make changes to it.
|
||||||
The ACL policy is read-only. You can view the current policy
|
<br />
|
||||||
but you cannot make changes to it.
|
To resolve this, you need to set the ACL policy mode to
|
||||||
<br />
|
database in your Headscale configuration.
|
||||||
To resolve this, you need to set the ACL policy mode to
|
</Notice>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -357,7 +293,6 @@ export default function Page() {
|
|||||||
setToasted(false)
|
setToasted(false)
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
acl,
|
acl,
|
||||||
api: data.isPolicyApi,
|
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json',
|
encType: 'application/json',
|
||||||
|
|||||||
@@ -91,9 +91,9 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const key = `dns.nameservers.split."${name}"`
|
const key = `dns.nameservers.split."${name}"`
|
||||||
|
const list = nameservers.filter((_, i) => i !== index)
|
||||||
submit({
|
submit({
|
||||||
[key]: nameservers
|
[key]: list.length ? list : null,
|
||||||
.filter((_, i) => i !== index),
|
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json',
|
encType: 'application/json',
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
Devices are accessible at
|
Devices are accessible at
|
||||||
{' '}
|
{' '}
|
||||||
<Code>
|
<Code>
|
||||||
[device].[user].{name}
|
[device].{name}
|
||||||
</Code>
|
</Code>
|
||||||
{' '}
|
{' '}
|
||||||
when Magic DNS is enabled.
|
when Magic DNS is enabled.
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export async function loader() {
|
|||||||
const dns = {
|
const dns = {
|
||||||
prefixes: config.prefixes,
|
prefixes: config.prefixes,
|
||||||
magicDns: config.dns.magic_dns,
|
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,
|
nameservers: config.dns.nameservers.global,
|
||||||
splitDns: config.dns.nameservers.split,
|
splitDns: config.dns.nameservers.split,
|
||||||
searchDomains: config.dns.search_domains,
|
searchDomains: config.dns.search_domains,
|
||||||
@@ -107,7 +109,7 @@ export default function Page() {
|
|||||||
on the tailnet. Devices will be accessible at
|
on the tailnet. Devices will be accessible at
|
||||||
{' '}
|
{' '}
|
||||||
<Code>
|
<Code>
|
||||||
[device].[user].
|
[device].
|
||||||
{data.baseDomain}
|
{data.baseDomain}
|
||||||
</Code>
|
</Code>
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -97,6 +97,35 @@ 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 {
|
||||||
|
await post('v1/node/register', session.get('hsApiKey')!, {
|
||||||
|
user, key,
|
||||||
|
})
|
||||||
|
|
||||||
|
return json({ message: 'Machine registered' })
|
||||||
|
} catch {
|
||||||
|
return json({ message: 'Failed to register machine' }, {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return json({ message: 'Invalid method' }, {
|
return json({ message: 'Invalid method' }, {
|
||||||
status: 400,
|
status: 400,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { Dispatch, SetStateAction, useState } 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 { Machine, User } from '~/types'
|
||||||
|
|
||||||
|
export interface NewProps {
|
||||||
|
server: string
|
||||||
|
users: User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function New(data: NewProps) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const mkeyState = useState(false)
|
||||||
|
const pkeyState = useState(false)
|
||||||
|
const [mkey, setMkey] = useState('')
|
||||||
|
const [user, setUser] = useState(data.users[0].id)
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
submit(e.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="register" />
|
||||||
|
<input type="hidden" name="id" value="_" />
|
||||||
|
<TextField
|
||||||
|
label='Machine Key'
|
||||||
|
placeholder='nodekey: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"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</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 control={pkeyState} isDisabled>
|
||||||
|
<KeyIcon className='w-4 h-4 mr-2'/>
|
||||||
|
Generate Pre-auth Key
|
||||||
|
</Menu.ItemButton>
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
|
|||||||
tags.unshift('Expired')
|
tags.unshift('Expired')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prefix = magic?.startsWith('[user]')
|
||||||
|
? magic.replace('[user]', machine.user.name)
|
||||||
|
: magic
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={machine.id}
|
key={machine.id}
|
||||||
@@ -102,16 +106,14 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
|
|||||||
'justify-between w-full break-keep',
|
'justify-between w-full break-keep',
|
||||||
)}
|
)}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
|
const ip = `${machine.givenName}.${prefix}`
|
||||||
await navigator.clipboard.writeText(ip)
|
await navigator.clipboard.writeText(ip)
|
||||||
toast('Copied hostname to clipboard')
|
toast('Copied hostname to clipboard')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{machine.givenName}
|
{machine.givenName}
|
||||||
.
|
.
|
||||||
{machine.user.name}
|
{prefix}
|
||||||
.
|
|
||||||
{magic}
|
|
||||||
<CopyIcon className="w-3 h-3" />
|
<CopyIcon className="w-3 h-3" />
|
||||||
</Menu.ItemButton>
|
</Menu.ItemButton>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useLiveData } from '~/utils/useLiveData'
|
|||||||
|
|
||||||
import { menuAction } from './action'
|
import { menuAction } from './action'
|
||||||
import MachineRow from './machine'
|
import MachineRow from './machine'
|
||||||
|
import NewMachine from './dialogs/new'
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
@@ -32,6 +33,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
if (config.dns.magic_dns) {
|
if (config.dns.magic_dns) {
|
||||||
magic = config.dns.base_domain
|
magic = config.dns.base_domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.dns.use_username_in_magic_dns) {
|
||||||
|
magic = `[user].${magic}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -39,6 +44,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
routes: routes.routes,
|
routes: routes.routes,
|
||||||
users: users.users,
|
users: users.users,
|
||||||
magic,
|
magic,
|
||||||
|
server: context.headscaleUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +58,10 @@ export default function Page() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
||||||
|
<NewMachine server={data.server} users={data.users} />
|
||||||
|
</div>
|
||||||
<table className="table-auto w-full rounded-lg">
|
<table className="table-auto w-full rounded-lg">
|
||||||
<thead className="text-gray-500 dark:text-gray-400">
|
<thead className="text-gray-500 dark:text-gray-400">
|
||||||
<tr className="text-left uppercase text-xs font-bold px-0.5">
|
<tr className="text-left uppercase text-xs font-bold px-0.5">
|
||||||
@@ -76,7 +85,7 @@ export default function Page() {
|
|||||||
based on their name and also at
|
based on their name and also at
|
||||||
{' '}
|
{' '}
|
||||||
<Code>
|
<Code>
|
||||||
[name].[user].
|
[name].
|
||||||
{data.magic}
|
{data.magic}
|
||||||
</Code>
|
</Code>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
return {
|
return {
|
||||||
acl: context.acl,
|
|
||||||
config: context.config,
|
config: context.config,
|
||||||
user: session.get('user'),
|
user: session.get('user'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export default function Add({ magic }: Props) {
|
|||||||
{' '}
|
{' '}
|
||||||
<Code>
|
<Code>
|
||||||
[machine].
|
[machine].
|
||||||
{username.length > 0 ? username : '[username]'}
|
|
||||||
.
|
.
|
||||||
{magic}
|
{magic}
|
||||||
</Code>
|
</Code>
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export default function Rename({ username, magic }: Props) {
|
|||||||
{' '}
|
{' '}
|
||||||
<Code>
|
<Code>
|
||||||
[machine].
|
[machine].
|
||||||
{newName.length > 0 ? newName : '[new-username]'}
|
|
||||||
.
|
.
|
||||||
{magic}
|
{magic}
|
||||||
</Code>
|
</Code>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
|
|||||||
import log from '~/utils/log'
|
import log from '~/utils/log'
|
||||||
|
|
||||||
export interface HeadplaneContext {
|
export interface HeadplaneContext {
|
||||||
|
debug: boolean
|
||||||
headscaleUrl: string
|
headscaleUrl: string
|
||||||
cookieSecret: string
|
cookieSecret: string
|
||||||
integration: IntegrationFactory | undefined
|
integration: IntegrationFactory | undefined
|
||||||
@@ -22,11 +23,6 @@ export interface HeadplaneContext {
|
|||||||
write: boolean
|
write: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
acl: {
|
|
||||||
read: boolean
|
|
||||||
write: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
oidc?: {
|
oidc?: {
|
||||||
issuer: string
|
issuer: string
|
||||||
client: string
|
client: string
|
||||||
@@ -43,6 +39,12 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
return 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 path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||||
const { config, contextData } = await checkConfig(path)
|
const { config, contextData } = await checkConfig(path)
|
||||||
|
|
||||||
@@ -65,11 +67,11 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
|
debug,
|
||||||
headscaleUrl,
|
headscaleUrl,
|
||||||
cookieSecret,
|
cookieSecret,
|
||||||
integration: await loadIntegration(),
|
integration: await loadIntegration(),
|
||||||
config: contextData,
|
config: contextData,
|
||||||
acl: await checkAcl(config),
|
|
||||||
oidc: await checkOidc(config),
|
oidc: await checkOidc(config),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,80 +83,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
: 'Unavailable',
|
: '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')
|
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
|
||||||
return context
|
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) {
|
async function checkConfig(path: string) {
|
||||||
|
log.debug('CTXT', 'Checking config at %s', path)
|
||||||
|
|
||||||
let config: HeadscaleConfig | undefined
|
let config: HeadscaleConfig | undefined
|
||||||
try {
|
try {
|
||||||
config = await loadConfig(path)
|
config = await loadConfig(path)
|
||||||
} catch {
|
} catch {
|
||||||
|
log.debug('CTXT', 'Config at %s failed to load', path)
|
||||||
return {
|
return {
|
||||||
config: undefined,
|
config: undefined,
|
||||||
contextData: {
|
contextData: {
|
||||||
@@ -166,9 +106,12 @@ async function checkConfig(path: string) {
|
|||||||
|
|
||||||
let write = false
|
let write = false
|
||||||
try {
|
try {
|
||||||
|
log.debug('CTXT', 'Checking write access to %s', path)
|
||||||
await access(path, constants.W_OK)
|
await access(path, constants.W_OK)
|
||||||
write = true
|
write = true
|
||||||
} catch {}
|
} catch {
|
||||||
|
log.debug('CTXT', 'No write access to %s', path)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
@@ -179,34 +122,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) {
|
async function checkOidc(config?: HeadscaleConfig) {
|
||||||
|
log.debug('CTXT', 'Checking OIDC configuration')
|
||||||
|
|
||||||
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
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
|
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
|
||||||
if (!rootKey) {
|
if (!rootKey) {
|
||||||
throw new Error('ROOT_API_KEY or API_KEY not set')
|
throw new Error('ROOT_API_KEY or API_KEY not set')
|
||||||
@@ -216,6 +138,10 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
let client = process.env.OIDC_CLIENT_ID
|
let client = process.env.OIDC_CLIENT_ID
|
||||||
let secret = process.env.OIDC_CLIENT_SECRET
|
let secret = process.env.OIDC_CLIENT_SECRET
|
||||||
|
|
||||||
|
log.debug('CTXT', 'Checking OIDC environment variables')
|
||||||
|
log.debug('CTXT', 'Issuer: %s', issuer)
|
||||||
|
log.debug('CTXT', 'Client: %s', client)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(issuer ?? client ?? secret)
|
(issuer ?? client ?? secret)
|
||||||
&& !(issuer && client && secret)
|
&& !(issuer && client && secret)
|
||||||
@@ -240,6 +166,7 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
secret = config.oidc?.client_secret
|
secret = config.oidc?.client_secret
|
||||||
|
|
||||||
if (!secret && config.oidc?.client_secret_path) {
|
if (!secret && config.oidc?.client_secret_path) {
|
||||||
|
log.debug('CTXT', 'Trying to read OIDC client secret from %s', config.oidc.client_secret_path)
|
||||||
try {
|
try {
|
||||||
const data = await readFile(
|
const data = await readFile(
|
||||||
config.oidc.client_secret_path,
|
config.oidc.client_secret_path,
|
||||||
@@ -249,7 +176,9 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
secret = data.trim()
|
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),
|
disable_check_updates: goBool.default(false),
|
||||||
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
|
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
|
||||||
randomize_client_port: goBool.default(false),
|
randomize_client_port: goBool.default(false),
|
||||||
acl_policy_path: z.string().optional(),
|
|
||||||
|
|
||||||
acme_email: z.string().optional(),
|
acme_email: z.string().optional(),
|
||||||
acme_url: 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')
|
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')
|
const data = await readFile(path, 'utf8')
|
||||||
configYaml = parseDocument(data)
|
configYaml = parseDocument(data)
|
||||||
|
|
||||||
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
|
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>
|
const loaded = configYaml.toJSON() as Record<string, unknown>
|
||||||
config = {
|
config = {
|
||||||
...loaded,
|
...loaded,
|
||||||
@@ -250,8 +251,10 @@ export async function loadConfig(path?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
log.debug('CFGX', 'Attempting to parse Headscale configuration')
|
||||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
log.debug('CFGX', 'Failed to load Headscale configuration')
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
log.error('CFGX', 'Recieved invalid configuration file')
|
log.error('CFGX', 'Recieved invalid configuration file')
|
||||||
log.error('CFGX', 'The following schema issues were found:')
|
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')
|
throw new Error('Config not loaded')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.debug('CFGX', 'Patching Headscale configuration')
|
||||||
for (const [key, value] of Object.entries(partial)) {
|
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
|
// 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
|
// the foo.bar as a single key, and not as two keys, so that needs
|
||||||
// to be split correctly.
|
// to be split correctly.
|
||||||
@@ -309,6 +314,11 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
|||||||
|
|
||||||
// Push the remaining element
|
// Push the remaining element
|
||||||
path.push(temp.replaceAll('"', ''))
|
path.push(temp.replaceAll('"', ''))
|
||||||
|
if (value === null) {
|
||||||
|
configYaml.deleteIn(path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
configYaml.setIn(path, value)
|
configYaml.setIn(path, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,5 +327,6 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
|||||||
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
|
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
|
||||||
|
|
||||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
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')
|
await writeFile(path, configYaml.toString(), 'utf8')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { loadContext } from './config/headplane'
|
import { loadContext } from './config/headplane'
|
||||||
|
import log from './log'
|
||||||
|
|
||||||
export class HeadscaleError extends Error {
|
export class HeadscaleError extends Error {
|
||||||
status: number
|
status: number
|
||||||
@@ -20,6 +21,8 @@ export class FatalError extends Error {
|
|||||||
export async function pull<T>(url: string, key: string) {
|
export async function pull<T>(url: string, key: string) {
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
const prefix = context.headscaleUrl
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
|
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`)
|
||||||
const response = await fetch(`${prefix}/api/${url}`, {
|
const response = await fetch(`${prefix}/api/${url}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`,
|
Authorization: `Bearer ${key}`,
|
||||||
@@ -27,6 +30,7 @@ export async function pull<T>(url: string, key: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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)
|
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) {
|
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
const prefix = context.headscaleUrl
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
|
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`)
|
||||||
const response = await fetch(`${prefix}/api/${url}`, {
|
const response = await fetch(`${prefix}/api/${url}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
@@ -45,6 +51,7 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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)
|
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) {
|
export async function put<T>(url: string, key: string, body?: unknown) {
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
const prefix = context.headscaleUrl
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
|
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`)
|
||||||
const response = await fetch(`${prefix}/api/${url}`, {
|
const response = await fetch(`${prefix}/api/${url}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
@@ -63,6 +72,7 @@ export async function put<T>(url: string, key: string, body?: unknown) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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)
|
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) {
|
export async function del<T>(url: string, key: string) {
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
const prefix = context.headscaleUrl
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
|
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`)
|
||||||
const response = await fetch(`${prefix}/api/${url}`, {
|
const response = await fetch(`${prefix}/api/${url}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -80,6 +92,7 @@ export async function del<T>(url: string, key: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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)
|
throw new HeadscaleError(await response.text(), response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export default {
|
|||||||
error: (category: string, message: string, ...args: unknown[]) => {
|
error: (category: string, message: string, ...args: unknown[]) => {
|
||||||
defaultLog('ERRO', category, message, ...args)
|
defaultLog('ERRO', category, message, ...args)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
debug: (category: string, message: string, ...args: unknown[]) => {
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
defaultLog('DEBG', category, message, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultLog(
|
function defaultLog(
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import { redirect } from '@remix-run/node'
|
|||||||
import {
|
import {
|
||||||
authorizationCodeGrantRequest,
|
authorizationCodeGrantRequest,
|
||||||
calculatePKCECodeChallenge,
|
calculatePKCECodeChallenge,
|
||||||
type Client,
|
Client,
|
||||||
discoveryRequest,
|
discoveryRequest,
|
||||||
generateRandomCodeVerifier,
|
generateRandomCodeVerifier,
|
||||||
generateRandomNonce,
|
generateRandomNonce,
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ export const {
|
|||||||
maxAge: 60 * 60 * 24, // 24 hours
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
path: '/',
|
path: '/',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
secrets: [process.env.COOKIE_SECRET!],
|
secrets: [process.env.COOKIE_SECRET!],
|
||||||
secure: true
|
secure: process.env.COOKIE_SECURE !== 'false',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ networks:
|
|||||||
driver: 'bridge'
|
driver: 'bridge'
|
||||||
services:
|
services:
|
||||||
headscale:
|
headscale:
|
||||||
image: 'headscale/headscale:0.23.0-beta1'
|
image: 'headscale/headscale:0.23.0-beta2'
|
||||||
container_name: 'headscale'
|
container_name: 'headscale'
|
||||||
restart: 'unless-stopped'
|
restart: 'unless-stopped'
|
||||||
command: 'serve'
|
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
|
become available. All of the integrations support automatic reloading of the
|
||||||
ACLs when the file is changed.
|
ACLs when the file is changed.
|
||||||
|
|
||||||
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`. This
|
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`.
|
||||||
can be overridden by setting the `ACL_FILE` environment variable and is also
|
> If `policy.path` is set and `policy.mode` is set to `file`, the ACL file will
|
||||||
overriden by the `acl_policy_path` key in the configuration file if set.
|
> be read from the path specified in the configuration file instead.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Headscale 0.23 beta-2 or later
|
- Headscale 0.23 or newer
|
||||||
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
||||||
|
|
||||||
Currently there are 3 integration providers that can do this for you:
|
Currently there are 3 integration providers that can do this for you:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Headplane in a production environment.
|
|||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Headscale 0.23 beta-2 or later
|
- Headscale 0.23 or newer
|
||||||
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
||||||
|
|
||||||
Docker heavily simplifies the deployment process, but this process can be
|
Docker heavily simplifies the deployment process, but this process can be
|
||||||
@@ -29,7 +29,7 @@ Here is a simple Docker Compose deployment:
|
|||||||
services:
|
services:
|
||||||
headplane:
|
headplane:
|
||||||
container_name: headplane
|
container_name: headplane
|
||||||
image: ghcr.io/tale/headplane:latest
|
image: ghcr.io/tale/headplane:0.3.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
OIDC_ISSUER: 'https://sso.example.com'
|
OIDC_ISSUER: 'https://sso.example.com'
|
||||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||||
DISABLE_API_KEY_LOGIN: 'true'
|
DISABLE_API_KEY_LOGIN: 'true'
|
||||||
|
COOKIE_SECURE: 'false'
|
||||||
|
|
||||||
# These are the default values
|
# These are the default values
|
||||||
HOST: '0.0.0.0'
|
HOST: '0.0.0.0'
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ You can configure Headplane using environment variables.
|
|||||||
|
|
||||||
#### Optional Variables
|
#### Optional Variables
|
||||||
|
|
||||||
|
- **`DEBUG`**: Enable debug logging (default: `false`).
|
||||||
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
|
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
|
||||||
- **`PORT`**: The port to bind the server to (default: `3000`).
|
- **`PORT`**: The port to bind the server to (default: `3000`).
|
||||||
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
|
- **`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`).
|
- **`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
|
#### Docker Integration
|
||||||
The Docker integration allows Headplane to manage the Headscale docker container.
|
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
|
```yaml
|
||||||
services:
|
services:
|
||||||
headscale:
|
headscale:
|
||||||
image: 'headscale/headscale:0.23.0-beta2'
|
image: 'headscale/headscale:0.23.0'
|
||||||
container_name: 'headscale'
|
container_name: 'headscale'
|
||||||
restart: 'unless-stopped'
|
restart: 'unless-stopped'
|
||||||
command: 'serve'
|
command: 'serve'
|
||||||
@@ -50,7 +50,7 @@ services:
|
|||||||
TZ: 'America/New_York'
|
TZ: 'America/New_York'
|
||||||
headplane:
|
headplane:
|
||||||
container_name: headplane
|
container_name: headplane
|
||||||
image: ghcr.io/tale/headplane:latest
|
image: ghcr.io/tale/headplane:0.3.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- './data:/var/lib/headscale'
|
- './data:/var/lib/headscale'
|
||||||
@@ -68,6 +68,9 @@ services:
|
|||||||
HOST: '0.0.0.0'
|
HOST: '0.0.0.0'
|
||||||
PORT: '3000'
|
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
|
# 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
|
# If you want to share the same OIDC configuration you do not need this
|
||||||
OIDC_CLIENT_ID: 'headscale'
|
OIDC_CLIENT_ID: 'headscale'
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ spec:
|
|||||||
serviceAccountName: default
|
serviceAccountName: default
|
||||||
containers:
|
containers:
|
||||||
- name: headplane
|
- name: headplane
|
||||||
image: ghcr.io/tale/headplane:latest
|
image: ghcr.io/tale/headplane:0.3.0
|
||||||
env:
|
env:
|
||||||
- name: COOKIE_SECRET
|
- name: COOKIE_SECRET
|
||||||
value: 'abcdefghijklmnopqrstuvwxyz'
|
value: 'abcdefghijklmnopqrstuvwxyz'
|
||||||
@@ -98,12 +98,16 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.name
|
fieldPath: metadata.name
|
||||||
|
|
||||||
|
# Only set this to false if you aren't behind a reverse proxy
|
||||||
|
- name: COOKIE_SECURE
|
||||||
|
value: 'false'
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: headscale-config
|
- name: headscale-config
|
||||||
mountPath: /etc/headscale
|
mountPath: /etc/headscale
|
||||||
|
|
||||||
- name: headscale
|
- name: headscale
|
||||||
image: headscale/headscale:0.23.0-beta2
|
image: headscale/headscale:0.23.0
|
||||||
command: ['serve']
|
command: ['serve']
|
||||||
env:
|
env:
|
||||||
- name: TZ
|
- name: TZ
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.3.3",
|
"vite": "^5.3.3",
|
||||||
"vite-plugin-babel": "^1.2.0",
|
"vite-plugin-babel": "^1.2.0",
|
||||||
"vite-tsconfig-paths": "^4.2.1"
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/react": "npm:types-react@beta",
|
"@types/react": "npm:types-react@beta",
|
||||||
|
|||||||
Generated
+7
-12
@@ -129,7 +129,7 @@ importers:
|
|||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0(@babel/core@7.24.7)(vite@5.3.3(@types/node@20.14.10))
|
version: 1.2.0(@babel/core@7.24.7)(vite@5.3.3(@types/node@20.14.10))
|
||||||
vite-tsconfig-paths:
|
vite-tsconfig-paths:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.3.2
|
||||||
version: 4.3.2(typescript@5.5.3)(vite@5.3.3(@types/node@20.14.10))
|
version: 4.3.2(typescript@5.5.3)(vite@5.3.3(@types/node@20.14.10))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@@ -1889,11 +1889,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001600:
|
caniuse-lite@1.0.30001666:
|
||||||
resolution: {integrity: sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==}
|
resolution: {integrity: sha512-gD14ICmoV5ZZM1OdzPWmpx+q4GyefaK06zi8hmfHV5xe4/2nOQX3+Dw5o+fSqOws2xVwL9j+anOPFwHzdEdV4g==}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001640:
|
|
||||||
resolution: {integrity: sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==}
|
|
||||||
|
|
||||||
caseless@0.12.0:
|
caseless@0.12.0:
|
||||||
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
|
||||||
@@ -6880,7 +6877,7 @@ snapshots:
|
|||||||
autoprefixer@10.4.19(postcss@8.4.39):
|
autoprefixer@10.4.19(postcss@8.4.39):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.23.0
|
browserslist: 4.23.0
|
||||||
caniuse-lite: 1.0.30001600
|
caniuse-lite: 1.0.30001666
|
||||||
fraction.js: 4.3.7
|
fraction.js: 4.3.7
|
||||||
normalize-range: 0.1.2
|
normalize-range: 0.1.2
|
||||||
picocolors: 1.0.0
|
picocolors: 1.0.0
|
||||||
@@ -6963,14 +6960,14 @@ snapshots:
|
|||||||
|
|
||||||
browserslist@4.23.0:
|
browserslist@4.23.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-lite: 1.0.30001600
|
caniuse-lite: 1.0.30001666
|
||||||
electron-to-chromium: 1.4.716
|
electron-to-chromium: 1.4.716
|
||||||
node-releases: 2.0.14
|
node-releases: 2.0.14
|
||||||
update-browserslist-db: 1.0.13(browserslist@4.23.0)
|
update-browserslist-db: 1.0.13(browserslist@4.23.0)
|
||||||
|
|
||||||
browserslist@4.23.1:
|
browserslist@4.23.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-lite: 1.0.30001640
|
caniuse-lite: 1.0.30001666
|
||||||
electron-to-chromium: 1.4.818
|
electron-to-chromium: 1.4.818
|
||||||
node-releases: 2.0.14
|
node-releases: 2.0.14
|
||||||
update-browserslist-db: 1.1.0(browserslist@4.23.1)
|
update-browserslist-db: 1.1.0(browserslist@4.23.1)
|
||||||
@@ -7019,9 +7016,7 @@ snapshots:
|
|||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001600: {}
|
caniuse-lite@1.0.30001666: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001640: {}
|
|
||||||
|
|
||||||
caseless@0.12.0: {}
|
caseless@0.12.0: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user