Compare commits

...

7 Commits

Author SHA1 Message Date
Aarnav Tale 90f0bf2555 chore: v0.2.4 2024-08-24 10:35:05 -04:00
Aarnav Tale ea2ffdf0c1 feat: support removing config values via null 2024-08-24 10:33:30 -04:00
Aarnav Tale 9aedd9baad chore: use beta2 on the dev env 2024-08-24 10:19:07 -04:00
Aarnav Tale 690b52d8c6 chore(TALE-29): remove acl from integration/context 2024-08-24 10:19:07 -04:00
Aarnav Tale a72a3d6e5f chore(TALE-29): remove references to ACL_FILE 2024-08-24 10:19:06 -04:00
Akira Yamazaki c4c1fd8aab feat: make secure flag of cookie configurable (#26) 2024-08-24 10:18:38 -04:00
Aarnav Tale 9801ef453d fix(TALE-29): remove all old ACL_FILE handling
No longer required if the minimum is beta2
2024-08-23 16:12:46 -04:00
18 changed files with 46 additions and 244 deletions
+6
View File
@@ -1,3 +1,9 @@
### 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
- Fixed a bug where removing Split DNS configurations would crash the UI.
### 0.2.3 (August 23, 2024) ### 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2 - Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1 - Support the new API policy mode for Headscale 0.23-beta1
-1
View File
@@ -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']
} }
-30
View File
@@ -88,36 +88,6 @@ export default createIntegration<Context>({
return context.client !== undefined return context.client !== undefined
}, },
onAclChange: async (context) => {
if (!context.client || !context.container) {
return
}
log.info('INTG', 'Sending SIGHUP to Headscale via Docker')
let attempts = 0
while (attempts <= context.maxAttempts) {
const response = await context.client.request({
method: 'POST',
path: `/v1.30/containers/${context.container}/kill?signal=SIGHUP`,
})
if (response.statusCode !== 204) {
if (attempts < context.maxAttempts) {
attempts++
await setTimeout(1000)
continue
}
const stringCode = response.statusCode.toString()
const body = await response.body.text()
throw new Error(`API request failed: ${stringCode} ${body}`)
}
break
}
},
onConfigChange: async (context) => { onConfigChange: async (context) => {
if (!context.client || !context.container) { if (!context.client || !context.container) {
return return
-1
View File
@@ -3,7 +3,6 @@ export interface IntegrationFactory<T = any> {
name: string name: string
context: T context: T
isAvailable: (context: T) => Promise<boolean> | boolean isAvailable: (context: T) => Promise<boolean> | boolean
onAclChange?: (context: T) => Promise<void> | void
onConfigChange?: (context: T) => Promise<void> | void onConfigChange?: (context: T) => Promise<void> | void
} }
-9
View File
@@ -177,15 +177,6 @@ export default createIntegration<Context>({
} }
}, },
onAclChange: (context) => {
if (!context.pid) {
return
}
log.info('INTG', 'Sending SIGHUP to Headscale')
kill(context.pid, 'SIGHUP')
},
onConfigChange: (context) => { onConfigChange: (context) => {
if (!context.pid) { if (!context.pid) {
return return
+1 -10
View File
@@ -70,14 +70,5 @@ export default createIntegration<Context>({
log.error('INTG', 'Failed to read /proc') log.error('INTG', 'Failed to read /proc')
return false return false
} }
}, }
onAclChange: (context) => {
if (!context.pid) {
return
}
log.info('INTG', 'Sending SIGHUP to Headscale')
kill(context.pid, 'SIGHUP')
},
}) })
+18 -85
View File
@@ -12,7 +12,7 @@ import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull, put } from '~/utils/headscale' import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
@@ -27,7 +27,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
session.get('hsApiKey')!, session.get('hsApiKey')!,
) )
console.log(policy)
try { try {
// We have read access, now do we have write access? // We have read access, now do we have write access?
// Attempt to set the policy to what we just got // Attempt to set the policy to what we just got
@@ -37,7 +36,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
return { return {
hasAclWrite: true, hasAclWrite: true,
isPolicyApi: true,
currentAcl: policy, currentAcl: policy,
aclType: 'json', aclType: 'json',
} as const } as const
@@ -49,35 +47,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (error.status === 500) { if (error.status === 500) {
return { return {
hasAclWrite: false, hasAclWrite: false,
isPolicyApi: true,
currentAcl: policy, currentAcl: policy,
aclType: 'json', aclType: 'json',
} as const } as const
} }
} }
} catch (error) { } catch {}
// Propagate our errors through normal error handling
if (!(error instanceof HeadscaleError)) {
throw error
}
// Not on 0.23-beta1 or later
if (error.status === 404) {
const { data, type, read, write } = await loadAcl()
return {
hasAclWrite: write,
isPolicyApi: false,
currentAcl: read ? data : '',
aclType: type,
}
}
throw error
}
return { return {
hasAclWrite: true, hasAclWrite: true,
isPolicyApi: true,
currentAcl: '', currentAcl: '',
aclType: 'json', aclType: 'json',
} as const } as const
@@ -91,32 +69,17 @@ export async function action({ request }: ActionFunctionArgs) {
}) })
} }
const data = await request.json() as { acl: string, api: boolean } const { acl } = await request.json() as { acl: string, api: boolean }
if (data.api) { try {
try { await put('v1/policy', session.get('hsApiKey')!, {
await put('v1/policy', session.get('hsApiKey')!, { policy: acl,
policy: data.acl,
})
return json({ success: true })
} catch (error) {
return json({ success: false }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
}
const context = await loadContext()
if (!context.acl.write) {
return json({ success: false }, {
status: 403,
}) })
}
await patchAcl(data.acl) return json({ success: true })
} catch (error) {
if (context.integration?.onAclChange) { return json({ success: false }, {
await context.integration.onAclChange(context.integration.context) status: error instanceof HeadscaleError ? error.status : 500,
})
} }
return json({ success: true }) return json({ success: true })
@@ -175,23 +138,6 @@ export function ErrorBoundary() {
<Code>database</Code> <Code>database</Code>
. .
</p> </p>
<p className="mb-2 text-md">
If you are running an older version of Headscale, the
{' '}
<Code>ACL_FILE</Code>
{' '}
environment variable is not set. Refer to the
{' '}
<Link
to="https://github.com/tale/headplane/blob/main/docs/Configuration.md"
name="Headplane Configuration"
>
Headplane Configuration
</Link>
{' '}
documentation for more information on how to set the
ACL file and integrate it with Headscale.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -225,25 +171,13 @@ export default function Page() {
? undefined ? undefined
: ( : (
<div className="mb-4"> <div className="mb-4">
{data.isPolicyApi <Notice className="w-fit">
? ( The ACL policy is read-only. You can view the current policy
<Notice className="w-fit"> but you cannot make changes to it.
The ACL policy is read-only. You can view the current policy <br />
but you cannot make changes to it. To resolve this, you need to set the ACL policy mode to
<br /> database in your Headscale configuration.
To resolve this, you need to set the ACL policy mode to </Notice>
database in your Headscale configuration.
</Notice>
)
: (
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy
but you cannot make changes to it.
<br />
To resolve this, you need to configure a Headplane integration
or make the ACL_FILE environment variable available.
</Notice>
)}
</div> </div>
)} )}
@@ -357,7 +291,6 @@ export default function Page() {
setToasted(false) setToasted(false)
fetcher.submit({ fetcher.submit({
acl, acl,
api: data.isPolicyApi,
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
+2 -2
View File
@@ -91,9 +91,9 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps)
}) })
} else { } else {
const key = `dns.nameservers.split."${name}"` const key = `dns.nameservers.split."${name}"`
const list = nameservers.filter((_, i) => i !== index)
submit({ submit({
[key]: nameservers [key]: list.length ? list : null,
.filter((_, i) => i !== index),
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
-1
View File
@@ -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'),
} }
-97
View File
@@ -22,11 +22,6 @@ export interface HeadplaneContext {
write: boolean write: boolean
} }
acl: {
read: boolean
write: boolean
}
oidc?: { oidc?: {
issuer: string issuer: string
client: string client: string
@@ -69,7 +64,6 @@ export async function loadContext(): Promise<HeadplaneContext> {
cookieSecret, cookieSecret,
integration: await loadIntegration(), integration: await loadIntegration(),
config: contextData, config: contextData,
acl: await checkAcl(config),
oidc: await checkOidc(config), oidc: await checkOidc(config),
} }
@@ -81,75 +75,10 @@ export async function loadContext(): Promise<HeadplaneContext> {
: 'Unavailable', : 'Unavailable',
) )
log.info('CTXT', 'ACL: %s', context.acl.read
? `Found ${context.acl.write ? '' : '(Read Only)'}`
: 'Unavailable',
)
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable') log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
return context return context
} }
export async function loadAcl(): Promise<{
data: string
type: 'json' | 'yaml'
read: boolean
write: boolean
}> {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await loadConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
throw new Error('No ACL file defined')
}
// Check for attributes
let read = false
let write = false
try {
await access(path, constants.R_OK)
read = true
} catch {}
try {
await access(path, constants.W_OK)
write = true
} catch {}
const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
try {
parse(data)
return { data, type: 'yaml', read, write }
} catch {
return { data, type: 'json', read, write }
}
}
export async function patchAcl(data: string) {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await loadConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
throw new Error('No ACL file defined')
}
await writeFile(path, data, 'utf8')
}
async function checkConfig(path: string) { async function checkConfig(path: string) {
let config: HeadscaleConfig | undefined let config: HeadscaleConfig | undefined
try { try {
@@ -179,32 +108,6 @@ 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) {
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true' const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
+5 -1
View File
@@ -45,7 +45,6 @@ const HeadscaleConfig = z.object({
disable_check_updates: goBool.default(false), disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default('120s'), ephemeral_node_inactivity_timeout: goDuration.default('120s'),
randomize_client_port: goBool.default(false), randomize_client_port: goBool.default(false),
acl_policy_path: z.string().optional(),
acme_email: z.string().optional(), acme_email: z.string().optional(),
acme_url: z.string().optional(), acme_url: z.string().optional(),
@@ -309,6 +308,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)
} }
+1 -2
View File
@@ -27,9 +27,8 @@ export const {
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24, // 24 hours
path: '/', path: '/',
sameSite: 'lax', sameSite: 'lax',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secrets: [process.env.COOKIE_SECRET!], secrets: [process.env.COOKIE_SECRET!],
secure: true secure: process.env.COOKIE_SECURE !== 'false',
} }
} }
) )
+1 -1
View File
@@ -8,7 +8,7 @@ networks:
driver: 'bridge' driver: 'bridge'
services: services:
headscale: headscale:
image: 'headscale/headscale:0.23.0-beta1' image: 'headscale/headscale:0.23.0-beta2'
container_name: 'headscale' container_name: 'headscale'
restart: 'unless-stopped' restart: 'unless-stopped'
command: 'serve' command: 'serve'
+3 -3
View File
@@ -54,9 +54,9 @@ When the ACL file is available for editing, the `Access Controls` tab will
become available. All of the integrations support automatic reloading of the become available. All of the integrations support automatic reloading of the
ACLs when the file is changed. ACLs when the file is changed.
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`. This > By default, the ACL file is read from `/etc/headscale/acl_policy.json`.
can be overridden by setting the `ACL_FILE` environment variable and is also > If `policy.path` is set and `policy.mode` is set to `file`, the ACL file will
overriden by the `acl_policy_path` key in the configuration file if set. > be read from the path specified in the configuration file instead.
## Deployment ## Deployment
+1
View File
@@ -43,6 +43,7 @@ services:
OIDC_ISSUER: 'https://sso.example.com' OIDC_ISSUER: 'https://sso.example.com'
OIDC_CLIENT_SECRET: 'super_secret_client_secret' OIDC_CLIENT_SECRET: 'super_secret_client_secret'
DISABLE_API_KEY_LOGIN: 'true' DISABLE_API_KEY_LOGIN: 'true'
COOKIE_SECURE: 'false'
# These are the default values # These are the default values
HOST: '0.0.0.0' HOST: '0.0.0.0'
+1 -1
View File
@@ -12,8 +12,8 @@ You can configure Headplane using environment variables.
- **`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.
+3
View File
@@ -68,6 +68,9 @@ services:
HOST: '0.0.0.0' HOST: '0.0.0.0'
PORT: '3000' PORT: '3000'
# Only set this to false if you aren't behind a reverse proxy
COOKIE_SECURE: 'false'
# Overrides the configuration file values if they are set in config.yaml # Overrides the configuration file values if they are set in config.yaml
# If you want to share the same OIDC configuration you do not need this # If you want to share the same OIDC configuration you do not need this
OIDC_CLIENT_ID: 'headscale' OIDC_CLIENT_ID: 'headscale'
+4
View File
@@ -98,6 +98,10 @@ spec:
valueFrom: valueFrom:
fieldRef: fieldRef:
fieldPath: metadata.name fieldPath: metadata.name
# Only set this to false if you aren't behind a reverse proxy
- name: COOKIE_SECURE
value: 'false'
volumeMounts: volumeMounts:
- name: headscale-config - name: headscale-config
mountPath: /etc/headscale mountPath: /etc/headscale