mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41ca4c40ad | |||
| f7c9a14e92 | |||
| 6e514a9064 | |||
| 631ea15895 | |||
| 762bc6a793 | |||
| 6c83e96341 | |||
| 03e6a26b3a | |||
| 5b716ab5ce | |||
| a7e4f3e4d2 | |||
| 40ddb69bc9 | |||
| 6aa6e77611 | |||
| c5c2f8a93b | |||
| acd042b4de |
@@ -33,7 +33,7 @@ jobs:
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
### 0.5.3 (March 1, 2025)
|
||||
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
|
||||
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
|
||||
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
|
||||
|
||||
### 0.5.2 (February 28, 2025)
|
||||
- Hotfixed an issue where the server bundle got reloaded on each request
|
||||
|
||||
### 0.5.1 (February 28, 2025)
|
||||
- Fixed an issue that caused the entire server to crash on start
|
||||
- Fixed the published semver tags from Docker
|
||||
- Fixed the Kubernetes integration not reading the config
|
||||
|
||||
### 0.5 (February 27, 2025)
|
||||
> This release is a major overhaul and contains a significant breaking change.
|
||||
> We now use a config file for all settings instead of environment variables.
|
||||
> Please see [config.example.yaml](/config.example.yaml) for the new format.
|
||||
|
||||
- Completely redesigned the UI from the ground up for accessibility and performance.
|
||||
- Switched to a config-file setup (this introduces breaking changes, see [config.example.yaml](/config.example.yaml) for the new format).
|
||||
- If the config is read-only, the options are still visible, just disabled (fixes [#48](https://github.com/tale/headplane/issues/48))
|
||||
|
||||
@@ -122,9 +122,7 @@ function Panel(props: DialogPanelProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
||||
isDisabled={
|
||||
isDisabled || !(ref.current?.checkValidity() ?? true)
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Asterisk } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
@@ -9,6 +10,7 @@ export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// TODO: Custom isInvalid logic for custom error messages
|
||||
export default function Input(props: InputProps) {
|
||||
const { label, labelHidden, className } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
@@ -42,6 +44,9 @@ export default function Input(props: InputProps) {
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{props.isRequired && (
|
||||
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
{...inputProps}
|
||||
|
||||
@@ -105,8 +105,8 @@ const headscaleConfig = type({
|
||||
magic_dns: goBool.default(true),
|
||||
base_domain: 'string = "headscale.net"',
|
||||
nameservers: type({
|
||||
global: 'string[]',
|
||||
split: 'Record<string, string[]>',
|
||||
'global?': 'string[]',
|
||||
'split?': 'Record<string, string[]>',
|
||||
}).default(() => ({ global: [], split: {} })),
|
||||
search_domains: type('string[]').default(() => []),
|
||||
extra_records: type({
|
||||
@@ -129,7 +129,7 @@ const headscaleConfig = type({
|
||||
client_secret_path: 'string?',
|
||||
expiry: goDuration.default('180d'),
|
||||
use_expiry_from_token: goBool.default(false),
|
||||
scope: 'string = "profile email"',
|
||||
scope: type('string[]').default(() => ['openid', 'email', 'profile']),
|
||||
extra_params: 'Record<string, string>?',
|
||||
allowed_domains: 'string[]?',
|
||||
allowed_groups: 'string[]?',
|
||||
|
||||
@@ -58,10 +58,10 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
);
|
||||
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
||||
if (this.context.validate_manifest === false) {
|
||||
log.warn('INTG', 'Skipping strict Pod status check');
|
||||
} else {
|
||||
const pod = process.env.POD_NAME;
|
||||
const pod = this.context.pod_name;
|
||||
if (!pod) {
|
||||
log.error('INTG', 'Missing POD_NAME variable');
|
||||
return false;
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ integration:
|
||||
# The path to the Docker socket (do not change this if you are unsure)
|
||||
# Docker socket paths must start with unix:// or tcp:// and at the moment
|
||||
# https connections are not supported.
|
||||
socket: "unix:///var/run/docker.dock"
|
||||
socket: "unix:///var/run/docker.sock"
|
||||
# Please refer to docs/integration/Kubernetes.md for more information
|
||||
# on how to configure the Kubernetes integration. There are requirements in
|
||||
# order to allow Headscale to be controlled by Headplane in a cluster.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> Since 0.5, you will need to manually migrate your configuration to the new format.
|
||||
|
||||
Headplane uses a configuration file to manage its settings
|
||||
([**config.example.yaml**](./config.example.yaml)). By default, Headplane looks
|
||||
([**config.example.yaml**](../config.example.yaml)). By default, Headplane looks
|
||||
for a the file at `/etc/headplane/config.yaml`. This can be changed using the
|
||||
**`HEADPLANE_CONFIG_PATH`** environment variable to point to a different location.
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Headplane Agent
|
||||
|
||||
The Headplane agent is a lightweight service that runs alongside the Headscale server.
|
||||
It's used to interface with devices on your network locally, unlocking the following:
|
||||
|
||||
- **Node Information/Status**: View Tailscale versions, OS versions, and connection details.
|
||||
- **SSH via Web (Soon)**: Connect to devices via SSH directly from the Headplane UI.
|
||||
|
||||
It's built on top of [tsnet](https://tailscale.com/kb/1244/tsnet), the official
|
||||
set of libraries published by Tailscale for creating local services that can
|
||||
join the tailnet.
|
||||
While it isn't required to run the agent, it's highly recommended to get the
|
||||
closest experience to the SaaS version of Tailscale. This is paired with the
|
||||
integrations provided by Headplane to manage DNS and Headscale settings.
|
||||
|
||||
### Installation
|
||||
The agent can either be ran as a standalone binary or as a Docker container.
|
||||
Agent binaries are available on the [releases](https://github.com/tale/headplane/releases) page.
|
||||
The Docker image is available through the `ghcr.io/tale/headplane-agent` tag.
|
||||
|
||||
The agent requires the following environment variables to be set:
|
||||
- **`HP_AGENT_HOSTNAME`**: A hostname you want to use for the agent.
|
||||
- **`HP_AGENT_TS_SERVER`**: The URL to your Headscale instance.
|
||||
- **`HP_AGENT_TS_AUTHKEY`**: An authorization key to authenticate with Headscale (see below).
|
||||
- **`HP_AGENT_HP_SERVER`**: The URL to your Headplane instance.
|
||||
- **`HP_AGENT_HP_AUTHKEY`**: The generated auth key to authenticate with Headplane.
|
||||
|
||||
If you already have Headplane setup, you can generate all of these values within
|
||||
the Headplane UI. Navigate to the `Settings` page and click `Agent` to get started.
|
||||
|
||||
HP_AGENT_HOSTNAME=headplane-agent
|
||||
HP_AGENT_TS_SERVER=http://localhost:8080
|
||||
#HP_AGENT_AUTH_KEY=3e0cd749021e5984267cde4b0a5a2ac32c1859e56f7911aa
|
||||
HP_AGENT_TS_AUTHKEY=a4dab065c735cb4eae4f12804cf7e111206f9c7c9247c629
|
||||
HP_AGENT_HP_SERVER=http://localhost:3000/admin
|
||||
@@ -34,7 +34,7 @@ Here is what a sample Docker Compose deployment would look like:
|
||||
services:
|
||||
headplane:
|
||||
# I recommend you pin the version to a specific release
|
||||
image: ghcr.io/tale/headplane:0.5.0
|
||||
image: ghcr.io/tale/headplane:0.5.1
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -148,7 +148,7 @@ spec:
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- name: headplane
|
||||
image: ghcr.io/tale/headplane:0.5.0
|
||||
image: ghcr.io/tale/headplane:0.5.1
|
||||
env:
|
||||
# Set these if the pod name for Headscale is not static
|
||||
# We will use the downward API to get the pod name instead
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ Here is what a sample Docker Compose deployment would look like:
|
||||
services:
|
||||
headplane:
|
||||
# I recommend you pin the version to a specific release
|
||||
image: ghcr.io/tale/headplane:0.5.0
|
||||
image: ghcr.io/tale/headplane:0.5.1
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { constants, access, readFile } from 'node:fs/promises';
|
||||
import { env } from 'node:process';
|
||||
import { type } from 'arktype';
|
||||
import dotenv from 'dotenv';
|
||||
import { parseDocument } from 'yaml';
|
||||
@@ -7,7 +8,7 @@ import log, { hpServer_loadLogger } from '~server/utils/log';
|
||||
import mutex from '~server/utils/mutex';
|
||||
import { HeadplaneConfig, coalesceConfig, validateConfig } from './parser';
|
||||
|
||||
declare global {
|
||||
declare namespace globalThis {
|
||||
let __cookie_context: {
|
||||
cookie_secret: string;
|
||||
cookie_secure: boolean;
|
||||
@@ -63,9 +64,9 @@ export async function hp_loadConfig() {
|
||||
let path = HEADPLANE_DEFAULT_CONFIG_PATH;
|
||||
|
||||
const envs = rootEnvs({
|
||||
HEADPLANE_DEBUG_LOG: process.env.HEADPLANE_DEBUG_LOG,
|
||||
HEADPLANE_CONFIG_PATH: process.env.HEADPLANE_CONFIG_PATH,
|
||||
HEADPLANE_LOAD_ENV_OVERRIDES: process.env.HEADPLANE_LOAD_ENV_OVERRIDES,
|
||||
HEADPLANE_DEBUG_LOG: env.HEADPLANE_DEBUG_LOG,
|
||||
HEADPLANE_CONFIG_PATH: env.HEADPLANE_CONFIG_PATH,
|
||||
HEADPLANE_LOAD_ENV_OVERRIDES: env.HEADPLANE_LOAD_ENV_OVERRIDES,
|
||||
});
|
||||
|
||||
if (envs instanceof type.errors) {
|
||||
@@ -79,7 +80,6 @@ export async function hp_loadConfig() {
|
||||
|
||||
// Load our debug based logger before ANYTHING
|
||||
hpServer_loadLogger(envs.HEADPLANE_DEBUG_LOG);
|
||||
|
||||
if (envs.HEADPLANE_CONFIG_PATH) {
|
||||
path = envs.HEADPLANE_CONFIG_PATH;
|
||||
}
|
||||
@@ -117,20 +117,17 @@ export async function hp_loadConfig() {
|
||||
testOidc(config.oidc);
|
||||
}
|
||||
|
||||
// @ts-expect-error: If we remove globalThis we get a runtime error
|
||||
globalThis.__cookie_context = {
|
||||
cookie_secret: config.server.cookie_secret,
|
||||
cookie_secure: config.server.cookie_secure,
|
||||
};
|
||||
|
||||
// @ts-expect-error: If we remove globalThis we get a runtime error
|
||||
globalThis.__hs_context = {
|
||||
url: config.headscale.url,
|
||||
config_path: config.headscale.config_path,
|
||||
config_strict: config.headscale.config_strict,
|
||||
};
|
||||
|
||||
// @ts-expect-error: If we remove globalThis we get a runtime error
|
||||
globalThis.__integration_context = config.integration;
|
||||
|
||||
runtimeConfig = config;
|
||||
@@ -190,7 +187,7 @@ function coalesceEnv(config: HeadplaneConfig) {
|
||||
const rootKeys: string[] = rootEnvs.props.map((prop) => prop.key);
|
||||
|
||||
// Typescript is still insanely stupid at nullish filtering
|
||||
const vars = Object.entries(process.env).filter(([key, value]) => {
|
||||
const vars = Object.entries(env).filter(([key, value]) => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const dockerConfig = type({
|
||||
|
||||
const kubernetesConfig = type({
|
||||
enabled: stringToBool,
|
||||
pod_name: 'string',
|
||||
validate_manifest: stringToBool,
|
||||
});
|
||||
|
||||
|
||||
+19
-6
@@ -6,29 +6,42 @@ import {
|
||||
writeReadableStreamToWritable,
|
||||
} from '@react-router/node';
|
||||
import mime from 'mime/lite';
|
||||
import type { RequestHandler } from 'react-router';
|
||||
import type { ViteDevServer } from 'vite';
|
||||
import appContext from '~server/context/app';
|
||||
import { loadDevtools, stacksafeTry } from '~server/dev/hot-server';
|
||||
import prodBuild from '~server/prod-handler';
|
||||
import { hp_loadConfig } from './context/loader';
|
||||
|
||||
declare global {
|
||||
// Prefix is a build-time constant
|
||||
const __hp_prefix: string;
|
||||
}
|
||||
|
||||
const devtools = import.meta.env.DEV ? await loadDevtools() : undefined;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Who cares man
|
||||
let devtools: { server: ViteDevServer; handler: any } | undefined = undefined;
|
||||
let prodHandler: RequestHandler | undefined = undefined;
|
||||
let loaded = false;
|
||||
|
||||
const prodHandler = import.meta.env.PROD ? await prodBuild() : undefined;
|
||||
|
||||
const buildPath = process.env.BUILD_PATH ?? './build';
|
||||
const baseDir = resolve(join(buildPath, 'client'));
|
||||
async function loadGlobals() {
|
||||
await hp_loadConfig();
|
||||
devtools = import.meta.env.DEV ? await loadDevtools() : undefined;
|
||||
prodHandler = import.meta.env.PROD ? await prodBuild() : undefined;
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
const baseDir = resolve(join('./build', 'client'));
|
||||
export const listener: RequestListener = async (req, res) => {
|
||||
if (!loaded) {
|
||||
await loadGlobals();
|
||||
}
|
||||
|
||||
const url = new URL(`http://${req.headers.host}${req.url}`);
|
||||
|
||||
// build:strip
|
||||
if (devtools) {
|
||||
await new Promise((resolve) => {
|
||||
devtools.server.middlewares(req, res, resolve);
|
||||
devtools?.server.middlewares(req, res, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user