mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| d041a62fcd | |||
| 0e6b5ea6d0 | |||
| a2054786f8 | |||
| 5a46fd0a97 | |||
| b8999161a2 | |||
| 224cbbdcaf | |||
| 75ba3a3dc7 | |||
| 4f57fdb43b | |||
| b170e11dd6 | |||
| 6b278309ed | |||
| ea8ecfb28f | |||
| 099bd3bcb8 | |||
| 0aa0406ea6 | |||
| 3cc726320a | |||
| bda151f4e8 | |||
| 6d411853d5 | |||
| dc4d05a2d9 | |||
| 361859f374 | |||
| c60652e39f | |||
| 1db385e716 | |||
| ab0cb7b782 | |||
| fd73832879 | |||
| 7804d83181 | |||
| fe40a5734e |
@@ -1,3 +1,43 @@
|
|||||||
|
### 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
|
||||||
|
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
||||||
|
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
|
||||||
|
|
||||||
|
### 0.2.2 (August 2, 2024)
|
||||||
|
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||||
|
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||||
|
- Reimplemented the integration system to be more resilient and log more information.
|
||||||
|
- Fixed an issue where the /proc integration found `undefined` PIDs.
|
||||||
|
|
||||||
|
### 0.2.1 (July 7, 2024)
|
||||||
|
- Added the ability to manage custom DNS records on your Tailnet.
|
||||||
|
- ACL tags for machines are now able to be changed via the machine menu.
|
||||||
|
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||||
|
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
|
||||||
|
|
||||||
### 0.2.0 (June 23, 2024)
|
### 0.2.0 (June 23, 2024)
|
||||||
- Fix the dropdown options for machines not working on the machines page.
|
- Fix the dropdown options for machines not working on the machines page.
|
||||||
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
||||||
|
|||||||
@@ -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']
|
||||||
}
|
}
|
||||||
@@ -102,11 +101,7 @@ export default function Header({ data }: Properties) {
|
|||||||
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
||||||
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
|
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
|
||||||
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
|
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
|
||||||
{data?.acl.read
|
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
||||||
? (
|
|
||||||
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
{data?.config.read
|
{data?.config.read
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { InfoIcon } from '@primer/octicons-react'
|
import { InfoIcon } from '@primer/octicons-react'
|
||||||
import clsx from 'clsx'
|
import type { ReactNode } from 'react'
|
||||||
import { type ReactNode } from 'react'
|
|
||||||
|
|
||||||
export default function Notice({ children }: { readonly children: ReactNode }) {
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Notice({ children, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={cn(
|
||||||
'p-4 rounded-md w-fit flex items-center gap-3',
|
'p-4 rounded-md w-full flex items-center gap-3',
|
||||||
'bg-slate-400 dark:bg-slate-700'
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<InfoIcon className='h-6 w-6 text-white'/>
|
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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"
|
||||||
|
name={props.name}
|
||||||
|
/>
|
||||||
|
<Button slot="decrement">
|
||||||
|
<DashIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button slot="increment">
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</AriaNumberField>
|
||||||
|
)
|
||||||
|
}
|
||||||
+139
-110
@@ -4,124 +4,153 @@ import { setTimeout } from 'node:timers/promises'
|
|||||||
import { Client } from 'undici'
|
import { Client } from 'undici'
|
||||||
|
|
||||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
import type { Integration } from '.'
|
import { createIntegration } from './integration'
|
||||||
|
|
||||||
// Integration name
|
interface Context {
|
||||||
const name = 'Docker'
|
client: Client | undefined
|
||||||
|
container: string | undefined
|
||||||
let url: URL | undefined
|
maxAttempts: number
|
||||||
let container: string | undefined
|
|
||||||
|
|
||||||
async function preflight() {
|
|
||||||
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
|
||||||
|
|
||||||
try {
|
|
||||||
url = new URL(path)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// The API is available as an HTTP endpoint
|
|
||||||
if (url.protocol === 'tcp:') {
|
|
||||||
url.protocol = 'http:'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the socket is accessible
|
|
||||||
if (url.protocol === 'unix:') {
|
|
||||||
try {
|
|
||||||
await access(path, constants.R_OK)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol === 'http:') {
|
|
||||||
try {
|
|
||||||
await fetch(new URL('/v1.30/version', url).href)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
container = process.env.HEADSCALE_CONTAINER
|
|
||||||
?.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
|
|
||||||
if (!container || container.length === 0) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sighup() {
|
export default createIntegration<Context>({
|
||||||
if (!url || !container) {
|
name: 'Docker',
|
||||||
return
|
context: {
|
||||||
}
|
client: undefined,
|
||||||
|
container: undefined,
|
||||||
|
maxAttempts: 10,
|
||||||
|
},
|
||||||
|
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()
|
||||||
|
|
||||||
// Supports the DOCKER_SOCK environment variable
|
if (!context.container || context.container.length === 0) {
|
||||||
const client = url.protocol === 'unix:'
|
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
|
||||||
? new Client('http://localhost', {
|
return false
|
||||||
socketPath: url.href,
|
}
|
||||||
})
|
|
||||||
: new Client(url.href)
|
|
||||||
|
|
||||||
const response = await client.request({
|
log.info('INTG', 'Using container: %s', context.container)
|
||||||
method: 'POST',
|
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
||||||
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`,
|
let url: URL | undefined
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.statusCode || response.statusCode !== 204) {
|
|
||||||
throw new Error('Failed to send SIGHUP to Headscale')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restart() {
|
|
||||||
if (!url || !container) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supports the DOCKER_SOCK environment variable
|
|
||||||
const client = url.protocol === 'unix:'
|
|
||||||
? new Client('http://localhost', {
|
|
||||||
socketPath: url.href,
|
|
||||||
})
|
|
||||||
: new Client(url.href)
|
|
||||||
|
|
||||||
const response = await client.request({
|
|
||||||
method: 'POST',
|
|
||||||
path: `/v1.30/containers/${container}/restart`,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.statusCode || response.statusCode !== 204) {
|
|
||||||
throw new Error('Failed to restart Headscale')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for Headscale to restart before continuing
|
|
||||||
let attempts = 0
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
|
|
||||||
while (true) {
|
|
||||||
try {
|
try {
|
||||||
await pull('v1', '')
|
url = new URL(path)
|
||||||
|
} catch {
|
||||||
|
log.error('INTG', 'Invalid Docker socket path: %s', path)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
|
||||||
|
log.error('INTG', 'Invalid Docker socket protocol: %s',
|
||||||
|
url.protocol,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API is available as an HTTP endpoint and this
|
||||||
|
// will simplify the fetching logic in undici
|
||||||
|
if (url.protocol === 'tcp:') {
|
||||||
|
// Apparently setting url.protocol doesn't work anymore?
|
||||||
|
const fetchU = url.href.replace(url.protocol, 'http:')
|
||||||
|
|
||||||
|
try {
|
||||||
|
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(fetchU)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the socket is accessible
|
||||||
|
if (url.protocol === 'unix:') {
|
||||||
|
try {
|
||||||
|
log.info('INTG', 'Checking socket: %s',
|
||||||
|
url.pathname,
|
||||||
|
)
|
||||||
|
await access(url.pathname, constants.R_OK)
|
||||||
|
} catch (error) {
|
||||||
|
log.debug('INTG', 'Failed to access Docker socket: %s', error)
|
||||||
|
log.error('INTG', 'Failed to access Docker socket: %s',
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
context.client = new Client('http://localhost', {
|
||||||
|
socketPath: url.pathname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.client !== undefined
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfigChange: async (context) => {
|
||||||
|
if (!context.client || !context.container) {
|
||||||
return
|
return
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof HeadscaleError && error.status === 401) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempts > 10) {
|
|
||||||
throw new Error('Headscale did not restart in time')
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts++
|
|
||||||
await setTimeout(1000)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { name, preflight, sighup, restart } satisfies Integration
|
log.info('INTG', 'Restarting Headscale via Docker')
|
||||||
|
|
||||||
|
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`,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts = 0
|
||||||
|
while (attempts <= context.maxAttempts) {
|
||||||
|
try {
|
||||||
|
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
|
||||||
|
await pull('v1', '')
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HeadscaleError && error.status === 401) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof HeadscaleError && error.status === 404) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts < context.maxAttempts) {
|
||||||
|
attempts++
|
||||||
|
await setTimeout(1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Missed restart deadline for ${context.container}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
+53
-36
@@ -1,58 +1,75 @@
|
|||||||
import docker from './docker'
|
import log from '~/utils/log'
|
||||||
import proc from './proc'
|
|
||||||
|
|
||||||
export interface Integration {
|
import dockerIntegration from './docker'
|
||||||
name: string
|
import { IntegrationFactory } from './integration'
|
||||||
preflight: () => Promise<boolean>
|
import kubernetesIntegration from './kubernetes'
|
||||||
sighup?: () => Promise<void>
|
import procIntegration from './proc'
|
||||||
restart?: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Because we previously supported the Docker integration by
|
export * from './integration'
|
||||||
// checking for the HEADSCALE_CONTAINER variable, we need to
|
|
||||||
// check for it here as well.
|
export async function loadIntegration() {
|
||||||
//
|
|
||||||
// This ensures that when people upgrade from older versions
|
|
||||||
// of Headplane, they don't explicitly need to define the new
|
|
||||||
// HEADSCALE_INTEGRATION variable that is needed to configure
|
|
||||||
// an integration.
|
|
||||||
export async function checkIntegration() {
|
|
||||||
let integration = process.env.HEADSCALE_INTEGRATION
|
let integration = process.env.HEADSCALE_INTEGRATION
|
||||||
?.trim()
|
?.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
||||||
// Old HEADSCALE_CONTAINER variable upgrade path
|
// Old HEADSCALE_CONTAINER variable upgrade path
|
||||||
|
// This ensures that when people upgrade from older versions of Headplane
|
||||||
|
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
|
||||||
|
// variable that is needed to configure docker
|
||||||
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
||||||
integration = 'docker'
|
integration = 'docker'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!integration) {
|
if (!integration) {
|
||||||
console.log('Running Headplane without any integrations')
|
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let module: Integration | undefined
|
let integrationFactory: IntegrationFactory | undefined
|
||||||
try {
|
switch (integration.toLowerCase().trim()) {
|
||||||
module = getIntegration(integration)
|
|
||||||
await module.preflight()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load integration', error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return module
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIntegration(name: string) {
|
|
||||||
switch (name) {
|
|
||||||
case 'docker': {
|
case 'docker': {
|
||||||
return docker
|
integrationFactory = dockerIntegration
|
||||||
|
break
|
||||||
}
|
}
|
||||||
case 'proc': {
|
|
||||||
return proc
|
case 'proc':
|
||||||
|
case 'native':
|
||||||
|
case 'linux': {
|
||||||
|
integrationFactory = procIntegration
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'kubernetes':
|
||||||
|
case 'k8s': {
|
||||||
|
integrationFactory = kubernetesIntegration
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Unknown integration: ${name}`)
|
log.error('INTG', 'Unknown integration: %s', integration)
|
||||||
|
throw new Error(`Unknown integration: ${integration}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Loading integration: %s', integration)
|
||||||
|
try {
|
||||||
|
const res = await integrationFactory.isAvailable(
|
||||||
|
integrationFactory.context,
|
||||||
|
)
|
||||||
|
if (!res) {
|
||||||
|
log.error('INTG', 'Integration %s is not available',
|
||||||
|
integration,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('INTG', 'Failed to load integration %s: %s',
|
||||||
|
integration,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Loaded integration: %s', integration)
|
||||||
|
return integrationFactory
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export interface IntegrationFactory<T = any> {
|
||||||
|
name: string
|
||||||
|
context: T
|
||||||
|
isAvailable: (context: T) => Promise<boolean> | boolean
|
||||||
|
onConfigChange?: (context: T) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIntegration<T>(
|
||||||
|
options: IntegrationFactory<T>,
|
||||||
|
) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { platform } from 'node:os'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { kill } from 'node:process'
|
||||||
|
|
||||||
|
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'
|
||||||
|
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
|
import { createIntegration } from './integration'
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
pid: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createIntegration<Context>({
|
||||||
|
name: 'Kubernetes (k8s)',
|
||||||
|
context: {
|
||||||
|
pid: undefined,
|
||||||
|
},
|
||||||
|
isAvailable: async (context) => {
|
||||||
|
if (platform() !== 'linux') {
|
||||||
|
log.error('INTG', 'Kubernetes is only available on Linux')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedFiles = new Set(files.map(file => join(svcRoot, file)))
|
||||||
|
const expectedFiles = [
|
||||||
|
Config.SERVICEACCOUNT_CA_PATH,
|
||||||
|
Config.SERVICEACCOUNT_TOKEN_PATH,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('INTG', 'Failed to access %s: %s', svcRoot, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
||||||
|
const namespace = await readFile(
|
||||||
|
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Some very ugly nesting but it's necessary
|
||||||
|
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
||||||
|
log.warn('INTG', 'Skipping strict Pod status check')
|
||||||
|
} else {
|
||||||
|
const pod = process.env.POD_NAME
|
||||||
|
if (!pod) {
|
||||||
|
log.error('INTG', 'Missing POD_NAME variable')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pod.trim().length === 0) {
|
||||||
|
log.error('INTG', 'Pod name is empty')
|
||||||
|
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()
|
||||||
|
|
||||||
|
const cluster = kc.getCurrentCluster()
|
||||||
|
if (!cluster) {
|
||||||
|
log.error('INTG', 'Malformed kubeconfig')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Service account connected to %s (%s)',
|
||||||
|
cluster.name,
|
||||||
|
cluster.server,
|
||||||
|
)
|
||||||
|
|
||||||
|
const kCoreV1Api = kc.makeApiClient(CoreV1Api)
|
||||||
|
|
||||||
|
log.info('INTG', 'Checking pod %s in namespace %s (%s)',
|
||||||
|
pod,
|
||||||
|
namespace,
|
||||||
|
kCoreV1Api.basePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.debug('INTG', 'Reading pod info for %s', pod)
|
||||||
|
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||||
|
pod,
|
||||||
|
namespace,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
log.error('INTG', 'Failed to read pod info: http %d',
|
||||||
|
response.statusCode,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Got pod info: %o', body.spec)
|
||||||
|
const shared = body.spec?.shareProcessNamespace
|
||||||
|
if (shared === undefined) {
|
||||||
|
log.error(
|
||||||
|
'INTG',
|
||||||
|
'Pod does not have spec.shareProcessNamespace set',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shared) {
|
||||||
|
log.error(
|
||||||
|
'INTG',
|
||||||
|
'Pod has set but disabled spec.shareProcessNamespace',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Pod %s enabled shared processes', pod)
|
||||||
|
} catch (error) {
|
||||||
|
log.error('INTG', 'Failed to read pod info: %s', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Looking for namespaced process in /proc')
|
||||||
|
const dir = resolve('/proc')
|
||||||
|
try {
|
||||||
|
const subdirs = await readdir(dir)
|
||||||
|
const promises = subdirs.map(async (dir) => {
|
||||||
|
const pid = Number.parseInt(dir, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(pid)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (error) {
|
||||||
|
log.debug('INTG', 'Failed to read %s: %s', path, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises)
|
||||||
|
const pids = []
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
pids.push(result.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||||
|
if (pids.length > 1) {
|
||||||
|
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||||
|
pids.length,
|
||||||
|
pids.join(', '),
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pids.length === 0) {
|
||||||
|
log.error('INTG', 'Could not find Headscale process')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
context.pid = pids[0]
|
||||||
|
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
log.error('INTG', 'Failed to read /proc')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfigChange: (context) => {
|
||||||
|
if (!context.pid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Sending SIGTERM to Headscale')
|
||||||
|
kill(context.pid, 'SIGTERM')
|
||||||
|
},
|
||||||
|
})
|
||||||
+63
-67
@@ -1,83 +1,79 @@
|
|||||||
import { access, constants, readdir, readFile } from 'node:fs/promises'
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
import { platform } from 'node:os'
|
import { platform } from 'node:os'
|
||||||
import { join, resolve } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
import { kill } from 'node:process'
|
import { kill } from 'node:process'
|
||||||
|
|
||||||
import type { Integration } from '.'
|
import log from '~/utils/log'
|
||||||
|
|
||||||
// Integration name
|
import { createIntegration } from './integration'
|
||||||
const name = 'Native Linux (/proc)'
|
|
||||||
|
|
||||||
// Check if we have a /proc and if it's readable
|
interface Context {
|
||||||
async function preflight() {
|
pid: number | undefined
|
||||||
if (platform() !== 'linux') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const dir = resolve('/proc')
|
|
||||||
try {
|
|
||||||
await access(dir, constants.R_OK)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to access /proc', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findPid() {
|
export default createIntegration<Context>({
|
||||||
const dirs = await readdir('/proc')
|
name: 'Native Linux (/proc)',
|
||||||
|
context: {
|
||||||
const promises = dirs.map(async (dir) => {
|
pid: undefined,
|
||||||
const pid = Number.parseInt(dir, 10)
|
},
|
||||||
|
isAvailable: async (context) => {
|
||||||
if (Number.isNaN(pid)) {
|
if (platform() !== 'linux') {
|
||||||
return
|
log.error('INTG', '/proc is only available on Linux')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join('/proc', dir, 'cmdline')
|
log.debug('INTG', 'Checking /proc for Headscale process')
|
||||||
|
const dir = resolve('/proc')
|
||||||
try {
|
try {
|
||||||
const data = await readFile(path, 'utf8')
|
const subdirs = await readdir(dir)
|
||||||
if (data.includes('headscale')) {
|
const promises = subdirs.map(async (dir) => {
|
||||||
return pid
|
const pid = Number.parseInt(dir, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(pid)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (error) {
|
||||||
|
log.error('INTG', 'Failed to read %s: %s', path, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises)
|
||||||
|
const pids = []
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
pids.push(result.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises)
|
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||||
const pids = []
|
if (pids.length > 1) {
|
||||||
|
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||||
|
pids.length,
|
||||||
|
pids.join(', '),
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
for (const result of results) {
|
if (pids.length === 0) {
|
||||||
if (result.status === 'fulfilled') {
|
log.error('INTG', 'Could not find Headscale process')
|
||||||
pids.push(result.value)
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
context.pid = pids[0]
|
||||||
|
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
log.error('INTG', 'Failed to read /proc')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
if (pids.length > 1) {
|
|
||||||
console.warn('Found multiple Headscale processes', pids)
|
|
||||||
console.log('Disabling the /proc integration')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pids.length === 0) {
|
|
||||||
console.warn('Could not find Headscale process')
|
|
||||||
console.log('Disabling the /proc integration')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return pids[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sighup() {
|
|
||||||
const pid = await findPid()
|
|
||||||
if (!pid) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
kill(pid, 'SIGHUP')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send SIGHUP to Headscale', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { name, preflight, sighup } satisfies Integration
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { ClientOnly } from 'remix-utils/client-only'
|
|||||||
import Fallback from '~/routes/_data.acls._index/fallback'
|
import Fallback from '~/routes/_data.acls._index/fallback'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
interface MonacoProps {
|
interface Props {
|
||||||
variant: 'editor' | 'diff'
|
variant: 'edit' | 'diff'
|
||||||
language: 'json' | 'yaml'
|
language: 'json' | 'yaml'
|
||||||
value: string
|
state: [string, (value: string) => void]
|
||||||
onChange: (value: string) => void
|
policy?: string
|
||||||
original?: string
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function monacoCallback(monaco: Monaco) {
|
function monacoCallback(monaco: Monaco) {
|
||||||
@@ -26,7 +26,7 @@ function monacoCallback(monaco: Monaco) {
|
|||||||
monaco.languages.register({ id: 'yaml' })
|
monaco.languages.register({ id: 'yaml' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MonacoEditor({ value, onChange, variant, original, language }: MonacoProps) {
|
export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
|
||||||
const [light, setLight] = useState(false)
|
const [light, setLight] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,29 +46,30 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="overflow-y-scroll h-editor text-sm">
|
<div className="overflow-y-scroll h-editor text-sm">
|
||||||
<ClientOnly fallback={<Fallback acl={value} />}>
|
<ClientOnly fallback={<Fallback acl={state[0]} />}>
|
||||||
{() => variant === 'editor'
|
{() => variant === 'edit'
|
||||||
? (
|
? (
|
||||||
<Editor
|
<Editor
|
||||||
height="100%"
|
height="100%"
|
||||||
language={language}
|
language={language}
|
||||||
theme={light ? 'light' : 'vs-dark'}
|
theme={light ? 'light' : 'vs-dark'}
|
||||||
value={value}
|
value={state[0]}
|
||||||
onChange={(updated) => {
|
onChange={(updated) => {
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updated !== value) {
|
if (updated !== state[0]) {
|
||||||
onChange(updated)
|
state[1](updated)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
loading={<Fallback acl={value} />}
|
loading={<Fallback acl={state[0]} />}
|
||||||
beforeMount={monacoCallback}
|
beforeMount={monacoCallback}
|
||||||
options={{
|
options={{
|
||||||
wordWrap: 'on',
|
wordWrap: 'on',
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
readOnly: isDisabled,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -77,14 +78,15 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
|
|||||||
height="100%"
|
height="100%"
|
||||||
language={language}
|
language={language}
|
||||||
theme={light ? 'light' : 'vs-dark'}
|
theme={light ? 'light' : 'vs-dark'}
|
||||||
original={original}
|
original={policy}
|
||||||
modified={value}
|
modified={state[0]}
|
||||||
loading={<Fallback acl={value} />}
|
loading={<Fallback acl={state[0]} />}
|
||||||
beforeMount={monacoCallback}
|
beforeMount={monacoCallback}
|
||||||
options={{
|
options={{
|
||||||
wordWrap: 'on',
|
wordWrap: 'on',
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
readOnly: isDisabled,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,32 +1,65 @@
|
|||||||
|
/* 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 } 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 { 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 Link from '~/components/Link'
|
import Link from '~/components/Link'
|
||||||
import Notice from '~/components/Notice'
|
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 { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
|
||||||
import Monaco from './editor'
|
import Monaco from './editor'
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const context = await loadContext()
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!context.acl.read) {
|
|
||||||
throw new Error('No ACL configuration is available')
|
try {
|
||||||
}
|
const { policy } = await pull<{ policy: string }>(
|
||||||
|
'v1/policy',
|
||||||
|
session.get('hsApiKey')!,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasAclWrite: true,
|
||||||
|
currentAcl: policy,
|
||||||
|
aclType: 'json',
|
||||||
|
} as const
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof HeadscaleError)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.status === 500) {
|
||||||
|
return {
|
||||||
|
hasAclWrite: false,
|
||||||
|
currentAcl: policy,
|
||||||
|
aclType: 'json',
|
||||||
|
} as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const { data, type } = await loadAcl()
|
|
||||||
return {
|
return {
|
||||||
hasAclWrite: context.acl.write,
|
hasAclWrite: true,
|
||||||
currentAcl: data,
|
currentAcl: '',
|
||||||
aclType: type,
|
aclType: 'json',
|
||||||
}
|
} as const
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
@@ -37,27 +70,102 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await loadContext()
|
const { acl } = await request.json() as { acl: string, api: boolean }
|
||||||
if (!context.acl.write) {
|
try {
|
||||||
return json({ success: false }, {
|
await put('v1/policy', session.get('hsApiKey')!, {
|
||||||
status: 403,
|
policy: acl,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const data = await request.json() as { acl: string }
|
await setTimeout(250)
|
||||||
await patchAcl(data.acl)
|
return json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
if (context.integration?.sighup) {
|
return json({ success: false }, {
|
||||||
await context.integration.sighup()
|
status: error instanceof HeadscaleError ? error.status : 500,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
|
const fetcher = useFetcher<typeof action>()
|
||||||
const [acl, setAcl] = useState(data.currentAcl)
|
const [acl, setAcl] = useState(data.currentAcl)
|
||||||
const fetcher = useFetcher()
|
const [toasted, setToasted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fetcher.data || toasted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetcher.data.success) {
|
||||||
|
toast('Updated tailnet ACL policy')
|
||||||
|
} else {
|
||||||
|
toast('Failed to update tailnet ACL policy')
|
||||||
|
}
|
||||||
|
|
||||||
|
setToasted(true)
|
||||||
|
setAcl(data.currentAcl)
|
||||||
|
}, [fetcher.data, toasted, data.currentAcl])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -65,9 +173,12 @@ export default function Page() {
|
|||||||
? undefined
|
? undefined
|
||||||
: (
|
: (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Notice>
|
<Notice className="w-fit">
|
||||||
The ACL policy file is readonly to Headplane.
|
The ACL policy is read-only. You can view the current policy
|
||||||
You will not be able to make changes here.
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -144,19 +255,18 @@ export default function Page() {
|
|||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel id="edit">
|
<TabPanel id="edit">
|
||||||
<Monaco
|
<Monaco
|
||||||
variant="editor"
|
isDisabled={!data.hasAclWrite}
|
||||||
|
variant="edit"
|
||||||
language={data.aclType}
|
language={data.aclType}
|
||||||
value={acl}
|
state={[acl, setAcl]}
|
||||||
onChange={setAcl}
|
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="diff">
|
<TabPanel id="diff">
|
||||||
<Monaco
|
<Monaco
|
||||||
variant="editor"
|
variant="diff"
|
||||||
language={data.aclType}
|
language={data.aclType}
|
||||||
value={acl}
|
state={[acl, setAcl]}
|
||||||
onChange={setAcl}
|
policy={data.currentAcl}
|
||||||
original={data.currentAcl}
|
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="preview">
|
<TabPanel id="preview">
|
||||||
@@ -180,14 +290,13 @@ export default function Page() {
|
|||||||
className="mr-2"
|
className="mr-2"
|
||||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
setToasted(false)
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
acl,
|
acl,
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json',
|
encType: 'application/json',
|
||||||
})
|
})
|
||||||
|
|
||||||
toast('Updated tailnet ACL policy')
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{fetcher.state === 'idle'
|
{fetcher.state === 'idle'
|
||||||
@@ -197,7 +306,10 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button>
|
<Button
|
||||||
|
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
|
||||||
|
onPress={() => { setAcl(data.currentAcl) }}
|
||||||
|
>
|
||||||
Discard Changes
|
Discard Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import Code from '~/components/Code'
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
|
import TextField from '~/components/TextField'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
records: { name: string, type: 'A', value: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddDNS({ records }: Props) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [ip, setIp] = useState('')
|
||||||
|
|
||||||
|
const isDuplicate = useMemo(() => {
|
||||||
|
if (name.length === 0 || ip.length === 0) return false
|
||||||
|
const lookup = records.find(record => record.name === name)
|
||||||
|
if (!lookup) return false
|
||||||
|
|
||||||
|
return lookup.value === ip
|
||||||
|
}, [records, name, ip])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Button>
|
||||||
|
Add DNS record
|
||||||
|
</Dialog.Button>
|
||||||
|
<Dialog.Panel>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Add DNS record
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text>
|
||||||
|
Enter the domain and IP address for the new DNS record.
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!name || !ip) return
|
||||||
|
|
||||||
|
setName('')
|
||||||
|
setIp('')
|
||||||
|
|
||||||
|
submit({
|
||||||
|
'dns.extra_records': [
|
||||||
|
...records,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
type: 'A',
|
||||||
|
value: ip,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Domain"
|
||||||
|
placeholder="test.example.com"
|
||||||
|
name="domain"
|
||||||
|
state={[name, setName]}
|
||||||
|
className={cn(
|
||||||
|
'mt-2',
|
||||||
|
isDuplicate && 'outline outline-red-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="IP Address"
|
||||||
|
placeholder="101.101.101.101"
|
||||||
|
name="ip"
|
||||||
|
state={[ip, setIp]}
|
||||||
|
className={cn(
|
||||||
|
isDuplicate && 'outline outline-red-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isDuplicate
|
||||||
|
? (
|
||||||
|
<p className="text-sm opacity-50">
|
||||||
|
A record with the domain name
|
||||||
|
{' '}
|
||||||
|
<Code>{name}</Code>
|
||||||
|
{' '}
|
||||||
|
and IP address
|
||||||
|
{' '}
|
||||||
|
<Code>{ip}</Code>
|
||||||
|
{' '}
|
||||||
|
already exists.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-8">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
isDisabled={isDuplicate}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ export default function AddNameserver({ nameservers }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submit({
|
submit({
|
||||||
'dns_config.restricted_nameservers': splitNs,
|
'dns.nameservers.split': splitNs,
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json',
|
encType: 'application/json',
|
||||||
@@ -65,7 +65,7 @@ export default function AddNameserver({ nameservers }: Props) {
|
|||||||
globalNs.push(ns)
|
globalNs.push(ns)
|
||||||
|
|
||||||
submit({
|
submit({
|
||||||
'dns_config.nameservers': globalNs,
|
'dns.nameservers.global': globalNs,
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json',
|
encType: 'application/json',
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useSubmit } from '@remix-run/react'
|
||||||
|
import { Button } from 'react-aria-components'
|
||||||
|
|
||||||
|
import Code from '~/components/Code'
|
||||||
|
import Link from '~/components/Link'
|
||||||
|
import TableList from '~/components/TableList'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
import AddDNS from './dialogs/dns'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
records: { name: string, type: 'A', value: string }[]
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DNS({ records, isDisabled }: Props) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-2/3">
|
||||||
|
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
|
Headscale supports adding custom DNS records to your Tailnet.
|
||||||
|
As of now, only
|
||||||
|
{' '}
|
||||||
|
<Code>A</Code>
|
||||||
|
{' '}
|
||||||
|
records are supported.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://headscale.net/dns-records/"
|
||||||
|
name="Headscale DNS Records documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TableList className="mb-8">
|
||||||
|
{records.length === 0
|
||||||
|
? (
|
||||||
|
<TableList.Item>
|
||||||
|
<p className="opacity-50 text-sm mx-auto">
|
||||||
|
No DNS records found
|
||||||
|
</p>
|
||||||
|
</TableList.Item>
|
||||||
|
)
|
||||||
|
: records.map((record, index) => (
|
||||||
|
<TableList.Item key={index}>
|
||||||
|
<div className="flex gap-24">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<p className="font-mono text-sm font-bold">{record.type}</p>
|
||||||
|
<p className="font-mono text-sm">{record.name}</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-sm">{record.value}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
'text-red-600 dark:text-red-400',
|
||||||
|
'hover:text-red-700 dark:hover:text-red-300',
|
||||||
|
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onPress={() => {
|
||||||
|
submit({
|
||||||
|
'dns.extra_records': records
|
||||||
|
.filter((_, i) => i !== index),
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</TableList.Item>
|
||||||
|
))}
|
||||||
|
</TableList>
|
||||||
|
|
||||||
|
{isDisabled
|
||||||
|
? undefined
|
||||||
|
: (
|
||||||
|
<AddDNS records={records} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'dns_config.domains': [...localDomains, newDomain]
|
'dns.search_domains': [...localDomains, newDomain]
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
@@ -212,8 +212,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
|
|||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
|
||||||
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
|
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'dns_config.magic_dns': !isEnabled
|
'dns.magic_dns': !isEnabled
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ import AddNameserver from './dialogs/nameserver'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nameservers: Record<string, string[]>
|
nameservers: Record<string, string[]>
|
||||||
override: boolean
|
|
||||||
isDisabled: boolean
|
isDisabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
|
export default function Nameservers({ nameservers, isDisabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||||
@@ -37,7 +36,6 @@ export default function Nameservers({ nameservers, override, isDisabled }: Props
|
|||||||
isGlobal={key === 'global'}
|
isGlobal={key === 'global'}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
nameservers={nameservers[key]}
|
nameservers={nameservers[key]}
|
||||||
override={override}
|
|
||||||
name={key}
|
name={key}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -57,11 +55,9 @@ interface ListProps {
|
|||||||
isDisabled: boolean
|
isDisabled: boolean
|
||||||
nameservers: string[]
|
nameservers: string[]
|
||||||
name: string
|
name: string
|
||||||
override: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
|
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
|
||||||
const [localOverride, setLocalOverride] = useState(override)
|
|
||||||
const submit = useSubmit()
|
const submit = useSubmit()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,30 +66,6 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
|
|||||||
<h2 className="text-md font-medium opacity-80">
|
<h2 className="text-md font-medium opacity-80">
|
||||||
{isGlobal ? 'Global Nameservers' : name}
|
{isGlobal ? 'Global Nameservers' : name}
|
||||||
</h2>
|
</h2>
|
||||||
{isGlobal
|
|
||||||
? (
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<span className="text-sm opacity-50">
|
|
||||||
Override local DNS
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
label="Override local DNS"
|
|
||||||
defaultSelected={localOverride}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
onChange={() => {
|
|
||||||
submit({
|
|
||||||
'dns_config.override_local_dns': !localOverride,
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json',
|
|
||||||
})
|
|
||||||
|
|
||||||
setLocalOverride(!localOverride)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: undefined}
|
|
||||||
</div>
|
</div>
|
||||||
<TableList>
|
<TableList>
|
||||||
{nameservers.map((ns, index) => (
|
{nameservers.map((ns, index) => (
|
||||||
@@ -111,17 +83,17 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (isGlobal) {
|
if (isGlobal) {
|
||||||
submit({
|
submit({
|
||||||
'dns_config.nameservers': nameservers
|
'dns.nameservers.global': nameservers
|
||||||
.filter((_, i) => i !== index),
|
.filter((_, i) => i !== index),
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json',
|
encType: 'application/json',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const key = `dns_config.restricted_nameservers."${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.
|
||||||
@@ -80,7 +80,7 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
variant='confirm'
|
variant='confirm'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
'dns_config.base_domain': newName
|
'dns.base_domain': newName
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
|||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
|
||||||
|
import DNS from './dns'
|
||||||
import Domains from './domains'
|
import Domains from './domains'
|
||||||
import MagicModal from './magic'
|
import MagicModal from './magic'
|
||||||
import Nameservers from './nameservers'
|
import Nameservers from './nameservers'
|
||||||
@@ -23,13 +24,14 @@ export async function loader() {
|
|||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
const dns = {
|
const dns = {
|
||||||
prefixes: config.prefixes,
|
prefixes: config.prefixes,
|
||||||
magicDns: config.dns_config.magic_dns,
|
magicDns: config.dns.magic_dns,
|
||||||
baseDomain: config.dns_config.base_domain,
|
baseDomain: config.dns.use_username_in_magic_dns
|
||||||
overrideLocal: config.dns_config.override_local_dns,
|
? `[user].${config.dns.base_domain}`
|
||||||
nameservers: config.dns_config.nameservers,
|
: config.dns.base_domain,
|
||||||
splitDns: config.dns_config.restricted_nameservers,
|
nameservers: config.dns.nameservers.global,
|
||||||
searchDomains: config.dns_config.domains,
|
splitDns: config.dns.nameservers.split,
|
||||||
extraRecords: config.dns_config.extra_records,
|
searchDomains: config.dns.search_domains,
|
||||||
|
extraRecords: config.dns.extra_records,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -56,8 +58,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
const data = await request.json() as Record<string, unknown>
|
const data = await request.json() as Record<string, unknown>
|
||||||
await patchConfig(data)
|
await patchConfig(data)
|
||||||
|
|
||||||
if (context.integration?.restart) {
|
if (context.integration?.onConfigChange) {
|
||||||
await context.integration.restart()
|
await context.integration.onConfigChange(context.integration.context)
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
return json({ success: true })
|
||||||
@@ -86,7 +88,11 @@ export default function Page() {
|
|||||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||||
<Nameservers
|
<Nameservers
|
||||||
nameservers={allNs}
|
nameservers={allNs}
|
||||||
override={data.overrideLocal}
|
isDisabled={!data.config.write}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DNS
|
||||||
|
records={data.extraRecords}
|
||||||
isDisabled={!data.config.write}
|
isDisabled={!data.config.write}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -103,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>
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* 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 { Link, useLoaderData } from '@remix-run/react'
|
||||||
|
|
||||||
import Attribute from '~/components/Attribute'
|
import Attribute from '~/components/Attribute'
|
||||||
import Card from '~/components/Card'
|
import Card from '~/components/Card'
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
import StatusCircle from '~/components/StatusCircle'
|
||||||
import { type Machine, Route, User } from '~/types'
|
import { Machine, Route, User } from '~/types'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { loadConfig } from '~/utils/config/headscale'
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
@@ -27,8 +27,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
if (context.config.read) {
|
if (context.config.read) {
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
if (config.dns_config.magic_dns) {
|
if (config.dns.magic_dns) {
|
||||||
magic = config.dns_config.base_domain
|
magic = config.dns.base_domain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ export default function Page() {
|
|||||||
|
|
||||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||||
|
|| machine.expiry === null
|
||||||
? false
|
? false
|
||||||
: new Date(machine.expiry).getTime() < Date.now()
|
: new Date(machine.expiry).getTime() < Date.now()
|
||||||
|
|
||||||
@@ -134,14 +135,17 @@ export default function Page() {
|
|||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
name="Expiry"
|
name="Expiry"
|
||||||
value={new Date(machine.expiry).toLocaleString()}
|
value={expired
|
||||||
|
? new Date(machine.expiry).toLocaleString()
|
||||||
|
: 'Never'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{magic
|
{magic
|
||||||
? (
|
? (
|
||||||
<Attribute
|
<Attribute
|
||||||
isCopyable
|
isCopyable
|
||||||
name="Domain"
|
name="Domain"
|
||||||
value={`${machine.givenName}.${machine.user.name}.${magic}`}
|
value={`${machine.givenName}.${magic}`}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: undefined}
|
: undefined}
|
||||||
|
|||||||
@@ -80,6 +80,63 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'tags': {
|
||||||
|
const tags = data.get('tags')?.toString()
|
||||||
|
.split(',') ?? []
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
|
||||||
|
tags,
|
||||||
|
})
|
||||||
|
|
||||||
|
return json({ message: 'Tags updated' })
|
||||||
|
} catch {
|
||||||
|
return json({ message: 'Failed to update tags' }, {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
default: {
|
||||||
return json({ message: 'Invalid method' }, {
|
return json({ message: 'Invalid method' }, {
|
||||||
status: 400,
|
status: 400,
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
|
|||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
The owner of the machine is the user associated with it.
|
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>
|
</Dialog.Text>
|
||||||
<Form
|
<Form
|
||||||
method="POST"
|
method="POST"
|
||||||
@@ -54,46 +52,16 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
{magic
|
{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
|
||||||
<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}
|
||||||
<Code className="text-sm">
|
.
|
||||||
{machine.givenName}
|
{magic}
|
||||||
.
|
</Code>
|
||||||
{owner}
|
.
|
||||||
.
|
</p>
|
||||||
{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>
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
: undefined}
|
: undefined}
|
||||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { PlusIcon, XIcon } from '@primer/octicons-react'
|
||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||||
|
import { Button, Input } from 'react-aria-components'
|
||||||
|
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
|
import Link from '~/components/Link'
|
||||||
|
import { type Machine } from '~/types'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface TagsProps {
|
||||||
|
readonly machine: Machine
|
||||||
|
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tags({ machine, state }: TagsProps) {
|
||||||
|
const [tags, setTags] = useState(machine.forcedTags)
|
||||||
|
const [tag, setTag] = useState('')
|
||||||
|
const submit = useSubmit()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Panel control={state}>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Edit ACL tags for
|
||||||
|
{' '}
|
||||||
|
{machine.givenName}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text>
|
||||||
|
ACL tags can be used to reference machines in your ACL policies.
|
||||||
|
See the
|
||||||
|
{' '}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1068/acl-tags"
|
||||||
|
name="Tailscale documentation"
|
||||||
|
>
|
||||||
|
Tailscale documentation
|
||||||
|
</Link>
|
||||||
|
{' '}
|
||||||
|
for more information.
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
submit(e.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="tags" />
|
||||||
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
|
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border border-ui-300 rounded-lg overflow-visible',
|
||||||
|
'dark:border-ui-700 dark:text-ui-300 mt-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-ui-200 dark:divide-ui-600">
|
||||||
|
{tags.length === 0
|
||||||
|
? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
|
'items-center justify-center rounded-t-lg',
|
||||||
|
'text-ui-600 dark:text-ui-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
No tags are set on this machine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: tags.map(item => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
id={item}
|
||||||
|
className={cn(
|
||||||
|
'px-2.5 py-1.5 flex',
|
||||||
|
'items-center justify-between',
|
||||||
|
'font-mono text-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
<Button
|
||||||
|
className="rounded-full p-0 w-6 h-6"
|
||||||
|
onPress={() => {
|
||||||
|
setTags(tags.filter(tag => tag !== item))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex px-2.5 py-1.5 w-full',
|
||||||
|
'border-t border-ui-300 dark:border-ui-700',
|
||||||
|
'rounded-b-lg justify-between items-center',
|
||||||
|
'dark:bg-ui-800 dark:text-ui-300',
|
||||||
|
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||||
|
tag.length > 0 && !tag.startsWith('tag:')
|
||||||
|
&& 'outline outline-red-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="tag:example"
|
||||||
|
className={cn(
|
||||||
|
'bg-transparent w-full',
|
||||||
|
'border-none focus:ring-0',
|
||||||
|
'focus:outline-none font-mono text-sm',
|
||||||
|
'dark:bg-transparent dark:text-ui-300',
|
||||||
|
)}
|
||||||
|
value={tag}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTag(e.currentTarget.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg p-0 h-6 w-6',
|
||||||
|
!tag.startsWith('tag:')
|
||||||
|
&& 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
isDisabled={
|
||||||
|
tag.length === 0
|
||||||
|
|| !tag.startsWith('tag:')
|
||||||
|
|| tags.includes(tag)
|
||||||
|
}
|
||||||
|
onPress={() => {
|
||||||
|
setTags([...tags, tag])
|
||||||
|
setTag('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Link } from '@remix-run/react'
|
|||||||
import Menu from '~/components/Menu'
|
import Menu from '~/components/Menu'
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
import StatusCircle from '~/components/StatusCircle'
|
||||||
import { toast } from '~/components/Toaster'
|
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 { cn } from '~/utils/cn'
|
||||||
|
|
||||||
import MenuOptions from './menu'
|
import MenuOptions from './menu'
|
||||||
@@ -19,6 +19,7 @@ interface Props {
|
|||||||
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||||
|
|| machine.expiry === null
|
||||||
? false
|
? false
|
||||||
: new Date(machine.expiry).getTime() < Date.now()
|
: new Date(machine.expiry).getTime() < Date.now()
|
||||||
|
|
||||||
@@ -31,6 +32,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 +107,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Expire from './dialogs/expire'
|
|||||||
import Move from './dialogs/move'
|
import Move from './dialogs/move'
|
||||||
import Rename from './dialogs/rename'
|
import Rename from './dialogs/rename'
|
||||||
import Routes from './dialogs/routes'
|
import Routes from './dialogs/routes'
|
||||||
|
import Tags from './dialogs/tags'
|
||||||
|
|
||||||
interface MenuProps {
|
interface MenuProps {
|
||||||
machine: Machine
|
machine: Machine
|
||||||
@@ -24,9 +25,11 @@ export default function Menu({ machine, routes, magic, users }: MenuProps) {
|
|||||||
const removeState = useState(false)
|
const removeState = useState(false)
|
||||||
const routesState = useState(false)
|
const routesState = useState(false)
|
||||||
const moveState = useState(false)
|
const moveState = useState(false)
|
||||||
|
const tagsState = useState(false)
|
||||||
|
|
||||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||||
|
|| machine.expiry === null
|
||||||
? false
|
? false
|
||||||
: new Date(machine.expiry).getTime() < Date.now()
|
: new Date(machine.expiry).getTime() < Date.now()
|
||||||
|
|
||||||
@@ -54,6 +57,10 @@ export default function Menu({ machine, routes, magic, users }: MenuProps) {
|
|||||||
routes={routes}
|
routes={routes}
|
||||||
state={routesState}
|
state={routesState}
|
||||||
/>
|
/>
|
||||||
|
<Tags
|
||||||
|
machine={machine}
|
||||||
|
state={tagsState}
|
||||||
|
/>
|
||||||
<Move
|
<Move
|
||||||
machine={machine}
|
machine={machine}
|
||||||
state={moveState}
|
state={moveState}
|
||||||
@@ -78,9 +85,9 @@ export default function Menu({ machine, routes, magic, users }: MenuProps) {
|
|||||||
<MenuComponent.ItemButton control={routesState}>
|
<MenuComponent.ItemButton control={routesState}>
|
||||||
Edit route settings
|
Edit route settings
|
||||||
</MenuComponent.ItemButton>
|
</MenuComponent.ItemButton>
|
||||||
<MenuComponent.Item className="opacity-50 hover:bg-transparent">
|
<MenuComponent.ItemButton control={tagsState}>
|
||||||
Edit ACL tags
|
Edit ACL tags
|
||||||
</MenuComponent.Item>
|
</MenuComponent.ItemButton>
|
||||||
<MenuComponent.ItemButton control={moveState}>
|
<MenuComponent.ItemButton control={moveState}>
|
||||||
Change owner
|
Change owner
|
||||||
</MenuComponent.ItemButton>
|
</MenuComponent.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'))
|
||||||
@@ -29,8 +30,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
if (context.config.read) {
|
if (context.config.read) {
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
if (config.dns_config.magic_dns) {
|
if (config.dns.magic_dns) {
|
||||||
magic = config.dns_config.base_domain
|
magic = config.dns.base_domain
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.dns.use_username_in_magic_dns) {
|
||||||
|
magic = `[user].${magic}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,222 @@
|
|||||||
|
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')
|
||||||
|
|
||||||
|
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()
|
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>
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import Dialog from '~/components/Dialog'
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
username: string
|
username: string
|
||||||
magic?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Remove({ username, magic }: Props) {
|
export default function Remove({ username }: Props) {
|
||||||
const submit = useSubmit()
|
const submit = useSubmit()
|
||||||
const dialogState = useState(false)
|
const dialogState = useState(false)
|
||||||
|
|
||||||
@@ -41,24 +40,6 @@ export default function Remove({ username, magic }: Props) {
|
|||||||
?
|
?
|
||||||
{' '}
|
{' '}
|
||||||
A deleted user cannot be recovered.
|
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>
|
</Dialog.Text>
|
||||||
<Form
|
<Form
|
||||||
method="POST"
|
method="POST"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Form, useSubmit } from '@remix-run/react'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
import Button from '~/components/Button'
|
||||||
import Code from '~/components/Code'
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog'
|
||||||
import TextField from '~/components/TextField'
|
import TextField from '~/components/TextField'
|
||||||
|
|
||||||
@@ -40,33 +39,6 @@ export default function Rename({ username, magic }: Props) {
|
|||||||
Enter a new username for
|
Enter a new username for
|
||||||
{' '}
|
{' '}
|
||||||
{username}
|
{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>
|
</Dialog.Text>
|
||||||
<Form
|
<Form
|
||||||
method="POST"
|
method="POST"
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
if (context.config.read) {
|
if (context.config.read) {
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
if (config.dns_config.magic_dns) {
|
if (config.dns.magic_dns) {
|
||||||
magic = config.dns_config.base_domain
|
magic = config.dns.base_domain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ function UserCard({ user, magic }: CardProps) {
|
|||||||
<Rename username={user.name} magic={magic} />
|
<Rename username={user.name} magic={magic} />
|
||||||
{user.machines.length === 0
|
{user.machines.length === 0
|
||||||
? (
|
? (
|
||||||
<Remove username={user.name} magic={magic} />
|
<Remove username={user.name} />
|
||||||
)
|
)
|
||||||
: undefined}
|
: undefined}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,8 +80,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(error)
|
|
||||||
return json({
|
return json({
|
||||||
error: 'Invalid API key',
|
error: 'Invalid API key',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 './Machine'
|
||||||
export * from './Route'
|
export * from './Route'
|
||||||
export * from './User'
|
export * from './User'
|
||||||
|
export * from './PreAuthKey'
|
||||||
|
|||||||
@@ -8,25 +8,21 @@ import { resolve } from 'node:path'
|
|||||||
|
|
||||||
import { parse } from 'yaml'
|
import { parse } from 'yaml'
|
||||||
|
|
||||||
import { checkIntegration, Integration } from '~/integration'
|
import { IntegrationFactory, loadIntegration } from '~/integration'
|
||||||
|
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
|
||||||
import { HeadscaleConfig, loadConfig } from './headscale'
|
import log from '~/utils/log'
|
||||||
|
|
||||||
export interface HeadplaneContext {
|
export interface HeadplaneContext {
|
||||||
|
debug: boolean
|
||||||
headscaleUrl: string
|
headscaleUrl: string
|
||||||
cookieSecret: string
|
cookieSecret: string
|
||||||
integration: Integration | undefined
|
integration: IntegrationFactory | undefined
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
read: boolean
|
read: boolean
|
||||||
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,70 +67,34 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
|
debug,
|
||||||
headscaleUrl,
|
headscaleUrl,
|
||||||
cookieSecret,
|
cookieSecret,
|
||||||
integration: await checkIntegration(),
|
integration: await loadIntegration(),
|
||||||
config: contextData,
|
config: contextData,
|
||||||
acl: await checkAcl(config),
|
|
||||||
oidc: await checkOidc(config),
|
oidc: await checkOidc(config),
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Completed loading the Headplane Context')
|
log.info('CTXT', 'Starting Headplane with Context')
|
||||||
console.log('Headscale URL:', headscaleUrl)
|
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl)
|
||||||
console.log('Integration:', context.integration?.name ?? 'None')
|
log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None')
|
||||||
console.log('Config:', contextData.read ? `Found ${contextData.write ? '' : '(Read Only)'}` : 'Unavailable')
|
log.info('CTXT', 'Config: %s', contextData.read
|
||||||
console.log('ACL:', context.acl.read ? `Found ${context.acl.write ? '' : '(Read Only)'}` : 'Unavailable')
|
? `Found ${contextData.write ? '' : '(Read Only)'}`
|
||||||
console.log('OIDC:', context.oidc ? 'Configured' : 'Unavailable')
|
: 'Unavailable',
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }> {
|
|
||||||
let path = process.env.ACL_FILE
|
|
||||||
if (!path) {
|
|
||||||
try {
|
|
||||||
const config = await loadConfig()
|
|
||||||
path = config.acl_policy_path
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
return { data: '', type: 'json' }
|
|
||||||
}
|
|
||||||
|
|
||||||
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' }
|
|
||||||
} catch {
|
|
||||||
return { data, type: 'json' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
||||||
@@ -140,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,
|
||||||
@@ -153,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')
|
||||||
@@ -190,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)
|
||||||
@@ -198,12 +150,23 @@ async function checkOidc(config?: HeadscaleConfig) {
|
|||||||
throw new Error('OIDC environment variables are incomplete')
|
throw new Error('OIDC environment variables are incomplete')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (issuer && client && secret) {
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
client,
|
||||||
|
secret,
|
||||||
|
rootKey,
|
||||||
|
disableKeyLogin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ((!issuer || !client || !secret) && config) {
|
if ((!issuer || !client || !secret) && config) {
|
||||||
issuer = config.oidc?.issuer
|
issuer = config.oidc?.issuer
|
||||||
client = config.oidc?.client_id
|
client = config.oidc?.client_id
|
||||||
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,
|
||||||
@@ -213,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { resolve } from 'node:path'
|
|||||||
import { type Document, parseDocument } from 'yaml'
|
import { type Document, parseDocument } from 'yaml'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
const goBool = z
|
const goBool = z
|
||||||
.union([z.boolean(), z.literal('true'), z.literal('false')])
|
.union([z.boolean(), z.literal('true'), z.literal('false')])
|
||||||
.transform((value) => {
|
.transform((value) => {
|
||||||
@@ -43,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(),
|
||||||
@@ -51,6 +52,11 @@ const HeadscaleConfig = z.object({
|
|||||||
unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
|
unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
|
||||||
unix_socket_permission: z.string().default('0o770'),
|
unix_socket_permission: z.string().default('0o770'),
|
||||||
|
|
||||||
|
policy: z.object({
|
||||||
|
mode: z.enum(['file', 'database']).default('file'),
|
||||||
|
path: z.string().optional(),
|
||||||
|
}).optional(),
|
||||||
|
|
||||||
tuning: z.object({
|
tuning: z.object({
|
||||||
batch_change_delay: goDuration.default('800ms'),
|
batch_change_delay: goDuration.default('800ms'),
|
||||||
node_mapsession_buffered_chan_size: z.number().default(30),
|
node_mapsession_buffered_chan_size: z.number().default(30),
|
||||||
@@ -82,18 +88,20 @@ const HeadscaleConfig = z.object({
|
|||||||
v6: z.string(),
|
v6: z.string(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
dns_config: z.object({
|
dns: z.object({
|
||||||
override_local_dns: goBool.default(false),
|
magic_dns: goBool.default(true),
|
||||||
nameservers: z.array(z.string()).default([]),
|
base_domain: z.string().default('headscale.net'),
|
||||||
restricted_nameservers: z.record(z.array(z.string())).default({}),
|
nameservers: z.object({
|
||||||
domains: z.array(z.string()).default([]),
|
global: z.array(z.string()).default([]),
|
||||||
|
split: z.record(z.array(z.string())).default({}),
|
||||||
|
}).default({ global: [], split: {} }),
|
||||||
|
search_domains: z.array(z.string()).default([]),
|
||||||
extra_records: z.array(z.object({
|
extra_records: z.array(z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
type: z.literal('A'),
|
type: z.literal('A'),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
})).default([]),
|
})).default([]),
|
||||||
magic_dns: goBool.default(false),
|
use_username_in_magic_dns: goBool.default(false),
|
||||||
base_domain: z.string().default('headscale.net'),
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
oidc: z.object({
|
oidc: z.object({
|
||||||
@@ -179,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,
|
||||||
@@ -218,44 +228,47 @@ export async function loadConfig(path?: string) {
|
|||||||
v6: '',
|
v6: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
dns_config: loaded.dns_config ?? {
|
dns: loaded.dns ?? {
|
||||||
override_local_dns: false,
|
nameservers: {
|
||||||
nameservers: [],
|
global: [],
|
||||||
restricted_nameservers: {},
|
split: {},
|
||||||
domains: [],
|
},
|
||||||
|
search_domains: [],
|
||||||
extra_records: [],
|
extra_records: [],
|
||||||
magic_dns: false,
|
magic_dns: false,
|
||||||
base_domain: 'headscale.net',
|
base_domain: 'headscale.net',
|
||||||
},
|
},
|
||||||
} as HeadscaleConfig
|
} as HeadscaleConfig
|
||||||
|
|
||||||
console.log('Loaded Headscale configuration in non-strict mode')
|
log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode')
|
||||||
console.log('By using this mode you forfeit GitHub issue support')
|
log.warn('CFGX', 'By using this mode you forfeit GitHub issue support')
|
||||||
console.log('This is very dangerous and comes with a few caveats:')
|
log.warn('CFGX', 'This is very dangerous and comes with a few caveats:')
|
||||||
console.log('- Headplane could very easily crash')
|
log.warn('CFGX', 'Headplane could very easily crash')
|
||||||
console.log('- Headplane could break your Headscale installation')
|
log.warn('CFGX', 'Headplane could break your Headscale installation')
|
||||||
console.log('- The UI could throw random errors/show incorrect data')
|
log.warn('CFGX', 'The UI could throw random errors/show incorrect data')
|
||||||
console.log('')
|
log.warn('CFGX', '')
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
console.log('Failed to parse the Headscale configuration file!')
|
log.error('CFGX', 'Recieved invalid configuration file')
|
||||||
console.log('The following schema issues were found:')
|
log.error('CFGX', 'The following schema issues were found:')
|
||||||
for (const issue of error.issues) {
|
for (const issue of error.issues) {
|
||||||
const path = issue.path.map(String).join('.')
|
const path = issue.path.map(String).join('.')
|
||||||
const message = issue.message
|
const message = issue.message
|
||||||
|
|
||||||
console.log(`- '${path}': ${message}`)
|
log.error('CFGX', ` '${path}': ${message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('')
|
log.error('CFGX', '')
|
||||||
console.log('Please fix the configuration file and try again.')
|
log.error('CFGX', 'Resolve these issues and try again.')
|
||||||
console.log('Headplane will operate as if no config is present.')
|
log.error('CFGX', 'Headplane will operate without the config')
|
||||||
console.log('')
|
log.error('CFGX', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
@@ -270,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.
|
||||||
@@ -299,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,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,28 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response.json() as Promise<T>)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${key}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +82,8 @@ export async function post<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: {
|
||||||
@@ -62,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export default {
|
||||||
|
info: (category: string, message: string, ...args: unknown[]) => {
|
||||||
|
defaultLog('INFO', category, message, ...args)
|
||||||
|
},
|
||||||
|
|
||||||
|
warn: (category: string, message: string, ...args: unknown[]) => {
|
||||||
|
defaultLog('WARN', category, message, ...args)
|
||||||
|
},
|
||||||
|
|
||||||
|
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(
|
||||||
|
level: string,
|
||||||
|
category: string,
|
||||||
|
message: string,
|
||||||
|
...args: unknown[]
|
||||||
|
) {
|
||||||
|
const date = new Date().toISOString()
|
||||||
|
console.log(`${date} (${level}) [${category}] ${message}`, ...args)
|
||||||
|
}
|
||||||
+5
-5
@@ -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,
|
||||||
@@ -51,8 +51,8 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
|
|||||||
const challenge = await calculatePKCECodeChallenge(verifier)
|
const challenge = await calculatePKCECodeChallenge(verifier)
|
||||||
|
|
||||||
const callback = new URL('/admin/oidc/callback', req.url)
|
const callback = new URL('/admin/oidc/callback', req.url)
|
||||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
|
||||||
callback.hostname = req.headers.get('Host') ?? ''
|
callback.host = req.headers.get('Host') ?? ''
|
||||||
const authUrl = new URL(processed.authorization_endpoint)
|
const authUrl = new URL(processed.authorization_endpoint)
|
||||||
|
|
||||||
authUrl.searchParams.set('client_id', oidcClient.client_id)
|
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)
|
const callback = new URL('/admin/oidc/callback', req.url)
|
||||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
|
||||||
callback.hostname = req.headers.get('Host') ?? ''
|
callback.host = req.headers.get('Host') ?? ''
|
||||||
|
|
||||||
const tokenResponse = await authorizationCodeGrantRequest(
|
const tokenResponse = await authorizationCodeGrantRequest(
|
||||||
processed,
|
processed,
|
||||||
|
|||||||
@@ -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
-2
@@ -2,14 +2,13 @@
|
|||||||
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
|
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
|
||||||
# I ONLY USE IT FOR DEVELOPING HEADPLANE
|
# I ONLY USE IT FOR DEVELOPING HEADPLANE
|
||||||
|
|
||||||
version: '3.9'
|
|
||||||
networks:
|
networks:
|
||||||
headplane-dev:
|
headplane-dev:
|
||||||
name: 'headplane-dev'
|
name: 'headplane-dev'
|
||||||
driver: 'bridge'
|
driver: 'bridge'
|
||||||
services:
|
services:
|
||||||
headscale:
|
headscale:
|
||||||
image: 'headscale/headscale:0.23.0-alpha5'
|
image: 'headscale/headscale:0.23.0-beta2'
|
||||||
container_name: 'headscale'
|
container_name: 'headscale'
|
||||||
restart: 'unless-stopped'
|
restart: 'unless-stopped'
|
||||||
command: 'serve'
|
command: 'serve'
|
||||||
|
|||||||
+29
-129
@@ -1,5 +1,11 @@
|
|||||||
# Advanced Integration
|
# Advanced Integration
|
||||||
|
|
||||||
|
The advanced integration methods unlock the full capabilities of Headplane.
|
||||||
|
This is the closest you can get to the SaaS experience if you were paying for
|
||||||
|
Tailscale.
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source
|
<source
|
||||||
media="(prefers-color-scheme: dark)"
|
media="(prefers-color-scheme: dark)"
|
||||||
@@ -15,21 +21,18 @@
|
|||||||
>
|
>
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
With the advanced integration it's possible to control Access Control Lists (ACLs) and the Headscale configuration via the Headplane UI.
|
The advanced integration allows you to manage the Headscale configuration via
|
||||||
Every single aspect of this integration is optional, meaning you can only use what you want.
|
the Headplane UI. When the configuration is available for editing, the `DNS`
|
||||||
Additionally, with an integration provider, you can automatically reload the configuration or ACLs when they are changed.
|
and `Settings` tabs will become available. When using the Docker or Kubernetes
|
||||||
|
integration, changes to the configuration file will be automatically applied
|
||||||
|
to Headscale.
|
||||||
|
|
||||||
## Configuration Editing
|
> By default, the configuration file is read from `/etc/headscale/config.yaml`.
|
||||||
|
This can be overridden by setting the `CONFIG_FILE` environment variable. Any
|
||||||
|
variables including `HEADSCALE_URL`, `OIDC_CLIENT_ID`, `OIDC_ISSUER`, and
|
||||||
|
`OIDC_CLIENT_SECRET` will take priority over the configuration file.
|
||||||
|
|
||||||
When the configuration file is available to Headplane, the `DNS` and `Settings` tabs will become functional.
|
### Access Control Lists (ACLs)
|
||||||
Similar to the Tailscale UI, you'll be able to edit the configuration without needing to manually edit the file.
|
|
||||||
Headscale will read the file from the path given in the `CONFIG_FILE` environment variable.
|
|
||||||
By default this is set to `/etc/headscale/config.yaml`.
|
|
||||||
|
|
||||||
> One important think to note is that environment variables always take priority over the configuration file.
|
|
||||||
> The `HEADSCALE_URL`, `OIDC_CLIENT_ID`, `OIDFC_ISSUER`, and `OIDC_CLIENT_SECRET` will be preferred over the configuration file if available.
|
|
||||||
|
|
||||||
## Access Control Lists (ACLs)
|
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source
|
<source
|
||||||
@@ -46,126 +49,23 @@ By default this is set to `/etc/headscale/config.yaml`.
|
|||||||
>
|
>
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
Headplane will enable the `Access Controls` tab if it is able to read an ACL file from Headscale.<br>
|
The advanced integration allows you to manage the ACLs via the Headplane UI.
|
||||||
The ACL file path is read from the following sources in order of priority:
|
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.
|
||||||
|
|
||||||
- **Environment Variable**: If you set the `ACL_FILE` environment variable, Headplane will read the file from that path.
|
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`.
|
||||||
- **Configuration Integration**: If you've set this up, then Headplane will read the `acl_policy_path` key from the configuration file.
|
> 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.
|
||||||
|
|
||||||
## Automatic Configuration Reload
|
## Deployment
|
||||||
|
|
||||||
When the configuration file is changed, Headscale will need to be restarted to apply the changes.
|
|
||||||
Similarly, when the ACL file is changed, Headscale will need to be sent a `SIGHUP` signal to reload the ACLs.
|
|
||||||
Currently there are 2 integration providers that can do this for you:
|
|
||||||
|
|
||||||
### Docker Integration
|
|
||||||
|
|
||||||
To enable the Docker integration, set `HEADSCALE_INTEGRATION=docker` in the environment variables.
|
|
||||||
Additionally, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
|
|
||||||
This should be either the name or ID of the Headscale container (you can retrieve this using `docker ps`).
|
|
||||||
If the other integrations aren't setup, then Headplane will automatically disable the Docker integration.
|
|
||||||
|
|
||||||
By default the integration will check for `/var/run/docker.sock`, however you can override this by
|
|
||||||
setting the `DOCKER_SOCK` environment variable if you use a different configuration than the default.
|
|
||||||
When setting `DOCKER_SOCK`, you'll need to include the protocol (e.g., `unix://` or `tcp://`).
|
|
||||||
Headplane currently does not support the HTTPS protocol for the Docker socket.
|
|
||||||
|
|
||||||
#### Example Docker Deployment
|
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Headscale 0.23 alpha 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)
|
||||||
- Headscale needs to be running in a docker container
|
|
||||||
|
|
||||||
Here's a good Docker Compose example:
|
Currently there are 3 integration providers that can do this for you:
|
||||||
```yaml
|
- [Docker Integration](/docs/integration/Docker.md)
|
||||||
version: '3.8'
|
- [Kubernetes Integration](/docs/integration/Kubernetes.md)
|
||||||
services:
|
- [Native Linux Integration](/docs/integration/Native.md)
|
||||||
headscale:
|
|
||||||
image: 'headscale/headscale:0.23.0-alpha5'
|
|
||||||
container_name: 'headscale'
|
|
||||||
restart: 'unless-stopped'
|
|
||||||
command: 'serve'
|
|
||||||
volumes:
|
|
||||||
- './data:/var/lib/headscale'
|
|
||||||
- './configs:/etc/headscale'
|
|
||||||
ports:
|
|
||||||
- '8080:8080'
|
|
||||||
environment:
|
|
||||||
TZ: 'America/New_York'
|
|
||||||
headplane:
|
|
||||||
container_name: headplane
|
|
||||||
image: ghcr.io/tale/headplane:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- './data:/var/lib/headscale'
|
|
||||||
- './configs:/etc/headscale'
|
|
||||||
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
|
||||||
ports:
|
|
||||||
- '3000:3000'
|
|
||||||
environment:
|
|
||||||
# This is always required for Headplane to work
|
|
||||||
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
|
|
||||||
|
|
||||||
HEADSCALE_INTEGRATION: 'docker'
|
|
||||||
HEADSCALE_CONTAINER: 'headscale'
|
|
||||||
DISABLE_API_KEY_LOGIN: 'true'
|
|
||||||
HOST: '0.0.0.0'
|
|
||||||
PORT: '3000'
|
|
||||||
|
|
||||||
# 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'
|
|
||||||
OIDC_ISSUER: 'https://sso.example.com'
|
|
||||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
|
||||||
|
|
||||||
# This NEEDS to be set with OIDC, regardless of what's in the config
|
|
||||||
# This needs to be a very long-lived (999 day) API key used to create
|
|
||||||
# shorter ones for OIDC and allow the OIDC functionality to work
|
|
||||||
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
|
||||||
```
|
|
||||||
|
|
||||||
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
|
|
||||||
> It explains what each variable does, how to configure them, and what the default values are.
|
|
||||||
|
|
||||||
You may also choose to run it natively with the distributed binaries on the releases page.
|
|
||||||
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
|
|
||||||
|
|
||||||
### Native Linux Integration (Beta)
|
|
||||||
|
|
||||||
The native integration for Linux relies on the `/proc` directory to locate the Headscale process.
|
|
||||||
To enable it, set the `HEADSCALE_INTEGRATION=proc` value in the environment variables.
|
|
||||||
Because of the way this integration works, it only supports automatically reloading ACLs.
|
|
||||||
It's still very experimental and may not work in all environments.
|
|
||||||
|
|
||||||
## Configuration Scenarios
|
|
||||||
|
|
||||||
Since the configuration is fairly modular you can have a variety of different setups.
|
|
||||||
This mostly applies to the Docker integration since the native integration isn't fully featured yet.
|
|
||||||
Here are a few examples to inspire you and show you what can work and what can't:
|
|
||||||
|
|
||||||
#### Full Integration
|
|
||||||
Headscale runs in a container, Headplane can run in either a container or natively.
|
|
||||||
Headplane is able to manage the configuration file and ACLs that Headscale uses.
|
|
||||||
When changes happen, the Docker integration will automatically reload the configuration and ACLs.
|
|
||||||
|
|
||||||
> Note that the full integration currently isn't possible if Headscale isn't running in a container.
|
|
||||||
|
|
||||||
#### Configuration Only
|
|
||||||
Headscale and Headplane can either run in containers or natively.
|
|
||||||
Headplane is able to manage the configuration file and ACLs that Headscale uses.
|
|
||||||
When changes are made, Headscale will need to be manually restarted to apply the changes.
|
|
||||||
|
|
||||||
#### ACL Only
|
|
||||||
Headscale and Headplane can either run in containers or natively.
|
|
||||||
Headplane is able to manage the ACLs that Headscale uses.
|
|
||||||
When changes are made, Headscale will need to be sent a `SIGHUP` to reload the ACLs.
|
|
||||||
In this scenario, Headplane does not have access to the configuration file.
|
|
||||||
|
|
||||||
#### Read-Only Configuration or ACLs
|
|
||||||
If the configuration or ACLs are read-only, Headplane will not be able to manage them.
|
|
||||||
Instead you'll only be able to view the configurations on the UI and need to edit them manually.
|
|
||||||
|
|
||||||
#### No Integration
|
|
||||||
If no integration is setup, Headplane will not be able to manage the configuration or ACLs.
|
|
||||||
This is the simplest setup by far, however it also heavily reduces the capabilities of Headplane.
|
|
||||||
|
|||||||
+23
-20
@@ -1,28 +1,35 @@
|
|||||||
# Basic Integration
|
# Basic Integration
|
||||||
|
|
||||||
The basic integration is not able to offer advanced features such as:
|
The basic integration is the simplest way to get started with Headplane.
|
||||||
- Automatic management of Access Control Lists (ACLs)
|
It's more of a preview and is heavily limited in the features it can offer
|
||||||
- Management of DNS settings for your tailnet
|
when compared to the [Advanced Integration](/docs/Advanced-Integration.md).
|
||||||
- Management of the Headscale configuration
|
|
||||||
|
|
||||||
In order to support these features please refer to the [Advanced Integration](./docs/Advanced-Integration.md) guide.
|
> Note that the Advanced integration is the recommend way to run
|
||||||
Note that in order to use this deployment strategy you need to run Headscale in a Docker container.
|
Headplane in a production environment.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
- No automatic management of Access Control Lists (ACLs)
|
||||||
|
- No management of DNS settings for your tailnet
|
||||||
|
- No capability to edit the configuration
|
||||||
|
- Limited support for OIDC authentication
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Headscale 0.23 alpha 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)
|
||||||
|
|
||||||
Headplane is currently best run in a Docker container due to the easy configuration.
|
Docker heavily simplifies the deployment process, but this process can be
|
||||||
Here's a very basic `docker-compose.yaml` file that utilizes each configuration variable.
|
adopted to run natively. Follow the first section of the deployment guide
|
||||||
|
in the [Native Integration](/docs/integration/Native.md#deployment) for a
|
||||||
|
bare-metal or virtual machine deployment.
|
||||||
|
|
||||||
|
Here is a simple Docker Compose deployment:
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
headplane:
|
headplane:
|
||||||
container_name: headplane
|
container_name: headplane
|
||||||
image: ghcr.io/tale/headplane:latest
|
image: ghcr.io/tale/headplane:0.3.2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
@@ -36,18 +43,14 @@ 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'
|
||||||
PORT: '3000'
|
PORT: '3000'
|
||||||
```
|
```
|
||||||
|
|
||||||
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
|
> For a breakdown of each configuration variable, please refer to the
|
||||||
> It explains what each variable does, how to configure them, and what the default values are.
|
[Configuration](/docs/Configuration.md) guide.
|
||||||
|
> It explains what each variable does, how to configure them, and what the
|
||||||
You may also choose to run it natively with the distributed binaries on the releases page.
|
default values are.
|
||||||
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
|
|
||||||
|
|
||||||
## ACL Configuration
|
|
||||||
If you would like to get the web ACL configuration working, you'll need to pass the `ACL_FILE` environment variable.
|
|
||||||
This should point to the path of the ACL file on the Headscale server (ie. `ACL_FILE=/etc/headscale/acl_policy.json`).
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## Docker Integration
|
||||||
|
|
||||||
|
The Docker integration allows you to run Headplane and Headscale separately
|
||||||
|
in a dockerized environment. It allows you to unlock full functionality such as
|
||||||
|
automatic reloading of ACLs, DNS management, and Headscale configuration
|
||||||
|
management.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
> When running with the Docker integration, it's assumed that both Headscale and
|
||||||
|
Headplane will run as containers. If you are running Headscale natively, then
|
||||||
|
refer to the [Native Integration](/docs/integration/Native.md) guide.
|
||||||
|
|
||||||
|
To enable the Docker integration, set the `HEADSCALE_INTEGRATION` environment
|
||||||
|
variable to `docker`. You'll also need to supply `HEADSCALE_CONTAINER` with the
|
||||||
|
name or ID of the Headscale container.
|
||||||
|
|
||||||
|
By default Headplane uses `unix:///var/run/docker.sock` to connect to Docker.
|
||||||
|
This can be overridden by setting the `DOCKER_SOCK` environment variable. For
|
||||||
|
example, a remote socket would be `tcp://<my-remote-host>:2375`. When setting
|
||||||
|
the variable, you'll need to specify the protocol (`unix://` or `tcp://`).
|
||||||
|
|
||||||
|
> The `DOCKER_SOCK` variable does not support the HTTPS protocol.
|
||||||
|
|
||||||
|
To enable the Docker integration, set `HEADSCALE_INTEGRATION=docker` in the environment variables.
|
||||||
|
Additionally, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
|
||||||
|
This should be either the name or ID of the Headscale container (you can retrieve this using `docker ps`).
|
||||||
|
If the other integrations aren't setup, then Headplane will automatically disable the Docker integration.
|
||||||
|
|
||||||
|
By default the integration will check for `/var/run/docker.sock`, however you can override this by
|
||||||
|
setting the `DOCKER_SOCK` environment variable if you use a different configuration than the default.
|
||||||
|
When setting `DOCKER_SOCK`, you'll need to include the protocol (e.g., `unix://` or `tcp://`).
|
||||||
|
Headplane currently does not support the HTTPS protocol for the Docker socket.
|
||||||
|
|
||||||
|
Here's an example deployment using Docker Compose (recommended). Keep in mind
|
||||||
|
that you'll NEED to setup a reverse proxy and this is incomplete:
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
headscale:
|
||||||
|
image: 'headscale/headscale:0.23.0'
|
||||||
|
container_name: 'headscale'
|
||||||
|
restart: 'unless-stopped'
|
||||||
|
command: 'serve'
|
||||||
|
volumes:
|
||||||
|
- './data:/var/lib/headscale'
|
||||||
|
- './configs:/etc/headscale'
|
||||||
|
ports:
|
||||||
|
- '8080:8080'
|
||||||
|
environment:
|
||||||
|
TZ: 'America/New_York'
|
||||||
|
headplane:
|
||||||
|
container_name: headplane
|
||||||
|
image: ghcr.io/tale/headplane:0.3.2
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- './data:/var/lib/headscale'
|
||||||
|
- './configs:/etc/headscale'
|
||||||
|
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
# This is always required for Headplane to work
|
||||||
|
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
|
||||||
|
|
||||||
|
HEADSCALE_INTEGRATION: 'docker'
|
||||||
|
HEADSCALE_CONTAINER: 'headscale'
|
||||||
|
DISABLE_API_KEY_LOGIN: 'true'
|
||||||
|
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'
|
||||||
|
OIDC_ISSUER: 'https://sso.example.com'
|
||||||
|
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||||
|
|
||||||
|
# This NEEDS to be set with OIDC, regardless of what's in the config
|
||||||
|
# This needs to be a very long-lived (999 day) API key used to create
|
||||||
|
# shorter ones for OIDC and allow the OIDC functionality to work
|
||||||
|
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||||
|
```
|
||||||
|
|
||||||
|
> For a breakdown of each configuration variable, please refer to the
|
||||||
|
[Configuration](/docs/Configuration.md) guide.
|
||||||
|
> It explains what each variable does, how to configure them, and what the
|
||||||
|
default values are.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
## Kubernetes Integration
|
||||||
|
|
||||||
|
The Kubernetes integration allows you to run Headplane and Headscale together
|
||||||
|
in a cluster. It allows you to unlock full functionality such as automatic
|
||||||
|
reloading of ACLs, DNS management, and Headscale configuration management.
|
||||||
|
|
||||||
|
Currently there are a few limitations to the Kubernetes integration:
|
||||||
|
- Headplane and Headscale need to run in the same Pod and share the same
|
||||||
|
process space for the integration to work correctly due to a limitation in
|
||||||
|
the Kubernetes API.
|
||||||
|
|
||||||
|
- The only supported methods of deploying the integration are through a
|
||||||
|
`Deployment` or `Pod` (more coming soon). You can still get around this with
|
||||||
|
the `HEADSCALE_INTEGRATION_UNSTRICT` variable, but it's not recommended.
|
||||||
|
|
||||||
|
- The integration will assume that the Headscale container will always restart
|
||||||
|
because the integration relies on a system call that will exit the container.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
In order to ensure Headplane can read Kubernetes resources, you'll need to
|
||||||
|
grant additional RBAC permissions to the default `ServiceAccount` in the
|
||||||
|
namespace. This can be done with the following:
|
||||||
|
```yaml
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: headplane-agent
|
||||||
|
namespace: default # Adjust namespace as needed
|
||||||
|
rules:
|
||||||
|
- apiGroups: ['']
|
||||||
|
resources: ['pods']
|
||||||
|
verbs: ['get', 'list']
|
||||||
|
- apiGroups: ['apps']
|
||||||
|
resources: ['deployments']
|
||||||
|
verbs: ['get', 'list']
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: headplane-agent
|
||||||
|
namespace: default # Adjust namespace as needed
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: headplane-agent
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: default # If you use a different service account, change this
|
||||||
|
namespace: default # Adjust namespace as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep in mind you'll need to make `PersistentVolumeClaim`s for the data and that
|
||||||
|
they need to be either `ReadWriteOnce` or `ReadWriteMany` depending on your
|
||||||
|
topology. Additionally, you can abstract environment variables and configuration
|
||||||
|
away into a `ConfigMap` or `Secret` for easier management.
|
||||||
|
|
||||||
|
The important parts of this deployment are the `HEADSCALE_INTEGRATION` and
|
||||||
|
`DEPLOYMENT_NAME` environment variables. The `HEADSCALE_INTEGRATION` variable
|
||||||
|
should be set to `kubernetes` and the `POST_NAME` variable should be set
|
||||||
|
to the name of the pod (done using the Downward API below).
|
||||||
|
|
||||||
|
> If you are having issues with validating `shareProcessNamespace`, you can
|
||||||
|
set `HEADSCALE_INTEGRATION_UNSTRICT` to `true` to disable the strict checks.
|
||||||
|
|
||||||
|
A basic deployment of the integration would look like this. Keep in mind that
|
||||||
|
you are responsible for setting up a reverse-proxy via an `Ingress` or `Service`
|
||||||
|
otherwise Headplane will not work:
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: headplane
|
||||||
|
namespace: default # Adjust namespace as needed
|
||||||
|
labels:
|
||||||
|
app: headplane
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: headplane
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: headplane
|
||||||
|
spec:
|
||||||
|
shareProcessNamespace: true
|
||||||
|
serviceAccountName: default
|
||||||
|
containers:
|
||||||
|
- name: headplane
|
||||||
|
image: ghcr.io/tale/headplane:0.3.2
|
||||||
|
env:
|
||||||
|
- name: COOKIE_SECRET
|
||||||
|
value: 'abcdefghijklmnopqrstuvwxyz'
|
||||||
|
- name: HEADSCALE_INTEGRATION
|
||||||
|
value: 'kubernetes'
|
||||||
|
- name: POD_NAME
|
||||||
|
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
|
||||||
|
command: ['serve']
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: 'America/New_York'
|
||||||
|
volumeMounts:
|
||||||
|
- name: headscale-data
|
||||||
|
mountPath: /var/lib/headscale
|
||||||
|
- name: headscale-config
|
||||||
|
mountPath: /etc/headscale
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: headscale-data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: headscale-data
|
||||||
|
- name: headscale-config
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: headscale-config
|
||||||
|
```
|
||||||
|
|
||||||
|
> For a breakdown of each configuration variable, please refer to the
|
||||||
|
[Configuration](/docs/Configuration.md) guide.
|
||||||
|
> It explains what each variable does, how to configure them, and what the
|
||||||
|
default values are.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Native Integration
|
||||||
|
|
||||||
|
The Native integration allows you to run both Headplane and Headscale on
|
||||||
|
bare-metal servers or virtual machines. This integration is best suited for
|
||||||
|
environments where Docker or Kubernetes are not available or not desired.
|
||||||
|
|
||||||
|
Currently the Native integration only supports automatic reloading of ACLs. It
|
||||||
|
cannot handle configuration changes as killing the `headscale` process can lead
|
||||||
|
to undefined behavior or the service not restarting.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Follow the instructions to install Headscale from the
|
||||||
|
[Linux Installation Guide](https://headscale.net/running-headscale-linux/). As
|
||||||
|
of now, Headplane requires Node.js 20 to be installed on the system. Once you
|
||||||
|
are ready, clone the repository (`git clone https://github.com/tale/headplane`),
|
||||||
|
install dependencies (`npm install`), build the project (`npm run build`), and
|
||||||
|
start the server (`npm start`).
|
||||||
|
|
||||||
|
> If you'd like, you can turn this into a `systemd` unit to manage the service.
|
||||||
|
> I plan to provide packages and unit files to make this easier in the future.
|
||||||
|
|
||||||
|
When running Headplane, you'll need to set environment variables to configure
|
||||||
|
the application. The `HEADSCALE_INTEGRATION` variable should be set to `proc`.
|
||||||
|
|
||||||
|
> For a breakdown of each configuration variable, please refer to the
|
||||||
|
[Configuration](/docs/Configuration.md) guide.
|
||||||
|
> It explains what each variable does, how to configure them, and what the default values are.
|
||||||
+12
-11
@@ -15,19 +15,20 @@
|
|||||||
"@dnd-kit/modifiers": "^7.0.0",
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@kubernetes/client-node": "^0.21.0",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@primer/octicons-react": "^19.10.0",
|
"@primer/octicons-react": "^19.10.0",
|
||||||
"@react-aria/toast": "3.0.0-beta.12",
|
"@react-aria/toast": "3.0.0-beta.12",
|
||||||
"@react-stately/toast": "3.0.0-beta.4",
|
"@react-stately/toast": "3.0.0-beta.4",
|
||||||
"@remix-run/node": "^2.9.2",
|
"@remix-run/node": "^2.10.2",
|
||||||
"@remix-run/react": "^2.9.2",
|
"@remix-run/react": "^2.10.2",
|
||||||
"@remix-run/serve": "^2.9.2",
|
"@remix-run/serve": "^2.10.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"isbot": "^5.1.9",
|
"isbot": "^5.1.11",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"react": "19.0.0-beta-26f2496093-20240514",
|
"react": "19.0.0-rc-f38c22b244-20240704",
|
||||||
"react-aria-components": "^1.2.1",
|
"react-aria-components": "^1.2.1",
|
||||||
"react-dom": "19.0.0-beta-26f2496093-20240514",
|
"react-dom": "19.0.0-rc-f38c22b244-20240704",
|
||||||
"remix-utils": "^7.6.0",
|
"remix-utils": "^7.6.0",
|
||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
"tailwindcss-react-aria-components": "^1.1.3",
|
"tailwindcss-react-aria-components": "^1.1.3",
|
||||||
@@ -37,20 +38,20 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@remix-run/dev": "^2.9.2",
|
"@remix-run/dev": "^2.10.2",
|
||||||
"@types/react": "npm:types-react@beta",
|
"@types/react": "npm:types-react@beta",
|
||||||
"@types/react-dom": "npm:types-react-dom@beta",
|
"@types/react-dom": "npm:types-react-dom@beta",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
|
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-tale": "^2.0.4",
|
"eslint-config-tale": "^2.0.4",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"typescript": "^5.5.2",
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.3.1",
|
"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
+1852
-1232
File diff suppressed because it is too large
Load Diff
+102
-39
@@ -186,7 +186,8 @@ log:
|
|||||||
# Path to a file containg ACL policies.
|
# Path to a file containg ACL policies.
|
||||||
# ACLs can be defined as YAML or HUJSON.
|
# ACLs can be defined as YAML or HUJSON.
|
||||||
# https://tailscale.com/kb/1018/acls/
|
# https://tailscale.com/kb/1018/acls/
|
||||||
acl_policy_path: /etc/headscale/acl.json
|
policy:
|
||||||
|
mode: 'database'
|
||||||
|
|
||||||
## DNS
|
## DNS
|
||||||
#
|
#
|
||||||
@@ -197,7 +198,7 @@ acl_policy_path: /etc/headscale/acl.json
|
|||||||
# - https://tailscale.com/kb/1081/magicdns/
|
# - https://tailscale.com/kb/1081/magicdns/
|
||||||
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
|
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
|
||||||
#
|
#
|
||||||
dns_config:
|
dns_config2:
|
||||||
# Whether to prefer using Headscale provided DNS or use local.
|
# Whether to prefer using Headscale provided DNS or use local.
|
||||||
override_local_dns: true
|
override_local_dns: true
|
||||||
|
|
||||||
@@ -253,6 +254,68 @@ dns_config:
|
|||||||
# The FQDN of the hosts will be
|
# The FQDN of the hosts will be
|
||||||
# `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_).
|
# `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_).
|
||||||
base_domain: ts.net
|
base_domain: ts.net
|
||||||
|
extra_records:
|
||||||
|
- name: test.example.com
|
||||||
|
type: A
|
||||||
|
value: 1.1.1.1
|
||||||
|
|
||||||
|
dns:
|
||||||
|
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
|
||||||
|
# Only works if there is at least a nameserver defined.
|
||||||
|
magic_dns: true
|
||||||
|
|
||||||
|
# Defines the base domain to create the hostnames for MagicDNS.
|
||||||
|
# This domain _must_ be different from the server_url domain.
|
||||||
|
# `base_domain` must be a FQDN, without the trailing dot.
|
||||||
|
# The FQDN of the hosts will be
|
||||||
|
# `hostname.base_domain` (e.g., _myhost.example.com_).
|
||||||
|
base_domain: example.com
|
||||||
|
|
||||||
|
# List of DNS servers to expose to clients.
|
||||||
|
nameservers:
|
||||||
|
global:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 1.0.0.1
|
||||||
|
- 2606:4700:4700::1111
|
||||||
|
- 2606:4700:4700::1001
|
||||||
|
|
||||||
|
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
|
||||||
|
# "abc123" is example NextDNS ID, replace with yours.
|
||||||
|
# - https://dns.nextdns.io/abc123
|
||||||
|
|
||||||
|
# Split DNS (see https://tailscale.com/kb/1054/dns/),
|
||||||
|
# a map of domains and which DNS server to use for each.
|
||||||
|
split:
|
||||||
|
{}
|
||||||
|
# foo.bar.com:
|
||||||
|
# - 1.1.1.1
|
||||||
|
# darp.headscale.net:
|
||||||
|
# - 1.1.1.1
|
||||||
|
# - 8.8.8.8
|
||||||
|
|
||||||
|
# Set custom DNS search domains. With MagicDNS enabled,
|
||||||
|
# your tailnet base_domain is always the first search domain.
|
||||||
|
search_domains: []
|
||||||
|
|
||||||
|
# Extra DNS records
|
||||||
|
# so far only A-records are supported (on the tailscale side)
|
||||||
|
# See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations
|
||||||
|
extra_records: []
|
||||||
|
# - name: "grafana.myvpn.example.com"
|
||||||
|
# type: "A"
|
||||||
|
# value: "100.64.0.3"
|
||||||
|
#
|
||||||
|
# # you can also put it in one line
|
||||||
|
# - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" }
|
||||||
|
|
||||||
|
# DEPRECATED
|
||||||
|
# Use the username as part of the DNS name for nodes, with this option enabled:
|
||||||
|
# node1.username.example.com
|
||||||
|
# while when this is disabled:
|
||||||
|
# node1.example.com
|
||||||
|
# This is a legacy option as Headscale has have this wrongly implemented
|
||||||
|
# while in upstream Tailscale, the username is not included.
|
||||||
|
use_username_in_magic_dns: false
|
||||||
|
|
||||||
# Unix socket used for the CLI to connect without authentication
|
# Unix socket used for the CLI to connect without authentication
|
||||||
# Note: for production you will want to set this to something like:
|
# Note: for production you will want to set this to something like:
|
||||||
@@ -268,46 +331,46 @@ oidc:
|
|||||||
issuer: "https://sso.example.com"
|
issuer: "https://sso.example.com"
|
||||||
client_id: "headscale"
|
client_id: "headscale"
|
||||||
client_secret: "super_secret_client_secret"
|
client_secret: "super_secret_client_secret"
|
||||||
# # Alternatively, set `client_secret_path` to read the secret from the file.
|
# # Alternatively, set `client_secret_path` to read the secret from the file.
|
||||||
# # It resolves environment variables, making integration to systemd's
|
# # It resolves environment variables, making integration to systemd's
|
||||||
# # `LoadCredential` straightforward:
|
# # `LoadCredential` straightforward:
|
||||||
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
||||||
# # client_secret and client_secret_path are mutually exclusive.
|
# # client_secret and client_secret_path are mutually exclusive.
|
||||||
#
|
#
|
||||||
# # The amount of time from a node is authenticated with OpenID until it
|
# # The amount of time from a node is authenticated with OpenID until it
|
||||||
# # expires and needs to reauthenticate.
|
# # expires and needs to reauthenticate.
|
||||||
# # Setting the value to "0" will mean no expiry.
|
# # Setting the value to "0" will mean no expiry.
|
||||||
expiry: 180d
|
expiry: 180d
|
||||||
#
|
#
|
||||||
# # Use the expiry from the token received from OpenID when the user logged
|
# # Use the expiry from the token received from OpenID when the user logged
|
||||||
# # in, this will typically lead to frequent need to reauthenticate and should
|
# # in, this will typically lead to frequent need to reauthenticate and should
|
||||||
# # only been enabled if you know what you are doing.
|
# # only been enabled if you know what you are doing.
|
||||||
# # Note: enabling this will cause `oidc.expiry` to be ignored.
|
# # Note: enabling this will cause `oidc.expiry` to be ignored.
|
||||||
# use_expiry_from_token: false
|
# use_expiry_from_token: false
|
||||||
#
|
#
|
||||||
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
|
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
|
||||||
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
|
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
|
||||||
#
|
#
|
||||||
# scope: ["openid", "profile", "email", "custom"]
|
# scope: ["openid", "profile", "email", "custom"]
|
||||||
# extra_params:
|
# extra_params:
|
||||||
# domain_hint: example.com
|
# domain_hint: example.com
|
||||||
#
|
#
|
||||||
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
|
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
|
||||||
# # authentication request will be rejected.
|
# # authentication request will be rejected.
|
||||||
#
|
#
|
||||||
allowed_domains:
|
allowed_domains:
|
||||||
- example.com
|
- example.com
|
||||||
# # Note: Groups from keycloak have a leading '/'
|
# # Note: Groups from keycloak have a leading '/'
|
||||||
# allowed_groups:
|
# allowed_groups:
|
||||||
# - /headscale
|
# - /headscale
|
||||||
# allowed_users:
|
# allowed_users:
|
||||||
# - alice@example.com
|
# - alice@example.com
|
||||||
#
|
#
|
||||||
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
|
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
|
||||||
# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name`
|
# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name`
|
||||||
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
|
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
|
||||||
# user: `first-name.last-name.example.com`
|
# user: `first-name.last-name.example.com`
|
||||||
#
|
#
|
||||||
strip_email_domain: true
|
strip_email_domain: true
|
||||||
|
|
||||||
# Logtail configuration
|
# Logtail configuration
|
||||||
|
|||||||
Reference in New Issue
Block a user